@assistant-ui/react-ai-sdk 1.3.6 → 1.3.8

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.
Files changed (35) hide show
  1. package/dist/ui/getVercelAIMessages.d.ts.map +1 -1
  2. package/dist/ui/getVercelAIMessages.js.map +1 -1
  3. package/dist/ui/use-chat/AssistantChatTransport.d.ts.map +1 -1
  4. package/dist/ui/use-chat/AssistantChatTransport.js.map +1 -1
  5. package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
  6. package/dist/ui/use-chat/useAISDKRuntime.js +22 -10
  7. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  8. package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
  9. package/dist/ui/use-chat/useChatRuntime.js +1 -1
  10. package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
  11. package/dist/ui/use-chat/useExternalHistory.d.ts.map +1 -1
  12. package/dist/ui/use-chat/useExternalHistory.js +137 -24
  13. package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
  14. package/dist/ui/use-chat/useStreamingTiming.d.ts +11 -0
  15. package/dist/ui/use-chat/useStreamingTiming.d.ts.map +1 -0
  16. package/dist/ui/use-chat/useStreamingTiming.js +84 -0
  17. package/dist/ui/use-chat/useStreamingTiming.js.map +1 -0
  18. package/dist/ui/utils/convertMessage.d.ts +4 -4
  19. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  20. package/dist/ui/utils/convertMessage.js +94 -76
  21. package/dist/ui/utils/convertMessage.js.map +1 -1
  22. package/dist/ui/utils/sliceMessagesUntil.d.ts.map +1 -1
  23. package/dist/ui/utils/sliceMessagesUntil.js.map +1 -1
  24. package/package.json +11 -5
  25. package/src/ui/use-chat/useAISDKRuntime.test.ts +225 -0
  26. package/src/ui/use-chat/{useAISDKRuntime.tsx → useAISDKRuntime.ts} +34 -10
  27. package/src/ui/use-chat/{useChatRuntime.tsx → useChatRuntime.ts} +1 -1
  28. package/src/ui/use-chat/useExternalHistory.ts +283 -0
  29. package/src/ui/use-chat/useStreamingTiming.ts +102 -0
  30. package/src/ui/utils/convertMessage.test.ts +125 -0
  31. package/src/ui/utils/convertMessage.ts +107 -82
  32. package/src/ui/use-chat/useExternalHistory.tsx +0 -134
  33. /package/src/ui/{getVercelAIMessages.tsx → getVercelAIMessages.ts} +0 -0
  34. /package/src/ui/use-chat/{AssistantChatTransport.tsx → AssistantChatTransport.ts} +0 -0
  35. /package/src/ui/utils/{sliceMessagesUntil.tsx → sliceMessagesUntil.ts} +0 -0
@@ -13,69 +13,100 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
13
13
 
14
14
  type MessageMetadata = ThreadMessageLike["metadata"];
15
15
 
16
- function stripClosingDelimiters(json: string) {
16
+ function stripClosingDelimiters(json: string): string {
17
17
  return json.replace(/[}\]"]+$/, "");
18
18
  }
19
19
 
20
- const convertParts = (
20
+ /**
21
+ * Resolves the interrupt fields for a tool call part.
22
+ *
23
+ * Two interrupt paths for tool approvals:
24
+ * 1. AI SDK server-side approval: approval-requested state with part.approval payload
25
+ * 2. Frontend tools: toolStatuses interrupt from context.human()
26
+ */
27
+ function getToolInterrupt(
28
+ part: { state: string; approval?: unknown },
29
+ toolStatus: { type: string; payload?: unknown } | undefined,
30
+ ): Record<string, unknown> {
31
+ if (part.state === "approval-requested" && "approval" in part) {
32
+ return {
33
+ interrupt: {
34
+ type: "human" as const,
35
+ payload: (part as { approval: unknown }).approval,
36
+ },
37
+ status: {
38
+ type: "requires-action" as const,
39
+ reason: "interrupt" as const,
40
+ },
41
+ };
42
+ }
43
+
44
+ if (toolStatus?.type === "interrupt") {
45
+ return {
46
+ interrupt: toolStatus.payload,
47
+ status: {
48
+ type: "requires-action" as const,
49
+ reason: "interrupt" as const,
50
+ },
51
+ };
52
+ }
53
+
54
+ return {};
55
+ }
56
+
57
+ type MessageContent = Exclude<ThreadMessageLike["content"], string>;
58
+
59
+ function convertParts(
21
60
  message: UIMessage,
22
61
  metadata: useExternalMessageConverter.Metadata,
23
- ) => {
62
+ ): MessageContent {
24
63
  if (!message.parts || message.parts.length === 0) {
25
64
  return [];
26
65
  }
27
66
 
28
- return message.parts
67
+ const converted = message.parts
29
68
  .filter((p) => p.type !== "step-start" && p.type !== "file")
30
69
  .map((part) => {
31
- const type = part.type;
32
-
33
- // Handle text parts
34
- if (type === "text") {
70
+ if (part.type === "text") {
35
71
  return {
36
72
  type: "text",
37
73
  text: part.text,
38
74
  } satisfies TextMessagePart;
39
75
  }
40
76
 
41
- // Handle reasoning parts
42
- if (type === "reasoning") {
77
+ if (part.type === "reasoning") {
43
78
  return {
44
79
  type: "reasoning",
45
80
  text: part.text,
46
81
  } satisfies ReasoningMessagePart;
47
82
  }
48
83
 
49
- // Handle tool parts (both static tool-* and dynamic-tool)
50
- // In AI SDK v6, isToolUIPart returns true for both static and dynamic tools
51
84
  if (isToolUIPart(part)) {
52
- // Use getToolName which works for both static and dynamic tools
53
85
  const toolName = getToolName(part);
54
86
  const toolCallId = part.toolCallId;
87
+ const args: ReadonlyJSONObject =
88
+ (part.input as ReadonlyJSONObject) || {};
55
89
 
56
- // Extract args and result based on state
57
- let args: ReadonlyJSONObject = {};
58
90
  let result: unknown;
59
91
  let isError = false;
60
92
 
61
- if (
62
- part.state === "input-streaming" ||
63
- part.state === "input-available"
64
- ) {
65
- args = (part.input as ReadonlyJSONObject) || {};
66
- } else if (part.state === "output-available") {
67
- args = (part.input as ReadonlyJSONObject) || {};
93
+ if (part.state === "output-available") {
68
94
  result = part.output;
69
95
  } else if (part.state === "output-error") {
70
- args = (part.input as ReadonlyJSONObject) || {};
71
96
  isError = true;
72
97
  result = { error: part.errorText };
98
+ } else if (part.state === "output-denied") {
99
+ isError = true;
100
+ result = {
101
+ error:
102
+ (part as { approval: { reason?: string } }).approval.reason ||
103
+ "Tool approval denied",
104
+ };
73
105
  }
74
106
 
75
107
  let argsText = JSON.stringify(args);
76
108
  if (part.state === "input-streaming") {
77
- // the argsText is not complete, so we need to strip the closing delimiters
78
- // these are added by the AI SDK in fix-json
109
+ // strip closing delimiters added by the AI SDK's fix-json
79
110
  argsText = stripClosingDelimiters(argsText);
80
111
  }
81
112
 
@@ -88,18 +119,11 @@ const convertParts = (
88
119
  args,
89
120
  result,
90
121
  isError,
91
- ...(toolStatus?.type === "interrupt" && {
92
- interrupt: toolStatus.payload,
93
- status: {
94
- type: "requires-action" as const,
95
- reason: "interrupt",
96
- },
97
- }),
122
+ ...getToolInterrupt(part, toolStatus),
98
123
  } satisfies ToolCallMessagePart;
99
124
  }
100
125
 
101
- // Handle source-url parts
102
- if (type === "source-url") {
126
+ if (part.type === "source-url") {
103
127
  return {
104
128
  type: "source",
105
129
  sourceType: "url",
@@ -109,87 +133,88 @@ const convertParts = (
109
133
  } satisfies SourceMessagePart;
110
134
  }
111
135
 
112
- // Handle source-document parts
113
- if (type === "source-document") {
136
+ if (part.type === "source-document") {
114
137
  console.warn(
115
- `Source document part type ${type} is not yet supported in conversion`,
138
+ "Source document parts are not yet supported in conversion",
116
139
  );
117
140
  return null;
118
141
  }
119
142
 
120
- // Handle data-* parts (AI SDK v5 data parts)
121
- if (type.startsWith("data-")) {
122
- const name = type.substring(5);
143
+ if (part.type.startsWith("data-")) {
123
144
  return {
124
145
  type: "data",
125
- name,
146
+ name: part.type.substring(5),
126
147
  data: (part as any).data,
127
148
  } satisfies DataMessagePart;
128
149
  }
129
150
 
130
- // For unsupported types, we'll skip them instead of throwing
131
- console.warn(`Unsupported message part type: ${type}`);
151
+ console.warn(`Unsupported message part type: ${part.type}`);
132
152
  return null;
133
153
  })
134
- .filter(Boolean) as any[];
135
- };
154
+ .filter(Boolean) as MessageContent[number][];
155
+
156
+ const seenToolCallIds = new Set<string>();
157
+ return converted.filter((part) => {
158
+ if (part.type === "tool-call" && part.toolCallId != null) {
159
+ if (seenToolCallIds.has(part.toolCallId)) return false;
160
+ seenToolCallIds.add(part.toolCallId);
161
+ }
162
+ return true;
163
+ });
164
+ }
136
165
 
137
166
  export const AISDKMessageConverter = unstable_createMessageConverter(
138
167
  (message: UIMessage, metadata: useExternalMessageConverter.Metadata) => {
139
- // UIMessage doesn't have createdAt, so we'll use current date or undefined
140
168
  const createdAt = new Date();
169
+ const content = convertParts(message, metadata);
170
+
141
171
  switch (message.role) {
142
172
  case "user":
143
173
  return {
144
174
  role: "user",
145
175
  id: message.id,
146
176
  createdAt,
147
- content: convertParts(message, metadata),
177
+ content,
148
178
  attachments: message.parts
149
179
  ?.filter((p) => p.type === "file")
150
- .map((part, idx) => {
151
- return {
152
- id: idx.toString(),
153
- type: part.mediaType.startsWith("image/") ? "image" : "file",
154
- name: part.filename ?? "file",
155
- content: [
156
- part.mediaType.startsWith("image/")
157
- ? {
158
- type: "image",
159
- image: part.url,
160
- filename: part.filename!,
161
- }
162
- : {
163
- type: "file",
164
- filename: part.filename!,
165
- data: part.url,
166
- mimeType: part.mediaType,
167
- },
168
- ],
169
- contentType: part.mediaType ?? "unknown/unknown",
170
- status: { type: "complete" as const },
171
- };
172
- }),
180
+ .map((part, idx) => ({
181
+ id: idx.toString(),
182
+ type: part.mediaType.startsWith("image/") ? "image" : "file",
183
+ name: part.filename ?? "file",
184
+ content: [
185
+ part.mediaType.startsWith("image/")
186
+ ? {
187
+ type: "image",
188
+ image: part.url,
189
+ filename: part.filename!,
190
+ }
191
+ : {
192
+ type: "file",
193
+ filename: part.filename!,
194
+ data: part.url,
195
+ mimeType: part.mediaType,
196
+ },
197
+ ],
198
+ contentType: part.mediaType ?? "unknown/unknown",
199
+ status: { type: "complete" as const },
200
+ })),
173
201
  metadata: message.metadata as MessageMetadata,
174
202
  };
175
203
 
176
204
  case "system":
205
+ case "assistant": {
206
+ const timing = metadata.messageTiming?.[message.id];
177
207
  return {
178
- role: "system",
179
- id: message.id,
180
- createdAt,
181
- content: convertParts(message, metadata),
182
- metadata: message.metadata as MessageMetadata,
183
- };
184
-
185
- case "assistant":
186
- return {
187
- role: "assistant",
208
+ role: message.role,
188
209
  id: message.id,
189
210
  createdAt,
190
- content: convertParts(message, metadata),
191
- metadata: message.metadata as MessageMetadata,
211
+ content,
212
+ metadata: {
213
+ ...(message.metadata as MessageMetadata),
214
+ ...(timing && { timing }),
215
+ },
192
216
  };
217
+ }
193
218
 
194
219
  default:
195
220
  console.warn(`Unsupported message role: ${message.role}`);
@@ -1,134 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- AssistantRuntime,
5
- ThreadHistoryAdapter,
6
- ThreadMessage,
7
- MessageFormatAdapter,
8
- getExternalStoreMessages,
9
- MessageFormatRepository,
10
- ExportedMessageRepository,
11
- INTERNAL,
12
- useAui,
13
- } from "@assistant-ui/react";
14
- import { useRef, useEffect, useState, RefObject, useCallback } from "react";
15
-
16
- const { MessageRepository } = INTERNAL;
17
-
18
- export const toExportedMessageRepository = <TMessage,>(
19
- toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
20
- messages: MessageFormatRepository<TMessage>,
21
- ): ExportedMessageRepository => {
22
- return {
23
- headId: messages.headId!,
24
- messages: messages.messages.map((m) => {
25
- const message = toThreadMessages([m.message])[0]!;
26
- return {
27
- ...m,
28
- message,
29
- };
30
- }),
31
- };
32
- };
33
-
34
- export const useExternalHistory = <TMessage,>(
35
- runtimeRef: RefObject<AssistantRuntime>,
36
- historyAdapter: ThreadHistoryAdapter | undefined,
37
- toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
38
- storageFormatAdapter: MessageFormatAdapter<TMessage, any>,
39
- onSetMessages: (messages: TMessage[]) => void,
40
- ) => {
41
- const loadedRef = useRef(false);
42
-
43
- const aui = useAui();
44
- const optionalThreadListItem = useCallback(
45
- () => (aui.threadListItem.source ? aui.threadListItem() : null),
46
- [aui],
47
- );
48
-
49
- const [isLoading, setIsLoading] = useState(false);
50
-
51
- const historyIds = useRef(new Set<string>());
52
-
53
- const onSetMessagesRef = useRef<typeof onSetMessages>(() => onSetMessages);
54
- useEffect(() => {
55
- onSetMessagesRef.current = onSetMessages;
56
- });
57
-
58
- // Load messages from history adapter on mount
59
- useEffect(() => {
60
- if (!historyAdapter || loadedRef.current) return;
61
-
62
- const loadHistory = async () => {
63
- setIsLoading(true);
64
- try {
65
- const repo = await historyAdapter
66
- .withFormat?.(storageFormatAdapter)
67
- .load();
68
- if (repo && repo.messages.length > 0) {
69
- const converted = toExportedMessageRepository(toThreadMessages, repo);
70
- runtimeRef.current.thread.import(converted);
71
-
72
- const tempRepo = new MessageRepository();
73
- tempRepo.import(converted);
74
- const messages = tempRepo.getMessages();
75
-
76
- onSetMessagesRef.current(
77
- messages.map(getExternalStoreMessages<TMessage>).flat(),
78
- );
79
-
80
- historyIds.current = new Set(
81
- converted.messages.map((m) => m.message.id),
82
- );
83
- }
84
- } catch (error) {
85
- console.error("Failed to load message history:", error);
86
- } finally {
87
- setIsLoading(false);
88
- }
89
- };
90
-
91
- if (!loadedRef.current) {
92
- loadedRef.current = true;
93
- if (!optionalThreadListItem()?.getState().remoteId) {
94
- setIsLoading(false);
95
- return;
96
- }
97
-
98
- loadHistory();
99
- }
100
- }, [
101
- historyAdapter,
102
- storageFormatAdapter,
103
- toThreadMessages,
104
- runtimeRef,
105
- optionalThreadListItem,
106
- ]);
107
-
108
- useEffect(() => {
109
- return runtimeRef.current.thread.subscribe(async () => {
110
- const { messages, isRunning } = runtimeRef.current.thread.getState();
111
- if (isRunning) return;
112
-
113
- for (let i = 0; i < messages.length; i++) {
114
- const message = messages[i]!;
115
- if (
116
- message.status === undefined ||
117
- message.status.type === "complete" ||
118
- message.status.type === "incomplete"
119
- ) {
120
- if (historyIds.current.has(message.id)) continue;
121
- historyIds.current.add(message.id);
122
-
123
- const parentId = i > 0 ? messages[i - 1]!.id : null;
124
- await historyAdapter?.withFormat?.(storageFormatAdapter).append({
125
- parentId,
126
- message: getExternalStoreMessages<TMessage>(message)[0]!,
127
- });
128
- }
129
- }
130
- });
131
- }, [historyAdapter, storageFormatAdapter, runtimeRef]);
132
-
133
- return isLoading;
134
- };