feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
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",
|
||||
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_row: 1,
|
||||
title: "ufw на dell",
|
||||
}),
|
||||
summary({
|
||||
id: BRANCH_BLASTER,
|
||||
kind: "branch",
|
||||
last_activity_at: ago(3 * HOUR),
|
||||
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 },
|
||||
};
|
||||
|
||||
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: 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user