@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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +26 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +37 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +11 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +7 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +28 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +249 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/modelContentEnvelope.d.ts +16 -0
- package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
- package/dist/converters/modelContentEnvelope.js +22 -0
- package/dist/converters/modelContentEnvelope.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +7 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +58 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolOutputConversion.d.ts +29 -0
- package/dist/converters/toolOutputConversion.d.ts.map +1 -0
- package/dist/converters/toolOutputConversion.js +31 -0
- package/dist/converters/toolOutputConversion.js.map +1 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +20 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/index.native.d.ts +12 -0
- package/dist/index.native.js +11 -0
- package/dist/model-context/injectInteractableContext.d.ts +37 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.js +52 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +27 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
- package/dist/model-context/injectQuoteContext.js +51 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -0
- package/dist/runtime/AISDKChat.d.ts +16 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +69 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +45 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +203 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +49 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +266 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +13 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +52 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +77 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +134 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +11 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +278 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +5 -0
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
- package/dist/runtime/useResourceCleanup.js +47 -0
- package/dist/runtime/useResourceCleanup.js.map +1 -0
- package/dist/runtime/useStreamingTiming.d.ts +15 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +38 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/frontendTools.d.ts +33 -0
- package/dist/tools/frontendTools.d.ts.map +1 -0
- package/dist/tools/frontendTools.js +33 -0
- package/dist/tools/frontendTools.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +73 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +245 -0
- package/dist/tools/generativeTools.js.map +1 -0
- package/dist/tools/mcp-stdio.node.d.ts +2 -0
- package/dist/tools/mcp-stdio.node.js +2 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +11 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +23 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +134 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +38 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +113 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +19 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +127 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +7 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +9 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +6 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +12 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +100 -0
- package/src/__tests__/standalone.e2e.ts +59 -0
- package/src/adapters/aiSDKFormatAdapter.ts +41 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +11 -0
- package/src/converters/convertMessage.test.ts +1130 -0
- package/src/converters/convertMessage.ts +457 -0
- package/src/converters/modelContentEnvelope.ts +41 -0
- package/src/converters/toCreateMessage.test.ts +596 -0
- package/src/converters/toCreateMessage.ts +99 -0
- package/src/converters/toolOutputConversion.ts +26 -0
- package/src/hooks.ts +23 -0
- package/src/index.native.ts +21 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +61 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +62 -0
- package/src/runtime/AISDKChat.integration.test.tsx +47 -0
- package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
- package/src/runtime/AISDKChat.test.ts +193 -0
- package/src/runtime/AISDKChat.ts +47 -0
- package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
- package/src/runtime/AISDKThreads.test.ts +548 -0
- package/src/runtime/AISDKThreads.ts +223 -0
- package/src/runtime/__tests__/controlled-transport.ts +43 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
- package/src/runtime/useAISDKRuntime.test.ts +1021 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +524 -0
- package/src/runtime/useChatOnTap.test.ts +58 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
- package/src/runtime/useChatRuntime.test.ts +522 -0
- package/src/runtime/useChatRuntime.ts +49 -0
- package/src/runtime/useChatThread.ts +286 -0
- package/src/runtime/useExternalHistory.test.ts +1289 -0
- package/src/runtime/useExternalHistory.ts +435 -0
- package/src/runtime/useResourceCleanup.ts +32 -0
- package/src/runtime/useStreamingTiming.ts +68 -0
- package/src/tools/frontendTools.test.ts +205 -0
- package/src/tools/frontendTools.ts +83 -0
- package/src/tools/generativeTools.test.ts +785 -0
- package/src/tools/generativeTools.ts +470 -0
- package/src/tools/mcp-stdio.node.ts +1 -0
- package/src/tools/mcp-stdio.unsupported.ts +12 -0
- package/src/transport/AssistantChatTransport.test.ts +300 -0
- package/src/transport/AssistantChatTransport.ts +204 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +161 -0
- package/src/usage.test.ts +186 -0
- package/src/usage.ts +174 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +55 -0
- package/src/utils/sliceMessagesUntil.ts +20 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { RESUMABLE_STREAM_ID_HEADER } from "./resumable";
|
|
5
|
+
import { AssistantChatTransport } from "./AssistantChatTransport";
|
|
6
|
+
|
|
7
|
+
const emptyStreamResponse = () =>
|
|
8
|
+
new Response(
|
|
9
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
10
|
+
{
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: { "content-type": "text/event-stream" },
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const createThreadListItem = (remoteId: string) => ({
|
|
17
|
+
initialize: vi.fn(async () => ({ remoteId, externalId: undefined })),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const sendMessagesOptions = {
|
|
21
|
+
trigger: "submit-message" as const,
|
|
22
|
+
chatId: "local-chat-id",
|
|
23
|
+
messageId: undefined,
|
|
24
|
+
messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
|
|
25
|
+
abortSignal: undefined,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("AssistantChatTransport.prepareSendMessagesRequest", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("passes the initialized remote thread id to prepareSendMessagesRequest as options.id", async () => {
|
|
34
|
+
const threadListItem = createThreadListItem("remote-thread-id");
|
|
35
|
+
const captured: { id: unknown } = { id: undefined };
|
|
36
|
+
const fetchMock = vi.fn(async () => emptyStreamResponse());
|
|
37
|
+
|
|
38
|
+
const transport = new AssistantChatTransport({
|
|
39
|
+
fetch: fetchMock as never,
|
|
40
|
+
prepareSendMessagesRequest: async (options) => {
|
|
41
|
+
captured.id = options.id;
|
|
42
|
+
return { body: { id: options.id } };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
transport.__internal_setGetThreadListItem(() => threadListItem as never);
|
|
46
|
+
|
|
47
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
48
|
+
|
|
49
|
+
expect(captured.id).toBe("remote-thread-id");
|
|
50
|
+
expect(threadListItem.initialize).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("uses the initialized remote thread id in the default request body", async () => {
|
|
54
|
+
const threadListItem = createThreadListItem("remote-thread-id");
|
|
55
|
+
let capturedBody: string | undefined;
|
|
56
|
+
const fetchMock = vi.fn(
|
|
57
|
+
async (_input: unknown, init: { body?: string } | undefined) => {
|
|
58
|
+
capturedBody = init?.body;
|
|
59
|
+
return emptyStreamResponse();
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const transport = new AssistantChatTransport({
|
|
64
|
+
fetch: fetchMock as never,
|
|
65
|
+
});
|
|
66
|
+
transport.__internal_setGetThreadListItem(() => threadListItem as never);
|
|
67
|
+
|
|
68
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
69
|
+
|
|
70
|
+
expect(threadListItem.initialize).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
const body = JSON.parse(capturedBody as string);
|
|
73
|
+
expect(body.id).toBe("remote-thread-id");
|
|
74
|
+
expect(body.messages).toEqual(sendMessagesOptions.messages);
|
|
75
|
+
expect(body.trigger).toBe("submit-message");
|
|
76
|
+
expect(body.messageId).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("falls back to the local chat id when no thread list item is available", async () => {
|
|
80
|
+
const captured: { id: unknown } = { id: undefined };
|
|
81
|
+
const fetchMock = vi.fn(async () => emptyStreamResponse());
|
|
82
|
+
|
|
83
|
+
const transport = new AssistantChatTransport({
|
|
84
|
+
fetch: fetchMock as never,
|
|
85
|
+
prepareSendMessagesRequest: async (options) => {
|
|
86
|
+
captured.id = options.id;
|
|
87
|
+
return { body: { id: options.id } };
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
transport.__internal_setGetThreadListItem(() => undefined);
|
|
91
|
+
|
|
92
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
93
|
+
|
|
94
|
+
expect(captured.id).toBe("local-chat-id");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("stores response stream ids under the local thread id", async () => {
|
|
98
|
+
const setStreamId = vi.fn();
|
|
99
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) => {
|
|
100
|
+
return new Response(
|
|
101
|
+
new ReadableStream({ start: (controller) => controller.close() }),
|
|
102
|
+
{
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: {
|
|
105
|
+
"content-type": "text/event-stream",
|
|
106
|
+
[RESUMABLE_STREAM_ID_HEADER]: "stream-1",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
const transport = new AssistantChatTransport({
|
|
112
|
+
fetch: fetchMock as never,
|
|
113
|
+
resumable: {
|
|
114
|
+
storage: {
|
|
115
|
+
getStreamId: vi.fn(),
|
|
116
|
+
setStreamId,
|
|
117
|
+
clear: vi.fn(),
|
|
118
|
+
},
|
|
119
|
+
resumeApi: "/api/chat/resume",
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
124
|
+
|
|
125
|
+
expect(setStreamId).toHaveBeenCalledWith("stream-1", "local-chat-id");
|
|
126
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
127
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
128
|
+
false,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not add the resumable thread header without resumable storage", async () => {
|
|
133
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) =>
|
|
134
|
+
emptyStreamResponse(),
|
|
135
|
+
);
|
|
136
|
+
const transport = new AssistantChatTransport({
|
|
137
|
+
fetch: fetchMock as never,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await transport.sendMessages(sendMessagesOptions as never);
|
|
141
|
+
|
|
142
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
143
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
144
|
+
false,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("reads reconnect stream ids under the local thread id", async () => {
|
|
149
|
+
const getStreamId = vi.fn(() => "stream-1");
|
|
150
|
+
const fetchMock = vi.fn(async (_input: string, _init?: RequestInit) =>
|
|
151
|
+
emptyStreamResponse(),
|
|
152
|
+
);
|
|
153
|
+
const transport = new AssistantChatTransport({
|
|
154
|
+
fetch: fetchMock as never,
|
|
155
|
+
resumable: {
|
|
156
|
+
storage: {
|
|
157
|
+
getStreamId,
|
|
158
|
+
setStreamId: vi.fn(),
|
|
159
|
+
clear: vi.fn(),
|
|
160
|
+
},
|
|
161
|
+
resumeApi: (streamId) => `/api/chat/resume/${streamId}`,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await transport.reconnectToStream({ chatId: "local-chat-id" });
|
|
166
|
+
|
|
167
|
+
expect(getStreamId).toHaveBeenCalledWith("local-chat-id");
|
|
168
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/chat/resume/stream-1");
|
|
169
|
+
const requestHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
|
170
|
+
expect(requestHeaders.has("x-assistant-ui-resumable-thread-id")).toBe(
|
|
171
|
+
false,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const createMemoryStorage = (initialId: string | null = null) => {
|
|
177
|
+
let id = initialId;
|
|
178
|
+
return {
|
|
179
|
+
getStreamId: () => id,
|
|
180
|
+
setStreamId: (next: string) => {
|
|
181
|
+
id = next;
|
|
182
|
+
},
|
|
183
|
+
clear: () => {
|
|
184
|
+
id = null;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// A null-body status response carrying a non-null empty body, as WebKit returns
|
|
190
|
+
// for a 204. The JS `Response` constructor cannot represent a null-body status
|
|
191
|
+
// with a body, so this is a plain object shaped like a Response.
|
|
192
|
+
const nullBodyStatusWithBody = (status: number) =>
|
|
193
|
+
({
|
|
194
|
+
status,
|
|
195
|
+
statusText: "",
|
|
196
|
+
headers: new Headers(),
|
|
197
|
+
body: new ReadableStream({ start: (controller) => controller.close() }),
|
|
198
|
+
}) as unknown as Response;
|
|
199
|
+
|
|
200
|
+
// The resumable wrapper replaces the transport's `fetch`, which is protected on
|
|
201
|
+
// the upstream `HttpChatTransport`; reach it through a structural cast.
|
|
202
|
+
const wrappedFetchOf = (
|
|
203
|
+
transport: AssistantChatTransport<UIMessage>,
|
|
204
|
+
): ((input: string, init?: unknown) => Promise<Response>) =>
|
|
205
|
+
(
|
|
206
|
+
transport as unknown as {
|
|
207
|
+
fetch: (input: string, init?: unknown) => Promise<Response>;
|
|
208
|
+
}
|
|
209
|
+
).fetch;
|
|
210
|
+
|
|
211
|
+
describe("AssistantChatTransport resumable fetch wrapper", () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
vi.clearAllMocks();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("passes a 204 with a non-null empty body through untouched (WebKit)", async () => {
|
|
217
|
+
const response = nullBodyStatusWithBody(204);
|
|
218
|
+
const fetchMock = vi.fn(async () => response);
|
|
219
|
+
const transport = new AssistantChatTransport({
|
|
220
|
+
resumable: {
|
|
221
|
+
storage: createMemoryStorage(),
|
|
222
|
+
resumeApi: "/api/resume",
|
|
223
|
+
},
|
|
224
|
+
fetch: fetchMock as never,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
228
|
+
|
|
229
|
+
expect(res).toBe(response);
|
|
230
|
+
expect(res.status).toBe(204);
|
|
231
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("passes a 204 with a null body through untouched", async () => {
|
|
235
|
+
const response = new Response(null, { status: 204 });
|
|
236
|
+
const fetchMock = vi.fn(async () => response);
|
|
237
|
+
const transport = new AssistantChatTransport({
|
|
238
|
+
resumable: {
|
|
239
|
+
storage: createMemoryStorage(),
|
|
240
|
+
resumeApi: "/api/resume",
|
|
241
|
+
},
|
|
242
|
+
fetch: fetchMock as never,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
246
|
+
|
|
247
|
+
expect(res).toBe(response);
|
|
248
|
+
expect(res.status).toBe(204);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("passes other null-body statuses with a body through untouched", async () => {
|
|
252
|
+
const response = nullBodyStatusWithBody(304);
|
|
253
|
+
const fetchMock = vi.fn(async () => response);
|
|
254
|
+
const transport = new AssistantChatTransport({
|
|
255
|
+
resumable: {
|
|
256
|
+
storage: createMemoryStorage(),
|
|
257
|
+
resumeApi: "/api/resume",
|
|
258
|
+
},
|
|
259
|
+
fetch: fetchMock as never,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
263
|
+
|
|
264
|
+
expect(res).toBe(response);
|
|
265
|
+
expect(res.status).toBe(304);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("still reconstructs and taps a normal streaming response, clearing storage on finish", async () => {
|
|
269
|
+
const storage = createMemoryStorage("stream-id-123");
|
|
270
|
+
const body = new ReadableStream({
|
|
271
|
+
start(controller) {
|
|
272
|
+
controller.enqueue(
|
|
273
|
+
new TextEncoder().encode('data: {"type":"finish"}\n\n'),
|
|
274
|
+
);
|
|
275
|
+
controller.close();
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const response = new Response(body, {
|
|
279
|
+
status: 200,
|
|
280
|
+
headers: { "content-type": "text/event-stream" },
|
|
281
|
+
});
|
|
282
|
+
const fetchMock = vi.fn(async () => response);
|
|
283
|
+
const transport = new AssistantChatTransport({
|
|
284
|
+
resumable: { storage, resumeApi: "/api/resume" },
|
|
285
|
+
fetch: fetchMock as never,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const res = await wrappedFetchOf(transport)("https://example.com", {});
|
|
289
|
+
|
|
290
|
+
expect(res.status).toBe(200);
|
|
291
|
+
expect(res).not.toBe(response);
|
|
292
|
+
expect(res.body).not.toBeNull();
|
|
293
|
+
const reader = res.body!.getReader();
|
|
294
|
+
while (true) {
|
|
295
|
+
const { done } = await reader.read();
|
|
296
|
+
if (done) break;
|
|
297
|
+
}
|
|
298
|
+
expect(storage.getStreamId()).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantRuntime,
|
|
3
|
+
ThreadListItemRuntime,
|
|
4
|
+
} from "@assistant-ui/core";
|
|
5
|
+
import {
|
|
6
|
+
DefaultChatTransport,
|
|
7
|
+
type HttpChatTransportInitOptions,
|
|
8
|
+
type UIMessage,
|
|
9
|
+
} from "ai";
|
|
10
|
+
import { toToolsJSONSchema } from "assistant-stream";
|
|
11
|
+
import {
|
|
12
|
+
RESUMABLE_STREAM_ID_HEADER,
|
|
13
|
+
type AssistantChatResumableOptions,
|
|
14
|
+
} from "./resumable";
|
|
15
|
+
|
|
16
|
+
export type InitializableThreadListItem = Pick<
|
|
17
|
+
ThreadListItemRuntime,
|
|
18
|
+
"initialize"
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
const FINISH_MARKER = '"type":"finish"';
|
|
22
|
+
const FINISH_BUFFER_LIMIT = 4096;
|
|
23
|
+
const FINISH_BUFFER_TAIL = 1024;
|
|
24
|
+
const RESUMABLE_THREAD_ID_HEADER = "x-assistant-ui-resumable-thread-id";
|
|
25
|
+
|
|
26
|
+
// 101/204/205/304 are null-body statuses per the fetch spec: `new Response(body, { status })`
|
|
27
|
+
// throws for them, and WebKit returns a non-null empty body, so the body check alone does not guard it.
|
|
28
|
+
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
29
|
+
|
|
30
|
+
export type AssistantChatTransportInitOptions<UI_MESSAGE extends UIMessage> =
|
|
31
|
+
HttpChatTransportInitOptions<UI_MESSAGE> & {
|
|
32
|
+
resumable?: AssistantChatResumableOptions;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class AssistantChatTransport<
|
|
36
|
+
UI_MESSAGE extends UIMessage,
|
|
37
|
+
> extends DefaultChatTransport<UI_MESSAGE> {
|
|
38
|
+
private runtime: AssistantRuntime | undefined;
|
|
39
|
+
private getThreadListItem:
|
|
40
|
+
| (() => InitializableThreadListItem | undefined)
|
|
41
|
+
| undefined;
|
|
42
|
+
private readonly resumable: AssistantChatResumableOptions | undefined;
|
|
43
|
+
|
|
44
|
+
private readonly __internal_initOptions:
|
|
45
|
+
| AssistantChatTransportInitOptions<UI_MESSAGE>
|
|
46
|
+
| undefined;
|
|
47
|
+
|
|
48
|
+
/** Constructs an unwired copy with the same init options. */
|
|
49
|
+
public __internal_clone(): AssistantChatTransport<UI_MESSAGE> {
|
|
50
|
+
const Constructor = this.constructor as new (
|
|
51
|
+
initOptions?: AssistantChatTransportInitOptions<UI_MESSAGE>,
|
|
52
|
+
) => AssistantChatTransport<UI_MESSAGE>;
|
|
53
|
+
return new Constructor(this.__internal_initOptions);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
constructor(initOptions?: AssistantChatTransportInitOptions<UI_MESSAGE>) {
|
|
57
|
+
const { resumable, ...rest } = initOptions ?? {};
|
|
58
|
+
const userFetch = rest.fetch;
|
|
59
|
+
const userPrepareReconnect = rest.prepareReconnectToStreamRequest;
|
|
60
|
+
|
|
61
|
+
super({
|
|
62
|
+
...rest,
|
|
63
|
+
...(resumable && {
|
|
64
|
+
fetch: wrapFetchWithResumable(resumable, userFetch),
|
|
65
|
+
prepareReconnectToStreamRequest: wrapPrepareReconnect(
|
|
66
|
+
resumable,
|
|
67
|
+
userPrepareReconnect,
|
|
68
|
+
),
|
|
69
|
+
}),
|
|
70
|
+
prepareSendMessagesRequest: async (options) => {
|
|
71
|
+
const threadId = options.id;
|
|
72
|
+
const context = this.runtime?.thread.getModelContext();
|
|
73
|
+
const threadListItem =
|
|
74
|
+
this.getThreadListItem?.() ?? this.runtime?.threads.mainItem;
|
|
75
|
+
const id = (await threadListItem?.initialize())?.remoteId ?? options.id;
|
|
76
|
+
|
|
77
|
+
const optionsEx = {
|
|
78
|
+
...options,
|
|
79
|
+
id,
|
|
80
|
+
body: {
|
|
81
|
+
callSettings: context?.callSettings,
|
|
82
|
+
system: context?.system,
|
|
83
|
+
config: context?.config,
|
|
84
|
+
tools: toToolsJSONSchema(context?.tools ?? {}),
|
|
85
|
+
...options?.body,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const preparedRequest =
|
|
89
|
+
await rest.prepareSendMessagesRequest?.(optionsEx);
|
|
90
|
+
const headers = resumable
|
|
91
|
+
? new Headers(preparedRequest?.headers ?? options.headers)
|
|
92
|
+
: undefined;
|
|
93
|
+
headers?.set(RESUMABLE_THREAD_ID_HEADER, threadId);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...preparedRequest,
|
|
97
|
+
...(headers && { headers }),
|
|
98
|
+
body: preparedRequest?.body ?? {
|
|
99
|
+
...optionsEx.body,
|
|
100
|
+
id,
|
|
101
|
+
messages: options.messages,
|
|
102
|
+
trigger: options.trigger,
|
|
103
|
+
messageId: options.messageId,
|
|
104
|
+
metadata: options.requestMetadata,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
this.resumable = resumable;
|
|
111
|
+
this.__internal_initOptions = initOptions;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setRuntime(runtime: AssistantRuntime) {
|
|
115
|
+
this.runtime = runtime;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getResumableAdapter(): AssistantChatResumableOptions | undefined {
|
|
119
|
+
return this.resumable;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
__internal_setGetThreadListItem(
|
|
123
|
+
getter: () => InitializableThreadListItem | undefined,
|
|
124
|
+
) {
|
|
125
|
+
this.getThreadListItem = getter;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function wrapFetchWithResumable(
|
|
130
|
+
resumable: AssistantChatResumableOptions,
|
|
131
|
+
userFetch: HttpChatTransportInitOptions<UIMessage>["fetch"],
|
|
132
|
+
): NonNullable<HttpChatTransportInitOptions<UIMessage>["fetch"]> {
|
|
133
|
+
const baseFetch: typeof globalThis.fetch = userFetch
|
|
134
|
+
? (input, init) => userFetch(input as RequestInfo | URL, init)
|
|
135
|
+
: globalThis.fetch.bind(globalThis);
|
|
136
|
+
|
|
137
|
+
return async (input, init) => {
|
|
138
|
+
const headers = new Headers(init?.headers);
|
|
139
|
+
const threadId = headers.get(RESUMABLE_THREAD_ID_HEADER) ?? undefined;
|
|
140
|
+
headers.delete(RESUMABLE_THREAD_ID_HEADER);
|
|
141
|
+
const res = await baseFetch(input, { ...init, headers });
|
|
142
|
+
const id = res.headers.get(RESUMABLE_STREAM_ID_HEADER);
|
|
143
|
+
if (id) resumable.storage.setStreamId(id, threadId);
|
|
144
|
+
if (!res.body || NULL_BODY_STATUSES.has(res.status)) return res;
|
|
145
|
+
|
|
146
|
+
const detectFinish = resumable.isFinishEvent ?? defaultIsFinishEvent;
|
|
147
|
+
// a single decoder is required so multi-byte sequences split across
|
|
148
|
+
// chunks buffer via stream: true rather than getting dropped.
|
|
149
|
+
const decoder = new TextDecoder();
|
|
150
|
+
let accumulator = "";
|
|
151
|
+
const tap = new TransformStream<Uint8Array, Uint8Array>({
|
|
152
|
+
transform(chunk, controller) {
|
|
153
|
+
controller.enqueue(chunk);
|
|
154
|
+
accumulator += decoder.decode(chunk, { stream: true });
|
|
155
|
+
if (detectFinish(chunk, accumulator)) {
|
|
156
|
+
if (!id || resumable.storage.getStreamId(threadId) === id) {
|
|
157
|
+
resumable.storage.clear(threadId);
|
|
158
|
+
}
|
|
159
|
+
accumulator = "";
|
|
160
|
+
} else if (accumulator.length > FINISH_BUFFER_LIMIT) {
|
|
161
|
+
accumulator = accumulator.slice(-FINISH_BUFFER_TAIL);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return new Response(res.body.pipeThrough(tap), {
|
|
167
|
+
status: res.status,
|
|
168
|
+
statusText: res.statusText,
|
|
169
|
+
headers: res.headers,
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function defaultIsFinishEvent(_chunk: Uint8Array, accumulator: string) {
|
|
175
|
+
return accumulator.includes(FINISH_MARKER);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function wrapPrepareReconnect(
|
|
179
|
+
resumable: AssistantChatResumableOptions,
|
|
180
|
+
userPrepareReconnect: HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"],
|
|
181
|
+
): NonNullable<
|
|
182
|
+
HttpChatTransportInitOptions<UIMessage>["prepareReconnectToStreamRequest"]
|
|
183
|
+
> {
|
|
184
|
+
return async (options) => {
|
|
185
|
+
const streamId = resumable.storage.getStreamId(options.id);
|
|
186
|
+
if (!streamId) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"AssistantChatTransport: no resumable stream id available; nothing to resume",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const api =
|
|
192
|
+
typeof resumable.resumeApi === "function"
|
|
193
|
+
? resumable.resumeApi(streamId)
|
|
194
|
+
: resumable.resumeApi;
|
|
195
|
+
const userPrepared = await userPrepareReconnect?.({ ...options, api });
|
|
196
|
+
const headers = new Headers(userPrepared?.headers ?? options.headers);
|
|
197
|
+
headers.set(RESUMABLE_THREAD_ID_HEADER, options.id);
|
|
198
|
+
return {
|
|
199
|
+
...userPrepared,
|
|
200
|
+
headers,
|
|
201
|
+
api: userPrepared?.api ?? api,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
}
|