@camelai/run 0.0.0 → 0.11.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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * A chat with one agent, for any UI framework: the agent's messages as simple typed parts, its status,
3
+ * the input it waits on, and the actions (send, answer, stop, loadOlder), over your server's agent
4
+ * handler (`createAgentHandler`) and the browser watcher. No dependencies and no Node APIs.
5
+ *
6
+ * const chat = createAgentChat({ endpoint: "/api/agent" });
7
+ * chat.subscribe(() => render(chat.getSnapshot()));
8
+ * await chat.send("Where is my order?");
9
+ *
10
+ * The snapshot is immutable, and a message or part that did not change is the same object from one
11
+ * snapshot to the next, so a UI re-renders only what changed (and keys rows by `id`: a sent message
12
+ * keeps its id when the agent's copy of it arrives).
13
+ */
14
+ import { watchAgent } from "./watch.js";
15
+ // ---------------------------------------------------------------------------------------------
16
+ // Answers
17
+ /** An answer to `input` from a plain value (see InputValue). */
18
+ export function answerValue(input, value) {
19
+ switch (input.kind) {
20
+ case "approval":
21
+ case "url":
22
+ if (typeof value !== "boolean")
23
+ throw new Error(`Answer ${input.kind === "approval" ? "an approval" : "a url step"} with true or false`);
24
+ return { action: value ? "accept" : "decline" };
25
+ case "question": {
26
+ const questions = (input.detail?.questions ?? []);
27
+ if (typeof value === "string" || Array.isArray(value)) {
28
+ if (questions.length !== 1)
29
+ throw new Error(`This input asks ${questions.length} questions: answer with { "<question>": "<answer>" } for each`);
30
+ return { action: "accept", content: { answers: { [questions[0].question]: value } } };
31
+ }
32
+ if (typeof value !== "object" || value === null)
33
+ throw new Error("Answer a question with the label chosen, or a map of question to answer");
34
+ return { action: "accept", content: { answers: value } };
35
+ }
36
+ case "form":
37
+ if (typeof value !== "object" || value === null || Array.isArray(value))
38
+ throw new Error("Answer a form with its fields, as an object");
39
+ return { action: "accept", content: value };
40
+ default:
41
+ throw new Error(`Unknown input kind: ${String(input.kind)}`);
42
+ }
43
+ }
44
+ const isAnswer = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && ["accept", "decline", "cancel"].includes(value.action) && Object.keys(value).every(key => key === "action" || key === "content");
45
+ const isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
46
+ const same = (a, b) => a.length === b.length && a.every((value, at) => value === b[at]);
47
+ const textOfContent = (content) => typeof content === "string" ? content
48
+ : Array.isArray(content) ? content.map(part => part?.type === "text" && typeof part.text === "string" ? part.text : "").join("") : "";
49
+ /** A tool result that stands in while its call waits on a person: the call is not done. */
50
+ const isInputPlaceholder = (result) => isRecord(result?.details) && !!result.details.inputRequired;
51
+ /** Whether a turn's last message hands off to tools (the turn goes on) rather than ending it. */
52
+ const continuesTurn = (message) => message?.role === "toolResult" || (message?.role === "assistant" && message.stopReason === "toolUse");
53
+ const isPresentFile = (name) => name === "present_file" || name.endsWith("__present_file");
54
+ const baseName = (path) => path.split("/").filter(Boolean).pop() ?? path;
55
+ function parseJson(text) {
56
+ const trimmed = text.trim();
57
+ if (!trimmed || !"{[\"".includes(trimmed[0]) && !/^(true|false|null|-?\d)/.test(trimmed))
58
+ return undefined;
59
+ try {
60
+ return JSON.parse(trimmed);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ function progressOf(update) {
67
+ if (update === undefined)
68
+ return undefined;
69
+ const details = isRecord(update) && isRecord(update.details) ? update.details : undefined;
70
+ const text = isRecord(update) ? textOfContent(update.content) : typeof update === "string" ? update : "";
71
+ const out = {};
72
+ if (details?.type === "progress") {
73
+ if (typeof details.progress === "number")
74
+ out.progress = details.progress;
75
+ if (typeof details.total === "number")
76
+ out.total = details.total;
77
+ if (typeof details.message === "string")
78
+ out.message = details.message;
79
+ }
80
+ if (text)
81
+ out.text = text;
82
+ return out;
83
+ }
84
+ /** Reuse `previous` when `next` has the same fields (compared with ===, arrays element by element). */
85
+ function reuse(previous, next) {
86
+ if (!previous)
87
+ return next;
88
+ const a = previous, b = next;
89
+ const keys = Object.keys(b);
90
+ if (keys.length !== Object.keys(a).length)
91
+ return next;
92
+ for (const key of keys) {
93
+ const x = a[key], y = b[key];
94
+ if (x === y)
95
+ continue;
96
+ if (Array.isArray(x) && Array.isArray(y) && same(x, y))
97
+ continue;
98
+ return next;
99
+ }
100
+ return previous;
101
+ }
102
+ /**
103
+ * The chat's messages from the agent's (a watcher's view) and the ones sent from here:
104
+ * - a user message is one message, with the id it was sent with (its `requestId`), so the bubble
105
+ * shown when it was sent is the same row once it arrives;
106
+ * - everything the agent did between two user messages is one assistant message, each tool result
107
+ * folded into its call's part;
108
+ * - a running turn continues the last one only when that one called tools; otherwise it is a new
109
+ * message at the index its first message will take, from its first token (never an empty row);
110
+ * - a message or part whose sources did not change is the same object as last time (`memo`).
111
+ */
112
+ export function projectMessages(input, memo = new Map()) {
113
+ const { messages, indexes } = input;
114
+ const results = new Map();
115
+ for (const message of messages)
116
+ if (message.role === "toolResult")
117
+ results.set(message.toolCallId, message);
118
+ const inputs = new Map();
119
+ for (const pending of input.inputs ?? [])
120
+ inputs.set(pending.toolCallId, pending);
121
+ const echoed = new Set();
122
+ for (const message of messages)
123
+ if (message.role === "user" && typeof message.requestId === "string")
124
+ echoed.add(message.requestId);
125
+ const local = (input.local ?? []).filter(send => !echoed.has(send.id));
126
+ const groups = [];
127
+ let open = null;
128
+ messages.forEach((message, position) => {
129
+ const index = indexes[position] ?? position;
130
+ if (message.role === "user") {
131
+ open = null;
132
+ groups.push({ kind: "user", index, message });
133
+ return;
134
+ }
135
+ if (message.role !== "assistant")
136
+ return;
137
+ if (!open) {
138
+ open = { kind: "turn", index, assistants: [] };
139
+ groups.push(open);
140
+ }
141
+ open.assistants.push(message);
142
+ });
143
+ // The turn streaming now (see above). Sends of ours still on their way come first.
144
+ const pendingSends = local.filter(send => send.status !== "failed").length;
145
+ let streaming = null;
146
+ if (input.partial || input.running) {
147
+ const last = groups.at(-1);
148
+ if (last?.kind === "turn" && continuesTurn(messages.at(-1)) && (input.partial || !pendingSends))
149
+ streaming = last;
150
+ // A run with nothing streaming yet gets no row: an empty one would flash (after a finished answer,
151
+ // before the run ends) or land above a message still on its way.
152
+ else if (input.partial) {
153
+ const next = (indexes.length ? indexes[indexes.length - 1] + 1 : 0) + pendingSends;
154
+ streaming = { kind: "turn", index: next, assistants: [] };
155
+ groups.push(streaming);
156
+ }
157
+ }
158
+ const out = [];
159
+ const nextMemo = new Map();
160
+ // Each value is kept with what it was built from, and the values built inside it (a message's
161
+ // parts), which a message reused as a whole carries forward.
162
+ let collector = null;
163
+ const carry = (id, entry) => {
164
+ nextMemo.set(id, entry);
165
+ for (const child of entry.children) {
166
+ const kept = memo.get(child);
167
+ if (kept)
168
+ carry(child, kept);
169
+ }
170
+ };
171
+ const remember = (id, sources, build) => {
172
+ collector?.push(id);
173
+ const previous = memo.get(id);
174
+ if (previous && same(previous.sources, sources)) {
175
+ carry(id, previous);
176
+ return previous.value;
177
+ }
178
+ const outer = collector, children = [];
179
+ collector = children;
180
+ let built;
181
+ try {
182
+ built = build();
183
+ }
184
+ finally {
185
+ collector = outer;
186
+ }
187
+ const value = reuse(previous?.value, built);
188
+ nextMemo.set(id, { sources, value, children });
189
+ return value;
190
+ };
191
+ for (const group of groups) {
192
+ if (group.kind === "user") {
193
+ const message = group.message;
194
+ const id = typeof message.requestId === "string" && message.requestId ? message.requestId : `m:${group.index}`;
195
+ out.push(remember(id, [message, group.index], () => userMessage(id, message, group.index)));
196
+ continue;
197
+ }
198
+ const id = `t:${group.index}`;
199
+ const isStreaming = group === streaming;
200
+ const partial = isStreaming ? input.partial : null;
201
+ // What the turn is built from: its messages, their calls' results, inputs, progress and file links.
202
+ const sources = [isStreaming, partial, ...group.assistants];
203
+ for (const assistant of [...group.assistants, ...(partial ? [partial] : [])]) {
204
+ for (const block of assistant.content) {
205
+ if (block?.type !== "toolCall")
206
+ continue;
207
+ sources.push(results.get(block.id), inputs.get(block.id), input.progress?.get(block.id));
208
+ if (isPresentFile(block.name) && typeof block.arguments?.path === "string")
209
+ sources.push(input.files?.get(block.arguments.path));
210
+ }
211
+ }
212
+ out.push(remember(id, sources, () => {
213
+ const parts = [];
214
+ let ordinal = 0;
215
+ const all = [...group.assistants, ...(partial ? [partial] : [])];
216
+ for (const assistant of all) {
217
+ const live = assistant === partial;
218
+ const count = assistant.content.length;
219
+ assistant.content.forEach((block, at) => {
220
+ if (!block)
221
+ return;
222
+ const position = ordinal++;
223
+ // Only the last block of the message streaming now is still being written.
224
+ const writing = live && at === count - 1;
225
+ const partId = `${id}:${position}`;
226
+ if (block.type === "text") {
227
+ if (block.text)
228
+ parts.push(remember(partId, [block, writing], () => ({ type: "text", id: partId, text: block.text, streaming: writing })));
229
+ }
230
+ else if (block.type === "thinking") {
231
+ if (block.redacted)
232
+ parts.push(remember(partId, [block, writing], () => ({ type: "reasoning", id: partId, text: "", streaming: writing, redacted: true })));
233
+ else if (block.thinking)
234
+ parts.push(remember(partId, [block, writing], () => ({ type: "reasoning", id: partId, text: block.thinking, streaming: writing })));
235
+ }
236
+ else if (block.type === "toolCall") {
237
+ const callId = typeof block.id === "string" && block.id ? block.id : partId;
238
+ const result = results.get(callId);
239
+ const pending = inputs.get(callId);
240
+ const progress = input.progress?.get(callId);
241
+ const name = String(block.name ?? "");
242
+ const args = isRecord(block.arguments) ? block.arguments : {};
243
+ if (isPresentFile(name) && typeof args.path === "string" && result && !result.isError && !isInputPlaceholder(result)) {
244
+ const url = input.files?.get(args.path);
245
+ parts.push(remember(`${id}:${callId}:file`, [block, result, url], () => fileAt(callId, args, result, url)));
246
+ return;
247
+ }
248
+ parts.push(remember(`${id}:${callId}`, [block, result, pending, progress, writing], () => toolAt(callId, name, args, result, pending, progress, writing)));
249
+ }
250
+ });
251
+ }
252
+ const last = group.assistants.at(-1);
253
+ const message = { id, role: "assistant", parts, createdAt: group.assistants[0]?.timestamp ?? partial?.timestamp ?? 0, streaming: isStreaming };
254
+ if (!isStreaming && last) {
255
+ if (last.stopReason === "aborted")
256
+ message.stopReason = "aborted";
257
+ else if (last.stopReason === "error") {
258
+ message.stopReason = "error";
259
+ if (last.errorMessage)
260
+ message.error = last.errorMessage;
261
+ }
262
+ else
263
+ message.stopReason = "stop";
264
+ }
265
+ return message;
266
+ }));
267
+ }
268
+ for (const send of local) {
269
+ out.push(remember(send.id, [send], () => ({
270
+ id: send.id, role: "user", parts: [{ type: "text", id: `${send.id}:0`, text: send.text, streaming: false }], text: send.text,
271
+ createdAt: send.createdAt, status: send.status, ...(send.error ? { error: send.error } : {}),
272
+ })));
273
+ }
274
+ memo.clear();
275
+ for (const [key, value] of nextMemo)
276
+ memo.set(key, value);
277
+ return out;
278
+ }
279
+ function userMessage(id, message, index) {
280
+ const parts = [];
281
+ const content = message.content;
282
+ if (typeof content === "string") {
283
+ if (content)
284
+ parts.push({ type: "text", id: `${id}:0`, text: content, streaming: false });
285
+ }
286
+ else
287
+ content.forEach((block, at) => {
288
+ if (block.type === "text" && block.text)
289
+ parts.push({ type: "text", id: `${id}:${at}`, text: block.text, streaming: false });
290
+ else if (block.type === "image")
291
+ parts.push({ type: "image", id: `${id}:${at}`, url: `data:${block.mimeType};base64,${block.data}`, mimeType: block.mimeType });
292
+ });
293
+ return {
294
+ id, role: "user", parts, text: textOfContent(content), createdAt: message.timestamp ?? 0, status: "sent", index,
295
+ ...(message.from ? { from: message.from } : {}), ...(message.metadata ? { metadata: message.metadata } : {}),
296
+ };
297
+ }
298
+ function toolAt(id, name, args, result, pending, progress, writing) {
299
+ const part = { type: "tool", id, name, args, state: "running" };
300
+ if (result && !isInputPlaceholder(result)) {
301
+ const text = textOfContent(result.content);
302
+ const data = parseJson(text);
303
+ part.state = result.isError ? "error" : "done";
304
+ part.result = { text, content: result.content, ...(result.details !== undefined ? { details: result.details } : {}), ...(data !== undefined ? { data } : {}) };
305
+ return part;
306
+ }
307
+ if (pending) {
308
+ part.state = "input_required";
309
+ part.input = pending;
310
+ }
311
+ else if (result)
312
+ part.state = "input_required";
313
+ else if (writing)
314
+ part.state = "input_streaming";
315
+ const shown = progressOf(progress);
316
+ if (shown)
317
+ part.progress = shown;
318
+ return part;
319
+ }
320
+ function fileAt(callId, args, result, url) {
321
+ const data = parseJson(textOfContent(result.content));
322
+ const path = typeof data?.path === "string" ? data.path : args.path;
323
+ return {
324
+ type: "file", id: `${callId}:file`, toolCallId: callId, path, name: baseName(path),
325
+ ...(typeof data?.contentType === "string" ? { contentType: data.contentType } : {}),
326
+ ...(typeof data?.size === "number" ? { size: data.size } : {}),
327
+ ...(typeof args.caption === "string" ? { caption: args.caption } : {}),
328
+ ...(url ? { url } : {}),
329
+ };
330
+ }
331
+ // ---------------------------------------------------------------------------------------------
332
+ // The store
333
+ /**
334
+ * Where the watcher reads the agent: the runtime (with the browser token), or, when the handler proxies
335
+ * reads, the handler itself (under the thread's path), with the chat's own headers and credentials.
336
+ */
337
+ export function readsFrom(minted, endpoint, thread, doFetch, headers, credentials) {
338
+ if (!minted.proxy)
339
+ return { url: minted.url, fetch: doFetch };
340
+ const base = `${endpoint.replace(/\/+$/, "")}${thread ? `/threads/${encodeURIComponent(thread)}` : ""}`;
341
+ return {
342
+ url: base,
343
+ fetch: async (input, init = {}) => {
344
+ const own = typeof headers === "function" ? await headers() : headers ?? {};
345
+ // The watcher's Authorization is a placeholder here: the handler adds the real token, and yours goes instead.
346
+ const merged = { ...init.headers, ...own };
347
+ if (!own.Authorization && !own.authorization)
348
+ delete merged.Authorization;
349
+ return doFetch(input, { ...init, headers: merged, credentials: credentials ?? "same-origin" });
350
+ },
351
+ };
352
+ }
353
+ const newId = () => `cm_${globalThis.crypto.randomUUID().replace(/-/g, "")}`;
354
+ const EMPTY = { status: "connecting", messages: [], inputs: [], error: null, hasOlder: false, connected: false, agentId: null };
355
+ /** A chat with the user's agent, through your agent handler: see `AgentChatOptions` and `ChatSnapshot`. */
356
+ export function createAgentChat(options) {
357
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
358
+ const listeners = new Set();
359
+ let snapshot = EMPTY;
360
+ let view = null;
361
+ let watcher = null;
362
+ let generation = 0;
363
+ let agentId = null;
364
+ let error = null;
365
+ let fatal = false;
366
+ let destroyed = false;
367
+ let local = [];
368
+ const answering = new Map();
369
+ const files = new Map();
370
+ let fileUrls = new Map();
371
+ const memo = new Map();
372
+ let scheduled = false;
373
+ function toError(value, fallback = "request_failed") {
374
+ if (isRecord(value) && typeof value.code === "string" && typeof value.message === "string")
375
+ return value;
376
+ return { code: fallback, message: value instanceof Error ? value.message : String(value) };
377
+ }
378
+ function report(value) { error = value; options.onError?.(value); schedule(); }
379
+ /** A call to the handler: its JSON, or a ChatError. */
380
+ async function call(action, body = {}) {
381
+ const extra = typeof options.headers === "function" ? await options.headers() : options.headers ?? {};
382
+ let response;
383
+ try {
384
+ response = await doFetch(options.endpoint, {
385
+ method: "POST", credentials: options.credentials ?? "same-origin",
386
+ headers: { "Content-Type": "application/json", ...extra },
387
+ body: JSON.stringify({ action, ...(options.thread !== undefined ? { thread: options.thread } : {}), ...body }),
388
+ });
389
+ }
390
+ catch (cause) {
391
+ throw { code: "network_error", message: `Could not reach ${options.endpoint}: ${cause.message}` };
392
+ }
393
+ const value = await response.json().catch(() => ({}));
394
+ if (!response.ok) {
395
+ const failure = typeof value.error === "object" ? value.error : { message: typeof value.error === "string" ? value.error : undefined };
396
+ throw { code: failure?.code ?? `http_${response.status}`, message: failure?.message ?? `${options.endpoint}: HTTP ${response.status}`, status: response.status };
397
+ }
398
+ return value;
399
+ }
400
+ function compute() {
401
+ const inputs = (view?.pendingInputs ?? []).map(input => {
402
+ const state = answering.get(input.id);
403
+ return state === undefined ? { ...input, answering: false } : { ...input, answering: state === null, ...(state ? { error: state } : {}) };
404
+ });
405
+ // Keep input objects stable while nothing about them changed.
406
+ const previousInputs = new Map(snapshot.inputs.map(input => [input.id, input]));
407
+ const stableInputs = inputs.map(input => reuse(previousInputs.get(input.id), input));
408
+ const now = Date.now();
409
+ const links = new Map();
410
+ for (const [path, link] of files)
411
+ if (!link.expiresAt || link.expiresAt > now + 30_000)
412
+ links.set(path, link.url);
413
+ if (links.size !== fileUrls.size || [...links].some(([path, url]) => fileUrls.get(path) !== url))
414
+ fileUrls = links;
415
+ const messages = projectMessages({
416
+ messages: view?.messages ?? [], indexes: view?.indexes ?? [], partial: view?.partial ?? null, progress: view?.progress,
417
+ running: view?.running ?? false, inputs: stableInputs, local, files: fileUrls,
418
+ }, memo);
419
+ const waiting = local.some(send => send.status !== "failed");
420
+ // A run whose answer just ended is still finishing: not a new wait.
421
+ const last = messages.at(-1);
422
+ const streaming = messages.some(message => message.role === "assistant" && message.streaming) || (!!view?.running && !waiting && last?.role === "assistant");
423
+ const status = fatal ? "error"
424
+ : streaming ? "streaming"
425
+ : waiting || view?.running ? "submitted"
426
+ : stableInputs.some(input => !input.answering) ? "input_required"
427
+ : !view?.transport ? "connecting"
428
+ : "ready";
429
+ const next = { status, messages, inputs: stableInputs, error, hasOlder: view?.hasOlder ?? false, connected: view?.connected ?? false, agentId };
430
+ return reuse(snapshot, next);
431
+ }
432
+ function emit() {
433
+ scheduled = false;
434
+ const next = compute();
435
+ if (next === snapshot)
436
+ return;
437
+ snapshot = next;
438
+ for (const listener of [...listeners])
439
+ listener();
440
+ }
441
+ function schedule() {
442
+ if (scheduled)
443
+ return;
444
+ scheduled = true;
445
+ queueMicrotask(emit);
446
+ }
447
+ /** The generation whose token is being fetched, so a second connect() meanwhile does not start another. */
448
+ let connecting = null;
449
+ function connect() {
450
+ if (destroyed || watcher || connecting === generation)
451
+ return;
452
+ const mine = ++generation;
453
+ connecting = mine;
454
+ fatal = false;
455
+ void (async () => {
456
+ let minted;
457
+ // A refusal (not signed in, not allowed) stops here; anything else is tried again, backing off.
458
+ for (let backoff = 1000;; backoff = Math.min(backoff * 2, 30_000)) {
459
+ try {
460
+ minted = await call("token");
461
+ break;
462
+ }
463
+ catch (cause) {
464
+ if (mine !== generation)
465
+ return;
466
+ const failure = toError(cause);
467
+ if (failure.status && failure.status < 500 && failure.status !== 429) {
468
+ fatal = true;
469
+ connecting = null;
470
+ report(failure);
471
+ return;
472
+ }
473
+ report(failure);
474
+ await new Promise(resolve => setTimeout(resolve, backoff));
475
+ if (mine !== generation)
476
+ return;
477
+ }
478
+ }
479
+ // Disconnected (or destroyed) while the token was on its way, or superseded by a later connect: stop here.
480
+ if (mine !== generation || destroyed)
481
+ return;
482
+ connecting = null;
483
+ try {
484
+ agentId = minted.agentId;
485
+ watcher?.close();
486
+ watcher = watchAgent({
487
+ ...options.watch, ...readsFrom(minted, options.endpoint, options.thread, doFetch, options.headers, options.credentials),
488
+ agentId: minted.agentId, token: minted.token, expiresAt: minted.expiresAt,
489
+ getToken: async () => { const renewed = await call("token"); return { token: renewed.token, expiresAt: renewed.expiresAt }; },
490
+ onChange: state => {
491
+ if (mine !== generation)
492
+ return;
493
+ view = state;
494
+ if (state.expired)
495
+ fatal = true;
496
+ else if (state.connected && error?.code === "stream_error")
497
+ error = null;
498
+ // Messages of ours the agent now has are done with.
499
+ if (local.length) {
500
+ const echoed = new Set(state.messages.flatMap(message => message.role === "user" && typeof message.requestId === "string" ? [message.requestId] : []));
501
+ if (local.some(send => echoed.has(send.id)))
502
+ local = local.filter(send => !echoed.has(send.id));
503
+ }
504
+ for (const id of answering.keys())
505
+ if (!state.pendingInputs.some(input => input.id === id))
506
+ answering.delete(id);
507
+ schedule();
508
+ },
509
+ onEvent: event => {
510
+ if (event.type === "file_presented" && event.url)
511
+ files.set(event.file.path, { url: event.url, ...(event.expiresAt ? { expiresAt: event.expiresAt } : {}) });
512
+ options.onEvent?.(event);
513
+ },
514
+ onError: cause => { if (mine === generation)
515
+ report({ code: "stream_error", message: cause.message }); },
516
+ });
517
+ view = watcher.state;
518
+ schedule();
519
+ }
520
+ catch (cause) {
521
+ if (mine !== generation)
522
+ return;
523
+ fatal = true;
524
+ report(toError(cause));
525
+ }
526
+ })();
527
+ }
528
+ function disconnect() {
529
+ generation++;
530
+ connecting = null;
531
+ watcher?.close();
532
+ watcher = null;
533
+ if (view) {
534
+ view = { ...view, connected: false };
535
+ schedule();
536
+ }
537
+ }
538
+ async function deliver(send) {
539
+ try {
540
+ await call("send", { text: send.text, clientId: send.id, ...(send.data !== undefined ? { data: send.data } : {}), ...(send.whileRunning ? { whileRunning: send.whileRunning } : {}) });
541
+ local = local.map(item => item.id === send.id ? { ...item, status: "sent" } : item);
542
+ if (error && error.code !== "stream_error")
543
+ error = null;
544
+ }
545
+ catch (cause) {
546
+ const failure = toError(cause);
547
+ local = local.map(item => item.id === send.id ? { ...item, status: "failed", error: failure } : item);
548
+ report(failure);
549
+ }
550
+ schedule();
551
+ }
552
+ const inputOf = (input) => typeof input === "string" ? snapshot.inputs.find(item => item.id === input) : input;
553
+ const chat = {
554
+ getSnapshot: () => snapshot,
555
+ subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
556
+ async send(text, sendOptions = {}) {
557
+ if (!text.trim())
558
+ throw new Error("Send some text");
559
+ const send = {
560
+ id: newId(), text, createdAt: Date.now(), status: "sending",
561
+ ...(sendOptions.data !== undefined ? { data: sendOptions.data } : {}),
562
+ ...(sendOptions.whileRunning ?? options.whileRunning ? { whileRunning: sendOptions.whileRunning ?? options.whileRunning } : {}),
563
+ };
564
+ local = [...local, send];
565
+ emit();
566
+ await deliver(send);
567
+ return { id: send.id };
568
+ },
569
+ async retry(messageId) {
570
+ const send = local.find(item => item.id === messageId && item.status === "failed");
571
+ if (!send)
572
+ return;
573
+ const again = { ...send, status: "sending" };
574
+ delete again.error;
575
+ local = local.map(item => item.id === messageId ? again : item);
576
+ emit();
577
+ await deliver(again);
578
+ },
579
+ async answer(target, value) {
580
+ const input = inputOf(target);
581
+ if (!input)
582
+ throw new Error("No such input is pending");
583
+ const answer = isAnswer(value) ? value : answerValue(input, value);
584
+ answering.set(input.id, null);
585
+ emit();
586
+ try {
587
+ await call("answer", { inputId: input.id, answer });
588
+ }
589
+ catch (cause) {
590
+ const failure = toError(cause);
591
+ answering.set(input.id, failure);
592
+ report(failure);
593
+ return;
594
+ }
595
+ schedule();
596
+ },
597
+ decline(target) { return chat.answer(target, { action: "decline" }); },
598
+ async stop() {
599
+ try {
600
+ await call("stop");
601
+ }
602
+ catch (cause) {
603
+ report(toError(cause));
604
+ }
605
+ },
606
+ async loadOlder() {
607
+ if (!watcher)
608
+ return false;
609
+ try {
610
+ return await watcher.loadOlder();
611
+ }
612
+ catch (cause) {
613
+ report(toError(cause));
614
+ return false;
615
+ }
616
+ },
617
+ async fileUrl(path) {
618
+ const known = files.get(path);
619
+ if (known && (!known.expiresAt || known.expiresAt > Date.now() + 30_000))
620
+ return known.url;
621
+ const link = await call("link", { path });
622
+ files.set(path, { url: link.url, ...(link.expiresAt ? { expiresAt: link.expiresAt } : {}) });
623
+ schedule();
624
+ return link.url;
625
+ },
626
+ connect,
627
+ disconnect,
628
+ destroy() { destroyed = true; disconnect(); listeners.clear(); },
629
+ };
630
+ if (options.autoConnect !== false)
631
+ connect();
632
+ return chat;
633
+ }