import { ApiClient, ApiError } from "$lib/api/client"; import type { BusEvent, ConversationInfo, ConversationSummary, HistoryMessage, } from "$lib/api/types"; const MINUTE = 60_000; const HOUR = 60 * MINUTE; const now = Date.now(); const ago = (ms: number) => new Date(now - ms).toISOString(); export const MASTER = "c1master0000"; export const BRANCH_UFW = "c2ufw0000000"; export const BRANCH_BLASTER = "c3blaster000"; export const DEEP_PANEL = "c4panel00000"; export const JOB_TRIAGE = "c5triage0000"; export const FORK_MERGE = "c6fork000000"; function summary( over: Partial & Pick ): ConversationSummary { return { agent: "beaver-dispatcher", context_tokens: 84_000, created_at: ago(9 * HOUR), flags: {}, last_activity_at: ago(4 * MINUTE), last_item: null, last_user_activity_at: ago(12 * MINUTE), origin: "telegram", parent_row: null, pending_question: false, running_turn: null, session_id: "9c0c3fb2-2ac7-4d6b-8d4e-1a3f0c1a2b3c", status: "open", title: null, ...over, }; } const ROWS: ConversationSummary[] = [ summary({ id: MASTER, kind: "master", last_item: { created_at: ago(4 * MINUTE), id: 41, origin: "telegram", priority: "user", status: "running", text: "посмотри, что с ufw на dell - порт 62990 снаружи не отвечает", }, running_turn: "t-run", title: "Master · 29 Aug", }), summary({ id: BRANCH_UFW, kind: "branch", last_activity_at: ago(35 * MINUTE), last_item: { created_at: ago(35 * MINUTE), id: 40, origin: "telegram", priority: "user", status: "done", text: "правило для 62990 добавлено, проверь снаружи", }, parent: MASTER, parent_row: 1, title: "ufw на dell", }), summary({ id: BRANCH_BLASTER, kind: "branch", last_activity_at: ago(3 * HOUR), parent: MASTER, parent_row: 1, pending_question: true, title: "бластер - что заказать", }), summary({ agent: "beaver-opus-high", id: DEEP_PANEL, kind: "deep", last_activity_at: ago(50 * MINUTE), origin: "markdown", title: "2026-08-29 - панель в Obsidian", }), summary({ agent: "beaver-triage", id: JOB_TRIAGE, kind: "job", last_activity_at: ago(2 * HOUR), origin: "cron", status: "closed", title: "триаж 14:00", }), summary({ id: FORK_MERGE, kind: "fork", last_activity_at: ago(6 * HOUR), origin: "api", status: "merged", title: "слив: утренняя ветка", }), ]; const INFO: Record> = { [MASTER]: { bindings: [ { external_id: "-1002233:general", frontend: "telegram", visible: true }, ], busy: true, live: true, queue: [ { created_at: ago(2 * MINUTE), id: 42, origin: "watch", priority: "normal", status: "queued", text: "vault: 📅 дни/2026-08-29.md +3 строки", }, ], turn: { id: "t-run", origin: "user", started_at: ago(48_000), text: "посмотри, что с ufw на dell - порт 62990 снаружи не отвечает", tools: [ { content: null, ended_at: null, input: { description: "проверить ufw на dell", prompt: "ssh dell 'sudo ufw status numbered' и сравнить с site.caddy", }, is_error: null, name: "Task", parent_tool_use_id: null, started_at: ago(40_000), tool_use_id: "tu_task", }, { content: "Status: active\n[ 1] 22/tcp ALLOW IN Anywhere\n[ 2] 443/tcp ALLOW IN Anywhere", ended_at: ago(30_000), input: { command: "ssh dell 'sudo ufw status numbered'" }, is_error: false, name: "Bash", parent_tool_use_id: "tu_task", started_at: ago(38_000), tool_use_id: "tu_bash", }, { content: null, ended_at: null, input: { file_path: "/vault/мета/бобер/скиллы/ops/komodo/SKILL.md" }, is_error: null, name: "Read", parent_tool_use_id: "tu_task", started_at: ago(8000), tool_use_id: "tu_read", }, ], }, }, [BRANCH_UFW]: { bindings: [ { external_id: "-1002233:517", frontend: "telegram", visible: true }, ], parent: MASTER, }, [BRANCH_BLASTER]: { bindings: [ { external_id: "-1002233:498", frontend: "telegram", visible: false }, ], parent: MASTER, question: { id: "q1", questions: [ { options: [ { description: "Ozon, 3 дня", label: "Xiaomi Mi Blaster" }, { description: "AliExpress, 2 недели", label: "Broadlink RM4" }, ], question: "Какой ИК-бластер заказать?", }, ], }, }, [DEEP_PANEL]: { bindings: [ { external_id: "2026-08-29 - панель в Obsidian.md", frontend: "markdown", visible: true, }, ], flags: { memory: true }, }, [JOB_TRIAGE]: { flags: { critical: false } }, [FORK_MERGE]: { parent: MASTER }, }; const HISTORY: Record = { [MASTER]: [ { content: "с утра: дневник приехал? что в хендауте", role: "user" }, { content: "Приехал. В [[2026-08-28|хендауте]] два хвоста: **бластер** ждёт выбора модели, и **ufw на dell** - порт 62990 снаружи молчит. Ветка по ufw открыта, см. [[ufw на dell]].", role: "assistant", }, { content: "ок, гони ufw", role: "user" }, { content: [ { text: "Смотрю правила через `ssh dell` и сверяю с `site.caddy`.", type: "text", }, { id: "tu_prev", input: { command: "cat /root/beaver-agent/caddy/site.caddy" }, name: "Bash", type: "tool_use", }, ], role: "assistant", }, { content: [ { content: "beaver.hhhhh.dev {\n reverse_proxy localhost:62990\n}", tool_use_id: "tu_prev", type: "tool_result", }, ], role: "user", }, { content: "Caddy проксирует на `localhost:62990`, значит снаружи должен отвечать 443, а не 62990 - проверю, открыт ли он вообще.", role: "assistant", }, ], [BRANCH_UFW]: [ { content: "правило для 62990 добавлено, проверь снаружи", role: "user" }, { content: "Снаружи `curl -sI https://b.hhhhh.dev/healthz` → 200. Порт 62990 наружу не нужен: его слушает только Caddy. Предлагаю правило убрать и слить ветку.", role: "assistant", }, ], [BRANCH_BLASTER]: [ { content: "нужен ИК-бластер для кондиционера, выбери", role: "user" }, { content: "Два варианта, спрошу кнопками.", role: "assistant" }, ], [DEEP_PANEL]: [ { content: "как панель в Obsidian должна вести себя в узкой колонке?", role: "user", }, { content: "Тред всегда на виду; переключатель разговоров - в шапке. Список как отдельный экран - только на телефоне.\n\n- узкая колонка: шапка + тред + композер\n- вкладка: рейл слева\n\nСм. [[архитектура#2. Окна]].", role: "assistant", }, ], }; interface Route { handle: (match: RegExpMatchArray, body: unknown) => unknown; method: string; pattern: RegExp; } // The gateway as the panel sees it, in memory: enough of ``/api`` for the // preview scenes and the smoke test, with a scripted event stream. export class FakeClient extends ApiClient { rows = ROWS.map((row) => ({ ...row })); readonly sent: { id: string; text: string; origin: string }[] = []; private readonly listeners = new Set<(event: BusEvent) => void>(); private seq = 1; constructor() { super("http://fake", "token"); } private readonly routes: Route[] = [ { handle: () => ({ agents: [], frontends: [], mcps: [] }), method: "GET", pattern: /^\/api\/agents$/, }, { handle: () => ({ conversations: [], files: [], query: "" }), method: "GET", pattern: /^\/api\/search/, }, { handle: () => ({ edges: [], nodes: [], root: "" }), method: "GET", pattern: /^\/api\/vault\/graph/, }, { handle: ([, id]) => ({ agent: { effort: "high", model: "claude-opus-5", name: "beaver-dispatcher", }, context_tokens: 84_000, files: [ { last_at: ago(MINUTE), other: 0, path: "📅 дни/2026-08-29.md", reads: 1, writes: 0, }, { last_at: ago(5 * MINUTE), other: 0, path: "👤 люди/Марта Ветрова.md", reads: 2, writes: 1, }, ], history_tokens_est: 60_000, id, kind: "master", prompt: { bytes: 24_000, granules: [ { bytes: 9000, path: "мета/бобер/промпты/гранулы/голос/role.md", tag: "role", tokens_est: 2800, }, { bytes: 15_000, path: "мета/бобер/промпты/гранулы/диспетчер.md", tag: "dispatcher", tokens_est: 4700, }, ], tokens_est: 7500, }, skills: [ { path: "мета/бобер/скиллы/общие", set: "общие", skills: [ { description: "серверы и стеки", name: "ops", path: "мета/бобер/скиллы/общие/ops", }, ], }, ], skills_tokens_est: 20, tool_counts: { Bash: 1, Read: 3 }, tools: { allowed: null, disallowed: [], gateway: ["say", "spawn"], mcps: ["komodo"], }, turns: 4, }), method: "GET", pattern: /^\/api\/conversations\/([^/]+)\/context/, }, { handle: () => ({ conversations: this.rows }), method: "GET", pattern: /^\/api\/conversations$/, }, { handle: ([, id]) => this.info(id), method: "GET", pattern: /^\/api\/conversations\/([^/]+)$/, }, { handle: ([, id]) => ({ id, messages: HISTORY[id] ?? [] }), method: "GET", pattern: /^\/api\/conversations\/([^/]+)\/history$/, }, { handle: ([, id]) => ({ entries: [], id, offset: 0, subpath: "", subpaths: [], total: 0, }), method: "GET", pattern: /^\/api\/conversations\/([^/]+)\/entries$/, }, { handle: ([, id], body) => { const { text, origin } = body as { text: string; origin: string }; this.sent.push({ id, origin, text }); return { id, item: this.sent.length, status: "queued" }; }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/messages$/, }, { handle: ([, id], body) => { const { text, origin } = body as { text: string; origin: string }; this.sent.push({ id, origin: `inject:${origin}`, text }); return { id, item: this.sent.length, priority: "normal" }; }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/inject$/, }, { handle: ([, id], body) => { const child = summary({ id: `child${this.rows.length}`, kind: "branch", parent_row: 1, title: (body as { title?: string }).title ?? "новая ветка", }); this.rows.unshift(child); return this.info(child.id, id); }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/branch$/, }, { handle: ([, id], body) => { const { frontend, external_id: external, visible, } = body as { frontend: string; external_id?: string; visible: boolean; }; if (!INFO[id]) { INFO[id] = {}; } const info = INFO[id]; const bindings = (info.bindings ?? []).filter( (b) => b.frontend !== frontend ); bindings.push({ external_id: external ?? `${frontend}:new`, frontend, visible, }); info.bindings = bindings; return this.info(id); }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/bind$/, }, { handle: ([, id], body) => { const row = this.row(id); row.flags = { ...row.flags, ...(body as Record) }; return row; }, method: "PATCH", pattern: /^\/api\/conversations\/([^/]+)\/flags$/, }, { handle: ([, id], body) => { const row = this.row(id); const patch = body as { status?: ConversationSummary["status"]; title?: string; }; if (patch.status) { row.status = patch.status; } if (typeof patch.title === "string") { row.title = patch.title; } return row; }, method: "PATCH", pattern: /^\/api\/conversations\/([^/]+)$/, }, { handle: ([, id]) => { this.row(id).status = "merged"; return { fork: "f", id, status: "merged", text: "слито" }; }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/merge$/, }, { handle: ([, id]) => { this.row(id).status = "closed"; return { digest: "мета/бобер/выжимки/2026-08-29 - панель.md", id, status: "closed", }; }, method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/close$/, }, { handle: ([, id], body) => ({ id, question_id: (body as { question_id: string }).question_id, }), method: "POST", pattern: /^\/api\/conversations\/([^/]+)\/answer$/, }, ]; private row(id: string): ConversationSummary { const row = this.rows.find((r) => r.id === id); if (!row) { throw new ApiError(404, `unknown conversation ${id}`, null); } return row; } private info(id: string, parent?: string): ConversationInfo { const row = this.row(id); const extra = INFO[id] ?? {}; return { ...row, bindings: [], busy: false, live: Boolean(row.session_id), parent: parent ?? null, question: null, queue: [], turn: null, ...extra, }; } private route(method: string, path: string, body?: unknown): unknown { for (const route of this.routes) { const match = route.method === method ? path.match(route.pattern) : null; if (match) { return route.handle(match, body); } } throw new ApiError(404, `no route for ${method} ${path}`, null); } override get(path: string): Promise { return Promise.resolve(this.route("GET", path) as T); } override post(path: string, body?: unknown): Promise { return Promise.resolve(this.route("POST", path, body ?? {}) as T); } override patch(path: string, body?: unknown): Promise { return Promise.resolve(this.route("PATCH", path, body ?? {}) as T); } override stream( _path: string, onEvent: (event: BusEvent) => void, signal: AbortSignal ): Promise { return new Promise((resolve) => { onEvent({ seq: 0, ts: new Date().toISOString(), type: "hello" }); this.listeners.add(onEvent); signal.addEventListener("abort", () => { this.listeners.delete(onEvent); resolve(); }); }); } emit(event: Omit & { ts?: string }): void { this.seq += 1; const full = { seq: this.seq, ts: new Date().toISOString(), ...event, } as BusEvent; for (const listener of this.listeners) { listener(full); } } }