Files
beaver-plugin-obsidian/preview/fake-client.ts
T

607 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ConversationSummary> & Pick<ConversationSummary, "id" | "kind">
): 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<string, Partial<ConversationInfo>> = {
[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 },
};
// Ten minutes apart, ending now: enough to show a time on every message.
const STEP_MS = 600_000;
function stamped(messages: HistoryMessage[]): HistoryMessage[] {
const end = Date.now();
return messages.map((message, index) => ({
...message,
ts: new Date(end - (messages.length - index) * STEP_MS).toISOString(),
}));
}
const HISTORY: Record<string, HistoryMessage[]> = {
[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: stamped(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<string, unknown>) };
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<T>(path: string): Promise<T> {
return Promise.resolve(this.route("GET", path) as T);
}
override post<T>(path: string, body?: unknown): Promise<T> {
return Promise.resolve(this.route("POST", path, body ?? {}) as T);
}
override patch<T>(path: string, body?: unknown): Promise<T> {
return Promise.resolve(this.route("PATCH", path, body ?? {}) as T);
}
override stream(
_path: string,
onEvent: (event: BusEvent) => void,
signal: AbortSignal
): Promise<void> {
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<BusEvent, "seq" | "ts"> & { 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);
}
}
}