@getfamiliar/plugin-web-chat 0.0.0-stage → 0.0.1

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 CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # Web chat plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Chat with familiar in the web UI. The page opens as the start page of the web UI (menu entry "Chat", weight -100) and runs on the chat channel `web`.
4
+
5
+ Until its first npm release the plugin is `private` and not `familiar.bundled`; enable it by adding `@getfamiliar/plugin-web-chat` to `config/plugins`. It needs the web UI (`web-ui` plugin, `webserver.enabled: true`).
6
+
7
+ ## Features
8
+
9
+ On par with `familiar cli-chat`, plus what a browser does better:
10
+
11
+ - **Messages** go to the handler `chat/web.md` (topic `chat:web`, shipped as a workspace template). They run `privileged`, because only logins in `webserver.auth.users` get in.
12
+ - **Direct handler calls**: `/topic/sub …` runs that handler exactly (`/mail/draft to the dentist`). Typing `/` suggests the handlers of your workspace: ↑/↓ to pick, Tab or Enter to complete, Esc to close. An unknown path is refused before anything is sent. A call whose handler writes no chat reply gets its result text as the reply, as in cli-chat.
13
+ - **Live progress** of every message you send, like cli-chat's spinner lines: agentruns (topic, model, state, subagents), each finished step with reasoning, intermediate text and every tool call with its arguments and result. While it runs the trace is open; afterwards it collapses into "Done in N steps, M tool calls" and can be expanded again. Progress is kept for a few minutes, so a reload still shows a turn in flight.
14
+ - **Proactive messages** (`send_chat`, cron handlers) show up live when `core.defaultChatChannel` is `web`. Messages that arrived while no tab was open are part of the history.
15
+ - **Failures** arrive as a chat message ("Something went wrong processing the chat message: …"), and the trace turns red.
16
+ - **History** of the `web` channel with "Earlier messages" paging. Replies are rendered as GitHub-flavored markdown with highlighted, copyable code blocks.
17
+ - **Files in both directions.**
18
+ - Drop files anywhere on the chat, paste them, or pick them with the paperclip. They show as removable thumbnails above the input and go with the next message, at most 20 files and 50 MB per message.
19
+ - They land in the event's scratch folder (`/scratch/<event-id>/`), where the assistant reads them. The message text records them as `[Attached: …]`, so the chat history keeps them.
20
+ - Every file the assistant writes directly into that folder is offered as a download under its reply. Files the user attached are shown with their message instead.
21
+ - Hidden files (leading `.`, e.g. spilled tool results) are never offered.
22
+ - The handler `chat/web.md` tells the assistant about this.
23
+ - `/clear` hides the transcript in this view. The assistant keeps its history, as in cli-chat. ↑/↓ on a single-line input walks through your earlier messages. Several tabs stay in sync.
24
+
25
+ Not (yet) possible:
26
+ - **No stopping a reply.** The core has no cancel for a running event yet.
27
+ - **No voice input.**
28
+ - **No streaming.** A reply appears when it is complete; the progress is visible meanwhile.
29
+
30
+ ## How it works
31
+
32
+ - `src/host/ChatHub.ts` holds the one chat-bus subscription for the channel `web` and fans it out to every browser tab. The bus marks a message delivered once, so tabs must not subscribe individually. It also sends your input as events (same rules as cli-chat) and tracks their agentruns and steps.
33
+ - `src/host/ChatController.ts` is mounted by the core at `/plugins/web-chat`, behind the web UI authentication:
34
+ - `GET /api/messages?before=&limit=` returns a history page (`ctx.chat.listMessages`);
35
+ - `POST /api/messages {text}` sends a line and answers `{ eventId }`, or 400 `{ error }`;
36
+ - `GET /api/handlers` returns the slash paths for autocompletion;
37
+ - `POST /api/messages` also takes `multipart/form-data` (`text` plus `files`), checked against the limits;
38
+ - `GET /api/files/:eventId/:name` downloads a visible scratch file. Only raster images go out inline; everything else is an attachment as `application/octet-stream`, with `nosniff` and a sandboxing CSP, so an uploaded or generated HTML/SVG file can never run as a page of the web UI's origin;
39
+ - `GET /api/events` is the server-sent events stream (`snapshot`, `message`, `turn`, `files`), with a keep-alive every 25 s;
40
+ - `GET /page/*` serves the built page module.
41
+ - `src/host/ChatProtocol.ts` holds the wire types, which the page imports type-only.
42
+ - `src/host/ChatFiles.ts` sanitizes upload names and keeps track of which files were uploads: the hidden manifest `.web-chat-uploads.json` in the scratch folder lists them.
43
+ - `src/page.tsx` is the page module the web UI mounts (`mount(container, context)`). Its stylesheet is scoped to the page container with `@scope` (`src/lib/scoped-style.ts`). The web UI and the page each bring a Tailwind build, and unscoped, the page's utilities would override the web UI's (e.g. `.hidden` beating the sidebar's `md:block`). For the same reason the page renders no portals: no Radix tooltips/popovers, use `title` or inline elements instead. The UI is built from the shadcn chatbot template's primitives in `src/components/ui/` (`message`, `message-scroller`, `bubble`, `trace`, `input-group`, `code-block`, `empty`, `alert`; generated by shadcn, excluded from Biome together with `lib/highlighter.tsx` and `hooks/use-highlighted-code.ts`). Our own components live in `src/components/`, the data in `src/hooks/use-chat.ts`.
44
+
45
+ ## Development
46
+
47
+ In a dev run (`npm run dev -- start`) the web UI loads `src/page.tsx` from source through its Vite server, so edits show up through hot module replacement without losing the chat state. Keep component files free of non-component exports (helpers go to `src/lib/`), or an edit falls back to a full reload. Host-side changes (`src/host/`) need a daemon restart.
48
+
49
+ `npm run build` compiles the host side to `build/` and bundles the page into `build/page/page.js` (React included; shiki grammars as lazily loaded chunks).
@@ -0,0 +1,25 @@
1
+ import { type WebUiEnv } from "@getfamiliar/shared";
2
+ import { type Context, Hono } from "hono";
3
+ import { type ChatHub } from "./ChatHub.js";
4
+ /** Serves the built page module (`build/page`). */
5
+ export interface PageAssetHandler {
6
+ handle(c: Context): Promise<Response>;
7
+ }
8
+ /**
9
+ * The web-chat controller, mounted by the core at `/plugins/web-chat`
10
+ * behind its authentication. Concrete routes only — the page's browser
11
+ * routes (`/plugins/web-chat`) fall through to the web UI.
12
+ *
13
+ * - `GET /api/messages?before=<id>&limit=<n>` — history page of the web channel
14
+ * - `POST /api/messages` — send a line: JSON `{ text }`, or `multipart/form-data`
15
+ * with `text` and any number of `files` (`{ eventId }`, or 400 / 413 `{ error }`)
16
+ * - `GET /api/files/:eventId/:name` — download a file of an event's scratch folder
17
+ * - `GET /api/handlers` — slash paths for autocompletion
18
+ * - `GET /api/events` — server-sent events (`snapshot`, `message`, `turn`)
19
+ * - `GET /page/*` — the page module
20
+ *
21
+ * @param hub The chat hub (resolved per request, so a restart swaps it).
22
+ * @param page Static handler for the built page.
23
+ * @returns The controller.
24
+ */
25
+ export declare function buildChatController(hub: () => ChatHub, page: PageAssetHandler): Hono<WebUiEnv>;
@@ -0,0 +1,170 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { Readable } from "node:stream";
4
+ import { contentTypeForFile } from "@getfamiliar/shared";
5
+ import { Hono } from "hono";
6
+ import { bodyLimit } from "hono/body-limit";
7
+ import { streamSSE } from "hono/streaming";
8
+ import { ChatInputError, MAX_HISTORY_PAGE } from "./ChatHub.js";
9
+ import { MAX_UPLOAD_BYTES } from "./ChatProtocol.js";
10
+ /** Room for the multipart framing and the text on top of the attachments. */
11
+ const BODY_OVERHEAD_BYTES = 1024 * 1024;
12
+ /** Default history page size. */
13
+ const DEFAULT_HISTORY_PAGE = 50;
14
+ /** Interval of SSE keep-alive comments (proxies drop idle connections). */
15
+ const KEEP_ALIVE_MS = 25_000;
16
+ /**
17
+ * The web-chat controller, mounted by the core at `/plugins/web-chat`
18
+ * behind its authentication. Concrete routes only — the page's browser
19
+ * routes (`/plugins/web-chat`) fall through to the web UI.
20
+ *
21
+ * - `GET /api/messages?before=<id>&limit=<n>` — history page of the web channel
22
+ * - `POST /api/messages` — send a line: JSON `{ text }`, or `multipart/form-data`
23
+ * with `text` and any number of `files` (`{ eventId }`, or 400 / 413 `{ error }`)
24
+ * - `GET /api/files/:eventId/:name` — download a file of an event's scratch folder
25
+ * - `GET /api/handlers` — slash paths for autocompletion
26
+ * - `GET /api/events` — server-sent events (`snapshot`, `message`, `turn`)
27
+ * - `GET /page/*` — the page module
28
+ *
29
+ * @param hub The chat hub (resolved per request, so a restart swaps it).
30
+ * @param page Static handler for the built page.
31
+ * @returns The controller.
32
+ */
33
+ export function buildChatController(hub, page) {
34
+ const app = new Hono();
35
+ app.get("/api/messages", async (c) => {
36
+ const before = c.req.query("before");
37
+ const limit = Number(c.req.query("limit") ?? DEFAULT_HISTORY_PAGE);
38
+ if (before !== undefined && !/^\d+$/.test(before)) {
39
+ return c.json({ error: "before must be a message id" }, 400);
40
+ }
41
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_PAGE) {
42
+ return c.json({ error: `limit must be between 1 and ${MAX_HISTORY_PAGE}` }, 400);
43
+ }
44
+ return c.json(await hub().history(before, limit));
45
+ });
46
+ app.post("/api/messages", bodyLimit({
47
+ maxSize: MAX_UPLOAD_BYTES + BODY_OVERHEAD_BYTES,
48
+ onError: (c) => c.json({
49
+ error: `the message is too large; at most ${MAX_UPLOAD_BYTES / 1024 / 1024} MB of attachments`,
50
+ }, 413),
51
+ }), async (c) => {
52
+ const input = await readSendInput(c);
53
+ if ("error" in input) {
54
+ return c.json({ error: input.error }, 400);
55
+ }
56
+ try {
57
+ return c.json({ eventId: await hub().send(input.text, input.uploads) }, 202);
58
+ }
59
+ catch (err) {
60
+ if (err instanceof ChatInputError) {
61
+ return c.json({ error: err.message }, 400);
62
+ }
63
+ throw err;
64
+ }
65
+ });
66
+ app.get("/api/files/:eventId/:name", async (c) => {
67
+ const file = await hub().download(c.req.param("eventId"), c.req.param("name"));
68
+ if (file === null) {
69
+ return c.json({ error: "no such file" }, 404);
70
+ }
71
+ return sendDownload(file);
72
+ });
73
+ app.get("/api/handlers", async (c) => c.json(await hub().handlerPaths()));
74
+ app.get("/api/events", (c) => streamSSE(c, async (stream) => {
75
+ // Serialize writes: hub events and keep-alives must not interleave.
76
+ let queue = Promise.resolve();
77
+ const send = (event) => {
78
+ queue = queue
79
+ .then(() => stream.writeSSE({ event: event.type, data: JSON.stringify(event) }))
80
+ .catch(() => stream.abort());
81
+ };
82
+ const disconnect = hub().addClient(send);
83
+ stream.onAbort(disconnect);
84
+ while (!stream.aborted && !stream.closed) {
85
+ await stream.sleep(KEEP_ALIVE_MS);
86
+ queue = queue
87
+ .then(() => stream.write(": keep-alive\n\n"))
88
+ .then(() => undefined, () => stream.abort());
89
+ }
90
+ disconnect();
91
+ }));
92
+ app.get("/page/*", (c) => page.handle(c));
93
+ return app;
94
+ }
95
+ /**
96
+ * Read the text and attachments of a send request (JSON or multipart).
97
+ *
98
+ * @param c Request context.
99
+ * @returns The input, or an error message for a malformed request.
100
+ */
101
+ async function readSendInput(c) {
102
+ const contentType = c.req.header("content-type") ?? "";
103
+ if (contentType.startsWith("multipart/form-data")) {
104
+ let body;
105
+ try {
106
+ body = await c.req.parseBody({ all: true });
107
+ }
108
+ catch {
109
+ return { error: "malformed multipart body" };
110
+ }
111
+ const text = typeof body.text === "string" ? body.text : "";
112
+ const rawFiles = body.files === undefined ? [] : [body.files].flat();
113
+ const uploads = [];
114
+ for (const file of rawFiles) {
115
+ if (!(file instanceof File)) {
116
+ return { error: "files must be file parts" };
117
+ }
118
+ uploads.push({ name: file.name, contents: Buffer.from(await file.arrayBuffer()) });
119
+ }
120
+ return { text, uploads };
121
+ }
122
+ let text;
123
+ try {
124
+ text = (await c.req.json()).text;
125
+ }
126
+ catch {
127
+ return { error: "expected a JSON body { text } or multipart/form-data" };
128
+ }
129
+ if (typeof text !== "string") {
130
+ return { error: "text must be a string" };
131
+ }
132
+ return { text, uploads: [] };
133
+ }
134
+ /**
135
+ * Stream a scratch file. Only raster images are shown inline; everything
136
+ * else is a download, served with `nosniff` and a sandboxing CSP, so a
137
+ * file the assistant or the user put there (HTML, SVG, …) can never run
138
+ * as a page of this origin.
139
+ *
140
+ * @param file The file.
141
+ * @returns The response (404 when it vanished meanwhile).
142
+ */
143
+ async function sendDownload(file) {
144
+ let size;
145
+ try {
146
+ size = (await stat(file.path)).size;
147
+ }
148
+ catch {
149
+ return new Response(JSON.stringify({ error: "no such file" }), {
150
+ status: 404,
151
+ headers: { "content-type": "application/json" },
152
+ });
153
+ }
154
+ const disposition = file.isInline ? "inline" : "attachment";
155
+ const asciiName = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
156
+ const stream = Readable.toWeb(createReadStream(file.path));
157
+ return new Response(stream, {
158
+ status: 200,
159
+ headers: {
160
+ "content-type": file.isInline
161
+ ? contentTypeForFile(file.name)
162
+ : "application/octet-stream",
163
+ "content-length": String(size),
164
+ "content-disposition": `${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
165
+ "x-content-type-options": "nosniff",
166
+ "content-security-policy": "default-src 'none'; sandbox",
167
+ "cache-control": "private, max-age=3600",
168
+ },
169
+ });
170
+ }
@@ -0,0 +1,60 @@
1
+ import { type EventFile, type HostContext } from "@getfamiliar/shared";
2
+ import type { ChatFileDto } from "./ChatProtocol.js";
3
+ /**
4
+ * Bookkeeping file in an event's scratch folder: which files the user
5
+ * attached (hidden, so neither the model's file listing nor the downloads
6
+ * show it).
7
+ */
8
+ export declare const UPLOAD_MANIFEST = ".web-chat-uploads.json";
9
+ /** A file the user attached to a message. */
10
+ export interface ChatUpload {
11
+ readonly name: string;
12
+ readonly contents: Buffer;
13
+ }
14
+ /**
15
+ * Turn a browser-supplied file name into a safe scratch basename: no
16
+ * path separators or control characters, no leading dots (those are
17
+ * internal files), at most {@link MAX_NAME_LENGTH} characters with the
18
+ * extension kept.
19
+ *
20
+ * @param raw Name from the upload.
21
+ * @returns A non-empty basename.
22
+ */
23
+ export declare function sanitizeUploadName(raw: string): string;
24
+ /**
25
+ * The event files for a message's uploads: sanitized, de-duplicated names
26
+ * (`photo.jpg`, `photo-2.jpg`) plus the upload manifest.
27
+ *
28
+ * @param uploads The attached files.
29
+ * @returns Files for `NewEvent.files` and the final names (in input order).
30
+ */
31
+ export declare function buildUploadFiles(uploads: readonly ChatUpload[]): {
32
+ files: EventFile[];
33
+ names: string[];
34
+ };
35
+ /**
36
+ * Whether a file may be shown inline in the browser.
37
+ *
38
+ * @param name File name.
39
+ * @returns True for raster images.
40
+ */
41
+ export declare function isInlineImage(name: string): boolean;
42
+ /**
43
+ * Download URL of an event file.
44
+ *
45
+ * @param apiBase Mount of the chat API (`/plugins/web-chat/api`).
46
+ * @param eventId Event id.
47
+ * @param name File name.
48
+ * @returns The URL.
49
+ */
50
+ export declare function fileUrl(apiBase: string, eventId: string, name: string): string;
51
+ /**
52
+ * The files of an event for the chat: every visible file of its scratch
53
+ * folder, marked as upload when the manifest lists it.
54
+ *
55
+ * @param scratch Host scratch API.
56
+ * @param apiBase Mount of the chat API, for the download URLs.
57
+ * @param eventId Event id.
58
+ * @returns The files, uploads first, each group by name.
59
+ */
60
+ export declare function listChatFiles(scratch: Pick<HostContext["scratch"], "listFiles" | "resolveFile">, apiBase: string, eventId: string): Promise<ChatFileDto[]>;
@@ -0,0 +1,146 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { isHiddenScratchFile } from "@getfamiliar/shared";
3
+ /**
4
+ * Bookkeeping file in an event's scratch folder: which files the user
5
+ * attached (hidden, so neither the model's file listing nor the downloads
6
+ * show it).
7
+ */
8
+ export const UPLOAD_MANIFEST = ".web-chat-uploads.json";
9
+ /** Longest file name kept (characters, extension included). */
10
+ const MAX_NAME_LENGTH = 120;
11
+ /** Extensions the browser may show inline (raster images only — no SVG, it can carry script). */
12
+ const INLINE_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"]);
13
+ /**
14
+ * Turn a browser-supplied file name into a safe scratch basename: no
15
+ * path separators or control characters, no leading dots (those are
16
+ * internal files), at most {@link MAX_NAME_LENGTH} characters with the
17
+ * extension kept.
18
+ *
19
+ * @param raw Name from the upload.
20
+ * @returns A non-empty basename.
21
+ */
22
+ export function sanitizeUploadName(raw) {
23
+ const base = raw.split(/[/\\]/).pop() ?? "";
24
+ let name = [...base]
25
+ .filter((char) => !isControlCharacter(char))
26
+ .join("")
27
+ .trim()
28
+ .replace(/^\.+/, "");
29
+ if (name === "") {
30
+ name = "file";
31
+ }
32
+ if (name.length > MAX_NAME_LENGTH) {
33
+ const dot = name.lastIndexOf(".");
34
+ const extension = dot > 0 && name.length - dot <= 16 ? name.slice(dot) : "";
35
+ name = name.slice(0, MAX_NAME_LENGTH - extension.length) + extension;
36
+ }
37
+ return name;
38
+ }
39
+ /**
40
+ * Whether a character is an ASCII control character (C0 or DEL).
41
+ *
42
+ * @param char One character.
43
+ * @returns True for control characters.
44
+ */
45
+ function isControlCharacter(char) {
46
+ const code = char.codePointAt(0) ?? 0;
47
+ return code < 0x20 || code === 0x7f;
48
+ }
49
+ /**
50
+ * The event files for a message's uploads: sanitized, de-duplicated names
51
+ * (`photo.jpg`, `photo-2.jpg`) plus the upload manifest.
52
+ *
53
+ * @param uploads The attached files.
54
+ * @returns Files for `NewEvent.files` and the final names (in input order).
55
+ */
56
+ export function buildUploadFiles(uploads) {
57
+ const taken = new Set();
58
+ const names = uploads.map((upload) => {
59
+ const name = sanitizeUploadName(upload.name);
60
+ let unique = name;
61
+ for (let counter = 2; taken.has(unique.toLowerCase()); counter++) {
62
+ const dot = name.lastIndexOf(".");
63
+ unique =
64
+ dot > 0
65
+ ? `${name.slice(0, dot)}-${counter}${name.slice(dot)}`
66
+ : `${name}-${counter}`;
67
+ }
68
+ taken.add(unique.toLowerCase());
69
+ return unique;
70
+ });
71
+ if (uploads.length === 0) {
72
+ return { files: [], names };
73
+ }
74
+ const files = uploads.map((upload, index) => ({
75
+ name: names[index],
76
+ contents: upload.contents,
77
+ }));
78
+ files.push({
79
+ name: UPLOAD_MANIFEST,
80
+ contents: Buffer.from(JSON.stringify({ uploads: names })),
81
+ });
82
+ return { files, names };
83
+ }
84
+ /**
85
+ * Whether a file may be shown inline in the browser.
86
+ *
87
+ * @param name File name.
88
+ * @returns True for raster images.
89
+ */
90
+ export function isInlineImage(name) {
91
+ const dot = name.lastIndexOf(".");
92
+ return dot > 0 && INLINE_IMAGE_EXTENSIONS.has(name.slice(dot).toLowerCase());
93
+ }
94
+ /**
95
+ * Download URL of an event file.
96
+ *
97
+ * @param apiBase Mount of the chat API (`/plugins/web-chat/api`).
98
+ * @param eventId Event id.
99
+ * @param name File name.
100
+ * @returns The URL.
101
+ */
102
+ export function fileUrl(apiBase, eventId, name) {
103
+ return `${apiBase}/files/${eventId}/${encodeURIComponent(name)}`;
104
+ }
105
+ /**
106
+ * The files of an event for the chat: every visible file of its scratch
107
+ * folder, marked as upload when the manifest lists it.
108
+ *
109
+ * @param scratch Host scratch API.
110
+ * @param apiBase Mount of the chat API, for the download URLs.
111
+ * @param eventId Event id.
112
+ * @returns The files, uploads first, each group by name.
113
+ */
114
+ export async function listChatFiles(scratch, apiBase, eventId) {
115
+ const all = await scratch.listFiles(eventId);
116
+ const uploads = all.some((file) => file.name === UPLOAD_MANIFEST)
117
+ ? await readManifest(scratch.resolveFile(eventId, UPLOAD_MANIFEST))
118
+ : new Set();
119
+ return all
120
+ .filter((file) => !isHiddenScratchFile(file.name))
121
+ .map((file) => ({
122
+ name: file.name,
123
+ size: file.size,
124
+ url: fileUrl(apiBase, eventId, file.name),
125
+ isImage: isInlineImage(file.name),
126
+ isUpload: uploads.has(file.name),
127
+ }))
128
+ .sort((a, b) => Number(b.isUpload) - Number(a.isUpload) || a.name.localeCompare(b.name));
129
+ }
130
+ /**
131
+ * Read the upload manifest.
132
+ *
133
+ * @param file Host path of the manifest.
134
+ * @returns The uploaded names (empty for a missing or malformed manifest).
135
+ */
136
+ async function readManifest(file) {
137
+ try {
138
+ const parsed = JSON.parse(await readFile(file, "utf8"));
139
+ return new Set(Array.isArray(parsed.uploads)
140
+ ? parsed.uploads.filter((name) => typeof name === "string")
141
+ : []);
142
+ }
143
+ catch {
144
+ return new Set();
145
+ }
146
+ }
@@ -0,0 +1,205 @@
1
+ import { type HostContext, type Logger, type NewEvent, type ParsedChatInput } from "@getfamiliar/shared";
2
+ import { type ChatUpload } from "./ChatFiles.js";
3
+ import { type ChatFileDto, type ChatHistoryDto, type ChatServerEvent } from "./ChatProtocol.js";
4
+ /** Largest history page the API serves. */
5
+ export declare const MAX_HISTORY_PAGE = 100;
6
+ /** The handler catalog operations the hub needs (`HandlerCatalog` from shared). */
7
+ export interface ChatHandlerCatalog {
8
+ list(): Promise<readonly {
9
+ readonly slashPath: string;
10
+ }[]>;
11
+ resolve(topic: string, options: {
12
+ readonly isExactTopic: boolean;
13
+ }): Promise<unknown | null>;
14
+ }
15
+ /** What the hub needs from the host. */
16
+ export interface ChatHubDeps {
17
+ readonly chat: HostContext["chat"];
18
+ readonly events: HostContext["events"];
19
+ readonly handlers: ChatHandlerCatalog;
20
+ readonly scratch: Pick<HostContext["scratch"], "listFiles" | "resolveFile">;
21
+ /** Mount of the chat API (`/plugins/web-chat/api`), for download URLs. */
22
+ readonly apiBase: string;
23
+ readonly log: Logger;
24
+ }
25
+ /** A file to stream to the browser. */
26
+ export interface ChatDownload {
27
+ /** Host path. */
28
+ readonly path: string;
29
+ readonly name: string;
30
+ /** Whether it may be shown inline (raster image). */
31
+ readonly isInline: boolean;
32
+ }
33
+ /** A browser connection receiving server events. */
34
+ export type ChatClient = (event: ChatServerEvent) => void;
35
+ /** A user input the hub refuses (answered with 400). */
36
+ export declare class ChatInputError extends Error {
37
+ constructor(message: string);
38
+ }
39
+ /**
40
+ * The web chat's server side: one subscription to the chat bus for the
41
+ * `web` channel, fanned out to every connected browser tab (the bus marks
42
+ * a row delivered once, so tabs must not subscribe individually), plus
43
+ * sending user input as events and tracking their progress.
44
+ *
45
+ * Mirrors cli-chat: plain messages go to `chat:web`, `/topic/sub …` lines
46
+ * are direct handler calls (validated against the workspace), events are
47
+ * `privileged` (only allowlisted users reach the web UI), failures are
48
+ * written to the chat, and a direct call that produced no chat reply gets
49
+ * its result text appended as the reply.
50
+ *
51
+ * Files: attachments of a message are staged into the event's scratch
52
+ * folder (with a hidden manifest marking them as uploads); every visible
53
+ * file in that folder is offered for download in the chat — the
54
+ * attachments at the user's message, everything else under the reply.
55
+ */
56
+ export declare class ChatHub {
57
+ private readonly deps;
58
+ private readonly clients;
59
+ private readonly turns;
60
+ /** Events of in-flight turns that already got an assistant message. */
61
+ private readonly answeredEvents;
62
+ private readonly retentionTimers;
63
+ private unsubscribe;
64
+ /**
65
+ * @param deps Host services.
66
+ */
67
+ constructor(deps: ChatHubDeps);
68
+ /**
69
+ * Subscribe to the `web` channel. Undelivered messages replayed by the
70
+ * bus are broadcast like live ones (there is nobody to show them to
71
+ * yet, so assistant messages stay undelivered until a tab is open).
72
+ */
73
+ start(): Promise<void>;
74
+ /** Drop the subscription, the turns and every client. */
75
+ stop(): Promise<void>;
76
+ /**
77
+ * Connect a browser tab. It first receives a snapshot of the turns in
78
+ * flight, then every event.
79
+ *
80
+ * @param client Event sink of the connection.
81
+ * @returns Disconnect function.
82
+ */
83
+ addClient(client: ChatClient): () => void;
84
+ /**
85
+ * One page of the web channel's history.
86
+ *
87
+ * @param beforeId Cursor (message id) or undefined for the newest page.
88
+ * @param limit Page size, 1 … {@link MAX_HISTORY_PAGE}.
89
+ * @returns The page and whether older messages exist.
90
+ */
91
+ history(beforeId: string | undefined, limit: number): Promise<ChatHistoryDto>;
92
+ /**
93
+ * Slash paths for autocompletion.
94
+ *
95
+ * @returns Handler paths and the page's builtins.
96
+ */
97
+ handlerPaths(): Promise<{
98
+ handlers: string[];
99
+ builtins: string[];
100
+ }>;
101
+ /**
102
+ * Send one typed line, with optional attachments, as an event.
103
+ *
104
+ * @param text The line (may be empty when files are attached).
105
+ * @param uploads Attached files, staged into the event's scratch folder.
106
+ * @returns The event id.
107
+ * @throws {ChatInputError} For an empty message, a builtin (handled by
108
+ * the page), an unknown handler path or too many / too large files.
109
+ */
110
+ send(text: string, uploads?: readonly ChatUpload[]): Promise<string>;
111
+ /**
112
+ * The visible files of an event (see {@link listChatFiles}).
113
+ *
114
+ * @param eventId Event id.
115
+ * @returns The files; empty when listing fails (logged).
116
+ */
117
+ filesOf(eventId: string): Promise<ChatFileDto[]>;
118
+ /**
119
+ * Locate a visible file of an event for download.
120
+ *
121
+ * @param eventId Event id (digits).
122
+ * @param name File name.
123
+ * @returns The file, or null when it is unknown, hidden or not a file.
124
+ */
125
+ download(eventId: string, name: string): Promise<ChatDownload | null>;
126
+ /**
127
+ * List an event's files and broadcast them (after a reply or when its
128
+ * turn finished — the assistant may have produced files).
129
+ *
130
+ * @param eventId Event id.
131
+ */
132
+ private broadcastFiles;
133
+ /**
134
+ * Bus handler: broadcast the message. User rows count as delivered
135
+ * (they came from the user); assistant rows only when a tab shows them.
136
+ *
137
+ * @param message The stored message.
138
+ * @returns Whether to mark it delivered.
139
+ */
140
+ private onChatMessage;
141
+ /**
142
+ * Record an agentrun state change of a turn.
143
+ *
144
+ * @param turn The turn.
145
+ * @param row The agentrun row.
146
+ */
147
+ private onAgentRun;
148
+ /**
149
+ * Record a finished step of a turn.
150
+ *
151
+ * @param turn The turn.
152
+ * @param step The step row.
153
+ */
154
+ private onStep;
155
+ /**
156
+ * The event finished: append the result text as the reply when the
157
+ * run produced no chat message (direct calls to non-chat handlers).
158
+ *
159
+ * @param turn The turn.
160
+ * @param resultText Result text of the last agentrun.
161
+ */
162
+ private onSettled;
163
+ /**
164
+ * The event failed (the host already wrote the failure to the chat).
165
+ *
166
+ * @param turn The turn.
167
+ * @param err The rejection.
168
+ */
169
+ private onFailed;
170
+ /**
171
+ * Mark a turn finished, broadcast it, and drop it after the retention time.
172
+ *
173
+ * @param turn The turn.
174
+ * @param state Final state.
175
+ * @param error Failure text or null.
176
+ */
177
+ private finishTurn;
178
+ /**
179
+ * Broadcast a turn's full state (once its event id is known).
180
+ *
181
+ * @param turn The turn.
182
+ */
183
+ private broadcastTurn;
184
+ /**
185
+ * Send an event to every connected tab. A failing client is dropped.
186
+ *
187
+ * @param event The event.
188
+ */
189
+ private broadcast;
190
+ }
191
+ /**
192
+ * The event for one typed line, as cli-chat builds it: plain messages are
193
+ * chat events on `chat:web`; direct handler calls are exact-topic runs
194
+ * whose typed line is stored as the user message.
195
+ *
196
+ * Attachments are named in the text, so the transcript (and the model's
197
+ * chat history) records them even when the line itself is empty.
198
+ *
199
+ * @param parsed The parsed line.
200
+ * @param attachmentNames Names of the attached files (staged separately).
201
+ * @returns The event (without `files`).
202
+ */
203
+ export declare function buildNewEvent(parsed: Extract<ParsedChatInput, {
204
+ kind: "handler";
205
+ }>, attachmentNames?: readonly string[]): NewEvent;