feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { Component } from "obsidian";
|
||||
import { mount } from "svelte";
|
||||
import { PanelState } from "../src/state.svelte";
|
||||
import App from "../src/ui/app.svelte";
|
||||
import { BRANCH_BLASTER, DEEP_PANEL, FakeClient, MASTER } from "./fake-client";
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// bits-ui opens menus on pointerdown and tabs on pointer or click: send both.
|
||||
function press(element: Element | null | undefined): void {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
element.dispatchEvent(
|
||||
new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
cancelable: true,
|
||||
pointerType: "mouse",
|
||||
})
|
||||
);
|
||||
(element as HTMLElement).focus();
|
||||
(element as HTMLElement).click();
|
||||
}
|
||||
|
||||
// Scenes for screenshots: open the page with a hash to land in a state.
|
||||
// Width comes from the window (``--window-size``), the state from here.
|
||||
async function main(): Promise<void> {
|
||||
const client = new FakeClient();
|
||||
const state = new PanelState();
|
||||
const scene = location.hash.slice(1) || "thread";
|
||||
state.selected = MASTER;
|
||||
if (scene === "empty") {
|
||||
state.selected = null;
|
||||
}
|
||||
if (scene === "question") {
|
||||
state.selected = BRANCH_BLASTER;
|
||||
}
|
||||
if (scene === "deep") {
|
||||
state.selected = DEEP_PANEL;
|
||||
}
|
||||
if (scene === "offline") {
|
||||
state.selected = MASTER;
|
||||
}
|
||||
document.body.classList.toggle("theme-light", scene.endsWith("-light"));
|
||||
document.body.classList.toggle("theme-dark", !scene.endsWith("-light"));
|
||||
|
||||
const app = {
|
||||
workspace: {
|
||||
openLinkText: (target: string) => console.log("open", target),
|
||||
},
|
||||
};
|
||||
mount(App, {
|
||||
props: {
|
||||
app: app as never,
|
||||
client: scene === "offline" ? null : client,
|
||||
component: new Component() as never,
|
||||
onOpenSettings: () => console.log("settings"),
|
||||
state,
|
||||
vaultSubpath: () => "💬 чаты",
|
||||
},
|
||||
target: document.getElementById("app") as HTMLElement,
|
||||
});
|
||||
await wait(80);
|
||||
|
||||
if (scene.startsWith("closed")) {
|
||||
press(document.querySelector('[aria-label="Switch conversation"]'));
|
||||
await wait(60);
|
||||
press(document.querySelector('[title="Show closed conversations"]'));
|
||||
}
|
||||
if (scene.startsWith("switcher")) {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Switch conversation"]')
|
||||
?.click();
|
||||
}
|
||||
if (scene.startsWith("menu")) {
|
||||
const row =
|
||||
document.querySelector<HTMLElement>(`[data-row="${BRANCH_BLASTER}"]`) ??
|
||||
(await (async () => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Switch conversation"]'
|
||||
)
|
||||
?.click();
|
||||
await wait(60);
|
||||
return document.querySelector<HTMLElement>(
|
||||
`[data-row="${BRANCH_BLASTER}"]`
|
||||
);
|
||||
})());
|
||||
row?.dispatchEvent(
|
||||
new MouseEvent("contextmenu", {
|
||||
bubbles: true,
|
||||
clientX: row.getBoundingClientRect().left + 120,
|
||||
clientY: row.getBoundingClientRect().top + 16,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (scene.startsWith("actions")) {
|
||||
press(document.querySelector('[aria-label="Conversation actions"]'));
|
||||
}
|
||||
if (scene.startsWith("activity")) {
|
||||
for (const trigger of document.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-slot="tabs-trigger"]'
|
||||
)) {
|
||||
if (trigger.textContent?.trim() === "Activity") {
|
||||
press(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scene.startsWith("branch")) {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Branch off this conversation"]'
|
||||
)
|
||||
?.click();
|
||||
}
|
||||
await wait(60);
|
||||
if (scene.startsWith("stream")) {
|
||||
// A tool lands while you watch: the tree grows without a reload.
|
||||
client.emit({
|
||||
conversation_id: MASTER,
|
||||
input: { command: "curl -sI https://b.hhhhh.dev/healthz" },
|
||||
name: "Bash",
|
||||
parent_tool_use_id: null,
|
||||
tool_use_id: "tu_live",
|
||||
turn_id: "t-run",
|
||||
type: "tool",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<title>beaver panel preview</title>
|
||||
<link href="/app.css" rel="stylesheet">
|
||||
<link href="/theme.css" rel="stylesheet">
|
||||
<link href="/styles.css" rel="stylesheet">
|
||||
<style>
|
||||
body.theme-dark,
|
||||
body.theme-light {
|
||||
--accent-h: 254;
|
||||
--accent-s: 80%;
|
||||
--accent-l: 68%;
|
||||
--font-interface:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--background-primary);
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-dark">
|
||||
<!--
|
||||
impeccable direction contract (mode operate, brief-pinned world: beaver-calendar's Obsidian skin)
|
||||
THESIS: the agent's terminal, not a chat app; the thread is always on screen, picking a
|
||||
conversation is a header switch. OWN-WORLD: Obsidian's own variables through .beaver-root;
|
||||
hairline rows, tabular numerals, uppercase 12px section labels; nothing pink.
|
||||
STORY: follow the note, watch tools stream, answer, branch with a seed, send back to Telegram.
|
||||
FIRST VIEWPORT: switcher over the thread header, tabs, live thread, composer pinned below;
|
||||
wide: the grouped rail on the left. FORM: two-pane operator console, pinned by the brief.
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review,
|
||||
the verdict, and DESIGN.md
|
||||
-->
|
||||
<div id="app"></div>
|
||||
<script defer src="/preview/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { extname, join, normalize } from "node:path";
|
||||
import { extractFile } from "@electron/asar";
|
||||
import esbuild from "esbuild";
|
||||
import { root, sharedLib, svelte } from "../esbuild.config.mjs";
|
||||
|
||||
const port = Number(process.env.PORT ?? 4174);
|
||||
|
||||
const ASAR_CANDIDATES = [
|
||||
process.env.OBSIDIAN_ASAR,
|
||||
"/Applications/Obsidian.app/Contents/Resources/obsidian.asar",
|
||||
join(homedir(), "Applications/Obsidian.app/Contents/Resources/obsidian.asar"),
|
||||
"/opt/Obsidian/resources/obsidian.asar",
|
||||
"/usr/lib/obsidian/resources/obsidian.asar",
|
||||
].filter(Boolean);
|
||||
|
||||
function findAppCss() {
|
||||
for (const candidate of ASAR_CANDIDATES) {
|
||||
if (!existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const path = join(mkdtempSync(join(tmpdir(), "beaver-app-")), "app.css");
|
||||
writeFileSync(path, extractFile(candidate, "app.css"));
|
||||
return path;
|
||||
} catch {
|
||||
/* try the next candidate */
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
"preview: could not read app.css from an Obsidian install. Set OBSIDIAN_ASAR to point at obsidian.asar; without it the chrome will not match the real app."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTheme() {
|
||||
if (process.env.THEME) {
|
||||
return process.env.THEME;
|
||||
}
|
||||
const vault = process.env.VAULT;
|
||||
if (!vault) {
|
||||
return null;
|
||||
}
|
||||
const themes = join(vault, ".obsidian/themes");
|
||||
if (!existsSync(themes)) {
|
||||
return null;
|
||||
}
|
||||
for (const name of readdirSync(themes)) {
|
||||
const css = join(themes, name, "theme.css");
|
||||
if (existsSync(css)) {
|
||||
return css;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const appCss = findAppCss();
|
||||
const theme = findTheme();
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
bundle: true,
|
||||
conditions: ["svelte", "browser"],
|
||||
entryPoints: [join(root, "preview/entry.ts")],
|
||||
format: "iife",
|
||||
logLevel: "info",
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
outfile: join(root, "preview/bundle.js"),
|
||||
plugins: [
|
||||
sharedLib({ obsidian: join(root, "tests/obsidian-stub.ts") }),
|
||||
svelte(),
|
||||
],
|
||||
sourcemap: "inline",
|
||||
target: "es2022",
|
||||
});
|
||||
await ctx.rebuild();
|
||||
await ctx.watch();
|
||||
|
||||
const TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
};
|
||||
|
||||
createServer((request, response) => {
|
||||
const url = decodeURIComponent((request.url ?? "/").split("?")[0]);
|
||||
const external = { "/app.css": appCss, "/theme.css": theme };
|
||||
|
||||
if (url in external) {
|
||||
const file = external[url];
|
||||
if (!file) {
|
||||
response.writeHead(200, { "content-type": TYPES[".css"] }).end("");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": TYPES[".css"],
|
||||
});
|
||||
createReadStream(file).pipe(response);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = join(root, normalize(url === "/" ? "/preview/index.html" : url));
|
||||
if (
|
||||
!(file.startsWith(root) && existsSync(file)) ||
|
||||
statSync(file).isDirectory()
|
||||
) {
|
||||
response.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": TYPES[extname(file)] ?? "application/octet-stream",
|
||||
});
|
||||
createReadStream(file).pipe(response);
|
||||
}).listen(port, () => {
|
||||
console.log(`preview: http://localhost:${port}/`);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// bun preview/shot.ts <out.png> <url> [width] [height]
|
||||
// Headless Chrome over CDP: the preview server must be running.
|
||||
const [out, url, w = "340", h = "720"] = process.argv.slice(2);
|
||||
const port = 9334;
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"--headless=new",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--window-size=${w},${h}`,
|
||||
"--user-data-dir=/tmp/beaver-preview-chrome",
|
||||
"about:blank",
|
||||
],
|
||||
{ stderr: "ignore", stdout: "ignore" }
|
||||
);
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
let targets: { type: string; webSocketDebuggerUrl: string }[] = [];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
try {
|
||||
// biome-ignore lint/performance/noAwaitInLoops: polling until Chrome is up
|
||||
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
|
||||
if (targets.length) {
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* not up yet */
|
||||
}
|
||||
await wait(250);
|
||||
}
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
if (!page) {
|
||||
proc.kill();
|
||||
throw new Error("no page target");
|
||||
}
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((resolve) => {
|
||||
ws.onopen = resolve;
|
||||
});
|
||||
let id = 0;
|
||||
const pending = new Map<number, (v: { result?: { data?: string } }) => void>();
|
||||
ws.onmessage = (m) => {
|
||||
const d = JSON.parse(String(m.data));
|
||||
if (d.id && pending.has(d.id)) {
|
||||
pending.get(d.id)?.(d);
|
||||
pending.delete(d.id);
|
||||
}
|
||||
};
|
||||
const send = (method: string, params: Record<string, unknown> = {}) =>
|
||||
new Promise<{ result?: { data?: string } }>((resolve) => {
|
||||
id += 1;
|
||||
pending.set(id, resolve);
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: Number(h),
|
||||
mobile: Number(w) < 500,
|
||||
width: Number(w),
|
||||
});
|
||||
await send("Page.enable");
|
||||
await send("Page.navigate", { url });
|
||||
await wait(2500);
|
||||
if (process.env.EVAL) {
|
||||
const evaluated = await send("Runtime.evaluate", {
|
||||
expression: process.env.EVAL,
|
||||
returnByValue: true,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
(evaluated.result as { result?: { value?: unknown } }).result?.value
|
||||
)
|
||||
);
|
||||
await wait(400);
|
||||
}
|
||||
const shot = await send("Page.captureScreenshot", { format: "png" });
|
||||
await Bun.write(out, Buffer.from(shot.result?.data ?? "", "base64"));
|
||||
console.log(`wrote ${out}`);
|
||||
ws.close();
|
||||
proc.kill();
|
||||
Reference in New Issue
Block a user