@assistant-ui/ai-sdk 0.0.1

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +26 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +37 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +11 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +7 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +28 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +249 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/modelContentEnvelope.d.ts +16 -0
  20. package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
  21. package/dist/converters/modelContentEnvelope.js +22 -0
  22. package/dist/converters/modelContentEnvelope.js.map +1 -0
  23. package/dist/converters/toCreateMessage.d.ts +7 -0
  24. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  25. package/dist/converters/toCreateMessage.js +58 -0
  26. package/dist/converters/toCreateMessage.js.map +1 -0
  27. package/dist/converters/toolOutputConversion.d.ts +29 -0
  28. package/dist/converters/toolOutputConversion.d.ts.map +1 -0
  29. package/dist/converters/toolOutputConversion.js +31 -0
  30. package/dist/converters/toolOutputConversion.js.map +1 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.d.ts.map +1 -0
  33. package/dist/hooks.js +20 -0
  34. package/dist/hooks.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +13 -0
  37. package/dist/index.native.d.ts +12 -0
  38. package/dist/index.native.js +11 -0
  39. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  40. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  41. package/dist/model-context/injectInteractableContext.js +52 -0
  42. package/dist/model-context/injectInteractableContext.js.map +1 -0
  43. package/dist/model-context/injectQuoteContext.d.ts +27 -0
  44. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  45. package/dist/model-context/injectQuoteContext.js +51 -0
  46. package/dist/model-context/injectQuoteContext.js.map +1 -0
  47. package/dist/runtime/AISDKChat.d.ts +16 -0
  48. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  49. package/dist/runtime/AISDKChat.js +69 -0
  50. package/dist/runtime/AISDKChat.js.map +1 -0
  51. package/dist/runtime/AISDKThreads.d.ts +45 -0
  52. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  53. package/dist/runtime/AISDKThreads.js +203 -0
  54. package/dist/runtime/AISDKThreads.js.map +1 -0
  55. package/dist/runtime/useAISDKRuntime.d.ts +49 -0
  56. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  57. package/dist/runtime/useAISDKRuntime.js +266 -0
  58. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  59. package/dist/runtime/useChatRuntime.d.ts +13 -0
  60. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  61. package/dist/runtime/useChatRuntime.js +52 -0
  62. package/dist/runtime/useChatRuntime.js.map +1 -0
  63. package/dist/runtime/useChatThread.d.ts +77 -0
  64. package/dist/runtime/useChatThread.d.ts.map +1 -0
  65. package/dist/runtime/useChatThread.js +134 -0
  66. package/dist/runtime/useChatThread.js.map +1 -0
  67. package/dist/runtime/useExternalHistory.d.ts +11 -0
  68. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  69. package/dist/runtime/useExternalHistory.js +278 -0
  70. package/dist/runtime/useExternalHistory.js.map +1 -0
  71. package/dist/runtime/useResourceCleanup.d.ts +5 -0
  72. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  73. package/dist/runtime/useResourceCleanup.js +47 -0
  74. package/dist/runtime/useResourceCleanup.js.map +1 -0
  75. package/dist/runtime/useStreamingTiming.d.ts +15 -0
  76. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  77. package/dist/runtime/useStreamingTiming.js +38 -0
  78. package/dist/runtime/useStreamingTiming.js.map +1 -0
  79. package/dist/tools/frontendTools.d.ts +33 -0
  80. package/dist/tools/frontendTools.d.ts.map +1 -0
  81. package/dist/tools/frontendTools.js +33 -0
  82. package/dist/tools/frontendTools.js.map +1 -0
  83. package/dist/tools/generativeTools.d.ts +73 -0
  84. package/dist/tools/generativeTools.d.ts.map +1 -0
  85. package/dist/tools/generativeTools.js +245 -0
  86. package/dist/tools/generativeTools.js.map +1 -0
  87. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  88. package/dist/tools/mcp-stdio.node.js +2 -0
  89. package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
  90. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  91. package/dist/tools/mcp-stdio.unsupported.js +11 -0
  92. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  93. package/dist/transport/AssistantChatTransport.d.ts +23 -0
  94. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  95. package/dist/transport/AssistantChatTransport.js +134 -0
  96. package/dist/transport/AssistantChatTransport.js.map +1 -0
  97. package/dist/transport/resumable.d.ts +38 -0
  98. package/dist/transport/resumable.d.ts.map +1 -0
  99. package/dist/transport/resumable.js +113 -0
  100. package/dist/transport/resumable.js.map +1 -0
  101. package/dist/usage.d.ts +19 -0
  102. package/dist/usage.d.ts.map +1 -0
  103. package/dist/usage.js +127 -0
  104. package/dist/usage.js.map +1 -0
  105. package/dist/utils/getVercelAIMessages.d.ts +7 -0
  106. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  107. package/dist/utils/getVercelAIMessages.js +9 -0
  108. package/dist/utils/getVercelAIMessages.js.map +1 -0
  109. package/dist/utils/sliceMessagesUntil.d.ts +6 -0
  110. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  111. package/dist/utils/sliceMessagesUntil.js +12 -0
  112. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  113. package/package.json +100 -0
  114. package/src/__tests__/standalone.e2e.ts +59 -0
  115. package/src/adapters/aiSDKFormatAdapter.ts +41 -0
  116. package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
  117. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  118. package/src/aiSDKExtras.ts +11 -0
  119. package/src/converters/convertMessage.test.ts +1130 -0
  120. package/src/converters/convertMessage.ts +457 -0
  121. package/src/converters/modelContentEnvelope.ts +41 -0
  122. package/src/converters/toCreateMessage.test.ts +596 -0
  123. package/src/converters/toCreateMessage.ts +99 -0
  124. package/src/converters/toolOutputConversion.ts +26 -0
  125. package/src/hooks.ts +23 -0
  126. package/src/index.native.ts +21 -0
  127. package/src/index.ts +29 -0
  128. package/src/model-context/injectInteractableContext.test.ts +167 -0
  129. package/src/model-context/injectInteractableContext.ts +61 -0
  130. package/src/model-context/injectQuoteContext.test.ts +33 -0
  131. package/src/model-context/injectQuoteContext.ts +62 -0
  132. package/src/runtime/AISDKChat.integration.test.tsx +47 -0
  133. package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
  134. package/src/runtime/AISDKChat.test.ts +193 -0
  135. package/src/runtime/AISDKChat.ts +47 -0
  136. package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
  137. package/src/runtime/AISDKThreads.test.ts +548 -0
  138. package/src/runtime/AISDKThreads.ts +223 -0
  139. package/src/runtime/__tests__/controlled-transport.ts +43 -0
  140. package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
  141. package/src/runtime/useAISDKRuntime.test.ts +1021 -0
  142. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  143. package/src/runtime/useAISDKRuntime.ts +524 -0
  144. package/src/runtime/useChatOnTap.test.ts +58 -0
  145. package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
  146. package/src/runtime/useChatRuntime.test.ts +522 -0
  147. package/src/runtime/useChatRuntime.ts +49 -0
  148. package/src/runtime/useChatThread.ts +286 -0
  149. package/src/runtime/useExternalHistory.test.ts +1289 -0
  150. package/src/runtime/useExternalHistory.ts +435 -0
  151. package/src/runtime/useResourceCleanup.ts +32 -0
  152. package/src/runtime/useStreamingTiming.ts +68 -0
  153. package/src/tools/frontendTools.test.ts +205 -0
  154. package/src/tools/frontendTools.ts +83 -0
  155. package/src/tools/generativeTools.test.ts +785 -0
  156. package/src/tools/generativeTools.ts +470 -0
  157. package/src/tools/mcp-stdio.node.ts +1 -0
  158. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  159. package/src/transport/AssistantChatTransport.test.ts +300 -0
  160. package/src/transport/AssistantChatTransport.ts +204 -0
  161. package/src/transport/resumable.test.ts +262 -0
  162. package/src/transport/resumable.ts +161 -0
  163. package/src/usage.test.ts +186 -0
  164. package/src/usage.ts +174 -0
  165. package/src/utils/getVercelAIMessages.ts +11 -0
  166. package/src/utils/sliceMessagesUntil.test.ts +55 -0
  167. package/src/utils/sliceMessagesUntil.ts +20 -0
@@ -0,0 +1,435 @@
1
+ "use client";
2
+
3
+ import type {
4
+ AssistantRuntime,
5
+ ThreadHistoryAdapter,
6
+ ThreadMessage,
7
+ MessageFormatAdapter,
8
+ MessageFormatRepository,
9
+ ExportedMessageRepository,
10
+ } from "@assistant-ui/core";
11
+ import { getExternalStoreMessages } from "@assistant-ui/core";
12
+ import { MessageRepository } from "@assistant-ui/core/internal";
13
+ import { useAui } from "@assistant-ui/store";
14
+ import {
15
+ useRef,
16
+ useEffect,
17
+ useState,
18
+ type RefObject,
19
+ useCallback,
20
+ useMemo,
21
+ } from "react";
22
+
23
+ export const toExportedMessageRepository = <TMessage>(
24
+ toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
25
+ messages: MessageFormatRepository<TMessage>,
26
+ ): ExportedMessageRepository => {
27
+ const survivingIds = new Set<string>();
28
+ const survivors = messages.messages.flatMap((m) => {
29
+ const message = toThreadMessages([m.message])[0];
30
+ if (!message) {
31
+ console.warn("Skipping a stored message that could not be loaded.");
32
+ return [];
33
+ }
34
+ if (m.parentId && !survivingIds.has(m.parentId)) return [];
35
+ survivingIds.add(message.id);
36
+ return [{ ...m, message }];
37
+ });
38
+
39
+ return {
40
+ headId:
41
+ messages.headId && survivingIds.has(messages.headId)
42
+ ? messages.headId
43
+ : null,
44
+ messages: survivors,
45
+ };
46
+ };
47
+
48
+ const isAwaitingToolApproval = (message: ThreadMessage) =>
49
+ message.status?.type === "requires-action" &&
50
+ message.status.reason === "tool-calls";
51
+
52
+ const snapshotExternalMessages = <TMessage>(
53
+ messages: readonly ThreadMessage[],
54
+ ) =>
55
+ new Map<string, TMessage[]>(
56
+ messages.map((message) => [
57
+ message.id,
58
+ [...getExternalStoreMessages<TMessage>(message)],
59
+ ]),
60
+ );
61
+
62
+ export const useExternalHistory = <TMessage>(
63
+ runtimeRef: RefObject<AssistantRuntime>,
64
+ historyAdapter: ThreadHistoryAdapter | undefined,
65
+ toThreadMessages: (messages: TMessage[]) => ThreadMessage[],
66
+ storageFormatAdapter: MessageFormatAdapter<TMessage, any>,
67
+ onSetMessages: (messages: TMessage[]) => void,
68
+ ) => {
69
+ const loadedRef = useRef(false);
70
+ const [itemEpoch, setItemEpoch] = useState(0);
71
+
72
+ const aui = useAui();
73
+ const optionalThreadListItem = useCallback(
74
+ () => (aui.threadListItem.source ? aui.threadListItem : null),
75
+ [aui],
76
+ );
77
+
78
+ const [hasLoaded, setHasLoaded] = useState(false);
79
+
80
+ const historyIds = useRef(new Set<string>());
81
+ const persistedInnerIds = useRef(new Set<string>());
82
+ const deferredTelemetryIds = useRef(new Set<string>());
83
+ const persistedExternalMessages = useRef(new Map<string, TMessage[]>());
84
+
85
+ const onSetMessagesRef = useRef(onSetMessages);
86
+ useEffect(() => {
87
+ onSetMessagesRef.current = onSetMessages;
88
+ });
89
+
90
+ const formatAdapter = useMemo(() => {
91
+ if (!historyAdapter) return undefined;
92
+ if (!historyAdapter.withFormat) {
93
+ throw new Error(
94
+ "useAISDKRuntime: ThreadHistoryAdapter is missing the required `withFormat` method.",
95
+ );
96
+ }
97
+ return historyAdapter.withFormat<TMessage, any>(storageFormatAdapter);
98
+ }, [historyAdapter, storageFormatAdapter]);
99
+
100
+ const isLoading = formatAdapter != null && !hasLoaded;
101
+
102
+ useEffect(() => {
103
+ if (!formatAdapter || loadedRef.current) return undefined;
104
+
105
+ const loadHistory = async () => {
106
+ try {
107
+ const repo = await formatAdapter.load();
108
+ if (repo && repo.messages.length > 0) {
109
+ for (const m of repo.messages) {
110
+ persistedInnerIds.current.add(
111
+ storageFormatAdapter.getId(m.message),
112
+ );
113
+ }
114
+ const converted = toExportedMessageRepository(toThreadMessages, repo);
115
+ runtimeRef.current.thread.import(converted);
116
+
117
+ const tempRepo = new MessageRepository();
118
+ tempRepo.import(converted);
119
+ const messages = tempRepo.getMessages();
120
+
121
+ onSetMessagesRef.current(
122
+ messages.flatMap(getExternalStoreMessages<TMessage>),
123
+ );
124
+
125
+ historyIds.current = new Set();
126
+ for (const m of converted.messages) {
127
+ historyIds.current.add(m.message.id);
128
+ if (isAwaitingToolApproval(m.message)) {
129
+ deferredTelemetryIds.current.add(m.message.id);
130
+ }
131
+ }
132
+ persistedExternalMessages.current =
133
+ snapshotExternalMessages<TMessage>(
134
+ converted.messages.map((m) => m.message),
135
+ );
136
+ }
137
+ } catch (error) {
138
+ console.error("Failed to load message history:", error);
139
+ } finally {
140
+ setHasLoaded(true);
141
+ }
142
+ };
143
+
144
+ formatAdapter.pin?.();
145
+
146
+ const remoteId = optionalThreadListItem()?.getState().remoteId;
147
+ if (!remoteId) {
148
+ setHasLoaded(true);
149
+ return aui.subscribe(() => {
150
+ if (optionalThreadListItem()?.getState().remoteId) {
151
+ setItemEpoch((n) => n + 1);
152
+ }
153
+ });
154
+ }
155
+
156
+ const threadState = runtimeRef.current.thread.getState();
157
+ if (threadState.isRunning || threadState.messages.length > 0) {
158
+ loadedRef.current = true;
159
+ setHasLoaded(true);
160
+ return undefined;
161
+ }
162
+
163
+ loadedRef.current = true;
164
+ void loadHistory();
165
+ return undefined;
166
+ }, [
167
+ formatAdapter,
168
+ toThreadMessages,
169
+ runtimeRef,
170
+ optionalThreadListItem,
171
+ aui,
172
+ itemEpoch,
173
+ storageFormatAdapter,
174
+ ]);
175
+
176
+ const runStartRef = useRef<number | null>(null);
177
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
178
+ const persistInFlightRef = useRef<Promise<void>>(Promise.resolve());
179
+ const stepBoundariesRef = useRef<number[]>([]);
180
+ const wasRunningRef = useRef(false);
181
+ const toolCallCountRef = useRef(0);
182
+
183
+ useEffect(() => {
184
+ if (!formatAdapter) return;
185
+ const adapter = formatAdapter;
186
+
187
+ const unsubscribe = runtimeRef.current.thread.subscribe(() => {
188
+ const threadState = runtimeRef.current.thread.getState();
189
+ const { isRunning } = threadState;
190
+ const wasRunning = wasRunningRef.current;
191
+ wasRunningRef.current = isRunning;
192
+
193
+ // Track step boundaries by content changes (more reliable than isRunning)
194
+ if (runStartRef.current != null) {
195
+ const lastMsg = threadState.messages.at(-1);
196
+ if (lastMsg?.role === "assistant") {
197
+ const currentToolCallCount = lastMsg.content.filter(
198
+ (p) => p.type === "tool-call",
199
+ ).length;
200
+ while (toolCallCountRef.current < currentToolCallCount) {
201
+ stepBoundariesRef.current.push(Date.now() - runStartRef.current);
202
+ toolCallCountRef.current++;
203
+ }
204
+ }
205
+ }
206
+
207
+ if (isRunning) {
208
+ if (runStartRef.current == null) {
209
+ runStartRef.current = Date.now();
210
+ stepBoundariesRef.current = [];
211
+ toolCallCountRef.current = 0;
212
+ adapter.pin?.();
213
+ }
214
+ // Cancel any pending persist — isRunning went back to true
215
+ if (persistTimerRef.current) {
216
+ clearTimeout(persistTimerRef.current);
217
+ persistTimerRef.current = null;
218
+ }
219
+ return;
220
+ }
221
+
222
+ // Only act on the true→false transition
223
+ if (!wasRunning) return;
224
+
225
+ // Record step boundary offset (synchronous for accuracy)
226
+ if (runStartRef.current != null) {
227
+ stepBoundariesRef.current.push(Date.now() - runStartRef.current);
228
+ }
229
+
230
+ // Debounce: wait one macrotask so agentic step flickers are absorbed
231
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
232
+ persistTimerRef.current = setTimeout(() => {
233
+ persistSettled(false);
234
+ }, 0);
235
+ });
236
+
237
+ function persistSettled(ignoreRunning: boolean) {
238
+ persistTimerRef.current = null;
239
+ const latest = runtimeRef.current.thread.getState();
240
+ if (!ignoreRunning && latest.isRunning) return;
241
+
242
+ const boundaries = stepBoundariesRef.current;
243
+ const durationMs = boundaries.length > 0 ? boundaries.at(-1) : undefined;
244
+
245
+ // Fallback: if only 1 boundary but message has multiple steps, distribute evenly
246
+ if (boundaries.length === 1 && durationMs != null) {
247
+ const lastAssistant = latest.messages.findLast(
248
+ (m) => m.role === "assistant",
249
+ );
250
+ if (lastAssistant) {
251
+ const tcCount = lastAssistant.content.filter(
252
+ (p) => p.type === "tool-call",
253
+ ).length;
254
+ if (tcCount > 0) {
255
+ const totalSteps = tcCount + 1;
256
+ const stepDur = durationMs / totalSteps;
257
+ boundaries.length = 0;
258
+ for (let i = 0; i < totalSteps; i++) {
259
+ boundaries.push(Math.round((i + 1) * stepDur));
260
+ }
261
+ }
262
+ }
263
+ }
264
+
265
+ // Build per-step timestamps when there are multiple steps
266
+ const stepTimestamps =
267
+ boundaries.length > 1
268
+ ? boundaries.map((endMs, i) => ({
269
+ start_ms: i === 0 ? 0 : boundaries[i - 1]!,
270
+ end_ms: endMs,
271
+ }))
272
+ : undefined;
273
+
274
+ runStartRef.current = null;
275
+ stepBoundariesRef.current = [];
276
+
277
+ const telemetryOptions = {
278
+ ...(durationMs != null ? { durationMs } : undefined),
279
+ ...(stepTimestamps != null ? { stepTimestamps } : undefined),
280
+ };
281
+
282
+ persistInFlightRef.current = persistInFlightRef.current
283
+ .then(async () => {
284
+ const changedRunMessageIds = new Set<string>();
285
+ for (const message of latest.messages) {
286
+ const externalMessages =
287
+ getExternalStoreMessages<TMessage>(message);
288
+ const previous = persistedExternalMessages.current.get(message.id);
289
+ if (
290
+ previous === undefined ||
291
+ previous.length !== externalMessages.length ||
292
+ externalMessages.some((item, index) => item !== previous[index])
293
+ ) {
294
+ changedRunMessageIds.add(message.id);
295
+ }
296
+ }
297
+
298
+ const { messages } = latest;
299
+ let lastInnerMessageId: string | null = null;
300
+ const failedUpdateIds = new Set<string>();
301
+
302
+ const getLastInnerId = (msgs: TMessage[]): string | null =>
303
+ msgs.length > 0 ? storageFormatAdapter.getId(msgs.at(-1)!) : null;
304
+
305
+ const toBatchItems = (msgs: TMessage[]) =>
306
+ msgs.map((msg, idx) => ({
307
+ parentId:
308
+ idx === 0
309
+ ? lastInnerMessageId
310
+ : storageFormatAdapter.getId(msgs[idx - 1]!),
311
+ message: msg,
312
+ }));
313
+
314
+ for (const message of messages) {
315
+ const innerMessages = getExternalStoreMessages<TMessage>(message);
316
+
317
+ const isTerminal =
318
+ message.status === undefined ||
319
+ message.status.type === "complete" ||
320
+ message.status.type === "incomplete";
321
+ const isAwaitingToolCalls = isAwaitingToolApproval(message);
322
+ // A paused message's later content can only reach storage via update, so it is persisted early only when the adapter supports update.
323
+ const isReady =
324
+ isTerminal ||
325
+ (isAwaitingToolCalls && adapter.update !== undefined);
326
+
327
+ if (!isReady) {
328
+ lastInnerMessageId =
329
+ getLastInnerId(innerMessages) ?? lastInnerMessageId;
330
+ continue;
331
+ }
332
+
333
+ const isPersistedMessage = historyIds.current.has(message.id);
334
+ if (isPersistedMessage && !changedRunMessageIds.has(message.id)) {
335
+ lastInnerMessageId =
336
+ getLastInnerId(innerMessages) ?? lastInnerMessageId;
337
+ continue;
338
+ }
339
+ if (!isPersistedMessage) {
340
+ historyIds.current.add(message.id);
341
+ deferredTelemetryIds.current.add(message.id);
342
+ }
343
+
344
+ const batchItems = toBatchItems(innerMessages);
345
+ for (const item of batchItems) {
346
+ const innerId = storageFormatAdapter.getId(item.message);
347
+ if (!persistedInnerIds.current.has(innerId)) {
348
+ await adapter.append(item);
349
+ persistedInnerIds.current.add(innerId);
350
+ } else if (durationMs !== undefined) {
351
+ try {
352
+ await adapter.update?.(item, innerId);
353
+ } catch {
354
+ // A failed update drops the message from the refreshed baseline so it retries on the next run stop.
355
+ failedUpdateIds.add(message.id);
356
+ }
357
+ }
358
+ }
359
+
360
+ lastInnerMessageId =
361
+ getLastInnerId(innerMessages) ?? lastInnerMessageId;
362
+
363
+ if (deferredTelemetryIds.current.has(message.id) && isTerminal) {
364
+ deferredTelemetryIds.current.delete(message.id);
365
+ adapter.reportTelemetry?.(batchItems, telemetryOptions);
366
+ }
367
+ }
368
+
369
+ const nextSnapshot = snapshotExternalMessages<TMessage>(
370
+ latest.messages,
371
+ );
372
+ for (const id of failedUpdateIds) {
373
+ nextSnapshot.delete(id);
374
+ }
375
+ persistedExternalMessages.current = nextSnapshot;
376
+ })
377
+ .catch((error) => {
378
+ console.error("Failed to persist message history:", error);
379
+ });
380
+ }
381
+
382
+ return () => {
383
+ unsubscribe();
384
+ if (persistTimerRef.current) {
385
+ clearTimeout(persistTimerRef.current);
386
+ persistTimerRef.current = null;
387
+ persistSettled(false);
388
+ }
389
+ };
390
+ }, [formatAdapter, storageFormatAdapter, runtimeRef]);
391
+
392
+ const deleteMessage = useCallback(
393
+ async (messageId: string) => {
394
+ const deleteMessages = formatAdapter?.delete?.bind(formatAdapter);
395
+ if (!deleteMessages) return;
396
+
397
+ const messages = runtimeRef.current.thread.getState().messages;
398
+ const messageIndex = messages.findIndex((m) => m.id === messageId);
399
+ if (messageIndex === -1) return;
400
+
401
+ const previousInnerMessages = messages
402
+ .slice(0, messageIndex)
403
+ .flatMap(getExternalStoreMessages<TMessage>);
404
+ let parentId = previousInnerMessages.at(-1)
405
+ ? storageFormatAdapter.getId(previousInnerMessages.at(-1)!)
406
+ : null;
407
+ const itemsToDelete = getExternalStoreMessages<TMessage>(
408
+ messages[messageIndex]!,
409
+ ).map((message) => {
410
+ const item = { parentId, message };
411
+ parentId = storageFormatAdapter.getId(message);
412
+ return item;
413
+ });
414
+
415
+ const deletion = persistInFlightRef.current.then(async () => {
416
+ await deleteMessages(itemsToDelete);
417
+
418
+ historyIds.current.delete(messageId);
419
+ deferredTelemetryIds.current.delete(messageId);
420
+ persistedExternalMessages.current.delete(messageId);
421
+ for (const item of itemsToDelete) {
422
+ persistedInnerIds.current.delete(
423
+ storageFormatAdapter.getId(item.message),
424
+ );
425
+ }
426
+ });
427
+
428
+ persistInFlightRef.current = deletion.catch(() => {});
429
+ await deletion;
430
+ },
431
+ [formatAdapter, runtimeRef, storageFormatAdapter],
432
+ );
433
+
434
+ return { isLoading, deleteMessage };
435
+ };
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useAssistantClientDestroySignal } from "@assistant-ui/store/internal";
3
+
4
+ export const useResourceCleanup = (enabled: boolean, cleanup: () => void) => {
5
+ const destroySignal = useAssistantClientDestroySignal();
6
+ const cleanupRef = useRef(cleanup);
7
+ const enabledRef = useRef(enabled);
8
+ const registeredSignalRef = useRef<AbortSignal | undefined>(undefined);
9
+
10
+ useEffect(() => {
11
+ cleanupRef.current = cleanup;
12
+ enabledRef.current = enabled;
13
+ });
14
+
15
+ useEffect(() => {
16
+ if (!enabled || !destroySignal) return undefined;
17
+ if (registeredSignalRef.current === destroySignal) return undefined;
18
+
19
+ registeredSignalRef.current = destroySignal;
20
+ destroySignal.addEventListener(
21
+ "abort",
22
+ () => {
23
+ if (enabledRef.current) cleanupRef.current();
24
+ },
25
+ { once: true },
26
+ );
27
+
28
+ // The listener must survive standalone soft unmounts so a later permanent
29
+ // client destroy still cleans up the retained resource state.
30
+ return undefined;
31
+ }, [destroySignal, enabled]);
32
+ };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import type { UIMessage } from "@ai-sdk/react";
4
+ import { isToolUIPart } from "ai";
5
+ import type { MessageTiming } from "@assistant-ui/core";
6
+ import {
7
+ useStreamingTiming as useStreamingTimingPrimitive,
8
+ type StreamingTimingAccessors,
9
+ } from "@assistant-ui/core/react";
10
+
11
+ const findAssistant = (
12
+ messages: readonly UIMessage[],
13
+ messageId: string,
14
+ ): UIMessage | undefined =>
15
+ messages.find((m) => m.role === "assistant" && m.id === messageId);
16
+
17
+ const getTextLength = (
18
+ messages: readonly UIMessage[],
19
+ messageId: string,
20
+ ): number => {
21
+ const message = findAssistant(messages, messageId);
22
+ if (!message?.parts) return 0;
23
+ let len = 0;
24
+ for (const part of message.parts) {
25
+ if (part.type === "text") len += part.text.length;
26
+ }
27
+ return len;
28
+ };
29
+
30
+ const getToolCallCount = (
31
+ messages: readonly UIMessage[],
32
+ messageId: string,
33
+ ): number => {
34
+ const message = findAssistant(messages, messageId);
35
+ if (!message?.parts) return 0;
36
+ let count = 0;
37
+ for (const part of message.parts) {
38
+ if (isToolUIPart(part)) count++;
39
+ }
40
+ return count;
41
+ };
42
+
43
+ const getAssistantMessageId = (
44
+ messages: readonly UIMessage[],
45
+ ): string | undefined => messages.findLast((m) => m.role === "assistant")?.id;
46
+
47
+ export const aiSdkStreamingTimingAccessors: StreamingTimingAccessors<UIMessage> =
48
+ {
49
+ getAssistantMessageId,
50
+ getTextLength,
51
+ getToolCallCount,
52
+ };
53
+
54
+ /**
55
+ * Tracks streaming timing for AI SDK messages client-side. Delegates to the
56
+ * shared `useStreamingTiming` primitive in `@assistant-ui/core/react`,
57
+ * adapted to the `UIMessage` shape. Timing is finalized when streaming ends
58
+ * and stored per message id.
59
+ */
60
+ export const useStreamingTiming = (
61
+ messages: UIMessage[],
62
+ isRunning: boolean,
63
+ ): Record<string, MessageTiming> =>
64
+ useStreamingTimingPrimitive(
65
+ messages,
66
+ isRunning,
67
+ aiSdkStreamingTimingAccessors,
68
+ );