@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,26 @@
|
|
|
1
|
+
import type { JSONValue } from "ai";
|
|
2
|
+
import type { ToolModelContentPart } from "assistant-stream";
|
|
3
|
+
|
|
4
|
+
export const toAISDKContent = (parts: readonly ToolModelContentPart[]) => ({
|
|
5
|
+
type: "content" as const,
|
|
6
|
+
value: parts.map((part) => {
|
|
7
|
+
if (part.type === "text") {
|
|
8
|
+
return { type: "text" as const, text: part.text };
|
|
9
|
+
}
|
|
10
|
+
const mediaType =
|
|
11
|
+
typeof part.mediaType === "string"
|
|
12
|
+
? part.mediaType
|
|
13
|
+
: "application/octet-stream";
|
|
14
|
+
return {
|
|
15
|
+
type: "file" as const,
|
|
16
|
+
data: { type: "data" as const, data: part.data },
|
|
17
|
+
mediaType,
|
|
18
|
+
...(part.filename !== undefined && { filename: part.filename }),
|
|
19
|
+
};
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const toAISDKDefaultOutput = (output: unknown) =>
|
|
24
|
+
typeof output === "string"
|
|
25
|
+
? { type: "text" as const, value: output }
|
|
26
|
+
: { type: "json" as const, value: (output ?? null) as JSONValue };
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { UseChatHelpers } from "@ai-sdk/react";
|
|
4
|
+
import type { UIMessage } from "ai";
|
|
5
|
+
import { aiSDKExtras } from "./aiSDKExtras";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read the last AI SDK chat error object from the runtime extras.
|
|
9
|
+
* `undefined` when there is no error or the current thread is not backed by
|
|
10
|
+
* the AI SDK runtime.
|
|
11
|
+
*/
|
|
12
|
+
export const useAISDKError = () => aiSDKExtras.use((e) => e.error, undefined);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The underlying `useChat` helpers object, for advanced views — reach
|
|
16
|
+
* `resumeStream`, `clearError`, or the raw `sendMessage` without forking the
|
|
17
|
+
* runtime hook. `undefined` when the current thread is not backed by the AI
|
|
18
|
+
* SDK runtime.
|
|
19
|
+
*/
|
|
20
|
+
export const useAISDKChat = <UI_MESSAGE extends UIMessage = UIMessage>() =>
|
|
21
|
+
aiSDKExtras.use((e) => e.chat, undefined) as
|
|
22
|
+
| UseChatHelpers<UI_MESSAGE>
|
|
23
|
+
| undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="@assistant-ui/core/react" />
|
|
2
|
+
|
|
3
|
+
export { useAISDKRuntime } from "./runtime/useAISDKRuntime";
|
|
4
|
+
export { useChatRuntime } from "./runtime/useChatRuntime";
|
|
5
|
+
export type { UseChatRuntimeOptions } from "./runtime/useChatRuntime";
|
|
6
|
+
export { AssistantChatTransport } from "./transport/AssistantChatTransport";
|
|
7
|
+
export {
|
|
8
|
+
RESUMABLE_STREAM_ID_HEADER,
|
|
9
|
+
createResumableSessionStorage,
|
|
10
|
+
} from "./transport/resumable";
|
|
11
|
+
export type {
|
|
12
|
+
AssistantChatResumableOptions,
|
|
13
|
+
ResumableClientStorage,
|
|
14
|
+
} from "./transport/resumable";
|
|
15
|
+
export { frontendTools, type FrontendTools } from "./tools/frontendTools";
|
|
16
|
+
export { injectQuoteContext } from "./model-context/injectQuoteContext";
|
|
17
|
+
export { unstable_injectInteractableContext } from "./model-context/injectInteractableContext";
|
|
18
|
+
export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
|
|
19
|
+
export { getThreadMessageTokenUsage, useThreadTokenUsage } from "./usage";
|
|
20
|
+
export { useAISDKChat, useAISDKError } from "./hooks";
|
|
21
|
+
export { AISDKChat, type AISDKChatOptions } from "./runtime/AISDKChat";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/// <reference types="@assistant-ui/core/react" />
|
|
2
|
+
|
|
3
|
+
export { useAISDKRuntime } from "./runtime/useAISDKRuntime";
|
|
4
|
+
export { useChatRuntime } from "./runtime/useChatRuntime";
|
|
5
|
+
export type { UseChatRuntimeOptions } from "./runtime/useChatRuntime";
|
|
6
|
+
export { AssistantChatTransport } from "./transport/AssistantChatTransport";
|
|
7
|
+
export {
|
|
8
|
+
RESUMABLE_STREAM_ID_HEADER,
|
|
9
|
+
createResumableSessionStorage,
|
|
10
|
+
} from "./transport/resumable";
|
|
11
|
+
export type {
|
|
12
|
+
AssistantChatResumableOptions,
|
|
13
|
+
ResumableClientStorage,
|
|
14
|
+
} from "./transport/resumable";
|
|
15
|
+
export { frontendTools, type FrontendTools } from "./tools/frontendTools";
|
|
16
|
+
export { injectQuoteContext } from "./model-context/injectQuoteContext";
|
|
17
|
+
export { unstable_injectInteractableContext } from "./model-context/injectInteractableContext";
|
|
18
|
+
export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
|
|
19
|
+
export { getThreadMessageTokenUsage, useThreadTokenUsage } from "./usage";
|
|
20
|
+
export { useAISDKChat, useAISDKError } from "./hooks";
|
|
21
|
+
export {
|
|
22
|
+
AISDKToolkit,
|
|
23
|
+
generativeTools,
|
|
24
|
+
type AISDKToolkitOptions,
|
|
25
|
+
type AISDKToolkitToolsOptions,
|
|
26
|
+
type GenerativeToolsOptions,
|
|
27
|
+
} from "./tools/generativeTools";
|
|
28
|
+
export { AISDKChat, type AISDKChatOptions } from "./runtime/AISDKChat";
|
|
29
|
+
export { AISDKThreads, type AISDKThreadsOptions } from "./runtime/AISDKThreads";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { unstable_injectInteractableContext } from "./injectInteractableContext";
|
|
4
|
+
import { injectQuoteContext } from "./injectQuoteContext";
|
|
5
|
+
|
|
6
|
+
type Interactable = {
|
|
7
|
+
name: string;
|
|
8
|
+
id: string;
|
|
9
|
+
state: unknown;
|
|
10
|
+
partial?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const userMsg = (
|
|
14
|
+
interactables?: Interactable[],
|
|
15
|
+
extraCustom?: Record<string, unknown>,
|
|
16
|
+
): UIMessage =>
|
|
17
|
+
({
|
|
18
|
+
id: "m1",
|
|
19
|
+
role: "user",
|
|
20
|
+
parts: [{ type: "text", text: "hello" }],
|
|
21
|
+
...(interactables || extraCustom
|
|
22
|
+
? {
|
|
23
|
+
metadata: {
|
|
24
|
+
custom: {
|
|
25
|
+
...extraCustom,
|
|
26
|
+
...(interactables ? { interactables } : {}),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
: {}),
|
|
31
|
+
}) as UIMessage;
|
|
32
|
+
|
|
33
|
+
const textOf = (part: unknown) => (part as { text?: string }).text;
|
|
34
|
+
const isStateText = (part: unknown) =>
|
|
35
|
+
(part as { type?: string }).type === "text" &&
|
|
36
|
+
(textOf(part) ?? "").startsWith("[Current state of");
|
|
37
|
+
|
|
38
|
+
describe("unstable_injectInteractableContext", () => {
|
|
39
|
+
it("returns the message unchanged when there is no interactable metadata", () => {
|
|
40
|
+
const input = [userMsg()];
|
|
41
|
+
const out = unstable_injectInteractableContext(input);
|
|
42
|
+
expect(out[0]).toBe(input[0]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("skips non-user messages", () => {
|
|
46
|
+
const input = [
|
|
47
|
+
{
|
|
48
|
+
id: "a1",
|
|
49
|
+
role: "assistant",
|
|
50
|
+
parts: [{ type: "text", text: "hi" }],
|
|
51
|
+
metadata: {
|
|
52
|
+
custom: {
|
|
53
|
+
interactables: [{ name: "note", id: "n1", state: { v: 1 } }],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as UIMessage,
|
|
57
|
+
];
|
|
58
|
+
const out = unstable_injectInteractableContext(input);
|
|
59
|
+
expect(out[0]).toBe(input[0]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("prepends a text part using the default format (id included for update_* addressing)", () => {
|
|
63
|
+
const out = unstable_injectInteractableContext([
|
|
64
|
+
userMsg([{ name: "note", id: "n1", state: { title: "Hi" } }]),
|
|
65
|
+
]);
|
|
66
|
+
expect(textOf(out[0]!.parts[0])).toBe(
|
|
67
|
+
'[Current state of "note" (id: "n1"): {"title":"Hi"}]\n\n',
|
|
68
|
+
);
|
|
69
|
+
expect(textOf(out[0]!.parts[1])).toBe("hello");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("formats a partial snapshot as changed fields", () => {
|
|
73
|
+
const out = unstable_injectInteractableContext([
|
|
74
|
+
userMsg([
|
|
75
|
+
{ name: "note", id: "n1", state: { title: "Hi" }, partial: true },
|
|
76
|
+
]),
|
|
77
|
+
]);
|
|
78
|
+
expect(textOf(out[0]!.parts[0])).toBe(
|
|
79
|
+
'[State of "note" (id: "n1") changed — updated fields: {"title":"Hi"}; fields not listed are unchanged]\n\n',
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("joins multiple interactables with a newline", () => {
|
|
84
|
+
const out = unstable_injectInteractableContext([
|
|
85
|
+
userMsg([
|
|
86
|
+
{ name: "note", id: "n1", state: { v: 1 } },
|
|
87
|
+
{ name: "board", id: "b1", state: { v: 2 } },
|
|
88
|
+
]),
|
|
89
|
+
]);
|
|
90
|
+
expect(textOf(out[0]!.parts[0])).toBe(
|
|
91
|
+
'[Current state of "note" (id: "n1"): {"v":1}]\n' +
|
|
92
|
+
'[Current state of "board" (id: "b1"): {"v":2}]\n\n',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("respects a custom format function", () => {
|
|
97
|
+
const out = unstable_injectInteractableContext(
|
|
98
|
+
[userMsg([{ name: "note", id: "n1", state: { v: 1 } }])],
|
|
99
|
+
(item) => `X:${item.name}`,
|
|
100
|
+
);
|
|
101
|
+
expect(textOf(out[0]!.parts[0])).toBe("X:note\n\n");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("leaves messages with an empty interactables array unchanged", () => {
|
|
105
|
+
const input = [userMsg([])];
|
|
106
|
+
expect(unstable_injectInteractableContext(input)[0]).toBe(input[0]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not throw when a user message has no parts", () => {
|
|
110
|
+
const input = [
|
|
111
|
+
{
|
|
112
|
+
id: "m1",
|
|
113
|
+
role: "user",
|
|
114
|
+
metadata: {
|
|
115
|
+
custom: {
|
|
116
|
+
interactables: [{ name: "note", id: "n1", state: { v: 1 } }],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
} as unknown as UIMessage,
|
|
120
|
+
];
|
|
121
|
+
const run = () => unstable_injectInteractableContext(input);
|
|
122
|
+
expect(run).not.toThrow();
|
|
123
|
+
expect(textOf(run()[0]!.parts[0])).toBe(
|
|
124
|
+
'[Current state of "note" (id: "n1"): {"v":1}]\n\n',
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("is idempotent on its own (does not double-inject)", () => {
|
|
129
|
+
const once = unstable_injectInteractableContext([
|
|
130
|
+
userMsg([{ name: "note", id: "n1", state: { v: 1 } }]),
|
|
131
|
+
]);
|
|
132
|
+
const twice = unstable_injectInteractableContext(once);
|
|
133
|
+
expect(twice[0]).toBe(once[0]);
|
|
134
|
+
expect(twice[0]!.parts.filter(isStateText)).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("composition with injectQuoteContext", () => {
|
|
138
|
+
const both = () =>
|
|
139
|
+
userMsg([{ name: "note", id: "n1", state: { v: 1 } }], {
|
|
140
|
+
quote: { text: "quoted" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("stacks both injections in a fixed order over source messages", () => {
|
|
144
|
+
// Apply interactable first, then quote — quote ends up outermost.
|
|
145
|
+
const out = injectQuoteContext(
|
|
146
|
+
unstable_injectInteractableContext([both()]),
|
|
147
|
+
);
|
|
148
|
+
const parts = out[0]!.parts;
|
|
149
|
+
expect(textOf(parts[0])).toBe("> quoted\n\n");
|
|
150
|
+
expect(isStateText(parts[1])).toBe(true);
|
|
151
|
+
expect(textOf(parts[2])).toBe("hello");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("is NOT idempotent across re-application: run injectors once over un-injected messages", () => {
|
|
155
|
+
// Each injector's `alreadyInjected` guard only inspects parts[0]. Once the
|
|
156
|
+
// other injector sits at parts[0], the guard misses and re-prepends. So the
|
|
157
|
+
// composition must run once over source messages, never re-run over output.
|
|
158
|
+
const once = injectQuoteContext(
|
|
159
|
+
unstable_injectInteractableContext([both()]),
|
|
160
|
+
);
|
|
161
|
+
const twice = injectQuoteContext(
|
|
162
|
+
unstable_injectInteractableContext(once),
|
|
163
|
+
);
|
|
164
|
+
expect(twice[0]!.parts.filter(isStateText)).toHaveLength(2);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import {
|
|
3
|
+
unstable_formatInteractableSnapshot,
|
|
4
|
+
unstable_getInteractableSnapshots,
|
|
5
|
+
type Unstable_InteractableSnapshotEntry,
|
|
6
|
+
} from "@assistant-ui/core";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Injects interactable state snapshots into messages as model-visible text.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors {@link injectQuoteContext}: reads the frozen snapshot stamped on a user
|
|
12
|
+
* message's `metadata.custom.interactables` (by the interactables scope at send
|
|
13
|
+
* time) and prepends a text part. Run this in your route handler before
|
|
14
|
+
* `convertToModelMessages`, which otherwise ignores `metadata.custom`.
|
|
15
|
+
*
|
|
16
|
+
* Wording is consumer-owned — pass `format` to control how each snapshot reads.
|
|
17
|
+
* A snapshot may originate from a user edit or an agent `update_*` call, so the
|
|
18
|
+
* default phrasing is neutral. Keep the instance id visible in custom wording:
|
|
19
|
+
* the model needs it to address the `update_*` tool's `id` parameter. A custom
|
|
20
|
+
* `format` must also handle entries with `partial: true`, whose `state` carries
|
|
21
|
+
* only the fields that changed since the model's last known state.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { convertToModelMessages, streamText } from "ai";
|
|
26
|
+
* import { unstable_injectInteractableContext } from "@assistant-ui/react-ai-sdk";
|
|
27
|
+
*
|
|
28
|
+
* export async function POST(req: Request) {
|
|
29
|
+
* const { messages } = await req.json();
|
|
30
|
+
* const result = streamText({
|
|
31
|
+
* model: myModel,
|
|
32
|
+
* messages: await convertToModelMessages(unstable_injectInteractableContext(messages)),
|
|
33
|
+
* });
|
|
34
|
+
* return result.toUIMessageStreamResponse();
|
|
35
|
+
* }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function unstable_injectInteractableContext(
|
|
39
|
+
messages: UIMessage[],
|
|
40
|
+
format: (
|
|
41
|
+
item: Unstable_InteractableSnapshotEntry,
|
|
42
|
+
) => string = unstable_formatInteractableSnapshot,
|
|
43
|
+
): UIMessage[] {
|
|
44
|
+
return messages.map((msg) => {
|
|
45
|
+
if (msg.role !== "user") return msg;
|
|
46
|
+
|
|
47
|
+
const items = unstable_getInteractableSnapshots(msg);
|
|
48
|
+
if (!items?.length) return msg;
|
|
49
|
+
|
|
50
|
+
const text = `${items.map(format).join("\n")}\n\n`;
|
|
51
|
+
|
|
52
|
+
const alreadyInjected =
|
|
53
|
+
msg.parts?.[0]?.type === "text" && msg.parts[0].text === text;
|
|
54
|
+
if (alreadyInjected) return msg;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...msg,
|
|
58
|
+
parts: [{ type: "text" as const, text }, ...(msg.parts ?? [])],
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { injectQuoteContext } from "./injectQuoteContext";
|
|
4
|
+
|
|
5
|
+
const textOf = (part: unknown) => (part as { text?: string }).text;
|
|
6
|
+
|
|
7
|
+
describe("injectQuoteContext", () => {
|
|
8
|
+
it("prepends the quote as a markdown blockquote", () => {
|
|
9
|
+
const out = injectQuoteContext([
|
|
10
|
+
{
|
|
11
|
+
id: "m1",
|
|
12
|
+
role: "user",
|
|
13
|
+
parts: [{ type: "text", text: "hello" }],
|
|
14
|
+
metadata: { custom: { quote: { text: "quoted" } } },
|
|
15
|
+
} as unknown as UIMessage,
|
|
16
|
+
]);
|
|
17
|
+
expect(textOf(out[0]!.parts[0])).toBe("> quoted\n\n");
|
|
18
|
+
expect(textOf(out[0]!.parts[1])).toBe("hello");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not throw when a user message has no parts", () => {
|
|
22
|
+
const input = [
|
|
23
|
+
{
|
|
24
|
+
id: "m1",
|
|
25
|
+
role: "user",
|
|
26
|
+
metadata: { custom: { quote: { text: "quoted" } } },
|
|
27
|
+
} as unknown as UIMessage,
|
|
28
|
+
];
|
|
29
|
+
const run = () => injectQuoteContext(input);
|
|
30
|
+
expect(run).not.toThrow();
|
|
31
|
+
expect(textOf(run()[0]!.parts[0])).toBe("> quoted\n\n");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { UIMessage } from "ai";
|
|
2
|
+
|
|
3
|
+
const getQuoteText = (metadata: unknown): string | undefined => {
|
|
4
|
+
if (!metadata || typeof metadata !== "object") return undefined;
|
|
5
|
+
|
|
6
|
+
const custom = (metadata as Record<string, unknown>).custom;
|
|
7
|
+
if (!custom || typeof custom !== "object") return undefined;
|
|
8
|
+
|
|
9
|
+
const quote = (custom as Record<string, unknown>).quote;
|
|
10
|
+
if (!quote || typeof quote !== "object") return undefined;
|
|
11
|
+
|
|
12
|
+
const text = (quote as Record<string, unknown>).text;
|
|
13
|
+
return typeof text === "string" ? text : undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Injects quote context into messages as markdown blockquotes.
|
|
18
|
+
*
|
|
19
|
+
* Use this in your route handler before `convertToModelMessages` so the LLM
|
|
20
|
+
* sees the quoted text that the user is referring to.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { convertToModelMessages, streamText } from "ai";
|
|
25
|
+
* import { injectQuoteContext } from "@assistant-ui/react-ai-sdk";
|
|
26
|
+
*
|
|
27
|
+
* export async function POST(req: Request) {
|
|
28
|
+
* const { messages } = await req.json();
|
|
29
|
+
* const result = streamText({
|
|
30
|
+
* model: myModel,
|
|
31
|
+
* messages: await convertToModelMessages(injectQuoteContext(messages)),
|
|
32
|
+
* });
|
|
33
|
+
* return result.toUIMessageStreamResponse();
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function injectQuoteContext(messages: UIMessage[]): UIMessage[] {
|
|
38
|
+
return messages.map((msg) => {
|
|
39
|
+
if (msg.role !== "user") return msg;
|
|
40
|
+
|
|
41
|
+
const text = getQuoteText(msg.metadata);
|
|
42
|
+
if (!text) return msg;
|
|
43
|
+
|
|
44
|
+
const blockquote = text
|
|
45
|
+
.split(/\r?\n/)
|
|
46
|
+
.map((line) => `> ${line}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
|
|
49
|
+
const alreadyInjected =
|
|
50
|
+
msg.parts?.[0]?.type === "text" &&
|
|
51
|
+
msg.parts[0].text === `${blockquote}\n\n`;
|
|
52
|
+
if (alreadyInjected) return msg;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...msg,
|
|
56
|
+
parts: [
|
|
57
|
+
{ type: "text" as const, text: `${blockquote}\n\n` },
|
|
58
|
+
...(msg.parts ?? []),
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { render, waitFor } from "@testing-library/react";
|
|
5
|
+
import { StrictMode } from "react";
|
|
6
|
+
import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/store";
|
|
7
|
+
import { flushTapSync } from "@assistant-ui/tap";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { AISDKChat } from "./AISDKChat";
|
|
10
|
+
import { createCancellableTransport } from "./__tests__/controlled-transport";
|
|
11
|
+
|
|
12
|
+
describe("AISDKChat React integration", () => {
|
|
13
|
+
it("does not treat React provider unmount as client destruction", async () => {
|
|
14
|
+
const { transport, getCancelCount, close } = createCancellableTransport();
|
|
15
|
+
let started = false;
|
|
16
|
+
let isRunning = () => false;
|
|
17
|
+
|
|
18
|
+
const SendOnMount = () => {
|
|
19
|
+
const aui = useAui();
|
|
20
|
+
isRunning = () => aui.thread.getState().isRunning;
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (started) return;
|
|
23
|
+
started = true;
|
|
24
|
+
flushTapSync(() => aui.composer.setText("keep streaming"));
|
|
25
|
+
flushTapSync(() => aui.composer.send());
|
|
26
|
+
}, [aui]);
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const view = render(
|
|
31
|
+
<StrictMode>
|
|
32
|
+
<AuiProvider config={AuiConfig({ threads: AISDKChat({ transport }) })}>
|
|
33
|
+
<SendOnMount />
|
|
34
|
+
</AuiProvider>
|
|
35
|
+
</StrictMode>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
await waitFor(() => {
|
|
39
|
+
expect(isRunning()).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
view.unmount();
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
44
|
+
expect(getCancelCount()).toBe(0);
|
|
45
|
+
close();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { flushTapSync } from "@assistant-ui/tap";
|
|
3
|
+
import { AuiConfig, createAssistantClient } from "@assistant-ui/store/client";
|
|
4
|
+
import { AISDKChat } from "./AISDKChat";
|
|
5
|
+
import { createControlledTransport } from "./__tests__/controlled-transport";
|
|
6
|
+
|
|
7
|
+
// Runs the streaming round trip against the compiled dist entry (aliased in
|
|
8
|
+
// vitest.react-compiler.config.ts), so the react-compiler transform and the
|
|
9
|
+
// react-shim import rewrite are exercised, not just the source hooks.
|
|
10
|
+
describe("AISDKChat compiled output", () => {
|
|
11
|
+
it("streams a chat round trip through the compiled entry", async () => {
|
|
12
|
+
const { transport, emit, close } = createControlledTransport();
|
|
13
|
+
const handle = createAssistantClient(
|
|
14
|
+
AuiConfig({ threads: AISDKChat({ transport }) }),
|
|
15
|
+
);
|
|
16
|
+
try {
|
|
17
|
+
handle.subscribe(() => {});
|
|
18
|
+
const aui = handle.getClient();
|
|
19
|
+
|
|
20
|
+
flushTapSync(() => aui.composer.setText("hi"));
|
|
21
|
+
flushTapSync(() => aui.composer.send());
|
|
22
|
+
|
|
23
|
+
await vi.waitFor(() => {
|
|
24
|
+
expect(aui.thread.getState().isRunning).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
emit(
|
|
28
|
+
{ type: "start" },
|
|
29
|
+
{ type: "text-start", id: "t1" },
|
|
30
|
+
{ type: "text-delta", id: "t1", delta: "hello " },
|
|
31
|
+
{ type: "text-delta", id: "t1", delta: "world" },
|
|
32
|
+
{ type: "text-end", id: "t1" },
|
|
33
|
+
{ type: "finish" },
|
|
34
|
+
);
|
|
35
|
+
close();
|
|
36
|
+
|
|
37
|
+
await vi.waitFor(() => {
|
|
38
|
+
const state = aui.thread.getState();
|
|
39
|
+
expect(state.isRunning).toBe(false);
|
|
40
|
+
expect(state.messages).toHaveLength(2);
|
|
41
|
+
expect(state.messages.at(-1)?.content).toContainEqual(
|
|
42
|
+
expect.objectContaining({ type: "text", text: "hello world" }),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
} finally {
|
|
46
|
+
handle.destroy();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|