@assistant-ui/react-ai-sdk 1.3.7 → 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 (34) 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.map +1 -1
  10. package/dist/ui/use-chat/useExternalHistory.d.ts.map +1 -1
  11. package/dist/ui/use-chat/useExternalHistory.js +137 -24
  12. package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
  13. package/dist/ui/use-chat/useStreamingTiming.d.ts +11 -0
  14. package/dist/ui/use-chat/useStreamingTiming.d.ts.map +1 -0
  15. package/dist/ui/use-chat/useStreamingTiming.js +84 -0
  16. package/dist/ui/use-chat/useStreamingTiming.js.map +1 -0
  17. package/dist/ui/utils/convertMessage.d.ts +4 -4
  18. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  19. package/dist/ui/utils/convertMessage.js +84 -75
  20. package/dist/ui/utils/convertMessage.js.map +1 -1
  21. package/dist/ui/utils/sliceMessagesUntil.d.ts.map +1 -1
  22. package/dist/ui/utils/sliceMessagesUntil.js.map +1 -1
  23. package/package.json +11 -5
  24. package/src/ui/use-chat/useAISDKRuntime.test.ts +225 -0
  25. package/src/ui/use-chat/{useAISDKRuntime.tsx → useAISDKRuntime.ts} +34 -10
  26. package/src/ui/use-chat/useExternalHistory.ts +283 -0
  27. package/src/ui/use-chat/useStreamingTiming.ts +102 -0
  28. package/src/ui/utils/convertMessage.test.ts +125 -0
  29. package/src/ui/utils/convertMessage.ts +98 -82
  30. package/src/ui/use-chat/useExternalHistory.tsx +0 -134
  31. /package/src/ui/{getVercelAIMessages.tsx → getVercelAIMessages.ts} +0 -0
  32. /package/src/ui/use-chat/{AssistantChatTransport.tsx → AssistantChatTransport.ts} +0 -0
  33. /package/src/ui/use-chat/{useChatRuntime.tsx → useChatRuntime.ts} +0 -0
  34. /package/src/ui/utils/{sliceMessagesUntil.tsx → sliceMessagesUntil.ts} +0 -0
@@ -13,14 +13,53 @@ 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
  }
@@ -28,54 +67,46 @@ const convertParts = (
28
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,96 +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[];
154
+ .filter(Boolean) as MessageContent[number][];
135
155
 
136
156
  const seenToolCallIds = new Set<string>();
137
- return converted.filter((part: any) => {
157
+ return converted.filter((part) => {
138
158
  if (part.type === "tool-call" && part.toolCallId != null) {
139
159
  if (seenToolCallIds.has(part.toolCallId)) return false;
140
160
  seenToolCallIds.add(part.toolCallId);
141
161
  }
142
162
  return true;
143
163
  });
144
- };
164
+ }
145
165
 
146
166
  export const AISDKMessageConverter = unstable_createMessageConverter(
147
167
  (message: UIMessage, metadata: useExternalMessageConverter.Metadata) => {
148
- // UIMessage doesn't have createdAt, so we'll use current date or undefined
149
168
  const createdAt = new Date();
169
+ const content = convertParts(message, metadata);
170
+
150
171
  switch (message.role) {
151
172
  case "user":
152
173
  return {
153
174
  role: "user",
154
175
  id: message.id,
155
176
  createdAt,
156
- content: convertParts(message, metadata),
177
+ content,
157
178
  attachments: message.parts
158
179
  ?.filter((p) => p.type === "file")
159
- .map((part, idx) => {
160
- return {
161
- id: idx.toString(),
162
- type: part.mediaType.startsWith("image/") ? "image" : "file",
163
- name: part.filename ?? "file",
164
- content: [
165
- part.mediaType.startsWith("image/")
166
- ? {
167
- type: "image",
168
- image: part.url,
169
- filename: part.filename!,
170
- }
171
- : {
172
- type: "file",
173
- filename: part.filename!,
174
- data: part.url,
175
- mimeType: part.mediaType,
176
- },
177
- ],
178
- contentType: part.mediaType ?? "unknown/unknown",
179
- status: { type: "complete" as const },
180
- };
181
- }),
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
+ })),
182
201
  metadata: message.metadata as MessageMetadata,
183
202
  };
184
203
 
185
204
  case "system":
205
+ case "assistant": {
206
+ const timing = metadata.messageTiming?.[message.id];
186
207
  return {
187
- role: "system",
208
+ role: message.role,
188
209
  id: message.id,
189
210
  createdAt,
190
- content: convertParts(message, metadata),
191
- metadata: message.metadata as MessageMetadata,
192
- };
193
-
194
- case "assistant":
195
- return {
196
- role: "assistant",
197
- id: message.id,
198
- createdAt,
199
- content: convertParts(message, metadata),
200
- metadata: message.metadata as MessageMetadata,
211
+ content,
212
+ metadata: {
213
+ ...(message.metadata as MessageMetadata),
214
+ ...(timing && { timing }),
215
+ },
201
216
  };
217
+ }
202
218
 
203
219
  default:
204
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
- };