@assistant-ui/react-langchain 0.0.34 → 0.0.36

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 (37) hide show
  1. package/dist/LangChainThreadController.d.ts +9 -0
  2. package/dist/LangChainThreadController.d.ts.map +1 -0
  3. package/dist/LangChainThreadController.js +21 -0
  4. package/dist/LangChainThreadController.js.map +1 -0
  5. package/dist/convertMessages.d.ts +2 -1
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/convertMessages.js +12 -12
  8. package/dist/convertMessages.js.map +1 -1
  9. package/dist/converter.d.ts +24 -1
  10. package/dist/converter.d.ts.map +1 -1
  11. package/dist/converter.js +84 -18
  12. package/dist/converter.js.map +1 -1
  13. package/dist/langChainThreadState.d.ts +54 -0
  14. package/dist/langChainThreadState.d.ts.map +1 -0
  15. package/dist/langChainThreadState.js +135 -0
  16. package/dist/langChainThreadState.js.map +1 -0
  17. package/dist/sdkIdentity.js +1 -1
  18. package/dist/types.d.ts +9 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/useStreamRuntime.d.ts +3 -2
  21. package/dist/useStreamRuntime.d.ts.map +1 -1
  22. package/dist/useStreamRuntime.js +159 -144
  23. package/dist/useStreamRuntime.js.map +1 -1
  24. package/dist/useSubagentTranscripts.js +1 -1
  25. package/package.json +9 -8
  26. package/src/LangChainThreadController.ts +24 -0
  27. package/src/convertMessages.test.ts +23 -0
  28. package/src/convertMessages.ts +43 -17
  29. package/src/converter.test.ts +322 -0
  30. package/src/converter.ts +138 -27
  31. package/src/getPendingToolCalls.test.ts +35 -0
  32. package/src/langChainThreadState.test.ts +203 -0
  33. package/src/langChainThreadState.ts +239 -0
  34. package/src/types.ts +9 -0
  35. package/src/useStreamRuntime.cloud-options.test.tsx +61 -0
  36. package/src/useStreamRuntime.test.tsx +296 -8
  37. package/src/useStreamRuntime.ts +221 -249
@@ -0,0 +1,322 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { convertLangChainContentBlock } from "./converter";
3
+
4
+ describe("convertLangChainContentBlock standard content blocks", () => {
5
+ it("converts a base64 image block to an image part", () => {
6
+ expect(
7
+ convertLangChainContentBlock({
8
+ type: "image",
9
+ mimeType: "image/png",
10
+ data: "ZmFrZQ==",
11
+ }),
12
+ ).toEqual({ type: "image", image: "data:image/png;base64,ZmFrZQ==" });
13
+ });
14
+
15
+ it("converts a url image block to an image part", () => {
16
+ expect(
17
+ convertLangChainContentBlock({
18
+ type: "image",
19
+ mimeType: "image/png",
20
+ url: "https://cdn.example/a.png",
21
+ }),
22
+ ).toEqual({ type: "image", image: "https://cdn.example/a.png" });
23
+ });
24
+
25
+ it("keeps a base64 image block that already carries a data url", () => {
26
+ expect(
27
+ convertLangChainContentBlock({
28
+ type: "image",
29
+ mimeType: "image/png",
30
+ data: "data:image/png;base64,ZmFrZQ==",
31
+ }),
32
+ ).toEqual({ type: "image", image: "data:image/png;base64,ZmFrZQ==" });
33
+ });
34
+
35
+ it("keeps a percent-encoded data url out of a base64 envelope", () => {
36
+ const image = "data:image/svg+xml,%3Csvg%3E%3C/svg%3E";
37
+ expect(
38
+ convertLangChainContentBlock({
39
+ type: "image",
40
+ mimeType: "image/svg+xml",
41
+ data: image,
42
+ }),
43
+ ).toEqual({ type: "image", image });
44
+ });
45
+
46
+ it("keeps an uppercase data url scheme out of a base64 envelope", () => {
47
+ const image = "DATA:image/png;base64,ZmFrZQ==";
48
+ expect(
49
+ convertLangChainContentBlock({
50
+ type: "image",
51
+ mimeType: "image/png",
52
+ data: image,
53
+ }),
54
+ ).toEqual({ type: "image", image });
55
+ });
56
+
57
+ it("reads the camelCase mime type of a base64 file block", () => {
58
+ expect(
59
+ convertLangChainContentBlock({
60
+ type: "file",
61
+ mimeType: "application/pdf",
62
+ data: "JVBERi0=",
63
+ }),
64
+ ).toEqual({
65
+ type: "file",
66
+ filename: "file",
67
+ data: "JVBERi0=",
68
+ mimeType: "application/pdf",
69
+ });
70
+ });
71
+
72
+ it("resolves the url of a file block that carries no source_type", () => {
73
+ expect(
74
+ convertLangChainContentBlock({
75
+ type: "file",
76
+ mimeType: "application/pdf",
77
+ url: "https://cdn.example/a.pdf",
78
+ metadata: { filename: "a.pdf" },
79
+ }),
80
+ ).toEqual({
81
+ type: "file",
82
+ filename: "a.pdf",
83
+ data: "https://cdn.example/a.pdf",
84
+ mimeType: "application/pdf",
85
+ sourceType: "url",
86
+ });
87
+ });
88
+
89
+ it("resolves the fileId of a file block as an id source", () => {
90
+ expect(
91
+ convertLangChainContentBlock({
92
+ type: "file",
93
+ mimeType: "application/pdf",
94
+ fileId: "file-abc123",
95
+ }),
96
+ ).toEqual({
97
+ type: "file",
98
+ filename: "file",
99
+ data: "file-abc123",
100
+ mimeType: "application/pdf",
101
+ sourceType: "id",
102
+ });
103
+ });
104
+
105
+ it("reads the Python base64 field of an image block", () => {
106
+ expect(
107
+ convertLangChainContentBlock({
108
+ type: "image",
109
+ mime_type: "image/png",
110
+ base64: "ZmFrZQ==",
111
+ }),
112
+ ).toEqual({ type: "image", image: "data:image/png;base64,ZmFrZQ==" });
113
+ });
114
+
115
+ it("resolves the Python file_id field as an id source", () => {
116
+ expect(
117
+ convertLangChainContentBlock({
118
+ type: "file",
119
+ mime_type: "application/pdf",
120
+ file_id: "file-python-123",
121
+ }),
122
+ ).toEqual({
123
+ type: "file",
124
+ filename: "file",
125
+ data: "file-python-123",
126
+ mimeType: "application/pdf",
127
+ sourceType: "id",
128
+ });
129
+ });
130
+
131
+ it("converts a video block to a file part", () => {
132
+ expect(
133
+ convertLangChainContentBlock({
134
+ type: "video",
135
+ mimeType: "video/mp4",
136
+ data: "ZmFrZQ==",
137
+ }),
138
+ ).toEqual({
139
+ type: "file",
140
+ filename: "video.mp4",
141
+ data: "ZmFrZQ==",
142
+ mimeType: "video/mp4",
143
+ });
144
+ });
145
+
146
+ it("converts an audio block that names its mime type in camelCase", () => {
147
+ expect(
148
+ convertLangChainContentBlock({
149
+ type: "audio",
150
+ mimeType: "audio/wav",
151
+ data: "ZmFrZQ==",
152
+ }),
153
+ ).toEqual({
154
+ type: "file",
155
+ filename: "audio.wav",
156
+ data: "ZmFrZQ==",
157
+ mimeType: "audio/wav",
158
+ });
159
+ });
160
+
161
+ it("converts a text-plain block to a file part", () => {
162
+ expect(
163
+ convertLangChainContentBlock({
164
+ type: "text-plain",
165
+ data: "ZmFrZQ==",
166
+ metadata: { filename: "notes.txt" },
167
+ }),
168
+ ).toEqual({
169
+ type: "file",
170
+ filename: "notes.txt",
171
+ data: "ZmFrZQ==",
172
+ mimeType: "text/plain",
173
+ });
174
+ });
175
+
176
+ it("falls back to a file part for an image referenced by id", () => {
177
+ expect(
178
+ convertLangChainContentBlock({
179
+ type: "image",
180
+ mimeType: "image/png",
181
+ fileId: "file-abc123",
182
+ }),
183
+ ).toEqual({
184
+ type: "file",
185
+ filename: "file",
186
+ data: "file-abc123",
187
+ mimeType: "image/png",
188
+ sourceType: "id",
189
+ });
190
+ });
191
+
192
+ it("falls back to a file part for a base64 image with no mime type", () => {
193
+ expect(
194
+ convertLangChainContentBlock({ type: "image", data: "ZmFrZQ==" }),
195
+ ).toEqual({
196
+ type: "file",
197
+ filename: "file",
198
+ data: "ZmFrZQ==",
199
+ mimeType: "application/octet-stream",
200
+ });
201
+ });
202
+
203
+ it("reports a block whose payload is not a string as unknown", () => {
204
+ expect(
205
+ convertLangChainContentBlock({
206
+ type: "image",
207
+ mimeType: "image/png",
208
+ data: new Uint8Array([1, 2, 3]),
209
+ }),
210
+ ).toBeUndefined();
211
+ });
212
+
213
+ it("converts a text-plain block carrying inline text to a base64 file part", () => {
214
+ expect(
215
+ convertLangChainContentBlock({
216
+ type: "text-plain",
217
+ text: "hello",
218
+ }),
219
+ ).toEqual({
220
+ type: "file",
221
+ filename: "file",
222
+ data: "aGVsbG8=",
223
+ mimeType: "text/plain",
224
+ });
225
+ });
226
+
227
+ it("round-trips non-ASCII inline text through base64", () => {
228
+ const text = "héllo ✓ https://example.com";
229
+ const part = convertLangChainContentBlock({ type: "text-plain", text });
230
+ expect(part).toMatchObject({ type: "file", mimeType: "text/plain" });
231
+ const data = (part as { data: string }).data;
232
+ const bytes = Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
233
+ expect(new TextDecoder().decode(bytes)).toBe(text);
234
+ });
235
+ });
236
+
237
+ describe("convertLangChainContentBlock legacy data content blocks", () => {
238
+ it("converts a base64 file block that names its mime type in snake_case", () => {
239
+ expect(
240
+ convertLangChainContentBlock({
241
+ type: "file",
242
+ data: "JVBERi0=",
243
+ mime_type: "application/pdf",
244
+ source_type: "base64",
245
+ metadata: { filename: "a.pdf" },
246
+ }),
247
+ ).toEqual({
248
+ type: "file",
249
+ filename: "a.pdf",
250
+ data: "JVBERi0=",
251
+ mimeType: "application/pdf",
252
+ });
253
+ });
254
+
255
+ it("converts a url source file block", () => {
256
+ expect(
257
+ convertLangChainContentBlock({
258
+ type: "file",
259
+ url: "https://cdn.example/a.pdf",
260
+ mime_type: "application/pdf",
261
+ source_type: "url",
262
+ }),
263
+ ).toEqual({
264
+ type: "file",
265
+ filename: "file",
266
+ data: "https://cdn.example/a.pdf",
267
+ mimeType: "application/pdf",
268
+ sourceType: "url",
269
+ });
270
+ });
271
+
272
+ it("converts an id source file block", () => {
273
+ expect(
274
+ convertLangChainContentBlock({
275
+ type: "file",
276
+ id: "file-abc123",
277
+ source_type: "id",
278
+ }),
279
+ ).toEqual({
280
+ type: "file",
281
+ filename: "file",
282
+ data: "file-abc123",
283
+ mimeType: "application/octet-stream",
284
+ sourceType: "id",
285
+ });
286
+ });
287
+
288
+ it("converts a base64 audio block", () => {
289
+ expect(
290
+ convertLangChainContentBlock({
291
+ type: "audio",
292
+ data: "ZmFrZQ==",
293
+ mime_type: "audio/mp3",
294
+ source_type: "base64",
295
+ }),
296
+ ).toEqual({
297
+ type: "file",
298
+ filename: "audio.mp3",
299
+ data: "ZmFrZQ==",
300
+ mimeType: "audio/mp3",
301
+ });
302
+ });
303
+
304
+ it("converts a base64 image data block", () => {
305
+ expect(
306
+ convertLangChainContentBlock({
307
+ type: "image",
308
+ data: "ZmFrZQ==",
309
+ mime_type: "image/png",
310
+ source_type: "base64",
311
+ }),
312
+ ).toEqual({ type: "image", image: "data:image/png;base64,ZmFrZQ==" });
313
+ });
314
+
315
+ it("leaves an unknown block type unconverted", () => {
316
+ expect(
317
+ convertLangChainContentBlock({
318
+ type: "non_standard",
319
+ } as never),
320
+ ).toBeUndefined();
321
+ });
322
+ });
package/src/converter.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  resolveFilePartSource,
11
11
  } from "@assistant-ui/core/internal";
12
12
  import type { StreamingTimingAccessors } from "@assistant-ui/core/react";
13
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
13
14
 
14
15
  /** Known content block types from @langchain/core messages. */
15
16
  export type LangChainContentBlock =
@@ -51,12 +52,143 @@ export type LangChainContentBlock =
51
52
  }
52
53
  | { type: "tool_use" | "input_json_delta" };
53
54
 
55
+ /**
56
+ * One block covers both multimodal vocabularies: the standard content blocks
57
+ * key their payload off a camelCase JavaScript field or snake_case Python
58
+ * field, while the legacy data content blocks use a `source_type` discriminator.
59
+ */
60
+ type LangChainMediaBlock = {
61
+ type: "image" | "video" | "audio" | "file" | "text-plain";
62
+ mimeType?: string;
63
+ mime_type?: string;
64
+ data?: string | Uint8Array;
65
+ base64?: string | Uint8Array;
66
+ url?: string;
67
+ fileId?: string;
68
+ file_id?: string;
69
+ id?: string;
70
+ text?: string;
71
+ source_type?: "base64" | "url" | "id" | "text";
72
+ metadata?: { filename?: string };
73
+ };
74
+
54
75
  type ConvertedContentPart =
55
76
  | ThreadUserMessage["content"][number]
56
77
  | ThreadAssistantMessage["content"][number];
57
78
 
79
+ type MediaSource = { data: string; sourceType?: "url" | "id" };
80
+
81
+ /**
82
+ * `id` names the block, not the payload, in the standard content blocks, so it
83
+ * only identifies a file under the legacy `source_type: "id"` discriminator.
84
+ */
85
+ const resolveMediaSource = (
86
+ part: LangChainMediaBlock,
87
+ ): MediaSource | undefined => {
88
+ switch (part.source_type) {
89
+ case "url":
90
+ return typeof part.url === "string"
91
+ ? { data: part.url, sourceType: "url" }
92
+ : undefined;
93
+ case "id":
94
+ return typeof part.id === "string"
95
+ ? { data: part.id, sourceType: "id" }
96
+ : undefined;
97
+ case "base64": {
98
+ const data = part.data ?? part.base64;
99
+ return typeof data === "string" ? { data } : undefined;
100
+ }
101
+ }
102
+ if (typeof part.url === "string")
103
+ return { data: part.url, sourceType: "url" };
104
+ const fileId = part.fileId ?? part.file_id;
105
+ if (typeof fileId === "string") return { data: fileId, sourceType: "id" };
106
+ const data = part.data ?? part.base64;
107
+ if (typeof data === "string") return { data };
108
+ if (part.type === "text-plain" && typeof part.text === "string")
109
+ return { data: textToBase64(part.text) };
110
+ return undefined;
111
+ };
112
+
113
+ const textToBase64 = (text: string): string => {
114
+ const bytes = new TextEncoder().encode(text);
115
+ let binary = "";
116
+ for (let i = 0; i < bytes.length; i += 0x8000) {
117
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
118
+ }
119
+ return btoa(binary);
120
+ };
121
+
122
+ const resolveMediaMimeType = (part: LangChainMediaBlock): string | undefined =>
123
+ typeof part.mimeType === "string"
124
+ ? part.mimeType
125
+ : typeof part.mime_type === "string"
126
+ ? part.mime_type
127
+ : undefined;
128
+
129
+ const defaultMediaFilename = (type: string, mimeType: string): string => {
130
+ if (type !== "audio" && type !== "video") return "file";
131
+ const subtype = mimeType.startsWith(`${type}/`)
132
+ ? mimeType.slice(type.length + 1)
133
+ : undefined;
134
+ return subtype ? `${type}.${subtype}` : type;
135
+ };
136
+
137
+ const resolveImage = (
138
+ source: MediaSource,
139
+ mimeType: string | undefined,
140
+ ): string | undefined => {
141
+ if (source.sourceType === "id") return undefined;
142
+ // `parseDataUrl` only matches the base64 form, so a percent-encoded data URL
143
+ // would otherwise be wrapped in a second base64 envelope.
144
+ if (source.sourceType === "url" || /^data:/i.test(source.data))
145
+ return source.data;
146
+ return mimeType ? `data:${mimeType};base64,${source.data}` : undefined;
147
+ };
148
+
149
+ const convertMediaBlock = (
150
+ part: LangChainMediaBlock,
151
+ ): ConvertedContentPart | undefined => {
152
+ const source = resolveMediaSource(part);
153
+ if (!source) return undefined;
154
+
155
+ const mimeType = resolveMediaMimeType(part);
156
+ if (part.type === "image") {
157
+ const image = resolveImage(source, mimeType);
158
+ if (image) return { type: "image" as const, image };
159
+ }
160
+
161
+ const resolvedMimeType =
162
+ mimeType ??
163
+ (part.type === "text-plain" ? "text/plain" : "application/octet-stream");
164
+ return {
165
+ type: "file" as const,
166
+ filename:
167
+ part.metadata?.filename ??
168
+ defaultMediaFilename(part.type, resolvedMimeType),
169
+ data: source.data,
170
+ mimeType: resolvedMimeType,
171
+ ...(source.sourceType && { sourceType: source.sourceType }),
172
+ };
173
+ };
174
+
175
+ export const normalizeToolCallArgs = (args: unknown): ReadonlyJSONObject => {
176
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
177
+ return {};
178
+ }
179
+
180
+ try {
181
+ const prototype = Object.getPrototypeOf(args);
182
+ return prototype === Object.prototype || prototype === null
183
+ ? (args as ReadonlyJSONObject)
184
+ : {};
185
+ } catch {
186
+ return {};
187
+ }
188
+ };
189
+
58
190
  export const convertLangChainContentBlock = (
59
- part: LangChainContentBlock,
191
+ part: LangChainContentBlock | LangChainMediaBlock,
60
192
  ): ConvertedContentPart | null | undefined => {
61
193
  const type = part.type;
62
194
  switch (type) {
@@ -74,33 +206,12 @@ export const convertLangChainContentBlock = (
74
206
  if (!image) return null;
75
207
  return { type: "image" as const, image };
76
208
  }
209
+ case "image":
210
+ case "video":
211
+ case "audio":
77
212
  case "file":
78
- return {
79
- type: "file" as const,
80
- filename: part.metadata?.filename ?? "file",
81
- data:
82
- part.source_type === "url"
83
- ? part.url
84
- : part.source_type === "id"
85
- ? part.id
86
- : part.data,
87
- mimeType: part.mime_type ?? "application/octet-stream",
88
- ...((part.source_type === "url" || part.source_type === "id") && {
89
- sourceType: part.source_type,
90
- }),
91
- };
92
- case "audio": {
93
- const mimeType = part.mime_type ?? "application/octet-stream";
94
- const subtype = mimeType.startsWith("audio/")
95
- ? mimeType.slice("audio/".length)
96
- : undefined;
97
- return {
98
- type: "file" as const,
99
- filename: subtype ? `audio.${subtype}` : "audio",
100
- data: part.data,
101
- mimeType,
102
- };
103
- }
213
+ case "text-plain":
214
+ return convertMediaBlock(part);
104
215
  case "thinking":
105
216
  return hasVisibleText(part.thinking)
106
217
  ? { type: "reasoning" as const, text: part.thinking }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LangChainBaseMessage } from "./types";
3
+ import { getPendingToolCalls } from "./useStreamRuntime";
4
+
5
+ const aiWithNullToolCall = {
6
+ id: "ai-1",
7
+ _getType: () => "ai",
8
+ content: "",
9
+ tool_calls: [null, { id: "call-1", name: "lookup", args: {} }],
10
+ } as unknown as LangChainBaseMessage;
11
+
12
+ describe("getPendingToolCalls", () => {
13
+ it("skips a null tool_calls entry and returns the remaining pending call", () => {
14
+ expect(
15
+ getPendingToolCalls([
16
+ { id: "human-1", _getType: () => "human", content: "look it up" },
17
+ aiWithNullToolCall,
18
+ ]),
19
+ ).toEqual([{ id: "call-1", name: "lookup", args: {} }]);
20
+ });
21
+
22
+ it("treats a call answered by a tool message as settled next to a null entry", () => {
23
+ expect(
24
+ getPendingToolCalls([
25
+ aiWithNullToolCall,
26
+ {
27
+ id: "tool-1",
28
+ _getType: () => "tool",
29
+ content: "done",
30
+ tool_call_id: "call-1",
31
+ } as LangChainBaseMessage,
32
+ ]),
33
+ ).toEqual([]);
34
+ });
35
+ });