@assistant-ui/react 0.15.12 → 0.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1147 +0,0 @@
1
- import { useState, useMemo, useEffect, useCallback, useRef } from "react";
2
- import { resource, withKey } from "@assistant-ui/tap";
3
- import {
4
- type ClientElement,
5
- type ClientOutput,
6
- useClientLookup,
7
- attachTransformScopes,
8
- useClientResource,
9
- Derived,
10
- } from "@assistant-ui/store";
11
-
12
- import type {
13
- AddToolResultOptions,
14
- AppendMessage,
15
- Attachment,
16
- AttachmentAdapter,
17
- CreateAttachment,
18
- PendingAttachment,
19
- RespondToToolApprovalOptions,
20
- ResumeToolCallOptions,
21
- MessagePartStatus,
22
- ThreadAssistantMessagePart,
23
- ThreadUserMessagePart,
24
- ThreadMessage,
25
- ToolCallMessagePartStatus,
26
- ExternalThreadQueueAdapter,
27
- ExternalThreadBranchAdapter,
28
- QueuePlacement,
29
- FeedbackAdapter,
30
- SpeechState,
31
- SpeechSynthesisAdapter,
32
- } from "@assistant-ui/core";
33
- import { ToolResponse } from "assistant-stream";
34
- import type { ReadonlyJSONValue } from "assistant-stream/utils";
35
- import type { QueueItemState } from "@assistant-ui/core/store";
36
- import type { ComposerSendOptions } from "@assistant-ui/core/store";
37
- import {
38
- fileMatchesAccept,
39
- getThreadMessageText,
40
- isCreateAttachment,
41
- resolveToolApprovalResponse,
42
- toMessagePartStatus,
43
- } from "@assistant-ui/core/internal";
44
- import { ModelContext, Suggestions } from "@assistant-ui/core/store";
45
- import { Tools, DataRenderers } from "@assistant-ui/core/react";
46
- import { SingleThreadList } from "./SingleThreadList";
47
-
48
- const EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = [];
49
- const EMPTY_BRANCH_IDS: readonly string[] = [];
50
-
51
- export type ExternalThreadMessage = ThreadMessage & {
52
- id: string;
53
- };
54
-
55
- const COMPLETE_STATUS: MessagePartStatus = Object.freeze({
56
- type: "complete",
57
- });
58
-
59
- const derivePartStatus = (
60
- message: ExternalThreadMessage,
61
- partIndex: number,
62
- part: ThreadAssistantMessagePart | ThreadUserMessagePart,
63
- ): ToolCallMessagePartStatus => {
64
- if (!message.status) return COMPLETE_STATUS;
65
- return toMessagePartStatus(message, partIndex, part);
66
- };
67
-
68
- export type ExternalThreadProps = {
69
- messages: readonly ExternalThreadMessage[];
70
- isRunning?: boolean;
71
- isLoading?: boolean | undefined;
72
- state?: ReadonlyJSONValue | undefined;
73
- extras?: unknown;
74
- /**
75
- * Whether sending new messages is currently disabled. When `true`, the
76
- * thread composer's input remains usable but `send()` is a no-op and
77
- * `composer.canSend` is `false`. Edit composers (saving message edits)
78
- * intentionally ignore this flag.
79
- */
80
- isSendDisabled?: boolean;
81
- /**
82
- * Callback for new messages (non-queue runtimes).
83
- * @note Unused when `queue` is provided — new messages are routed through `queue.enqueue` instead.
84
- */
85
- onNew?: (message: AppendMessage) => void;
86
- onEdit?: (message: AppendMessage) => void;
87
- onReload?: (parentId: string | null) => void;
88
- onStartRun?: () => void;
89
- onCancel?: () => void;
90
- onResume?: (() => void) | undefined;
91
- onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
92
- /** Callback for resuming a tool call that is waiting for human input. */
93
- onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
94
- onLoadExternalState?: ((state: unknown) => void) | undefined;
95
- attachmentAdapter?: AttachmentAdapter | undefined;
96
- feedbackAdapter?: FeedbackAdapter | undefined;
97
- speechAdapter?: SpeechSynthesisAdapter | undefined;
98
- /** Queue adapter for runtimes that support message queuing and steering. */
99
- queue?: ExternalThreadQueueAdapter;
100
- /** Branch adapter for runtimes that track sibling variants of messages. */
101
- branches?: ExternalThreadBranchAdapter;
102
- /** Callback for tool approval decisions. Absent: responding to an approval throws a capability error. */
103
- onRespondToToolApproval?: (options: RespondToToolApprovalOptions) => void;
104
- };
105
-
106
- type MessageClientProps = {
107
- message: ExternalThreadMessage;
108
- index: number;
109
- parentId: string | null;
110
- onEdit?: (message: AppendMessage) => void;
111
- onReload?: () => void;
112
- queue?: ExternalThreadQueueAdapter | undefined;
113
- branches?: ExternalThreadBranchAdapter | undefined;
114
- onRespondToToolApproval?:
115
- | ((options: RespondToToolApprovalOptions) => void)
116
- | undefined;
117
- onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
118
- onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
119
- attachmentAdapter?: AttachmentAdapter | undefined;
120
- submittedFeedback: "positive" | "negative" | undefined;
121
- onSubmitFeedback: (feedback: { type: "positive" | "negative" }) => void;
122
- speech: SpeechState | undefined;
123
- onSpeak: () => void;
124
- onStopSpeaking: () => void;
125
- };
126
-
127
- // Message Client - minimal implementation
128
- const useMessageClient = ({
129
- message,
130
- index,
131
- parentId,
132
- onEdit,
133
- onReload,
134
- queue,
135
- branches,
136
- onRespondToToolApproval,
137
- onAddToolResult,
138
- onResumeToolCall,
139
- attachmentAdapter,
140
- submittedFeedback,
141
- onSubmitFeedback,
142
- speech,
143
- onSpeak,
144
- onStopSpeaking,
145
- }: MessageClientProps): ClientOutput<"message"> => {
146
- const [isCopied, setIsCopied] = useState(false);
147
- const [isHovering, setIsHovering] = useState(false);
148
-
149
- const partClients = useClientLookup(
150
- message.content.map((part, idx) =>
151
- withKey(
152
- idx,
153
- PartResource({
154
- part,
155
- status: derivePartStatus(message, idx, part),
156
- messageId: message.id,
157
- onRespondToToolApproval,
158
- onAddToolResult,
159
- onResumeToolCall,
160
- }),
161
- ),
162
- ),
163
- );
164
-
165
- const attachmentClients = useClientLookup(
166
- (message.attachments ?? []).map((attachment) =>
167
- withKey(
168
- attachment.id,
169
- AttachmentResource({
170
- attachment,
171
- onRemove: () => {},
172
- }),
173
- ),
174
- ),
175
- );
176
-
177
- const handleBeginEdit = () => {
178
- if (!onEdit) throw new Error("Runtime does not support editing.");
179
- };
180
-
181
- const handleSendEdit = (msg: AppendMessage) => {
182
- if (!onEdit) throw new Error("Runtime does not support editing.");
183
- onEdit({
184
- ...msg,
185
- parentId,
186
- sourceId: message.id,
187
- });
188
- };
189
-
190
- const composerClient = useClientResource(
191
- ComposerClientResource({
192
- type: "edit",
193
- canCancel: true,
194
- onBeginEdit: handleBeginEdit,
195
- onSend: handleSendEdit,
196
- message,
197
- queue,
198
- attachmentAdapter,
199
- }),
200
- );
201
-
202
- const branchIds = branches?.getBranches(message.id) ?? EMPTY_BRANCH_IDS;
203
- const branchIndex = branchIds.indexOf(message.id);
204
- const branchNumber = branchIndex === -1 ? 1 : branchIndex + 1;
205
- const branchCount = branchIndex === -1 ? 1 : branchIds.length;
206
-
207
- const state = useMemo(() => {
208
- const messageWithFeedback: ExternalThreadMessage =
209
- submittedFeedback && message.role === "assistant"
210
- ? {
211
- ...message,
212
- metadata: {
213
- ...message.metadata,
214
- submittedFeedback: { type: submittedFeedback },
215
- },
216
- }
217
- : message;
218
- return {
219
- ...messageWithFeedback,
220
- attachments: message.attachments ?? [],
221
- parentId,
222
- isLast: false, // Will be set by thread
223
- branchNumber,
224
- branchCount,
225
- speech,
226
- parts: partClients.state,
227
- isCopied,
228
- isHovering,
229
- index,
230
- composer: composerClient.state,
231
- };
232
- }, [
233
- message,
234
- parentId,
235
- isCopied,
236
- isHovering,
237
- index,
238
- composerClient.state,
239
- partClients.state,
240
- branchNumber,
241
- branchCount,
242
- submittedFeedback,
243
- speech,
244
- ]);
245
-
246
- return {
247
- getState: () => state,
248
- composer: () => composerClient.methods,
249
- delete: () => {},
250
- reload: () => {
251
- onReload?.();
252
- },
253
- speak: onSpeak,
254
- stopSpeaking: onStopSpeaking,
255
- submitFeedback: onSubmitFeedback,
256
- switchToBranch: ({ position, branchId }) => {
257
- if (!branches) return;
258
- const target =
259
- branchId ??
260
- (branchIndex === -1
261
- ? undefined
262
- : position === "previous"
263
- ? branchIds[branchIndex - 1]
264
- : position === "next"
265
- ? branchIds[branchIndex + 1]
266
- : undefined);
267
- if (target !== undefined && target !== message.id)
268
- branches.switchToBranch(target);
269
- },
270
- getCopyText: () => getThreadMessageText(message),
271
- part: (selector) => {
272
- if ("index" in selector) {
273
- return partClients.get(selector);
274
- }
275
- const partIndex = state.parts.findIndex(
276
- (p) => p.type === "tool-call" && p.toolCallId === selector.toolCallId,
277
- );
278
- return partClients.get({ index: partIndex });
279
- },
280
- attachment: (selector) => {
281
- if ("id" in selector) {
282
- return attachmentClients.get({ key: selector.id });
283
- }
284
- return attachmentClients.get(selector);
285
- },
286
- setIsCopied,
287
- setIsHovering,
288
- };
289
- };
290
-
291
- const MessageClient = resource(useMessageClient);
292
-
293
- type PartResourceProps = {
294
- part: ThreadAssistantMessagePart | ThreadUserMessagePart;
295
- status: ToolCallMessagePartStatus;
296
- messageId: string;
297
- onRespondToToolApproval?:
298
- | ((options: RespondToToolApprovalOptions) => void)
299
- | undefined;
300
- onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
301
- onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
302
- };
303
-
304
- // Part Client - minimal implementation
305
- const usePartResource = ({
306
- part,
307
- status,
308
- messageId,
309
- onRespondToToolApproval,
310
- onAddToolResult,
311
- onResumeToolCall,
312
- }: PartResourceProps): ClientOutput<"part"> => {
313
- const state = useMemo(
314
- () => ({ ...part, status: status as MessagePartStatus }),
315
- [part, status],
316
- );
317
-
318
- return {
319
- getState: () => state,
320
- addToolResult: (result) => {
321
- if (!onAddToolResult)
322
- throw new Error(
323
- "Runtime does not support tool results (onAddToolResult is not set).",
324
- );
325
- if (part.type !== "tool-call")
326
- throw new Error("Tried to add tool result on non-tool message part");
327
-
328
- const response = ToolResponse.toResponse(result);
329
- onAddToolResult({
330
- messageId,
331
- toolName: part.toolName,
332
- toolCallId: part.toolCallId,
333
- result: response.result as ReadonlyJSONValue,
334
- isError: response.isError,
335
- ...(response.artifact !== undefined && { artifact: response.artifact }),
336
- ...(response.modelContent !== undefined && {
337
- modelContent: response.modelContent,
338
- }),
339
- });
340
- },
341
- resumeToolCall: (payload) => {
342
- if (!onResumeToolCall)
343
- throw new Error(
344
- "Runtime does not support resuming tool calls (onResumeToolCall is not set).",
345
- );
346
- if (part.type !== "tool-call")
347
- throw new Error("Tried to resume tool call on non-tool message part");
348
-
349
- onResumeToolCall({ toolCallId: part.toolCallId, payload });
350
- },
351
- respondToToolApproval: (response) => {
352
- if (!onRespondToToolApproval)
353
- throw new Error("Runtime does not support tool approvals.");
354
-
355
- if (part.type !== "tool-call")
356
- throw new Error(
357
- "Tried to respond to tool approval on non-tool message part",
358
- );
359
-
360
- if (
361
- !part.approval ||
362
- part.approval.approved !== undefined ||
363
- part.approval.resolution !== undefined
364
- )
365
- throw new Error("Tool call has no pending approval");
366
-
367
- onRespondToToolApproval(
368
- resolveToolApprovalResponse(part.approval, response),
369
- );
370
- },
371
- };
372
- };
373
-
374
- const PartResource = resource(usePartResource);
375
-
376
- type AttachmentResourceProps = {
377
- attachment: Attachment;
378
- onRemove?: () => void | Promise<void>;
379
- };
380
-
381
- // Attachment Client - minimal implementation
382
- const useAttachmentResource = ({
383
- attachment,
384
- onRemove,
385
- }: AttachmentResourceProps): ClientOutput<"attachment"> => {
386
- return {
387
- getState: () => attachment,
388
- remove: async () => {
389
- await onRemove?.();
390
- },
391
- };
392
- };
393
-
394
- const AttachmentResource = resource(useAttachmentResource);
395
-
396
- type ComposerClientResourceProps = {
397
- type: "thread" | "edit";
398
- canCancel: boolean;
399
- isSendDisabled?: boolean;
400
- onCancel?: () => void;
401
- onBeginEdit?: () => void;
402
- onSend?: (message: AppendMessage) => void;
403
- message?: ExternalThreadMessage;
404
- queue?: ExternalThreadQueueAdapter | undefined;
405
- attachmentAdapter?: AttachmentAdapter | undefined;
406
- };
407
-
408
- const useQueueItemClient = ({
409
- item,
410
- onMove,
411
- onRemove,
412
- }: {
413
- item: QueueItemState;
414
- onMove: (placement: QueuePlacement) => void;
415
- onRemove: () => void;
416
- }): ClientOutput<"queueItem"> => {
417
- return {
418
- getState: () => item,
419
- steer: () => onMove({ lane: "steer", insertAfter: null }),
420
- move: onMove,
421
- remove: onRemove,
422
- };
423
- };
424
-
425
- const QueueItemClient = resource(useQueueItemClient);
426
-
427
- const drainAdapterAdd = async (
428
- result: ReturnType<AttachmentAdapter["add"]>,
429
- upsert: (attachment: Attachment) => void,
430
- ) => {
431
- if (Symbol.asyncIterator in result) {
432
- for await (const attachment of result) {
433
- upsert(attachment);
434
- }
435
- } else {
436
- upsert(await result);
437
- }
438
- };
439
-
440
- // State whose setter tracks the latest value in a ref, so imperative
441
- // call sequences (setText immediately followed by send) observe the write
442
- // before React re-renders — legacy composer parity.
443
- const useLiveState = <T>(initial: T) => {
444
- const [state, setState] = useState(initial);
445
- const ref = useRef(state);
446
- const set = useCallback((next: T | ((prev: T) => T)) => {
447
- ref.current =
448
- typeof next === "function" ? (next as (prev: T) => T)(ref.current) : next;
449
- setState(ref.current);
450
- }, []);
451
- return [state, set, ref] as const;
452
- };
453
-
454
- // Composer Client - minimal implementation
455
- const useComposerClientResource = ({
456
- type,
457
- canCancel,
458
- isSendDisabled = false,
459
- onCancel,
460
- onBeginEdit,
461
- onSend,
462
- message,
463
- queue,
464
- attachmentAdapter,
465
- }: ComposerClientResourceProps): ClientOutput<"composer"> => {
466
- const [isEditing, setIsEditing, isEditingRef] = useLiveState(
467
- type === "thread",
468
- );
469
- const [text, setText, textRef] = useLiveState("");
470
- const [role, setRole, roleRef] = useLiveState<
471
- "user" | "assistant" | "system"
472
- >("user");
473
- const [runConfig, setRunConfig, runConfigRef] = useLiveState<
474
- Record<string, unknown>
475
- >({});
476
- const [attachments, setAttachments, attachmentsRef] = useLiveState<
477
- readonly Attachment[]
478
- >([]);
479
- const [quote, setQuote, quoteRef] = useLiveState<
480
- { readonly text: string; readonly messageId: string } | undefined
481
- >(undefined);
482
-
483
- const updateFromMessage = () => {
484
- if (!message) return;
485
- const messageText = message.content
486
- .filter((part) => part.type === "text")
487
- .map((part) => part.text)
488
- .join("\n\n");
489
- setText(messageText);
490
- setRole(message.role);
491
- setAttachments(message.attachments ?? []);
492
- };
493
-
494
- const attachmentClients = useClientLookup(
495
- attachments.map((attachment) =>
496
- withKey(
497
- attachment.id,
498
- AttachmentResource({
499
- attachment,
500
- onRemove: async () => {
501
- await attachmentAdapter?.remove(attachment);
502
- setAttachments((prev) =>
503
- prev.filter((a) => a.id !== attachment.id),
504
- );
505
- },
506
- }),
507
- ),
508
- ),
509
- );
510
-
511
- const removePendingAttachments = async (removed: readonly Attachment[]) => {
512
- if (!attachmentAdapter) return;
513
- await Promise.all(
514
- removed
515
- .filter((a) => a.status.type !== "complete")
516
- .map((a) => attachmentAdapter.remove(a)),
517
- );
518
- };
519
-
520
- const upsertAttachment = (attachment: Attachment) => {
521
- setAttachments((prev) => {
522
- const idx = prev.findIndex((a) => a.id === attachment.id);
523
- if (idx === -1) return [...prev, attachment];
524
- const next = [...prev];
525
- next[idx] = attachment;
526
- return next;
527
- });
528
- };
529
-
530
- const steerItems = queue?.steerItems ?? EMPTY_QUEUE_ITEMS;
531
- const laneItems = queue?.items ?? EMPTY_QUEUE_ITEMS;
532
- const queueItems = useMemo(
533
- () =>
534
- steerItems.length === 0
535
- ? laneItems
536
- : laneItems.length === 0
537
- ? steerItems
538
- : [...steerItems, ...laneItems],
539
- [steerItems, laneItems],
540
- );
541
- const queueItemClients = useClientLookup(
542
- queueItems.map((item) =>
543
- withKey(
544
- item.id,
545
- QueueItemClient({
546
- item,
547
- onMove: (placement) => queue?.move(item.id, placement),
548
- onRemove: () => queue?.remove(item.id),
549
- }),
550
- ),
551
- ),
552
- );
553
-
554
- const state = useMemo(() => {
555
- const isEmpty = !text.trim() && !attachments.length;
556
- return {
557
- text,
558
- role,
559
- attachments: attachmentClients.state,
560
- runConfig,
561
- isEditing,
562
- canCancel,
563
- canSend: isEditing && !isEmpty && !isSendDisabled,
564
- attachmentAccept: attachmentAdapter?.accept ?? "*",
565
- isEmpty,
566
- type,
567
- dictation: undefined,
568
- quote,
569
- queue: queueItems,
570
- };
571
- }, [
572
- text,
573
- role,
574
- attachmentClients.state,
575
- runConfig,
576
- isEditing,
577
- canCancel,
578
- isSendDisabled,
579
- type,
580
- attachments.length,
581
- quote,
582
- queueItems,
583
- attachmentAdapter?.accept,
584
- ]);
585
-
586
- return {
587
- getState: () => state,
588
- setText,
589
- setRole,
590
- setRunConfig,
591
- addAttachment: async (fileOrAttachment: File | CreateAttachment) => {
592
- if (attachmentAdapter) {
593
- const file = isCreateAttachment(fileOrAttachment)
594
- ? {
595
- name: fileOrAttachment.name,
596
- type: fileOrAttachment.contentType ?? "",
597
- }
598
- : { name: fileOrAttachment.name, type: fileOrAttachment.type };
599
- if (!fileMatchesAccept(file, attachmentAdapter.accept))
600
- throw new Error(
601
- `File type ${file.type || "unknown"} is not accepted. Accepted types: ${attachmentAdapter.accept}`,
602
- );
603
- }
604
- if (!isCreateAttachment(fileOrAttachment) && attachmentAdapter) {
605
- await drainAdapterAdd(
606
- attachmentAdapter.add({ file: fileOrAttachment }),
607
- upsertAttachment,
608
- );
609
- } else if (!isCreateAttachment(fileOrAttachment)) {
610
- const newAttachment: Attachment = {
611
- id: Math.random().toString(36).substring(7),
612
- type: "file",
613
- name: fileOrAttachment.name,
614
- contentType: fileOrAttachment.type,
615
- file: fileOrAttachment,
616
- status: { type: "complete" },
617
- content: [],
618
- };
619
- setAttachments((prev) => [...prev, newAttachment]);
620
- } else {
621
- const newAttachment: Attachment = {
622
- id: fileOrAttachment.id ?? Math.random().toString(36).substring(7),
623
- type: fileOrAttachment.type ?? "document",
624
- name: fileOrAttachment.name,
625
- contentType: fileOrAttachment.contentType,
626
- content: fileOrAttachment.content,
627
- status: { type: "complete" },
628
- };
629
- setAttachments((prev) => [...prev, newAttachment]);
630
- }
631
- },
632
- clearAttachments: async () => {
633
- const removed = attachmentsRef.current;
634
- setAttachments([]);
635
- await removePendingAttachments(removed);
636
- },
637
- attachment: (selector) => {
638
- if ("id" in selector) {
639
- return attachmentClients.get({ key: selector.id });
640
- }
641
- return attachmentClients.get(selector);
642
- },
643
- reset: async () => {
644
- const removed = attachmentsRef.current;
645
- setText("");
646
- setRole("user");
647
- setRunConfig({});
648
- setAttachments([]);
649
- setQuote(undefined);
650
- await removePendingAttachments(removed);
651
- },
652
- send: (opts?: ComposerSendOptions) => {
653
- const currentQuote = quoteRef.current;
654
- const currentText = textRef.current;
655
- const currentAttachments = attachmentsRef.current;
656
- const isEmpty = !currentText.trim() && !currentAttachments.length;
657
- if (!isEditingRef.current) throw new Error("Composer is not available");
658
- if (isEmpty || isSendDisabled) return;
659
-
660
- setText("");
661
- setAttachments([]);
662
- setQuote(undefined);
663
-
664
- const dispatch = (sendAttachments: readonly Attachment[]) => {
665
- const composedMessage: AppendMessage = {
666
- role: roleRef.current,
667
- content: currentText
668
- ? [{ type: "text" as const, text: currentText }]
669
- : [],
670
- attachments: sendAttachments as any,
671
- createdAt: new Date(),
672
- parentId: null,
673
- sourceId: null,
674
- runConfig: runConfigRef.current,
675
- startRun: opts?.startRun,
676
- metadata: {
677
- custom: { ...(currentQuote ? { quote: currentQuote } : {}) },
678
- },
679
- };
680
- // edit sends carry a sourceId contract; only thread sends queue
681
- if (queue && type === "thread") {
682
- if (opts?.steer ?? canCancel) queue.steer(composedMessage);
683
- else queue.enqueue(composedMessage);
684
- } else {
685
- onSend?.(composedMessage);
686
- }
687
- if (type === "edit") setIsEditing(false);
688
- };
689
-
690
- if (attachmentAdapter && currentAttachments.length > 0) {
691
- void Promise.all(
692
- currentAttachments.map((attachment) =>
693
- attachment.status.type === "complete"
694
- ? attachment
695
- : attachmentAdapter.send(attachment as PendingAttachment),
696
- ),
697
- ).then(dispatch, (error) => {
698
- // Upload failed: merge the failed send back into the draft.
699
- setText((prev) =>
700
- currentText && prev
701
- ? currentText + "\n" + prev
702
- : currentText || prev,
703
- );
704
- setQuote((prev) => prev ?? currentQuote);
705
- setAttachments((prev) => [...currentAttachments, ...prev]);
706
- console.error("Failed to send attachments", error);
707
- });
708
- } else {
709
- dispatch(currentAttachments);
710
- }
711
- },
712
- cancel: () => {
713
- onCancel?.();
714
- if (type === "edit") setIsEditing(false);
715
- },
716
- beginEdit: () => {
717
- onBeginEdit?.();
718
- if (type === "thread") return;
719
- if (isEditingRef.current) throw new Error("Edit already in progress");
720
- setIsEditing(true);
721
- updateFromMessage();
722
- },
723
- startDictation: () => {},
724
- stopDictation: () => {},
725
- setQuote,
726
- queueItem: (selector: { index: number } | { id: string }) => {
727
- if ("id" in selector) {
728
- return queueItemClients.get({ key: selector.id });
729
- }
730
- return queueItemClients.get(selector);
731
- },
732
- };
733
- };
734
-
735
- const ComposerClientResource = resource(useComposerClientResource);
736
-
737
- const createSpeechController = (
738
- notify: (speech: SpeechState | undefined) => void,
739
- ) => {
740
- let session: { messageId: string; cancel: () => void } | undefined;
741
-
742
- const clear = () => {
743
- if (!session) return;
744
- session.cancel();
745
- session = undefined;
746
- notify(undefined);
747
- };
748
-
749
- return {
750
- speak: (
751
- adapter: SpeechSynthesisAdapter,
752
- message: ExternalThreadMessage,
753
- ) => {
754
- clear();
755
-
756
- const utterance = adapter.speak(getThreadMessageText(message));
757
- let unsub: (() => void) | undefined;
758
- unsub = utterance.subscribe(() => {
759
- if (utterance.status.type === "ended") {
760
- unsub?.();
761
- session = undefined;
762
- notify(undefined);
763
- } else {
764
- notify({ messageId: message.id, status: utterance.status });
765
- }
766
- });
767
-
768
- if (utterance.status.type === "ended") {
769
- unsub();
770
- notify(undefined);
771
- return;
772
- }
773
-
774
- session = {
775
- messageId: message.id,
776
- cancel: () => {
777
- unsub!();
778
- utterance.cancel();
779
- },
780
- };
781
- notify({ messageId: message.id, status: utterance.status });
782
- },
783
- stop: () => {
784
- if (!session) throw new Error("No message is being spoken");
785
- clear();
786
- },
787
- stopMessage: (messageId: string) => {
788
- if (session?.messageId !== messageId)
789
- throw new Error("Message is not being spoken");
790
- clear();
791
- },
792
- dispose: clear,
793
- };
794
- };
795
-
796
- const dedupeMessagesById = (messages: readonly ExternalThreadMessage[]) => {
797
- const seenIds = new Set<string>();
798
- const deduped: ExternalThreadMessage[] = [];
799
- for (let i = messages.length - 1; i >= 0; i--) {
800
- const message = messages[i]!;
801
- if (seenIds.has(message.id)) {
802
- console.warn(
803
- `ExternalThread: duplicate message id "${message.id}" in the provided messages array; keeping the last occurrence.`,
804
- );
805
- continue;
806
- }
807
- seenIds.add(message.id);
808
- deduped.push(message);
809
- }
810
- return deduped.length === messages.length ? messages : deduped.reverse();
811
- };
812
-
813
- // External Thread Client
814
- const useExternalThread = ({
815
- messages: messagesProp,
816
- isRunning = false,
817
- isLoading = false,
818
- state: threadState,
819
- extras,
820
- isSendDisabled = false,
821
- onNew,
822
- onEdit,
823
- onReload,
824
- onStartRun,
825
- onCancel,
826
- onResume,
827
- onAddToolResult,
828
- onResumeToolCall,
829
- onLoadExternalState,
830
- attachmentAdapter,
831
- feedbackAdapter,
832
- speechAdapter,
833
- queue,
834
- branches,
835
- onRespondToToolApproval,
836
- }: ExternalThreadProps): ClientOutput<"thread"> => {
837
- const messages = useMemo(
838
- () => dedupeMessagesById(messagesProp),
839
- [messagesProp],
840
- );
841
-
842
- // Local entries are optimistic: they apply only while the message's
843
- // external submittedFeedback still equals the value seen at click time.
844
- const [submittedFeedback, setSubmittedFeedback] = useState<
845
- Record<
846
- string,
847
- {
848
- type: "positive" | "negative";
849
- external: "positive" | "negative" | undefined;
850
- }
851
- >
852
- >({});
853
-
854
- const feedbackFor = (msg: ExternalThreadMessage) => {
855
- const entry = submittedFeedback[msg.id];
856
- return entry && msg.metadata.submittedFeedback?.type === entry.external
857
- ? entry.type
858
- : undefined;
859
- };
860
-
861
- useEffect(() => {
862
- setSubmittedFeedback((prev) => {
863
- const live = Object.entries(prev).filter(([id, entry]) => {
864
- const msg = messages.find((m) => m.id === id);
865
- return !!msg && msg.metadata.submittedFeedback?.type === entry.external;
866
- });
867
- return live.length === Object.keys(prev).length
868
- ? prev
869
- : Object.fromEntries(live);
870
- });
871
- }, [messages]);
872
-
873
- const handleSubmitFeedback = (
874
- message: ExternalThreadMessage,
875
- { type }: { type: "positive" | "negative" },
876
- ) => {
877
- if (!feedbackAdapter) throw new Error("Feedback adapter not configured");
878
- feedbackAdapter.submit({ message, type });
879
-
880
- if (message.role === "assistant") {
881
- setSubmittedFeedback((prev) => ({
882
- ...prev,
883
- [message.id]: {
884
- type,
885
- external: message.metadata.submittedFeedback?.type,
886
- },
887
- }));
888
- }
889
- };
890
-
891
- const [speechState, setSpeech] = useState<SpeechState | undefined>(undefined);
892
- const [speechController] = useState(() => createSpeechController(setSpeech));
893
-
894
- const hasSpeechAdapter = !!speechAdapter;
895
- const speech = hasSpeechAdapter ? speechState : undefined;
896
- useEffect(() => {
897
- if (!hasSpeechAdapter) speechController.dispose();
898
- }, [hasSpeechAdapter, speechController]);
899
-
900
- useEffect(() => () => speechController.dispose(), [speechController]);
901
-
902
- const handleSpeak = (message: ExternalThreadMessage) => {
903
- if (!speechAdapter) throw new Error("Speech adapter not configured");
904
- speechController.speak(speechAdapter, message);
905
- };
906
-
907
- const handleReload = (messageId: string) => {
908
- const messageIndex = messages.findIndex((m) => m.id === messageId);
909
- if (messageIndex === -1) return;
910
-
911
- const parentId = messageIndex > 0 ? messages[messageIndex - 1]!.id : null;
912
- onReload?.(parentId);
913
- };
914
-
915
- const messageClients = useClientLookup(
916
- messages.map((msg, index) => {
917
- const props: MessageClientProps = {
918
- message: msg,
919
- index,
920
- parentId: index > 0 ? messages[index - 1]!.id : null,
921
- onReload: () => handleReload(msg.id),
922
- queue,
923
- branches,
924
- onRespondToToolApproval,
925
- onAddToolResult,
926
- onResumeToolCall,
927
- attachmentAdapter,
928
- submittedFeedback: feedbackFor(msg),
929
- onSubmitFeedback: (feedback) => handleSubmitFeedback(msg, feedback),
930
- speech: speech?.messageId === msg.id ? speech : undefined,
931
- onSpeak: () => handleSpeak(msg),
932
- onStopSpeaking: () => speechController.stopMessage(msg.id),
933
- };
934
- if (onEdit) props.onEdit = onEdit;
935
- return withKey(msg.id, MessageClient(props));
936
- }),
937
- );
938
-
939
- const handleCancelRun = () => {
940
- onCancel?.();
941
- };
942
-
943
- const handleSendNew = (message: AppendMessage) => {
944
- // The composer does not know the thread; stamp the current head as the
945
- // parent (legacy composer parity).
946
- onNew?.({ ...message, parentId: messages.at(-1)?.id ?? null });
947
- };
948
-
949
- const headId = messages.at(-1)?.id ?? null;
950
- const composerQueue = useMemo(
951
- (): ExternalThreadQueueAdapter | undefined =>
952
- queue && {
953
- ...queue,
954
- enqueue: (message) =>
955
- queue.enqueue({ ...message, parentId: message.parentId ?? headId }),
956
- steer: (message) =>
957
- queue.steer({ ...message, parentId: message.parentId ?? headId }),
958
- },
959
- [queue, headId],
960
- );
961
-
962
- const composerClient = useClientResource(
963
- ComposerClientResource({
964
- type: "thread",
965
- canCancel: isRunning,
966
- isSendDisabled,
967
- onCancel: handleCancelRun,
968
- onSend: handleSendNew,
969
- queue: composerQueue,
970
- attachmentAdapter,
971
- }),
972
- );
973
-
974
- const hasQueue = !!queue;
975
- const hasBranches = !!branches;
976
- const hasEdit = !!onEdit;
977
- const hasReload = !!onReload;
978
- const hasAttachments = !!attachmentAdapter;
979
- const hasFeedback = !!feedbackAdapter;
980
- const hasSpeech = !!speechAdapter;
981
- const state = useMemo(() => {
982
- const messageStates = messageClients.state.map((s, idx, arr) => ({
983
- ...s,
984
- isLast: idx === arr.length - 1,
985
- }));
986
-
987
- return {
988
- isEmpty: messages.length === 0,
989
- isDisabled: false,
990
- isLoading,
991
- isRunning,
992
- capabilities: {
993
- edit: hasEdit,
994
- delete: false,
995
- reload: hasReload,
996
- refetchThread: false,
997
- cancel: isRunning,
998
- speech: hasSpeech,
999
- attachments: hasAttachments,
1000
- feedback: hasFeedback,
1001
- voice: false,
1002
- switchToBranch: hasBranches,
1003
- switchBranchDuringRun: false,
1004
- unstable_copy: false,
1005
- dictation: false,
1006
- queue: hasQueue,
1007
- },
1008
- messages: messageStates,
1009
- state: threadState ?? {},
1010
- suggestions: [],
1011
- extras,
1012
- speech,
1013
- voice: undefined,
1014
- composer: composerClient.state,
1015
- };
1016
- }, [
1017
- messages,
1018
- isRunning,
1019
- isLoading,
1020
- threadState,
1021
- extras,
1022
- hasQueue,
1023
- hasBranches,
1024
- hasEdit,
1025
- hasReload,
1026
- hasAttachments,
1027
- hasFeedback,
1028
- hasSpeech,
1029
- speech,
1030
- messageClients.state,
1031
- composerClient.state,
1032
- ]);
1033
-
1034
- return {
1035
- getState: () => state,
1036
- composer: () => composerClient.methods,
1037
- append: (message) => {
1038
- const appendMessage: AppendMessage =
1039
- typeof message === "string"
1040
- ? {
1041
- createdAt: new Date(),
1042
- parentId: messages.at(-1)?.id ?? null,
1043
- sourceId: null,
1044
- runConfig: {},
1045
- role: "user",
1046
- content: [{ type: "text", text: message }],
1047
- attachments: [],
1048
- metadata: { custom: {} },
1049
- }
1050
- : {
1051
- createdAt: message.createdAt ?? new Date(),
1052
- parentId: message.parentId ?? messages.at(-1)?.id ?? null,
1053
- sourceId: message.sourceId ?? null,
1054
- role: message.role ?? "user",
1055
- content: message.content,
1056
- attachments: message.attachments ?? [],
1057
- metadata: message.metadata ?? { custom: {} },
1058
- runConfig: message.runConfig ?? {},
1059
- startRun: message.startRun,
1060
- };
1061
- if (queue) {
1062
- queue.enqueue(appendMessage);
1063
- } else {
1064
- onNew?.(appendMessage);
1065
- }
1066
- },
1067
- deleteMessage: () => {},
1068
- startRun: () => {
1069
- onStartRun?.();
1070
- },
1071
- resumeRun: () => {
1072
- if (!onResume)
1073
- throw new Error(
1074
- "Runtime does not support resuming runs (onResume is not set).",
1075
- );
1076
- onResume();
1077
- },
1078
- cancelRun: handleCancelRun,
1079
- importExternalState: (state: unknown) => {
1080
- if (!onLoadExternalState)
1081
- throw new Error(
1082
- "Runtime does not support importing external states (onLoadExternalState is not set).",
1083
- );
1084
- onLoadExternalState(state);
1085
- },
1086
- getModelContext: () => ({ tools: {}, config: {} }),
1087
- export: () => ({ messages: [] }),
1088
- import: () => {},
1089
- reset: () => {},
1090
- message: (selector) => {
1091
- if ("id" in selector) {
1092
- return messageClients.get({ key: selector.id });
1093
- }
1094
- return messageClients.get(selector);
1095
- },
1096
- stopSpeaking: speechController.stop,
1097
- connectVoice: () => {},
1098
- disconnectVoice: () => {},
1099
- getVoiceVolume: () => 0,
1100
- subscribeVoiceVolume: () => () => {},
1101
- muteVoice: () => {},
1102
- unmuteVoice: () => {},
1103
- };
1104
- };
1105
-
1106
- export const ExternalThread = resource(useExternalThread);
1107
-
1108
- attachTransformScopes(useExternalThread, (scopes, parent) => {
1109
- if (!scopes.threads && parent.threads.source === null) {
1110
- const threadElement = scopes.thread as ClientElement<"thread">;
1111
- scopes.threads = SingleThreadList({ thread: threadElement });
1112
- // scopes mount in key order; re-declare thread after the threads source it resolves from
1113
- delete scopes.thread;
1114
- scopes.thread = Derived({
1115
- source: "threads",
1116
- query: { type: "main" },
1117
- get: (aui) => aui.threads.thread("main"),
1118
- });
1119
- }
1120
-
1121
- if (!scopes.threadListItem && parent.threadListItem.source === null) {
1122
- scopes.threadListItem = Derived({
1123
- source: "threads",
1124
- query: { type: "main" },
1125
- get: (aui) => aui.threads.item("main"),
1126
- });
1127
- }
1128
-
1129
- scopes.composer ??= Derived({
1130
- source: "thread",
1131
- query: {},
1132
- get: (aui) => aui.thread.composer(),
1133
- });
1134
-
1135
- if (!scopes.modelContext && parent.modelContext.source === null) {
1136
- scopes.modelContext = ModelContext();
1137
- }
1138
- if (!scopes.tools && parent.tools.source === null) {
1139
- scopes.tools = Tools({});
1140
- }
1141
- if (!scopes.dataRenderers && parent.dataRenderers.source === null) {
1142
- scopes.dataRenderers = DataRenderers();
1143
- }
1144
- if (!scopes.suggestions && parent.suggestions.source === null) {
1145
- scopes.suggestions = Suggestions();
1146
- }
1147
- });