@a-t-h-i/bot-lobby 0.6.12 → 0.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +4 -3
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- package/webui/dist/vite.svg +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture sets for `npm run web:dev`: one JSON file per scenario under
|
|
3
|
+
* `webui/fixtures/`. Dev-only; the production server never reads these.
|
|
4
|
+
* Unknown scenario names fall back to `full`.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { ChatRole } from "../../lobby/feed.ts";
|
|
10
|
+
import type { PromptKind } from "../../lobby/prompt-hub.ts";
|
|
11
|
+
import type { SnapshotTask } from "../protocol.ts";
|
|
12
|
+
|
|
13
|
+
/** Every scenario the mock serves; `?scenario=` picks one. */
|
|
14
|
+
export const SCENARIOS = [
|
|
15
|
+
"full",
|
|
16
|
+
"empty",
|
|
17
|
+
"loading",
|
|
18
|
+
"error",
|
|
19
|
+
"reconnecting",
|
|
20
|
+
"question",
|
|
21
|
+
"questions3",
|
|
22
|
+
"answered-in-terminal",
|
|
23
|
+
"terminal-dialog",
|
|
24
|
+
"issues",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type ScenarioName = (typeof SCENARIOS)[number];
|
|
28
|
+
|
|
29
|
+
export interface FixturePrompt {
|
|
30
|
+
kind: PromptKind;
|
|
31
|
+
from: string;
|
|
32
|
+
payload: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FixtureSettled extends FixturePrompt {
|
|
36
|
+
answer: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One scenario file: what every protocol call answers from. */
|
|
40
|
+
export interface ScenarioFixture {
|
|
41
|
+
status: {
|
|
42
|
+
workspace: { name: string; branch?: string };
|
|
43
|
+
sessionId: string;
|
|
44
|
+
sessionName: string;
|
|
45
|
+
busy: boolean;
|
|
46
|
+
terminalDialog: boolean;
|
|
47
|
+
issuesEnabled: boolean;
|
|
48
|
+
};
|
|
49
|
+
zen: { task?: Partial<SnapshotTask> & { id: string; title: string; state: string; plan?: string }; runs: unknown[] };
|
|
50
|
+
feed: {
|
|
51
|
+
chat: Array<{ role: ChatRole; text: string }>;
|
|
52
|
+
activity: Array<{ source: string; text: string; kind: "info" | "success" | "warning" | "error"; pending: boolean }>;
|
|
53
|
+
thoughts: Array<{ source: string; text: string }>;
|
|
54
|
+
reply: string;
|
|
55
|
+
chatOlder: boolean;
|
|
56
|
+
};
|
|
57
|
+
history: Array<{ role: ChatRole; text: string }>;
|
|
58
|
+
prompts: FixturePrompt[];
|
|
59
|
+
settled: FixtureSettled[];
|
|
60
|
+
/** Streamed back (in deltas) after `lobby.send`. */
|
|
61
|
+
oracleReply: string;
|
|
62
|
+
/** The Settings page's mock: a partial config merged over the defaults, and the models Pi offers. */
|
|
63
|
+
settings?: { config?: Record<string, unknown>; models?: Array<{ id: string; label: string; thinkingLevels: string[] }> };
|
|
64
|
+
/** Opened through `promptHub` shortly after a send, if any. */
|
|
65
|
+
questionAfterSend: FixturePrompt | null;
|
|
66
|
+
/** Tasks backing `tasks.list` (absent means none). */
|
|
67
|
+
mockTasks?: Array<Record<string, unknown>>;
|
|
68
|
+
/** Saved plans backing `plans.start`/`plans.discard` and the pending rows. */
|
|
69
|
+
mockPlans?: Array<{ id: string; title: string; brief: string }>;
|
|
70
|
+
/** Archived tasks backing `tasks.archived`. */
|
|
71
|
+
mockArchived?: Array<Record<string, unknown>>;
|
|
72
|
+
/** Task ids with auto mode on. */
|
|
73
|
+
autoTasks?: string[];
|
|
74
|
+
/** Comments by task id for `tasks.comments`. */
|
|
75
|
+
taskComments?: Record<string, Array<Record<string, unknown>>>;
|
|
76
|
+
/** Background sessions for `sessions.list` (plain data, made live below). */
|
|
77
|
+
backgroundSessions?: Array<{ key: string; name: string; status: string; sessionId?: string; planId?: string; dialogs?: Array<Record<string, unknown>> }>;
|
|
78
|
+
/** Live sessions in other terminals for `sessions.list`. */
|
|
79
|
+
liveSessions?: Array<{ sessionId: string; pid: number; name?: string; taskId?: string; mode: string }>;
|
|
80
|
+
/** The planning session for the `planner.*` calls (absent means none started). */
|
|
81
|
+
mockPlanner?: {
|
|
82
|
+
seats: string[];
|
|
83
|
+
members: Array<Record<string, unknown>>;
|
|
84
|
+
messages: Array<Record<string, unknown>>;
|
|
85
|
+
draft?: string;
|
|
86
|
+
questions: Array<Record<string, unknown>>;
|
|
87
|
+
notes: Array<Record<string, unknown>>;
|
|
88
|
+
round: number;
|
|
89
|
+
retryable: boolean;
|
|
90
|
+
};
|
|
91
|
+
/** Quick-fix jobs for the `quickfix.*` calls, oldest first (absent means none). */
|
|
92
|
+
mockQuickfix?: Array<Record<string, unknown>>;
|
|
93
|
+
/** Metric records for `metrics.get` (absent means none). */
|
|
94
|
+
mockMetrics?: Array<Record<string, unknown>>;
|
|
95
|
+
/** Classifier records for `metrics.get` (absent means none). */
|
|
96
|
+
mockClassifierMetrics?: Array<Record<string, unknown>>;
|
|
97
|
+
/** Knowledge files for `knowledge.files` (absent means none). */
|
|
98
|
+
mockKnowledge?: Array<{ agent: string; file: string; label: string; chars: number; over: boolean; notes: number }>;
|
|
99
|
+
/** One open knowledge file for `knowledge.open` (absent means an empty file). */
|
|
100
|
+
mockKnowledgeView?: { agent: string; file: string; label: string; chars: number; over: boolean; notes: number; content: string; entries: Array<Record<string, unknown>>; attached: Array<Record<string, unknown>>; detached: Array<Record<string, unknown>> };
|
|
101
|
+
/** Excalidraw sessions for `excalidraw.list` (masked links only; absent means none). */
|
|
102
|
+
mockExcalidraw?: Array<{ id: string; name: string; masked: string; agents: string[]; contribute: boolean; addedAt: string }>;
|
|
103
|
+
/** The full link `excalidraw.reveal` returns (absent means none). */
|
|
104
|
+
mockExcalidrawLink?: string;
|
|
105
|
+
/** Open pull requests for the `git.*` calls, each with its detail fields (absent means none). */
|
|
106
|
+
mockPulls?: Array<Record<string, unknown>>;
|
|
107
|
+
/** Saved reviews of those pull requests, by number. */
|
|
108
|
+
mockReviews?: Array<Record<string, unknown>>;
|
|
109
|
+
/** Jev's reads of those pull requests, by number. */
|
|
110
|
+
mockReads?: Array<Record<string, unknown>>;
|
|
111
|
+
/** Open issues for the `issues.*` calls, each with its detail fields (absent means none). */
|
|
112
|
+
mockIssues?: Array<Record<string, unknown>>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether `value` names a scenario. */
|
|
116
|
+
export function isScenario(value: string): value is ScenarioName {
|
|
117
|
+
return (SCENARIOS as readonly string[]).includes(value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The scenario to serve; unknown or missing names mean `full`. */
|
|
121
|
+
export function resolveScenario(value: string | undefined): ScenarioName {
|
|
122
|
+
return value !== undefined && isScenario(value) ? value : "full";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Where `webui/fixtures/<name>.json` lives. */
|
|
126
|
+
export function fixturePath(name: ScenarioName): string {
|
|
127
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
128
|
+
return join(root, "webui", "fixtures", `${name}.json`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
132
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function hasKeys(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
|
136
|
+
return isRecord(value) && keys.every((key) => key in value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The parsed file, or throws when it does not cover every protocol call. */
|
|
140
|
+
export function loadScenario(name: string | undefined): ScenarioFixture {
|
|
141
|
+
const scenario = resolveScenario(name);
|
|
142
|
+
const parsed: unknown = JSON.parse(readFileSync(fixturePath(scenario), "utf8"));
|
|
143
|
+
if (!hasKeys(parsed, ["status", "zen", "feed", "history", "prompts", "oracleReply"])) {
|
|
144
|
+
throw new Error(`fixtures/${scenario}.json must cover status, zen, feed, history, prompts and oracleReply`);
|
|
145
|
+
}
|
|
146
|
+
if (!hasKeys(parsed.status, ["workspace", "sessionId", "sessionName", "busy", "terminalDialog", "issuesEnabled"])) {
|
|
147
|
+
throw new Error(`fixtures/${scenario}.json status must carry workspace, session, busy, terminalDialog and issuesEnabled`);
|
|
148
|
+
}
|
|
149
|
+
if (!hasKeys(parsed.zen, ["runs"]) || !hasKeys(parsed.feed, ["chat", "activity", "thoughts", "reply", "chatOlder"])) {
|
|
150
|
+
throw new Error(`fixtures/${scenario}.json zen/feed are missing their fields`);
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(parsed.history) || !Array.isArray(parsed.prompts) || typeof parsed.oracleReply !== "string") {
|
|
153
|
+
throw new Error(`fixtures/${scenario}.json history/prompts/oracleReply have the wrong shape`);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...(parsed as unknown as ScenarioFixture),
|
|
157
|
+
settled: Array.isArray(parsed.settled) ? (parsed.settled as ScenarioFixture["settled"]) : [],
|
|
158
|
+
questionAfterSend: isRecord(parsed.questionAfterSend) ? (parsed.questionAfterSend as unknown as FixturePrompt) : null,
|
|
159
|
+
taskComments: isRecord(parsed.taskComments) ? (parsed.taskComments as ScenarioFixture["taskComments"]) : {},
|
|
160
|
+
mockTasks: Array.isArray(parsed.mockTasks) ? (parsed.mockTasks as ScenarioFixture["mockTasks"]) : [],
|
|
161
|
+
mockPlans: Array.isArray(parsed.mockPlans) ? (parsed.mockPlans as ScenarioFixture["mockPlans"]) : [],
|
|
162
|
+
mockArchived: Array.isArray(parsed.mockArchived) ? (parsed.mockArchived as ScenarioFixture["mockArchived"]) : [],
|
|
163
|
+
autoTasks: Array.isArray(parsed.autoTasks) ? (parsed.autoTasks as string[]) : [],
|
|
164
|
+
backgroundSessions: Array.isArray(parsed.backgroundSessions) ? (parsed.backgroundSessions as ScenarioFixture["backgroundSessions"]) : [],
|
|
165
|
+
liveSessions: Array.isArray(parsed.liveSessions) ? (parsed.liveSessions as ScenarioFixture["liveSessions"]) : [],
|
|
166
|
+
mockPlanner: isRecord(parsed.mockPlanner) ? (parsed.mockPlanner as unknown as ScenarioFixture["mockPlanner"]) : undefined,
|
|
167
|
+
mockQuickfix: Array.isArray(parsed.mockQuickfix) ? (parsed.mockQuickfix as ScenarioFixture["mockQuickfix"]) : [],
|
|
168
|
+
mockMetrics: Array.isArray(parsed.mockMetrics) ? (parsed.mockMetrics as ScenarioFixture["mockMetrics"]) : [],
|
|
169
|
+
mockClassifierMetrics: Array.isArray(parsed.mockClassifierMetrics) ? (parsed.mockClassifierMetrics as ScenarioFixture["mockClassifierMetrics"]) : [],
|
|
170
|
+
mockKnowledge: Array.isArray(parsed.mockKnowledge) ? (parsed.mockKnowledge as ScenarioFixture["mockKnowledge"]) : [],
|
|
171
|
+
mockKnowledgeView: isRecord(parsed.mockKnowledgeView) ? (parsed.mockKnowledgeView as unknown as ScenarioFixture["mockKnowledgeView"]) : undefined,
|
|
172
|
+
mockExcalidraw: Array.isArray(parsed.mockExcalidraw) ? (parsed.mockExcalidraw as ScenarioFixture["mockExcalidraw"]) : [],
|
|
173
|
+
mockExcalidrawLink: typeof parsed.mockExcalidrawLink === "string" ? (parsed.mockExcalidrawLink as string) : undefined,
|
|
174
|
+
mockPulls: Array.isArray(parsed.mockPulls) ? (parsed.mockPulls as ScenarioFixture["mockPulls"]) : [],
|
|
175
|
+
mockReviews: Array.isArray(parsed.mockReviews) ? (parsed.mockReviews as ScenarioFixture["mockReviews"]) : [],
|
|
176
|
+
mockReads: Array.isArray(parsed.mockReads) ? (parsed.mockReads as ScenarioFixture["mockReads"]) : [],
|
|
177
|
+
mockIssues: Array.isArray(parsed.mockIssues) ? (parsed.mockIssues as ScenarioFixture["mockIssues"]) : [],
|
|
178
|
+
settings: isRecord(parsed.settings) ? (parsed.settings as ScenarioFixture["settings"]) : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run web:dev`: the real loopback server with a fixture-backed service,
|
|
3
|
+
* so the page builds with no Pi. The scenario comes from `--scenario=<name>`
|
|
4
|
+
* or `WEB_SCENARIO` (default `full`); the printed link carries it as
|
|
5
|
+
* `?scenario=`. This is a dev CLI, so stdout is allowed here only — the
|
|
6
|
+
* production server still writes nothing. Rebuild with `npm run web:build`,
|
|
7
|
+
* then reload the page when `webui/dist` changes (the watcher says so).
|
|
8
|
+
*/
|
|
9
|
+
import { watch } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { loadConfig } from "../../state/project.ts";
|
|
13
|
+
import { startWebServer } from "../server.ts";
|
|
14
|
+
import { createFixtureService } from "./fake-service.ts";
|
|
15
|
+
import { resolveScenario } from "./fixtures.ts";
|
|
16
|
+
|
|
17
|
+
/** `--scenario=<name>` wins, then `WEB_SCENARIO`, then the default. */
|
|
18
|
+
function argScenario(): string | undefined {
|
|
19
|
+
const found = process.argv.slice(2).find((arg) => arg.startsWith("--scenario="));
|
|
20
|
+
return found ? found.slice("--scenario=".length) : process.env.WEB_SCENARIO;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Config `lobby.web.port` (0 means any free port); an unreadable config means 0. */
|
|
24
|
+
function configuredPort(): number {
|
|
25
|
+
try {
|
|
26
|
+
return loadConfig().lobby.web.port ?? 0;
|
|
27
|
+
} catch {
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The server link with the scenario in its query string. */
|
|
33
|
+
function withScenario(link: string, scenario: string): string {
|
|
34
|
+
return link.replace("#", `?scenario=${scenario}#`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Say when a rebuild lands, so the page gets reloaded; keep it to a line. */
|
|
38
|
+
function watchDist(): void {
|
|
39
|
+
const dist = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "webui", "dist");
|
|
40
|
+
try {
|
|
41
|
+
watch(dist, () => {
|
|
42
|
+
console.log("webui/dist changed — reload the page (rebuild: npm run web:build)");
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
console.log("webui/dist is missing — build it first: npm run web:build");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const scenario = resolveScenario(argScenario());
|
|
50
|
+
const service = createFixtureService(scenario);
|
|
51
|
+
const server = await startWebServer({ service, port: configuredPort() });
|
|
52
|
+
console.log(`bot-lobby mock [${scenario}]: ${withScenario(server.link, scenario)}`);
|
|
53
|
+
console.log("switch sets: WEB_SCENARIO=<name> npm run web:dev (or --scenario=<name>)");
|
|
54
|
+
watchDist();
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/events`, the page's server-sent event stream. Says `hello` with
|
|
3
|
+
* every topic's version on connect, then `changed {topic,version}` (at most
|
|
4
|
+
* one per topic per 40 ms), `feed` deltas (entries after the client's last
|
|
5
|
+
* ids, passed as query params) and the streaming `reply` text. At most 8
|
|
6
|
+
* streams; the oldest closes when a ninth opens. A `:ping` every 15 s keeps
|
|
7
|
+
* proxies and phones from closing an idle stream.
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
import { lobbyTopics, type LobbyTopic } from "../lobby/topics.ts";
|
|
11
|
+
import type { LobbyService } from "../lobby/service.ts";
|
|
12
|
+
import type { StreamEvent } from "./protocol.ts";
|
|
13
|
+
import { onNotice, watchFeed, type Notice } from "./notices.ts";
|
|
14
|
+
|
|
15
|
+
/** Concurrent streams; the oldest closes when one more opens. */
|
|
16
|
+
export const MAX_STREAMS = 8;
|
|
17
|
+
/** Topic changes share one message per topic per this many ms (the lobby's frame). */
|
|
18
|
+
export const COALESCE_MS = 40;
|
|
19
|
+
/** How often an idle stream says it is alive. */
|
|
20
|
+
export const HEARTBEAT_MS = 15_000;
|
|
21
|
+
|
|
22
|
+
interface Stream {
|
|
23
|
+
res: ServerResponse;
|
|
24
|
+
beat: ReturnType<typeof setInterval>;
|
|
25
|
+
activityAfter: number;
|
|
26
|
+
thoughtsAfter: number;
|
|
27
|
+
chatAfter: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function frame(event: StreamEvent): string {
|
|
31
|
+
return `data: ${JSON.stringify(event)}\n\n`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function idOf(entry: unknown): number {
|
|
35
|
+
return typeof (entry as { id?: unknown }).id === "number" ? ((entry as { id: number }).id as number) : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The event stream hub for one server; re-attached when the service changes. */
|
|
39
|
+
export class EventHub {
|
|
40
|
+
private streams: Stream[] = [];
|
|
41
|
+
private pending = new Map<LobbyTopic, number>();
|
|
42
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
private service: LobbyService | undefined;
|
|
44
|
+
private unsubTopics: (() => void) | undefined;
|
|
45
|
+
private unsubFeed: (() => void) | undefined;
|
|
46
|
+
private unsubFeedWatch: (() => void) | undefined;
|
|
47
|
+
private readonly unsubNotices: () => void;
|
|
48
|
+
private lastReply = "";
|
|
49
|
+
private readonly heartbeatMs: number;
|
|
50
|
+
constructor(heartbeatMs: number = HEARTBEAT_MS) {
|
|
51
|
+
this.heartbeatMs = heartbeatMs;
|
|
52
|
+
this.unsubNotices = onNotice((notice) => this.notice(notice));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Open streams (one per browser tab watching). */
|
|
56
|
+
clients(): number {
|
|
57
|
+
return this.streams.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Follow `service`'s topics and feed; open streams hear `hello` again. */
|
|
61
|
+
attach(service: LobbyService): void {
|
|
62
|
+
this.detach();
|
|
63
|
+
this.service = service;
|
|
64
|
+
this.unsubTopics = lobbyTopics.subscribe((topic, version) => this.changed(topic, version));
|
|
65
|
+
const feed = service.feed;
|
|
66
|
+
this.lastReply = feed.reply;
|
|
67
|
+
this.unsubFeed = feed.onChange(() => this.scheduleFlush());
|
|
68
|
+
this.unsubFeedWatch = watchFeed(feed);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Stop following the service (streams stay open across a rebind). */
|
|
72
|
+
detach(): void {
|
|
73
|
+
this.unsubTopics?.();
|
|
74
|
+
this.unsubFeed?.();
|
|
75
|
+
this.unsubFeedWatch?.();
|
|
76
|
+
this.unsubTopics = undefined;
|
|
77
|
+
this.unsubFeed = undefined;
|
|
78
|
+
this.unsubFeedWatch = undefined;
|
|
79
|
+
this.service = undefined;
|
|
80
|
+
if (this.timer) clearTimeout(this.timer);
|
|
81
|
+
this.timer = undefined;
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Open a stream; closes the oldest past the cap, says `hello`. */
|
|
86
|
+
add(_req: IncomingMessage, res: ServerResponse, query: { activityAfter?: number; thoughtsAfter?: number; chatAfter?: number }): void {
|
|
87
|
+
if (this.streams.length >= MAX_STREAMS) this.dropOldest();
|
|
88
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
89
|
+
const stream: Stream = {
|
|
90
|
+
res,
|
|
91
|
+
beat: setInterval(() => this.ping(stream), this.heartbeatMs),
|
|
92
|
+
activityAfter: query.activityAfter ?? 0,
|
|
93
|
+
thoughtsAfter: query.thoughtsAfter ?? 0,
|
|
94
|
+
chatAfter: query.chatAfter ?? 0,
|
|
95
|
+
};
|
|
96
|
+
if (typeof stream.beat === "object" && "unref" in stream.beat) (stream.beat as unknown as { unref(): void }).unref();
|
|
97
|
+
this.streams.push(stream);
|
|
98
|
+
res.write(`retry: 2000\n${frame({ type: "hello", versions: lobbyTopics.versions() })}`);
|
|
99
|
+
this.flushFeed(stream);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Forget a stream (its request closed). */
|
|
103
|
+
remove(res: ServerResponse): void {
|
|
104
|
+
const index = this.streams.findIndex((stream) => stream.res === res);
|
|
105
|
+
if (index < 0) return;
|
|
106
|
+
const [stream] = this.streams.splice(index, 1);
|
|
107
|
+
clearInterval(stream!.beat);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Say `hello` again on every open stream (after a rebind). */
|
|
111
|
+
hello(): void {
|
|
112
|
+
for (const stream of [...this.streams]) this.send(stream, frame({ type: "hello", versions: lobbyTopics.versions() }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A lobby notice: every open stream hears it, and `notices` moves on. */
|
|
116
|
+
private notice(notice: Notice): void {
|
|
117
|
+
const text = frame({ type: "notice", text: notice.text, level: notice.level });
|
|
118
|
+
for (const stream of [...this.streams]) this.send(stream, text);
|
|
119
|
+
lobbyTopics.bump("notices");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Close every stream and stop following the service. */
|
|
123
|
+
close(): void {
|
|
124
|
+
this.detach();
|
|
125
|
+
this.unsubNotices();
|
|
126
|
+
for (const stream of this.streams) {
|
|
127
|
+
clearInterval(stream.beat);
|
|
128
|
+
try {
|
|
129
|
+
stream.res.end();
|
|
130
|
+
} catch {
|
|
131
|
+
// Already gone; forgetting it is enough.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.streams = [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private dropOldest(): void {
|
|
138
|
+
const oldest = this.streams.shift();
|
|
139
|
+
if (!oldest) return;
|
|
140
|
+
clearInterval(oldest.beat);
|
|
141
|
+
try {
|
|
142
|
+
oldest.res.end();
|
|
143
|
+
} catch {
|
|
144
|
+
// Already gone; forgetting it is enough.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private send(stream: Stream, text: string): void {
|
|
149
|
+
try {
|
|
150
|
+
stream.res.write(text);
|
|
151
|
+
} catch {
|
|
152
|
+
this.remove(stream.res);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private ping(stream: Stream): void {
|
|
157
|
+
this.send(stream, ":ping\n\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A topic changed; at most one message per topic per coalescing window. */
|
|
161
|
+
changed(topic: LobbyTopic, version: number): void {
|
|
162
|
+
this.pending.set(topic, version);
|
|
163
|
+
this.scheduleFlush();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private scheduleFlush(): void {
|
|
167
|
+
if (this.timer) return;
|
|
168
|
+
this.timer = setTimeout(() => {
|
|
169
|
+
this.timer = undefined;
|
|
170
|
+
this.flushPending();
|
|
171
|
+
this.flushFeedAll();
|
|
172
|
+
}, COALESCE_MS);
|
|
173
|
+
if (typeof this.timer === "object" && "unref" in this.timer) (this.timer as unknown as { unref(): void }).unref();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private flushPending(): void {
|
|
177
|
+
const changed = [...this.pending];
|
|
178
|
+
this.pending.clear();
|
|
179
|
+
for (const [topic, version] of changed) {
|
|
180
|
+
const text = frame({ type: "changed", topic, version });
|
|
181
|
+
for (const stream of [...this.streams]) this.send(stream, text);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private flushFeedAll(): void {
|
|
186
|
+
for (const stream of [...this.streams]) this.flushFeed(stream);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** New feed entries past the stream's cursors, and the reply text when it moved. */
|
|
190
|
+
private flushFeed(stream: Stream): void {
|
|
191
|
+
const feed = this.service?.feed;
|
|
192
|
+
if (!feed) return;
|
|
193
|
+
const activity = feed.activity.filter((entry) => entry.id > stream.activityAfter);
|
|
194
|
+
const thoughts = feed.thoughts.filter((entry) => entry.id > stream.thoughtsAfter);
|
|
195
|
+
const chat = feed.chat.filter((entry) => entry.id > stream.chatAfter);
|
|
196
|
+
for (const entry of activity) stream.activityAfter = Math.max(stream.activityAfter, idOf(entry));
|
|
197
|
+
for (const entry of thoughts) stream.thoughtsAfter = Math.max(stream.thoughtsAfter, idOf(entry));
|
|
198
|
+
for (const entry of chat) stream.chatAfter = Math.max(stream.chatAfter, idOf(entry));
|
|
199
|
+
if (activity.length > 0 || thoughts.length > 0 || chat.length > 0) {
|
|
200
|
+
this.send(stream, frame({ type: "feed", activity, thoughts, chat }));
|
|
201
|
+
}
|
|
202
|
+
if (feed.reply !== this.lastReply) {
|
|
203
|
+
this.lastReply = feed.reply;
|
|
204
|
+
this.send(stream, frame({ type: "reply", text: feed.reply }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview images over HTTP: `GET /files/preview/<task>/<name>`. Only files
|
|
3
|
+
* under that task's preview dir (resolved with `realpath`, so symlinks cannot
|
|
4
|
+
* escape it), only `.png/.jpg/.jpeg/.gif/.webp`, with the right type and
|
|
5
|
+
* `nosniff`. The session cookie is required, like everywhere else.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerResponse } from "node:http";
|
|
8
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
9
|
+
import { extname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { previewDir } from "../ask/relay.ts";
|
|
12
|
+
import { applyBaseHeaders, HttpError, sendError } from "./api/index.ts";
|
|
13
|
+
|
|
14
|
+
const PREVIEW_TYPES: Record<string, string> = {
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function previewsRoot(): string {
|
|
23
|
+
return join(tmpdir(), "bot-lobby-previews");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether `task`/`name` name a file without reaching outside their folder. */
|
|
27
|
+
export function isPreviewName(task: string, name: string): boolean {
|
|
28
|
+
if (!/^[\w.-]+$/.test(task) || !/^[\w.-]+$/.test(name)) return false;
|
|
29
|
+
const ext = extname(name).toLowerCase();
|
|
30
|
+
return PREVIEW_TYPES[ext] !== undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An absolute image path under the previews root as its preview URL, if it is one. */
|
|
34
|
+
export function previewUrlFor(absPath: string): string | undefined {
|
|
35
|
+
const root = previewsRoot();
|
|
36
|
+
const resolved = resolve(absPath);
|
|
37
|
+
if (resolved !== root && !resolved.startsWith(root + sep)) return undefined;
|
|
38
|
+
const rel = relative(root, resolved);
|
|
39
|
+
const [task, name, ...rest] = rel.split(sep);
|
|
40
|
+
if (!task || !name || rest.length > 0 || !isPreviewName(task, name)) return undefined;
|
|
41
|
+
return `/files/preview/${task}/${name}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every absolute preview path in a prompt payload as its preview URL. */
|
|
45
|
+
export function rewritePreviewImages(payload: unknown): unknown {
|
|
46
|
+
if (typeof payload === "string") return previewUrlFor(payload) ?? payload;
|
|
47
|
+
if (Array.isArray(payload)) return payload.map(rewritePreviewImages);
|
|
48
|
+
if (payload && typeof payload === "object") {
|
|
49
|
+
return Object.fromEntries(Object.entries(payload as Record<string, unknown>).map(([key, value]) => [key, rewritePreviewImages(value)]));
|
|
50
|
+
}
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The preview file's bytes, jailed to its task's preview dir, or undefined when refused. */
|
|
55
|
+
export async function readPreview(task: string, name: string): Promise<{ body: Buffer; type: string } | undefined> {
|
|
56
|
+
if (!isPreviewName(task, name)) return undefined;
|
|
57
|
+
let real: string;
|
|
58
|
+
try {
|
|
59
|
+
real = await realpath(join(previewDir(task), name));
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
let base: string;
|
|
64
|
+
try {
|
|
65
|
+
base = await realpath(previewDir(task));
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (real !== base && !real.startsWith(base + sep)) return undefined;
|
|
70
|
+
try {
|
|
71
|
+
return { body: await readFile(real), type: PREVIEW_TYPES[extname(name).toLowerCase()]! };
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serve one preview; 401 without the cookie, 404 for anything jailed away. */
|
|
78
|
+
export async function servePreview(res: ServerResponse, task: string, name: string, signedIn: boolean): Promise<void> {
|
|
79
|
+
if (!signedIn) {
|
|
80
|
+
sendError(res, "unauthorized", "open the link Pi printed", 401);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const found = await readPreview(task, name);
|
|
84
|
+
if (!found) throw new HttpError(404, "not_found", "no such preview");
|
|
85
|
+
applyBaseHeaders(res);
|
|
86
|
+
res.writeHead(200, { "Content-Type": found.type, "Cache-Control": "no-store" });
|
|
87
|
+
res.end(found.body);
|
|
88
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lobby notices that reach the web page as toasts. Two sources: the `notice`
|
|
3
|
+
* on an action's API reply, and the feed's LOBBY warnings and errors (info
|
|
4
|
+
* and success stay activity lines, not toasts). A later web server forwards
|
|
5
|
+
* each one on the event stream and bumps the `notices` topic. Process-global
|
|
6
|
+
* like the feed, so a notice raised before the server starts is simply lost.
|
|
7
|
+
*/
|
|
8
|
+
import type { ActivityKind, LobbyFeed } from "../lobby/feed.ts";
|
|
9
|
+
|
|
10
|
+
export type NoticeLevel = "info" | "success" | "warning" | "error";
|
|
11
|
+
|
|
12
|
+
export interface Notice {
|
|
13
|
+
text: string;
|
|
14
|
+
level: NoticeLevel;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type NoticeListener = (notice: Notice) => void;
|
|
18
|
+
|
|
19
|
+
const listeners = new Set<NoticeListener>();
|
|
20
|
+
|
|
21
|
+
/** Tell every listener; blank text is dropped. */
|
|
22
|
+
export function pushNotice(text: string, level: NoticeLevel = "info"): void {
|
|
23
|
+
const body = text.trim();
|
|
24
|
+
if (!body) return;
|
|
25
|
+
for (const listener of [...listeners]) listener({ text: body, level });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Hear every notice; returns the unsubscribe function. */
|
|
29
|
+
export function onNotice(listener: NoticeListener): () => void {
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
return () => {
|
|
32
|
+
listeners.delete(listener);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `notice` string on an API reply, when it carries one. */
|
|
37
|
+
export function noticeText(result: unknown): string | undefined {
|
|
38
|
+
if (typeof result !== "object" || result === null) return undefined;
|
|
39
|
+
const notice = (result as { notice?: unknown }).notice;
|
|
40
|
+
return typeof notice === "string" && notice.trim() ? notice : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Forward the feed's LOBBY warnings and errors as notices. Entries already in
|
|
45
|
+
* the feed when this is called are not replayed, so a rebind does not re-toast
|
|
46
|
+
* the log; returns the unsubscribe function.
|
|
47
|
+
*/
|
|
48
|
+
export function watchFeed(feed: LobbyFeed): () => void {
|
|
49
|
+
let after = feed.activity.at(-1)?.id ?? 0;
|
|
50
|
+
return feed.onChange(() => {
|
|
51
|
+
for (const entry of feed.activity) {
|
|
52
|
+
if (entry.id <= after) continue;
|
|
53
|
+
if (entry.source === "LOBBY" && (entry.kind === "warning" || entry.kind === "error")) pushNotice(entry.text, kindLevel(entry.kind));
|
|
54
|
+
}
|
|
55
|
+
after = feed.activity.at(-1)?.id ?? after;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function kindLevel(kind: ActivityKind): NoticeLevel {
|
|
60
|
+
return kind;
|
|
61
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL in the platform's browser: Termux, macOS, Windows or Linux.
|
|
3
|
+
* Over an SSH session there is no browser to open, so nothing is attempted.
|
|
4
|
+
* Detached, errors ignored, never throws: true when a browser was attempted.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
export interface OpenDeps {
|
|
9
|
+
spawn?: typeof spawn;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
platform?: NodeJS.Platform;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function launch(run: typeof spawn, command: string, args: string[]): boolean {
|
|
15
|
+
try {
|
|
16
|
+
const child = run(command, args, { detached: true, stdio: "ignore" });
|
|
17
|
+
child.on("error", () => {});
|
|
18
|
+
child.unref();
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Open `url` in the desktop browser; false when none was attempted. */
|
|
26
|
+
export function openBrowser(url: string, deps?: OpenDeps): boolean {
|
|
27
|
+
const run = deps?.spawn ?? spawn;
|
|
28
|
+
const env = deps?.env ?? process.env;
|
|
29
|
+
const platform = deps?.platform ?? process.platform;
|
|
30
|
+
if (env.SSH_CONNECTION || env.SSH_TTY) return false;
|
|
31
|
+
if (env.TERMUX_VERSION) return launch(run, "termux-open-url", [url]);
|
|
32
|
+
if (platform === "darwin") return launch(run, "open", [url]);
|
|
33
|
+
if (platform === "win32") return launch(run, "cmd", ["/c", "start", '""', url]);
|
|
34
|
+
return launch(run, "xdg-open", [url]);
|
|
35
|
+
}
|