@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.
@@ -0,0 +1,393 @@
1
+ import { DaemonStoppedError, EVENT_PRIORITY, isHiddenScratchFile, parseChatInput, } from "@getfamiliar/shared";
2
+ import { buildUploadFiles, isInlineImage, listChatFiles } from "./ChatFiles.js";
3
+ import { MAX_UPLOAD_BYTES, MAX_UPLOAD_FILES, WEB_CHAT_BUILTINS, WEB_CHAT_CHANNEL, WEB_CHAT_TOPIC, } from "./ChatProtocol.js";
4
+ import { toMessageDto, toRunDto, toStepDto } from "./StepProjection.js";
5
+ /** How long a finished turn stays in the snapshot (so a reload still shows its trace). */
6
+ const FINISHED_TURN_RETENTION_MS = 5 * 60 * 1000;
7
+ /** Largest history page the API serves. */
8
+ export const MAX_HISTORY_PAGE = 100;
9
+ /** A user input the hub refuses (answered with 400). */
10
+ export class ChatInputError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "ChatInputError";
14
+ }
15
+ }
16
+ /**
17
+ * The web chat's server side: one subscription to the chat bus for the
18
+ * `web` channel, fanned out to every connected browser tab (the bus marks
19
+ * a row delivered once, so tabs must not subscribe individually), plus
20
+ * sending user input as events and tracking their progress.
21
+ *
22
+ * Mirrors cli-chat: plain messages go to `chat:web`, `/topic/sub …` lines
23
+ * are direct handler calls (validated against the workspace), events are
24
+ * `privileged` (only allowlisted users reach the web UI), failures are
25
+ * written to the chat, and a direct call that produced no chat reply gets
26
+ * its result text appended as the reply.
27
+ *
28
+ * Files: attachments of a message are staged into the event's scratch
29
+ * folder (with a hidden manifest marking them as uploads); every visible
30
+ * file in that folder is offered for download in the chat — the
31
+ * attachments at the user's message, everything else under the reply.
32
+ */
33
+ export class ChatHub {
34
+ deps;
35
+ clients = new Set();
36
+ turns = new Map();
37
+ /** Events of in-flight turns that already got an assistant message. */
38
+ answeredEvents = new Set();
39
+ retentionTimers = new Set();
40
+ unsubscribe = null;
41
+ /**
42
+ * @param deps Host services.
43
+ */
44
+ constructor(deps) {
45
+ this.deps = deps;
46
+ }
47
+ /**
48
+ * Subscribe to the `web` channel. Undelivered messages replayed by the
49
+ * bus are broadcast like live ones (there is nobody to show them to
50
+ * yet, so assistant messages stay undelivered until a tab is open).
51
+ */
52
+ async start() {
53
+ this.unsubscribe = await this.deps.chat.subscribe({ channelId: WEB_CHAT_CHANNEL }, (message) => Promise.resolve(this.onChatMessage(message)));
54
+ }
55
+ /** Drop the subscription, the turns and every client. */
56
+ async stop() {
57
+ const unsubscribe = this.unsubscribe;
58
+ this.unsubscribe = null;
59
+ for (const timer of this.retentionTimers) {
60
+ clearTimeout(timer);
61
+ }
62
+ this.retentionTimers.clear();
63
+ this.turns.clear();
64
+ this.answeredEvents.clear();
65
+ this.clients.clear();
66
+ await unsubscribe?.();
67
+ }
68
+ /**
69
+ * Connect a browser tab. It first receives a snapshot of the turns in
70
+ * flight, then every event.
71
+ *
72
+ * @param client Event sink of the connection.
73
+ * @returns Disconnect function.
74
+ */
75
+ addClient(client) {
76
+ this.clients.add(client);
77
+ client({ type: "snapshot", turns: [...this.turns.values()].map(toTurnDto) });
78
+ return () => {
79
+ this.clients.delete(client);
80
+ };
81
+ }
82
+ /**
83
+ * One page of the web channel's history.
84
+ *
85
+ * @param beforeId Cursor (message id) or undefined for the newest page.
86
+ * @param limit Page size, 1 … {@link MAX_HISTORY_PAGE}.
87
+ * @returns The page and whether older messages exist.
88
+ */
89
+ async history(beforeId, limit) {
90
+ const page = await this.deps.chat.listMessages({
91
+ channelId: WEB_CHAT_CHANNEL,
92
+ beforeId,
93
+ limit: limit + 1,
94
+ });
95
+ const hasMore = page.length > limit;
96
+ const messages = (hasMore ? page.slice(1) : page).map(toMessageDto);
97
+ const files = {};
98
+ for (const eventId of new Set(messages.map((message) => message.eventId))) {
99
+ const eventFiles = await this.filesOf(eventId);
100
+ if (eventFiles.length > 0) {
101
+ files[eventId] = eventFiles;
102
+ }
103
+ }
104
+ return { messages, hasMore, files };
105
+ }
106
+ /**
107
+ * Slash paths for autocompletion.
108
+ *
109
+ * @returns Handler paths and the page's builtins.
110
+ */
111
+ async handlerPaths() {
112
+ const handlers = (await this.deps.handlers.list()).map((handler) => handler.slashPath);
113
+ return { handlers, builtins: [...WEB_CHAT_BUILTINS] };
114
+ }
115
+ /**
116
+ * Send one typed line, with optional attachments, as an event.
117
+ *
118
+ * @param text The line (may be empty when files are attached).
119
+ * @param uploads Attached files, staged into the event's scratch folder.
120
+ * @returns The event id.
121
+ * @throws {ChatInputError} For an empty message, a builtin (handled by
122
+ * the page), an unknown handler path or too many / too large files.
123
+ */
124
+ async send(text, uploads = []) {
125
+ validateUploads(uploads);
126
+ const { files, names } = buildUploadFiles(uploads);
127
+ const parsed = parseChatInput(text, WEB_CHAT_BUILTINS);
128
+ if (parsed.kind === "builtin") {
129
+ throw new ChatInputError(`${parsed.command} is handled by the chat page, not sent`);
130
+ }
131
+ if (parsed.prompt === "" && parsed.topic === undefined && names.length === 0) {
132
+ throw new ChatInputError("empty message");
133
+ }
134
+ if (parsed.topic !== undefined) {
135
+ const resolved = await this.deps.handlers.resolve(parsed.topic, { isExactTopic: true });
136
+ if (resolved === null) {
137
+ throw new ChatInputError(`no handler found for /${parsed.topic.split(":").join("/")}`);
138
+ }
139
+ }
140
+ const turn = {
141
+ eventId: "",
142
+ state: "running",
143
+ runs: new Map(),
144
+ steps: [],
145
+ error: null,
146
+ };
147
+ const event = buildNewEvent(parsed, names);
148
+ const handle = await this.deps.events.emit(files.length > 0 ? { ...event, files } : event, {
149
+ onAgentRun: (row) => this.onAgentRun(turn, row),
150
+ onStep: (step) => this.onStep(turn, step),
151
+ });
152
+ turn.eventId = handle.id;
153
+ this.turns.set(handle.id, turn);
154
+ this.broadcastTurn(turn);
155
+ void handle.settled.then((resultText) => this.onSettled(turn, resultText), (err) => this.onFailed(turn, err));
156
+ return handle.id;
157
+ }
158
+ /**
159
+ * The visible files of an event (see {@link listChatFiles}).
160
+ *
161
+ * @param eventId Event id.
162
+ * @returns The files; empty when listing fails (logged).
163
+ */
164
+ async filesOf(eventId) {
165
+ try {
166
+ return await listChatFiles(this.deps.scratch, this.deps.apiBase, eventId);
167
+ }
168
+ catch (err) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ this.deps.log.warn(`web-chat: could not list the files of event ${eventId}: ${message}`);
171
+ return [];
172
+ }
173
+ }
174
+ /**
175
+ * Locate a visible file of an event for download.
176
+ *
177
+ * @param eventId Event id (digits).
178
+ * @param name File name.
179
+ * @returns The file, or null when it is unknown, hidden or not a file.
180
+ */
181
+ async download(eventId, name) {
182
+ if (!/^\d+$/.test(eventId)) {
183
+ return null;
184
+ }
185
+ const files = await this.deps.scratch.listFiles(eventId);
186
+ const file = files.find((entry) => entry.name === name && !isHiddenScratchFile(entry.name));
187
+ if (file === undefined) {
188
+ return null;
189
+ }
190
+ return {
191
+ path: this.deps.scratch.resolveFile(eventId, file.name),
192
+ name: file.name,
193
+ isInline: isInlineImage(file.name),
194
+ };
195
+ }
196
+ /**
197
+ * List an event's files and broadcast them (after a reply or when its
198
+ * turn finished — the assistant may have produced files).
199
+ *
200
+ * @param eventId Event id.
201
+ */
202
+ async broadcastFiles(eventId) {
203
+ if (this.clients.size === 0) {
204
+ return;
205
+ }
206
+ this.broadcast({ type: "files", eventId, files: await this.filesOf(eventId) });
207
+ }
208
+ /**
209
+ * Bus handler: broadcast the message. User rows count as delivered
210
+ * (they came from the user); assistant rows only when a tab shows them.
211
+ *
212
+ * @param message The stored message.
213
+ * @returns Whether to mark it delivered.
214
+ */
215
+ onChatMessage(message) {
216
+ if (message.role === "assistant") {
217
+ this.answeredEvents.add(message.eventId);
218
+ }
219
+ this.broadcast({ type: "message", message: toMessageDto(message) });
220
+ void this.broadcastFiles(message.eventId);
221
+ return message.role === "user" || this.clients.size > 0;
222
+ }
223
+ /**
224
+ * Record an agentrun state change of a turn.
225
+ *
226
+ * @param turn The turn.
227
+ * @param row The agentrun row.
228
+ */
229
+ onAgentRun(turn, row) {
230
+ turn.runs.set(row.id, toRunDto(row));
231
+ this.broadcastTurn(turn);
232
+ }
233
+ /**
234
+ * Record a finished step of a turn.
235
+ *
236
+ * @param turn The turn.
237
+ * @param step The step row.
238
+ */
239
+ onStep(turn, step) {
240
+ turn.steps.push(toStepDto(step));
241
+ this.broadcastTurn(turn);
242
+ }
243
+ /**
244
+ * The event finished: append the result text as the reply when the
245
+ * run produced no chat message (direct calls to non-chat handlers).
246
+ *
247
+ * @param turn The turn.
248
+ * @param resultText Result text of the last agentrun.
249
+ */
250
+ async onSettled(turn, resultText) {
251
+ const text = resultText.trim();
252
+ if (!this.answeredEvents.has(turn.eventId) && text !== "") {
253
+ try {
254
+ await this.deps.chat.appendAssistantMessage(turn.eventId, text);
255
+ }
256
+ catch (err) {
257
+ const message = err instanceof Error ? err.message : String(err);
258
+ this.deps.log.error(`web-chat: could not store the result of event ${turn.eventId} as reply: ${message}`);
259
+ }
260
+ }
261
+ this.finishTurn(turn, "done", null);
262
+ }
263
+ /**
264
+ * The event failed (the host already wrote the failure to the chat).
265
+ *
266
+ * @param turn The turn.
267
+ * @param err The rejection.
268
+ */
269
+ onFailed(turn, err) {
270
+ const message = err instanceof DaemonStoppedError
271
+ ? "the daemon stopped"
272
+ : err instanceof Error
273
+ ? err.message
274
+ : String(err);
275
+ this.finishTurn(turn, "failed", message);
276
+ }
277
+ /**
278
+ * Mark a turn finished, broadcast it, and drop it after the retention time.
279
+ *
280
+ * @param turn The turn.
281
+ * @param state Final state.
282
+ * @param error Failure text or null.
283
+ */
284
+ finishTurn(turn, state, error) {
285
+ turn.state = state;
286
+ turn.error = error;
287
+ this.answeredEvents.delete(turn.eventId);
288
+ this.broadcastTurn(turn);
289
+ void this.broadcastFiles(turn.eventId);
290
+ const timer = setTimeout(() => {
291
+ this.retentionTimers.delete(timer);
292
+ this.turns.delete(turn.eventId);
293
+ }, FINISHED_TURN_RETENTION_MS);
294
+ timer.unref();
295
+ this.retentionTimers.add(timer);
296
+ }
297
+ /**
298
+ * Broadcast a turn's full state (once its event id is known).
299
+ *
300
+ * @param turn The turn.
301
+ */
302
+ broadcastTurn(turn) {
303
+ if (turn.eventId !== "") {
304
+ this.broadcast({ type: "turn", turn: toTurnDto(turn) });
305
+ }
306
+ }
307
+ /**
308
+ * Send an event to every connected tab. A failing client is dropped.
309
+ *
310
+ * @param event The event.
311
+ */
312
+ broadcast(event) {
313
+ for (const client of this.clients) {
314
+ try {
315
+ client(event);
316
+ }
317
+ catch (err) {
318
+ const message = err instanceof Error ? err.message : String(err);
319
+ this.deps.log.warn(`web-chat: dropping a browser connection that failed: ${message}`);
320
+ this.clients.delete(client);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * The event for one typed line, as cli-chat builds it: plain messages are
327
+ * chat events on `chat:web`; direct handler calls are exact-topic runs
328
+ * whose typed line is stored as the user message.
329
+ *
330
+ * Attachments are named in the text, so the transcript (and the model's
331
+ * chat history) records them even when the line itself is empty.
332
+ *
333
+ * @param parsed The parsed line.
334
+ * @param attachmentNames Names of the attached files (staged separately).
335
+ * @returns The event (without `files`).
336
+ */
337
+ export function buildNewEvent(parsed, attachmentNames = []) {
338
+ const attached = attachmentNames.length === 0 ? "" : `[Attached: ${attachmentNames.join(", ")}]`;
339
+ const withAttachments = (text) => attached === "" ? text : text === "" ? attached : `${text}\n\n${attached}`;
340
+ const common = {
341
+ priority: EVENT_PRIORITY.CHAT,
342
+ preferredChatChannelId: WEB_CHAT_CHANNEL,
343
+ privileged: true,
344
+ outputChatOnFailure: true,
345
+ };
346
+ if (parsed.topic === undefined) {
347
+ return {
348
+ ...common,
349
+ topic: WEB_CHAT_TOPIC,
350
+ isChat: true,
351
+ prompt: withAttachments(parsed.prompt),
352
+ };
353
+ }
354
+ return {
355
+ ...common,
356
+ topic: parsed.topic,
357
+ isExactTopic: true,
358
+ isChat: false,
359
+ prompt: withAttachments(parsed.prompt.length > 0 ? parsed.prompt : "(handler invoked from web-chat)"),
360
+ userChatMessage: withAttachments(parsed.rawInput),
361
+ };
362
+ }
363
+ /**
364
+ * Snapshot a turn for the browser.
365
+ *
366
+ * @param turn The turn.
367
+ * @returns The DTO.
368
+ */
369
+ function toTurnDto(turn) {
370
+ return {
371
+ eventId: turn.eventId,
372
+ state: turn.state,
373
+ runs: [...turn.runs.values()],
374
+ steps: [...turn.steps],
375
+ error: turn.error,
376
+ };
377
+ }
378
+ /**
379
+ * Enforce the attachment limits.
380
+ *
381
+ * @param uploads The attached files.
382
+ * @throws {ChatInputError} For more than {@link MAX_UPLOAD_FILES} files or
383
+ * more than {@link MAX_UPLOAD_BYTES} in total.
384
+ */
385
+ function validateUploads(uploads) {
386
+ if (uploads.length > MAX_UPLOAD_FILES) {
387
+ throw new ChatInputError(`at most ${MAX_UPLOAD_FILES} files per message`);
388
+ }
389
+ const total = uploads.reduce((sum, upload) => sum + upload.contents.length, 0);
390
+ if (total > MAX_UPLOAD_BYTES) {
391
+ throw new ChatInputError(`the attachments are ${Math.ceil(total / 1024 / 1024)} MB; at most ${MAX_UPLOAD_BYTES / 1024 / 1024} MB per message`);
392
+ }
393
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Wire format between the web-chat controller and its page. Types only:
3
+ * the page imports them with `import type`, so nothing of the host side
4
+ * ends up in the browser bundle.
5
+ */
6
+ /** Channel id of the web chat (`events.preferred_chat_channel_id`). */
7
+ export declare const WEB_CHAT_CHANNEL = "web";
8
+ /** Topic of plain web chat messages (direct handler calls carry their own). */
9
+ export declare const WEB_CHAT_TOPIC = "chat:web";
10
+ /** Commands the page handles itself (not sent to the server). */
11
+ export declare const WEB_CHAT_BUILTINS: readonly ["/clear"];
12
+ /** Largest total size of the files attached to one message. */
13
+ export declare const MAX_UPLOAD_BYTES: number;
14
+ /** Most files attached to one message. */
15
+ export declare const MAX_UPLOAD_FILES = 20;
16
+ /** A file in a chat event's scratch folder. */
17
+ export interface ChatFileDto {
18
+ readonly name: string;
19
+ /** Size in bytes. */
20
+ readonly size: number;
21
+ /** Download URL (same origin, behind the web UI authentication). */
22
+ readonly url: string;
23
+ /** Whether the browser may show it inline as an image (thumbnail / preview). */
24
+ readonly isImage: boolean;
25
+ /** Whether the user attached it (shown at the user's message) or the assistant produced it (shown under the reply). */
26
+ readonly isUpload: boolean;
27
+ }
28
+ /** One stored chat message. */
29
+ export interface ChatMessageDto {
30
+ /** Message id (bigint as string); increases with insertion order. */
31
+ readonly id: string;
32
+ /** Event the message belongs to. */
33
+ readonly eventId: string;
34
+ readonly role: "user" | "assistant";
35
+ readonly text: string;
36
+ /** ISO timestamp. */
37
+ readonly createdAt: string;
38
+ }
39
+ /** Lifecycle of one agentrun of a turn. */
40
+ export interface ChatRunDto {
41
+ readonly id: string;
42
+ readonly parentId: string | null;
43
+ readonly topic: string;
44
+ /** Resolved `<provider>/<model>`, once known. */
45
+ readonly model: string | null;
46
+ readonly state: "pending" | "running" | "waiting" | "done" | "failed";
47
+ readonly error: string | null;
48
+ }
49
+ /** One tool call of a step (a `tool_call` proxy call shows its target). */
50
+ export interface ChatToolCallDto {
51
+ readonly id: string;
52
+ readonly name: string;
53
+ /** Truncated JSON of the arguments. */
54
+ readonly input: string;
55
+ /** Truncated output or error text, once the step finished. */
56
+ readonly output: string | null;
57
+ readonly isError: boolean;
58
+ }
59
+ /** One finished model step of an agentrun. */
60
+ export interface ChatStepDto {
61
+ readonly agentRunId: string;
62
+ /** 1-based step number within the agentrun. */
63
+ readonly stepNumber: number;
64
+ readonly reasoning: string | null;
65
+ readonly text: string | null;
66
+ readonly tools: readonly ChatToolCallDto[];
67
+ }
68
+ /** A message the user sent from the web chat and its progress. */
69
+ export interface ChatTurnDto {
70
+ readonly eventId: string;
71
+ readonly state: "running" | "done" | "failed";
72
+ readonly runs: readonly ChatRunDto[];
73
+ readonly steps: readonly ChatStepDto[];
74
+ /** Failure text when `state` is `failed`. */
75
+ readonly error: string | null;
76
+ }
77
+ /** Answer of `GET /api/messages`. */
78
+ export interface ChatHistoryDto {
79
+ /** Oldest first. */
80
+ readonly messages: readonly ChatMessageDto[];
81
+ /** Whether older messages exist (pass the first id as `before`). */
82
+ readonly hasMore: boolean;
83
+ /** Files of the events of these messages, by event id (events without files are left out). */
84
+ readonly files: Readonly<Record<string, readonly ChatFileDto[]>>;
85
+ }
86
+ /** Answer of `GET /api/handlers`. */
87
+ export interface ChatHandlersDto {
88
+ /** Slash paths of the direct-callable handlers (`/mail/draft`). */
89
+ readonly handlers: readonly string[];
90
+ /** Commands the page handles itself. */
91
+ readonly builtins: readonly string[];
92
+ }
93
+ /** Answer of `POST /api/messages`. */
94
+ export type ChatSendResultDto = {
95
+ readonly eventId: string;
96
+ } | {
97
+ readonly error: string;
98
+ };
99
+ /** Server-sent events of `GET /api/events`. */
100
+ export type ChatServerEvent =
101
+ /** First event of a stream: the turns in flight. */
102
+ {
103
+ readonly type: "snapshot";
104
+ readonly turns: readonly ChatTurnDto[];
105
+ }
106
+ /** A message was stored on the web channel (both roles). */
107
+ | {
108
+ readonly type: "message";
109
+ readonly message: ChatMessageDto;
110
+ }
111
+ /** A turn changed (full state). */
112
+ | {
113
+ readonly type: "turn";
114
+ readonly turn: ChatTurnDto;
115
+ }
116
+ /** The files of an event changed (after a reply or when its turn finished). */
117
+ | {
118
+ readonly type: "files";
119
+ readonly eventId: string;
120
+ readonly files: readonly ChatFileDto[];
121
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Wire format between the web-chat controller and its page. Types only:
3
+ * the page imports them with `import type`, so nothing of the host side
4
+ * ends up in the browser bundle.
5
+ */
6
+ /** Channel id of the web chat (`events.preferred_chat_channel_id`). */
7
+ export const WEB_CHAT_CHANNEL = "web";
8
+ /** Topic of plain web chat messages (direct handler calls carry their own). */
9
+ export const WEB_CHAT_TOPIC = "chat:web";
10
+ /** Commands the page handles itself (not sent to the server). */
11
+ export const WEB_CHAT_BUILTINS = ["/clear"];
12
+ /** Largest total size of the files attached to one message. */
13
+ export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
14
+ /** Most files attached to one message. */
15
+ export const MAX_UPLOAD_FILES = 20;
@@ -0,0 +1,33 @@
1
+ import type { AgentRunRow, ChatMessage, StepResultRow } from "@getfamiliar/shared";
2
+ import type { ChatMessageDto, ChatRunDto, ChatStepDto } from "./ChatProtocol.js";
3
+ /**
4
+ * Project a stored chat message for the browser.
5
+ *
6
+ * @param message The message.
7
+ * @returns The DTO.
8
+ */
9
+ export declare function toMessageDto(message: ChatMessage): ChatMessageDto;
10
+ /**
11
+ * Project an agentrun row for the browser.
12
+ *
13
+ * @param row The row.
14
+ * @returns The DTO.
15
+ */
16
+ export declare function toRunDto(row: AgentRunRow): ChatRunDto;
17
+ /**
18
+ * Project a finished step for the browser: reasoning and text (truncated)
19
+ * and every tool call with a short argument / result preview. Calls
20
+ * through the `tool_call` proxy show the invoked tool's name and arguments.
21
+ *
22
+ * @param step The step row.
23
+ * @returns The DTO.
24
+ */
25
+ export declare function toStepDto(step: StepResultRow): ChatStepDto;
26
+ /**
27
+ * A short text preview of any value (strings as-is, the rest as JSON).
28
+ *
29
+ * @param value The value.
30
+ * @param maxLength Cut-off length.
31
+ * @returns The preview, with `…` when cut.
32
+ */
33
+ export declare function preview(value: unknown, maxLength: number): string;