feat(ui,api,telegram): shared conversation panel for obsidian and /admin/panel, bind materializes a window, inbox survives link previews
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import type { ApiClient } from "$lib/api/client";
|
||||
import { LiveStream } from "$lib/api/live.svelte";
|
||||
import type { BusEvent, ConversationSummary } from "$lib/api/types";
|
||||
|
||||
const RELOAD_DEBOUNCE_MS = 1500;
|
||||
const PAGE = 500;
|
||||
|
||||
// The conversation index kept fresh by ``/api/events``: rows land as
|
||||
// ``conversation.*`` events arrive, turn markers flip ``running_turn``
|
||||
// without a refetch, and anything that moves the queue reloads once.
|
||||
// Shared by the admin's rail and the panel's picker.
|
||||
export class ConversationIndex {
|
||||
conversations = $state<ConversationSummary[]>([]);
|
||||
loaded = $state(false);
|
||||
readonly live: LiveStream;
|
||||
protected readonly client: () => ApiClient | null;
|
||||
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(client: () => ApiClient | null) {
|
||||
this.client = client;
|
||||
this.live = new LiveStream(client, "/api/events", {
|
||||
onEvent: (event) => this.apply(event),
|
||||
prepare: () => this.load(),
|
||||
});
|
||||
}
|
||||
|
||||
get running(): ConversationSummary[] {
|
||||
return this.conversations.filter((row) => row.running_turn);
|
||||
}
|
||||
|
||||
get open(): ConversationSummary[] {
|
||||
return this.conversations.filter((row) => row.status === "open");
|
||||
}
|
||||
|
||||
byId(id: string): ConversationSummary | undefined {
|
||||
return this.conversations.find((row) => row.id === id);
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
const client = this.client();
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const list = await client.conversations({ limit: PAGE });
|
||||
this.conversations = list.conversations;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
apply(event: BusEvent): void {
|
||||
switch (event.type) {
|
||||
case "conversation.created":
|
||||
case "conversation.updated": {
|
||||
if (typeof event.id === "string") {
|
||||
this.upsert(event as unknown as ConversationSummary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "turn.start":
|
||||
case "turn.end": {
|
||||
const row = event.conversation_id
|
||||
? this.byId(event.conversation_id)
|
||||
: undefined;
|
||||
if (row) {
|
||||
row.running_turn =
|
||||
event.type === "turn.start" ? (event.turn_id ?? null) : null;
|
||||
row.last_activity_at = event.ts;
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "message.queued":
|
||||
case "inject.queued":
|
||||
case "reply": {
|
||||
this.reloadSoon();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.live.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.live.stop();
|
||||
}
|
||||
|
||||
private upsert(row: ConversationSummary): void {
|
||||
const index = this.conversations.findIndex((c) => c.id === row.id);
|
||||
if (index >= 0) {
|
||||
this.conversations[index] = { ...this.conversations[index], ...row };
|
||||
} else {
|
||||
this.conversations.unshift(row);
|
||||
}
|
||||
}
|
||||
|
||||
private reloadSoon(): void {
|
||||
if (this.reloadTimer) {
|
||||
return;
|
||||
}
|
||||
this.reloadTimer = setTimeout(() => {
|
||||
this.reloadTimer = null;
|
||||
this.load().catch(() => undefined);
|
||||
}, RELOAD_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user