pi-roundtable-webchat 0.8.0

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/src/prompts.ts ADDED
@@ -0,0 +1,198 @@
1
+ import {
2
+ type Approval,
3
+ type OwnerAnswer,
4
+ type OwnerPrompts,
5
+ type OwnerQuestion,
6
+ type Speaker,
7
+ TIERS,
8
+ type Tier,
9
+ } from "pi-roundtable";
10
+ import type {
11
+ ErrorCode,
12
+ PromptFrame,
13
+ PromptOutcome,
14
+ ServerFrame,
15
+ } from "./protocol.ts";
16
+
17
+ /** Who answers: the conversation's person, at their tier as of the answer. */
18
+ export interface Answerer {
19
+ id: string;
20
+ tier: Tier;
21
+ }
22
+
23
+ interface Open {
24
+ conversation: string;
25
+ principal: string;
26
+ frame: PromptFrame;
27
+ /** The lowest tier that may approve; questions have none. */
28
+ minTier?: Tier;
29
+ settle(outcome: PromptOutcome, answer?: OwnerAnswer): void;
30
+ }
31
+
32
+ export interface PromptDeskOptions {
33
+ /** Sends a frame to every connection of a person. */
34
+ send(principal: string, frame: ServerFrame): void;
35
+ /** An unanswered prompt expires after this long. */
36
+ timeoutMs: number;
37
+ }
38
+
39
+ const atLeast = (tier: Tier, least: Tier) =>
40
+ TIERS.indexOf(tier) >= TIERS.indexOf(least);
41
+
42
+ /**
43
+ * The approvals and questions open in web conversations. A prompt goes to every connection of
44
+ * the conversation's person, survives a reconnect (each `ready` sends the open ones again), and
45
+ * closes when answered, after `timeoutMs` (expired), or when the turn stops (cancelled). Only the
46
+ * conversation's person may answer, and an approval only at the tier it needs.
47
+ */
48
+ export class PromptDesk {
49
+ readonly #open = new Map<string, Open>();
50
+ readonly #options: PromptDeskOptions;
51
+
52
+ constructor(options: PromptDeskOptions) {
53
+ this.#options = options;
54
+ }
55
+
56
+ /**
57
+ * The prompts of a conversation for the speaker whose turn runs there. An approval whose tier
58
+ * the speaker lacks expires at once without being shown: no one else can answer in a private
59
+ * conversation, so the call stays held, as an unanswered card leaves it.
60
+ */
61
+ prompts(conversation: string, speaker: Speaker): OwnerPrompts {
62
+ return {
63
+ confirm: async (title, message, signal, minTier = "owner") => {
64
+ if (!atLeast(speaker.tier, minTier)) return "expired";
65
+ const outcome = await this.#ask(
66
+ conversation,
67
+ speaker.id,
68
+ (id) => ({ id, kind: "approval", title, message }),
69
+ signal,
70
+ minTier,
71
+ );
72
+ return toApproval(outcome.outcome);
73
+ },
74
+ ask: async (title, question, signal) => {
75
+ const { outcome, answer } = await this.#ask(
76
+ conversation,
77
+ speaker.id,
78
+ (id) => questionFrame(id, title, question),
79
+ signal,
80
+ );
81
+ return outcome === "answered" ? answer : undefined;
82
+ },
83
+ };
84
+ }
85
+
86
+ /** The open prompts of a person, as frames to send again when they connect. */
87
+ openFor(principal: string): ServerFrame[] {
88
+ return [...this.#open.values()]
89
+ .filter((open) => open.principal === principal)
90
+ .map((open) => ({
91
+ type: "prompt",
92
+ conversation: open.conversation,
93
+ prompt: open.frame,
94
+ }));
95
+ }
96
+
97
+ /** Approves or declines; an error code when the prompt is not an approval this person may answer. */
98
+ approve(
99
+ from: Answerer,
100
+ prompt: string,
101
+ approved: boolean,
102
+ ): ErrorCode | undefined {
103
+ const open = this.#open.get(prompt);
104
+ if (open?.frame.kind !== "approval") return "unknown_prompt";
105
+ if (open.principal !== from.id) return "forbidden";
106
+ if (open.minTier && !atLeast(from.tier, open.minTier)) return "forbidden";
107
+ open.settle(approved ? "approved" : "declined");
108
+ return undefined;
109
+ }
110
+
111
+ /** Answers a question; an error code when the answer does not fit the question or the person may not answer it. */
112
+ answer(
113
+ from: Answerer,
114
+ prompt: string,
115
+ answer: OwnerAnswer,
116
+ ): ErrorCode | undefined {
117
+ const open = this.#open.get(prompt);
118
+ if (open?.frame.kind !== "ask") return "unknown_prompt";
119
+ if (open.principal !== from.id) return "forbidden";
120
+ if (!fits(open.frame, answer)) return "bad_frame";
121
+ open.settle("answered", answer);
122
+ return undefined;
123
+ }
124
+
125
+ #ask(
126
+ conversation: string,
127
+ principal: string,
128
+ frameOf: (id: string) => PromptFrame,
129
+ signal: AbortSignal | undefined,
130
+ minTier?: Tier,
131
+ ): Promise<{ outcome: PromptOutcome; answer?: OwnerAnswer }> {
132
+ const { send, timeoutMs } = this.#options;
133
+ if (signal?.aborted) return Promise.resolve({ outcome: "cancelled" });
134
+ return new Promise((resolve) => {
135
+ const id = crypto.randomUUID();
136
+ const frame = frameOf(id);
137
+ const onAbort = () => settle("cancelled");
138
+ const timer = setTimeout(() => settle("expired"), timeoutMs);
139
+ const settle = (outcome: PromptOutcome, answer?: OwnerAnswer) => {
140
+ if (!this.#open.delete(id)) return;
141
+ clearTimeout(timer);
142
+ signal?.removeEventListener("abort", onAbort);
143
+ send(principal, {
144
+ type: "prompt_closed",
145
+ conversation,
146
+ prompt: id,
147
+ outcome,
148
+ });
149
+ resolve(answer ? { outcome, answer } : { outcome });
150
+ };
151
+ this.#open.set(id, {
152
+ conversation,
153
+ principal,
154
+ frame,
155
+ ...(minTier ? { minTier } : {}),
156
+ settle,
157
+ });
158
+ signal?.addEventListener("abort", onAbort, { once: true });
159
+ send(principal, { type: "prompt", conversation, prompt: frame });
160
+ });
161
+ }
162
+ }
163
+
164
+ function toApproval(outcome: PromptOutcome): Approval {
165
+ if (outcome === "approved" || outcome === "declined") return outcome;
166
+ return outcome === "cancelled" ? "cancelled" : "expired";
167
+ }
168
+
169
+ function questionFrame(
170
+ id: string,
171
+ title: string,
172
+ question: OwnerQuestion,
173
+ ): PromptFrame {
174
+ return {
175
+ id,
176
+ kind: "ask",
177
+ title,
178
+ question: question.question,
179
+ options: question.options.map((option) => ({ ...option })),
180
+ multi: question.multi,
181
+ allowOther: question.allowOther,
182
+ };
183
+ }
184
+
185
+ /** Whether an answer is one the question allows: offered labels, one unless multi, own text only where allowed. */
186
+ function fits(
187
+ frame: Extract<PromptFrame, { kind: "ask" }>,
188
+ answer: OwnerAnswer,
189
+ ): boolean {
190
+ const labels = frame.options.map((option) => option.label);
191
+ const text = answer.text?.trim();
192
+ if (!answer.choices.every((choice) => labels.includes(choice))) return false;
193
+ if (new Set(answer.choices).size !== answer.choices.length) return false;
194
+ if (!frame.multi && answer.choices.length > 1) return false;
195
+ if (labels.length === 0) return answer.choices.length === 0 && Boolean(text);
196
+ if (text && !frame.allowOther) return false;
197
+ return answer.choices.length > 0 || Boolean(text);
198
+ }
@@ -0,0 +1,202 @@
1
+ import type { AskOption, Tier, TurnProgress } from "pi-roundtable";
2
+
3
+ /**
4
+ * The WebSocket subprotocol a client offers, and the server echoes, for this version of the
5
+ * protocol. A later incompatible version gets a new name, so an old client is refused at the
6
+ * handshake rather than misreading frames.
7
+ */
8
+ export const WEBCHAT_PROTOCOL = "roundtable.webchat.v1";
9
+
10
+ /** The version `ready` reports. */
11
+ export const WEBCHAT_PROTOCOL_VERSION = 1;
12
+
13
+ /** The subprotocol that carries a one-time ticket: `ticket.<ticket>`, offered beside `WEBCHAT_PROTOCOL`. */
14
+ export const TICKET_PROTOCOL_PREFIX = "ticket.";
15
+
16
+ /** Close codes the server uses besides the standard ones. */
17
+ export const CLOSE_CODES = Object.freeze({
18
+ /** The token expired without a fresh `auth`, or a fresh one was refused. */
19
+ tokenExpired: 4401,
20
+ /** The person is no longer admitted: the policy gives them no tier, or a fresh token names someone else. */
21
+ notAdmitted: 4403,
22
+ });
23
+
24
+ /** A persona a client may open a conversation with. */
25
+ export interface PersonaSummary {
26
+ kind: string;
27
+ label: string;
28
+ }
29
+
30
+ /** A file a reply carries, inline. */
31
+ export interface ReplyFileFrame {
32
+ name: string;
33
+ /** The bytes, base64. */
34
+ data: string;
35
+ }
36
+
37
+ /** An approval or a question the conversation's turn asks the person. */
38
+ export type PromptFrame =
39
+ | { id: string; kind: "approval"; title: string; message: string }
40
+ | {
41
+ id: string;
42
+ kind: "ask";
43
+ title: string;
44
+ question: string;
45
+ /** None means a free-text answer. */
46
+ options: readonly AskOption[];
47
+ multi: boolean;
48
+ allowOther: boolean;
49
+ };
50
+
51
+ /** How a prompt closed. */
52
+ export type PromptOutcome =
53
+ | "approved"
54
+ | "declined"
55
+ | "answered"
56
+ | "expired"
57
+ | "cancelled";
58
+
59
+ /** Why the server refused a frame or a request. */
60
+ export type ErrorCode =
61
+ | "bad_frame"
62
+ | "unknown_conversation"
63
+ | "forbidden"
64
+ | "unknown_persona"
65
+ | "unknown_prompt"
66
+ | "too_many_conversations"
67
+ | "busy";
68
+
69
+ /** What a client sends: one JSON object per WebSocket text message. */
70
+ export type ClientFrame =
71
+ /** A fresh token for the same person, sent when the server asks with `reauth`. */
72
+ | { type: "auth"; token: string }
73
+ /**
74
+ * A message. Without `conversation` it opens a new conversation of `persona`; `id` is the
75
+ * client's own reference, echoed by `accepted` or `error`.
76
+ */
77
+ | {
78
+ type: "send";
79
+ id: string;
80
+ conversation?: string;
81
+ persona?: string;
82
+ text: string;
83
+ }
84
+ /** Stops the conversation's running turn. */
85
+ | { type: "stop"; conversation: string }
86
+ /** Approves or declines an approval prompt. */
87
+ | { type: "approval"; prompt: string; approved: boolean }
88
+ /** Answers a question prompt: the chosen options' labels and, where allowed, their own text. */
89
+ | { type: "answer"; prompt: string; choices: string[]; text?: string };
90
+
91
+ /** What the server sends: one JSON object per WebSocket text message. */
92
+ export type ServerFrame =
93
+ /** The connection is authenticated; sent first, and again after a fresh token. */
94
+ | {
95
+ type: "ready";
96
+ protocol: typeof WEBCHAT_PROTOCOL_VERSION;
97
+ speaker: { id: string; name: string; tier: Tier };
98
+ personas: readonly PersonaSummary[];
99
+ /** When the token expires, as an ISO time. */
100
+ expiresAt: string;
101
+ }
102
+ /** The message `id` was taken into `conversation`, a new one when the client named none. */
103
+ | { type: "accepted"; id: string; conversation: string }
104
+ /** The assistant is, or is no longer, working in the conversation. */
105
+ | { type: "typing"; conversation: string; on: boolean }
106
+ /** A stop control applies, or no longer applies, to the conversation. */
107
+ | { type: "stoppable"; conversation: string; on: boolean }
108
+ /** What the running turn writes and which tools it runs, as it goes. */
109
+ | { type: "progress"; conversation: string; event: TurnProgress }
110
+ /** The turn's answer, in full markdown, with any files it made. */
111
+ | {
112
+ type: "reply";
113
+ conversation: string;
114
+ text: string;
115
+ thinking?: string;
116
+ files?: readonly ReplyFileFrame[];
117
+ }
118
+ /** The turn ended without an answer: it failed, the host refused it before it ran, or it was stopped. The cause stays in the server's log. */
119
+ | { type: "failed"; conversation: string; stopped: boolean }
120
+ /** The turn asks the person; answer with `approval` or `answer`. */
121
+ | { type: "prompt"; conversation: string; prompt: PromptFrame }
122
+ | {
123
+ type: "prompt_closed";
124
+ conversation: string;
125
+ prompt: string;
126
+ outcome: PromptOutcome;
127
+ }
128
+ /** The token expires soon: send `auth` with a fresh one before `expiresAt`, or the server closes with 4401. */
129
+ | { type: "reauth"; expiresAt: string }
130
+ /** A frame was refused; `ref` is the `send` id or prompt id it was about. */
131
+ | { type: "error"; code: ErrorCode; ref?: string };
132
+
133
+ /** The longest client reference, conversation id, or prompt id accepted. */
134
+ const ID_CHARS = 128;
135
+
136
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
137
+ typeof value === "object" && value !== null && !Array.isArray(value);
138
+
139
+ const isId = (value: unknown): value is string =>
140
+ typeof value === "string" && value.length > 0 && value.length <= ID_CHARS;
141
+
142
+ const optional = <T>(value: unknown, check: (v: unknown) => v is T) =>
143
+ value === undefined || check(value);
144
+
145
+ /** One client frame, checked field by field; undefined for anything else. */
146
+ export function parseClientFrame(
147
+ raw: string | Uint8Array,
148
+ ): ClientFrame | undefined {
149
+ let value: unknown;
150
+ try {
151
+ value = JSON.parse(
152
+ typeof raw === "string" ? raw : new TextDecoder().decode(raw),
153
+ );
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ if (!isRecord(value)) return undefined;
158
+ switch (value.type) {
159
+ case "auth":
160
+ return typeof value.token === "string" && value.token !== ""
161
+ ? { type: "auth", token: value.token }
162
+ : undefined;
163
+ case "send": {
164
+ const { id, conversation, persona, text } = value;
165
+ if (!isId(id) || typeof text !== "string") return undefined;
166
+ if (!optional(conversation, isId) || !optional(persona, isId))
167
+ return undefined;
168
+ if (conversation === undefined && persona === undefined) return undefined;
169
+ return {
170
+ type: "send",
171
+ id,
172
+ text,
173
+ ...(conversation === undefined ? {} : { conversation }),
174
+ ...(persona === undefined ? {} : { persona }),
175
+ };
176
+ }
177
+ case "stop":
178
+ return isId(value.conversation)
179
+ ? { type: "stop", conversation: value.conversation }
180
+ : undefined;
181
+ case "approval":
182
+ return isId(value.prompt) && typeof value.approved === "boolean"
183
+ ? { type: "approval", prompt: value.prompt, approved: value.approved }
184
+ : undefined;
185
+ case "answer": {
186
+ const { prompt, choices, text } = value;
187
+ if (!isId(prompt) || !Array.isArray(choices)) return undefined;
188
+ if (!choices.every((choice) => typeof choice === "string"))
189
+ return undefined;
190
+ if (!optional(text, (t): t is string => typeof t === "string"))
191
+ return undefined;
192
+ return {
193
+ type: "answer",
194
+ prompt,
195
+ choices: choices as string[],
196
+ ...(text === undefined ? {} : { text }),
197
+ };
198
+ }
199
+ default:
200
+ return undefined;
201
+ }
202
+ }
package/src/rest.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { ConversationRecord, Logger } from "pi-roundtable";
2
+ import { type Admitted, Refusal, type WebChat } from "./chat.ts";
3
+ import { TokenRefused } from "./oidc.ts";
4
+ import type { TicketBook } from "./tickets.ts";
5
+
6
+ export interface RestOptions {
7
+ chat: WebChat;
8
+ tickets: TicketBook;
9
+ /** The route's path, such as `/chat`, without a trailing slash. */
10
+ path: string;
11
+ /** The browser origins allowed to call the API; "any" answers every origin. */
12
+ origins: readonly string[] | "any";
13
+ logger: Logger;
14
+ }
15
+
16
+ /** The most transcript entries one request returns. */
17
+ const MAX_LIMIT = 500;
18
+ const DEFAULT_LIMIT = 50;
19
+ const MAX_BODY_BYTES = 16 * 1024;
20
+
21
+ type HeaderMap = Record<string, string>;
22
+
23
+ const json = (body: unknown, status = 200, headers: HeaderMap = {}) =>
24
+ Response.json(body, { status, headers });
25
+
26
+ function bearer(request: Request): string | undefined {
27
+ const header = request.headers.get("authorization");
28
+ const match = header ? /^Bearer\s+(\S+)$/i.exec(header) : null;
29
+ return match?.[1];
30
+ }
31
+
32
+ function summary(record: ConversationRecord, surface: string) {
33
+ return {
34
+ conversation: record.key.slice(surface.length + 1),
35
+ persona: record.kind,
36
+ ...(record.title === undefined ? {} : { title: record.title }),
37
+ createdAt: record.createdAt.toISOString(),
38
+ lastActiveAt: record.lastActiveAt.toISOString(),
39
+ };
40
+ }
41
+
42
+ async function body(request: Request): Promise<Record<string, unknown>> {
43
+ const text = await request.text();
44
+ if (text.length > MAX_BODY_BYTES) throw new Refusal("bad_frame");
45
+ let value: unknown;
46
+ try {
47
+ value = text === "" ? {} : JSON.parse(text);
48
+ } catch {
49
+ throw new Refusal("bad_frame");
50
+ }
51
+ if (typeof value !== "object" || value === null || Array.isArray(value))
52
+ throw new Refusal("bad_frame");
53
+ return value as Record<string, unknown>;
54
+ }
55
+
56
+ const STATUS: Record<string, number> = {
57
+ bad_frame: 400,
58
+ forbidden: 403,
59
+ unknown_conversation: 404,
60
+ unknown_persona: 404,
61
+ too_many_conversations: 429,
62
+ };
63
+
64
+ /**
65
+ * The web chat's REST API, under its path, every call with `Authorization: Bearer <token>`:
66
+ * `POST tickets` (a one-time ticket for the WebSocket), `GET conversations` (the caller's own),
67
+ * `POST conversations` (`{ persona, title? }` opens one), and `GET conversations/<id>/messages`
68
+ * (`?limit=`, its last messages). A browser on another origin gets CORS headers when its origin
69
+ * is allowed and 403 otherwise.
70
+ */
71
+ export function restHandler(options: RestOptions) {
72
+ const { chat, tickets, path, origins, logger } = options;
73
+ const surface = chat.surface.surface;
74
+ const cors = (origin: string | null): HeaderMap | undefined => {
75
+ if (origin === null) return {};
76
+ if (origins !== "any" && !origins.includes(origin)) return undefined;
77
+ return { "Access-Control-Allow-Origin": origin, Vary: "Origin" };
78
+ };
79
+ async function route(
80
+ request: Request,
81
+ url: URL,
82
+ who: Admitted,
83
+ headers: HeaderMap,
84
+ ): Promise<Response> {
85
+ const rest = url.pathname.slice(path.length + 1);
86
+ const { speaker, identity } = who;
87
+ if (rest === "tickets" && request.method === "POST") {
88
+ const { ticket, expiresAt } = tickets.issue(identity);
89
+ return json({ ticket, expiresAt: expiresAt.toISOString() }, 201, headers);
90
+ }
91
+ if (rest === "conversations" && request.method === "GET") {
92
+ const records = await chat.list(speaker);
93
+ return json(
94
+ { conversations: records.map((r) => summary(r, surface)) },
95
+ 200,
96
+ headers,
97
+ );
98
+ }
99
+ if (rest === "conversations" && request.method === "POST") {
100
+ const { persona, title } = await body(request);
101
+ if (typeof persona !== "string") throw new Refusal("bad_frame");
102
+ if (title !== undefined && typeof title !== "string")
103
+ throw new Refusal("bad_frame");
104
+ const conversation = chat.open(speaker, persona, title);
105
+ return json({ conversation, persona }, 201, headers);
106
+ }
107
+ const messages = /^conversations\/([A-Za-z0-9-]{1,128})\/messages$/.exec(
108
+ rest,
109
+ );
110
+ if (messages?.[1] && request.method === "GET") {
111
+ const asked = Number(url.searchParams.get("limit") ?? DEFAULT_LIMIT);
112
+ const limit = Number.isInteger(asked)
113
+ ? Math.min(Math.max(asked, 1), MAX_LIMIT)
114
+ : DEFAULT_LIMIT;
115
+ const entries = await chat.transcript(speaker, messages[1], limit);
116
+ return json({ messages: entries }, 200, headers);
117
+ }
118
+ return json({ error: "not_found" }, 404, headers);
119
+ }
120
+ return async (request: Request): Promise<Response> => {
121
+ const headers = cors(request.headers.get("origin"));
122
+ if (!headers) return json({ error: "forbidden" }, 403);
123
+ if (request.method === "OPTIONS")
124
+ return new Response(null, {
125
+ status: 204,
126
+ headers: {
127
+ ...headers,
128
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
129
+ "Access-Control-Allow-Headers": "authorization, content-type",
130
+ "Access-Control-Max-Age": "600",
131
+ },
132
+ });
133
+ // A request the listener received always has an absolute URL.
134
+ const url = URL.parse(request.url);
135
+ if (!url) return json({ error: "not_found" }, 404, headers);
136
+ const token = bearer(request);
137
+ const challenge = { ...headers, "WWW-Authenticate": "Bearer" };
138
+ if (!token) return json({ error: "unauthorized" }, 401, challenge);
139
+ let who: Admitted;
140
+ try {
141
+ who = await chat.admit(token);
142
+ } catch (error) {
143
+ if (error instanceof TokenRefused) {
144
+ logger.info({ reason: error.reason }, "a web chat token was refused");
145
+ return json({ error: "unauthorized" }, 401, challenge);
146
+ }
147
+ if (error instanceof Refusal)
148
+ return json({ error: "forbidden" }, 403, headers);
149
+ throw error;
150
+ }
151
+ try {
152
+ return await route(request, url, who, headers);
153
+ } catch (error) {
154
+ if (!(error instanceof Refusal)) throw error;
155
+ return json({ error: error.code }, STATUS[error.code] ?? 400, headers);
156
+ }
157
+ };
158
+ }
package/src/surface.ts ADDED
@@ -0,0 +1,126 @@
1
+ import {
2
+ type ChannelKey,
3
+ type ChatSurface,
4
+ type InboundMessage,
5
+ type OutboundReply,
6
+ type OwnerPrompts,
7
+ PluginError,
8
+ parseChannelKey,
9
+ type Speaker,
10
+ type TurnProgress,
11
+ } from "pi-roundtable";
12
+ import type { PromptDesk } from "./prompts.ts";
13
+ import type { ServerFrame } from "./protocol.ts";
14
+
15
+ export interface WebSurfaceOptions {
16
+ /** The key prefix of the conversations, such as "web". */
17
+ surface: string;
18
+ /** Whose a conversation is, by its id; undefined while the process has not seen it. */
19
+ principalOf(conversation: string): string | undefined;
20
+ /** Sends a frame to every connection of a person. */
21
+ send(principal: string, frame: ServerFrame): void;
22
+ prompts: PromptDesk;
23
+ }
24
+
25
+ /**
26
+ * The web conversations as a chat surface: what the host shows in a conversation goes to every
27
+ * open connection of its person, as JSON frames. A reply is sent whole, in markdown, with its
28
+ * files inline.
29
+ */
30
+ export class WebSurface implements ChatSurface {
31
+ readonly surface: string;
32
+ readonly supportsFiles = true;
33
+ readonly #options: WebSurfaceOptions;
34
+ #deliver: ((message: InboundMessage) => void) | undefined;
35
+
36
+ constructor(options: WebSurfaceOptions) {
37
+ this.surface = options.surface;
38
+ this.#options = options;
39
+ }
40
+
41
+ async start(deliver: (message: InboundMessage) => void): Promise<void> {
42
+ this.#deliver = deliver;
43
+ }
44
+
45
+ async stop(): Promise<void> {
46
+ this.#deliver = undefined;
47
+ }
48
+
49
+ /** Hands a person's message to the host; throws before the host started the surface. */
50
+ deliver(message: InboundMessage): void {
51
+ if (!this.#deliver)
52
+ throw new PluginError(
53
+ "the web chat surface has not started; messages are taken once the host runs",
54
+ );
55
+ this.#deliver(message);
56
+ }
57
+
58
+ /** The conversation id of a channel this surface serves. */
59
+ conversationOf(channel: ChannelKey): string {
60
+ const { surface, id } = parseChannelKey(channel);
61
+ if (surface !== this.surface)
62
+ throw new PluginError(`${channel} is not a ${this.surface} conversation`);
63
+ return id;
64
+ }
65
+
66
+ #to(channel: ChannelKey): { principal: string; conversation: string } {
67
+ const conversation = this.conversationOf(channel);
68
+ const principal = this.#options.principalOf(conversation);
69
+ if (!principal)
70
+ throw new PluginError(
71
+ `no one is known to own ${channel}; a web conversation is known once its person writes in it`,
72
+ );
73
+ return { principal, conversation };
74
+ }
75
+
76
+ async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
77
+ const { principal, conversation } = this.#to(channel);
78
+ this.#options.send(principal, {
79
+ type: "reply",
80
+ conversation,
81
+ text: reply.chunks.join("\n"),
82
+ ...(reply.thinking ? { thinking: reply.thinking } : {}),
83
+ ...(reply.files?.length
84
+ ? {
85
+ files: reply.files.map((file) => ({
86
+ name: file.name,
87
+ data: Buffer.from(file.data).toString("base64"),
88
+ })),
89
+ }
90
+ : {}),
91
+ });
92
+ }
93
+
94
+ /** A pair of frames: `on` now, and the returned function sends `off` once. */
95
+ #toggle(channel: ChannelKey, type: "typing" | "stoppable"): () => void {
96
+ const { principal, conversation } = this.#to(channel);
97
+ this.#options.send(principal, { type, conversation, on: true });
98
+ let on = true;
99
+ return () => {
100
+ if (!on) return;
101
+ on = false;
102
+ this.#options.send(principal, { type, conversation, on: false });
103
+ };
104
+ }
105
+
106
+ startTyping(channel: ChannelKey): () => void {
107
+ return this.#toggle(channel, "typing");
108
+ }
109
+
110
+ showStop(channel: ChannelKey): () => void {
111
+ return this.#toggle(channel, "stoppable");
112
+ }
113
+
114
+ /** Prompts for the conversation's own person only; a turn for anyone else asks nothing here. */
115
+ prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
116
+ if (!speaker) return undefined;
117
+ const { principal, conversation } = this.#to(channel);
118
+ if (speaker.id !== principal) return undefined;
119
+ return this.#options.prompts.prompts(conversation, speaker);
120
+ }
121
+
122
+ progress(channel: ChannelKey, event: TurnProgress): void {
123
+ const { principal, conversation } = this.#to(channel);
124
+ this.#options.send(principal, { type: "progress", conversation, event });
125
+ }
126
+ }