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:
hh
2026-08-29 18:30:33 +02:00
parent be114e6eaf
commit 60dc7db925
58 changed files with 2858 additions and 499 deletions
+17 -95
View File
@@ -1,124 +1,54 @@
import { LiveStream } from "./api/live.svelte";
import type {
BusEvent,
ConversationSummary,
LimitsResponse,
LimitWindow,
} from "./api/types";
import type { BusEvent, LimitsResponse, LimitWindow } from "./api/types";
import { ConversationIndex } from "./panel/index.svelte";
import { session } from "./session.svelte";
const TAPE_SIZE = 120;
const RELOAD_DEBOUNCE_MS = 1500;
const QUIET = new Set(["stream", "hello"]);
// Gateway-wide live state: the conversation index kept fresh by
// ``/api/events``, the last non-chatty events as a tape, and the quota
// windows updated the moment the SDK reports them.
class Gateway {
conversations = $state<ConversationSummary[]>([]);
// Gateway-wide live state for the admin: the conversation index, the last
// non-chatty events as a tape, and the quota windows updated the moment
// the SDK reports them.
class Gateway extends ConversationIndex {
tape = $state<BusEvent[]>([]);
limits = $state<LimitsResponse | null>(null);
loaded = $state(false);
readonly live = new LiveStream(() => session.client, "/api/events", {
onEvent: (event) => this.apply(event),
prepare: () => this.load(),
});
get running(): ConversationSummary[] {
return this.conversations.filter((row) => row.running_turn);
constructor() {
super(() => session.client);
}
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 } = session;
override async load(): Promise<void> {
const client = this.client();
if (!client) {
return;
}
const [list, limits] = await Promise.all([
client.conversations({ limit: 500 }),
const [, limits] = await Promise.all([
super.load(),
client.limits().catch(() => null),
]);
this.conversations = list.conversations;
if (limits) {
this.limits = limits;
}
this.loaded = true;
}
async refreshLimits(): Promise<void> {
const { client } = session;
const client = this.client();
if (client) {
this.limits = await client.limits();
}
}
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);
}
}
apply(event: BusEvent): void {
override apply(event: BusEvent): void {
if (!QUIET.has(event.type)) {
this.tape.unshift(event);
if (this.tape.length > TAPE_SIZE) {
this.tape.length = TAPE_SIZE;
}
}
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 "rate_limit": {
this.applyLimit(event);
return;
}
case "message.queued":
case "inject.queued":
case "reply": {
this.reloadSoon();
return;
}
default:
}
}
private reloadTimer: ReturnType<typeof setTimeout> | null = null;
private reloadSoon(): void {
if (this.reloadTimer) {
if (event.type === "rate_limit") {
this.applyLimit(event);
return;
}
this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;
this.load().catch(() => undefined);
}, RELOAD_DEBOUNCE_MS);
super.apply(event);
}
private applyLimit(event: BusEvent): void {
@@ -140,14 +70,6 @@ class Gateway {
this.refreshLimits().catch(() => undefined);
}
}
start(): void {
this.live.start();
}
stop(): void {
this.live.stop();
}
}
export const gateway = new Gateway();