@assistant-ui/react-langchain 0.0.21 → 0.0.23

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.
@@ -1,6 +1,6 @@
1
1
  import { LangChainBaseMessage, UIMessage } from "./types.js";
2
- import { AppendMessage, MessageTiming } from "@assistant-ui/core";
3
2
  import { useExternalMessageConverter } from "@assistant-ui/core/react";
3
+ import { AppendMessage, MessageTiming } from "@assistant-ui/core";
4
4
  //#region src/convertMessages.d.ts
5
5
  type LangChainMessageConverterMetadata = useExternalMessageConverter.Metadata & {
6
6
  uiMessagesByParent?: Map<string, UIMessage[]>;
@@ -12,10 +12,12 @@ declare const getMessageContent: (msg: AppendMessage) => string | ({
12
12
  type: "text";
13
13
  text: string;
14
14
  image_url?: never;
15
- url?: never;
15
+ id?: never;
16
16
  mime_type?: never;
17
+ filename?: never;
17
18
  metadata?: never;
18
19
  source_type?: never;
20
+ url?: never;
19
21
  data?: never;
20
22
  } | {
21
23
  type: "image_url";
@@ -23,32 +25,62 @@ declare const getMessageContent: (msg: AppendMessage) => string | ({
23
25
  url: string;
24
26
  };
25
27
  text?: never;
26
- url?: never;
28
+ id?: never;
27
29
  mime_type?: never;
30
+ filename?: never;
28
31
  metadata?: never;
29
32
  source_type?: never;
33
+ url?: never;
34
+ data?: never;
35
+ } | {
36
+ type: "file";
37
+ id: string;
38
+ mime_type: string;
39
+ filename: string;
40
+ metadata: {
41
+ filename: string;
42
+ };
43
+ source_type: "id";
44
+ text?: never;
45
+ image_url?: never;
46
+ url?: never;
30
47
  data?: never;
31
48
  } | {
32
49
  type: "file";
33
50
  url: string;
34
51
  mime_type: string;
52
+ filename: string;
35
53
  metadata: {
36
54
  filename: string;
37
55
  };
38
56
  source_type: "url";
39
57
  text?: never;
40
58
  image_url?: never;
59
+ id?: never;
41
60
  data?: never;
42
61
  } | {
43
62
  type: "file";
44
63
  data: string;
45
64
  mime_type: string;
65
+ filename: string;
46
66
  metadata: {
47
67
  filename: string;
48
68
  };
49
69
  source_type: "base64";
50
70
  text?: never;
51
71
  image_url?: never;
72
+ id?: never;
73
+ url?: never;
74
+ } | {
75
+ type: "audio";
76
+ data: string;
77
+ mime_type: string;
78
+ source_type: "base64";
79
+ text?: never;
80
+ image_url?: never;
81
+ id?: never;
82
+ filename?: never;
83
+ metadata?: never;
52
84
  url?: never;
53
85
  })[];
54
86
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"convertMessages.d.ts","names":[],"sources":["../src/convertMessages.ts"],"mappings":";;;;KAgBK,oCACH,4BAA4B;EAC1B,qBAAqB,YAAY;EACjC,gBAAgB,eAAe;;cAStB,iBAAkB,SAAS;cAyE3B,8BACX,SAAS,sBACT,WAAU,sCACT,4BAA4B;cAyFlB,oBAAqB,KAAK"}
1
+ {"version":3,"file":"convertMessages.d.ts","names":[],"sources":["../src/convertMessages.ts"],"mappings":";;;;KAgBK,oCACH,4BAA4B;EAC1B,qBAAqB,YAAY;EACjC,gBAAgB,eAAe;;cAStB,iBAAkB,SAAS;cAiH3B,8BACX,SAAS,sBACT,WAAU,sCACT,4BAA4B;cA0GlB,oBAAqB,KAAK"}
@@ -35,8 +35,19 @@ const contentToParts = (content) => {
35
35
  type: "file",
36
36
  filename: part.metadata?.filename ?? "file",
37
37
  data: part.source_type === "url" ? part.url : part.source_type === "id" ? part.id : part.data,
38
- mimeType: part.mime_type ?? "application/octet-stream"
38
+ mimeType: part.mime_type ?? "application/octet-stream",
39
+ ...(part.source_type === "url" || part.source_type === "id") && { sourceType: part.source_type }
39
40
  };
41
+ case "audio": {
42
+ const mimeType = part.mime_type ?? "application/octet-stream";
43
+ const subtype = mimeType.startsWith("audio/") ? mimeType.slice(6) : void 0;
44
+ return {
45
+ type: "file",
46
+ filename: subtype ? `audio.${subtype}` : "audio",
47
+ data: part.data,
48
+ mimeType
49
+ };
50
+ }
40
51
  case "thinking": return {
41
52
  type: "reasoning",
42
53
  text: part.thinking
@@ -51,6 +62,22 @@ const contentToParts = (content) => {
51
62
  }
52
63
  }).filter((p) => p !== null);
53
64
  };
65
+ const hasVisibleText = (text) => typeof text === "string" && text.trim() !== "";
66
+ /**
67
+ * Audio output arrives outside the content array: providers leave `content`
68
+ * empty and put the spoken text in `additional_kwargs.audio.transcript`. The
69
+ * audio bytes stay behind because no provider reports their media type, and a
70
+ * streamed response carries raw PCM rather than a playable file.
71
+ */
72
+ const withAudioTranscript = (parts, additionalKwargs) => {
73
+ const transcript = (additionalKwargs?.audio)?.transcript;
74
+ if (typeof transcript !== "string" || !hasVisibleText(transcript)) return parts;
75
+ if (parts.some((part) => part.type === "text" && hasVisibleText(part.text))) return parts;
76
+ return [...parts.filter((part) => part.type !== "text"), {
77
+ type: "text",
78
+ text: transcript
79
+ }];
80
+ };
54
81
  const getCustomMetadata = (additionalKwargs) => additionalKwargs?.metadata ?? {};
55
82
  const getStringContent = (content) => {
56
83
  if (typeof content === "string") return content;
@@ -88,7 +115,7 @@ const convertLangChainBaseMessage = (message, metadata = {}) => {
88
115
  role: "assistant",
89
116
  id: message.id,
90
117
  content: [
91
- ...contentToParts(message.content),
118
+ ...withAudioTranscript(contentToParts(message.content), message.additional_kwargs),
92
119
  ...toolCallParts,
93
120
  ...uiDataParts
94
121
  ],
@@ -117,15 +144,28 @@ const convertLangChainBaseMessage = (message, metadata = {}) => {
117
144
  };
118
145
  }
119
146
  };
147
+ /**
148
+ * Audio media types that reach a provider's audio input through the LangChain
149
+ * `audio` block. langchain-core derives OpenAI's `input_audio.format` by
150
+ * splitting `mime_type` on `/`, and that format is a wav-or-mp3 enum, so
151
+ * `audio/mpeg` passes the converter and is rejected at the provider.
152
+ */
153
+ const audioBlockMimeTypes = /* @__PURE__ */ new Map([
154
+ ["audio/mp3", "audio/mp3"],
155
+ ["audio/mpeg", "audio/mp3"],
156
+ ["audio/wav", "audio/wav"],
157
+ ["audio/wave", "audio/wav"],
158
+ ["audio/x-wav", "audio/wav"]
159
+ ]);
120
160
  const getMessageContent = (msg) => {
121
161
  const allContent = [...msg.content, ...msg.attachments?.flatMap((a) => a.content) ?? []];
122
- const hasNonText = allContent.some((part) => part.type === "file" || part.type === "image");
162
+ const hasNonText = allContent.some((part) => part.type === "file" || part.type === "image" || part.type === "audio");
123
163
  const hasText = allContent.some((part) => part.type === "text");
124
164
  if (hasNonText && !hasText) allContent.unshift({
125
165
  type: "text",
126
166
  text: " "
127
167
  });
128
- const content = allContent.map((part) => {
168
+ const content = allContent.flatMap((part) => {
129
169
  const type = part.type;
130
170
  switch (type) {
131
171
  case "text": return {
@@ -138,22 +178,46 @@ const getMessageContent = (msg) => {
138
178
  };
139
179
  case "file": {
140
180
  const metadata = { filename: part.filename ?? "file" };
141
- if (httpUrlPattern.test(part.data)) return {
181
+ if (part.sourceType === "id") return {
182
+ type: "file",
183
+ id: part.data,
184
+ mime_type: part.mimeType,
185
+ filename: metadata.filename,
186
+ metadata,
187
+ source_type: "id"
188
+ };
189
+ if (part.sourceType === "url" || httpUrlPattern.test(part.data)) return {
142
190
  type: "file",
143
191
  url: part.data,
144
192
  mime_type: part.mimeType,
193
+ filename: metadata.filename,
145
194
  metadata,
146
195
  source_type: "url"
147
196
  };
148
197
  const parsed = parseDataUrl(part.data);
198
+ const audioMimeType = audioBlockMimeTypes.get((parsed?.mimeType ?? part.mimeType).toLowerCase());
199
+ if (audioMimeType) return {
200
+ type: "audio",
201
+ data: parsed?.data ?? part.data,
202
+ mime_type: audioMimeType,
203
+ source_type: "base64"
204
+ };
149
205
  return {
150
206
  type: "file",
151
207
  data: parsed?.data ?? part.data,
152
208
  mime_type: parsed?.mimeType ?? part.mimeType,
209
+ filename: metadata.filename,
153
210
  metadata,
154
211
  source_type: "base64"
155
212
  };
156
213
  }
214
+ case "audio": return {
215
+ type: "audio",
216
+ data: parseDataUrl(part.audio.data)?.data ?? part.audio.data,
217
+ mime_type: `audio/${part.audio.format}`,
218
+ source_type: "base64"
219
+ };
220
+ case "data": return [];
157
221
  case "tool-call": throw new Error("Tool call appends are not supported.");
158
222
  default: throw new Error(`Unsupported append message part type: ${type}`);
159
223
  }
@@ -1 +1 @@
1
- {"version":3,"file":"convertMessages.js","names":["parts","_exhaustiveCheck"],"sources":["../src/convertMessages.ts"],"sourcesContent":["\"use client\";\n\nimport type { useExternalMessageConverter } from \"@assistant-ui/core/react\";\nimport type {\n AppendMessage,\n DataMessagePart,\n MessageTiming,\n} from \"@assistant-ui/core\";\nimport { httpUrlPattern, parseDataUrl } from \"@assistant-ui/core/internal\";\nimport type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type {\n LangChainBaseMessage,\n LangChainContentBlock,\n UIMessage,\n} from \"./types\";\n\ntype LangChainMessageConverterMetadata =\n useExternalMessageConverter.Metadata & {\n uiMessagesByParent?: Map<string, UIMessage[]>;\n messageTiming?: Record<string, MessageTiming>;\n };\n\nconst uiMessageToDataPart = (ui: UIMessage): DataMessagePart => ({\n type: \"data\",\n name: ui.name,\n data: ui.props,\n});\n\nexport const getMessageType = (message: LangChainBaseMessage): string => {\n if (typeof message._getType === \"function\") return message._getType();\n if (\"type\" in message)\n return (message as Record<string, unknown>).type as string;\n throw new Error(\"Cannot determine message type\");\n};\n\nconst contentToParts = (content: unknown) => {\n if (typeof content === \"string\")\n return [{ type: \"text\" as const, text: content }];\n\n const parts = content as readonly LangChainContentBlock[];\n return parts\n .map((part) => {\n const type = part.type;\n switch (type) {\n case \"text\":\n case \"text_delta\":\n return { type: \"text\" as const, text: part.text };\n case \"image_url\": {\n const image =\n typeof part.image_url === \"string\"\n ? part.image_url\n : part.image_url?.url;\n if (!image) return null;\n return { type: \"image\" as const, image };\n }\n case \"file\":\n return {\n type: \"file\" as const,\n filename: part.metadata?.filename ?? \"file\",\n data:\n part.source_type === \"url\"\n ? part.url\n : part.source_type === \"id\"\n ? part.id\n : part.data,\n mimeType: part.mime_type ?? \"application/octet-stream\",\n };\n case \"thinking\":\n return { type: \"reasoning\" as const, text: part.thinking };\n case \"reasoning\":\n return {\n type: \"reasoning\" as const,\n text:\n part.summary?.map((s) => s?.text ?? \"\").join(\"\\n\\n\\n\") ??\n part.reasoning ??\n \"\",\n };\n case \"tool_use\":\n case \"input_json_delta\":\n return null;\n default:\n return null;\n }\n })\n .filter((p) => p !== null);\n};\n\nconst getCustomMetadata = (\n additionalKwargs: Record<string, unknown> | undefined,\n): Record<string, unknown> =>\n (additionalKwargs?.metadata as Record<string, unknown>) ?? {};\n\nconst getStringContent = (content: unknown): string => {\n if (typeof content === \"string\") return content;\n const parts = content as readonly LangChainContentBlock[];\n return parts\n .filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\");\n};\n\nexport const convertLangChainBaseMessage = (\n message: LangChainBaseMessage,\n metadata: LangChainMessageConverterMetadata = {},\n): useExternalMessageConverter.Message => {\n const type = getMessageType(message);\n\n switch (type) {\n case \"system\":\n return {\n role: \"system\",\n id: message.id,\n content: [{ type: \"text\", text: getStringContent(message.content) }],\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n },\n };\n\n case \"human\":\n return {\n role: \"user\",\n id: message.id,\n content: contentToParts(message.content),\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n },\n };\n\n case \"ai\": {\n const toolCallParts =\n message.tool_calls?.map((tc) => ({\n type: \"tool-call\" as const,\n toolCallId: tc.id,\n toolName: tc.name,\n args: tc.args as ReadonlyJSONObject,\n argsText: JSON.stringify(tc.args),\n })) ?? [];\n\n const assistantStatus =\n typeof message.status === \"object\" ? message.status : undefined;\n\n const uiDataParts =\n (message.id\n ? metadata.uiMessagesByParent\n ?.get(message.id)\n ?.map(uiMessageToDataPart)\n : undefined) ?? [];\n\n const timing = metadata.messageTiming?.[message.id ?? \"\"];\n\n return {\n role: \"assistant\",\n id: message.id,\n content: [\n ...contentToParts(message.content),\n ...toolCallParts,\n ...uiDataParts,\n ],\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n ...(timing && { timing }),\n },\n ...(assistantStatus && { status: assistantStatus }),\n };\n }\n\n case \"tool\":\n return {\n role: \"tool\",\n toolName: message.name ?? \"\",\n toolCallId: message.tool_call_id ?? \"\",\n result: message.content,\n artifact: message.artifact,\n isError: message.status === \"error\",\n };\n\n default:\n return {\n role: \"system\",\n id: message.id,\n content: [\n {\n type: \"text\",\n text:\n typeof message.content === \"string\"\n ? message.content\n : JSON.stringify(message.content),\n },\n ],\n };\n }\n};\n\nexport const getMessageContent = (msg: AppendMessage) => {\n const allContent = [\n ...msg.content,\n ...(msg.attachments?.flatMap((a) => a.content) ?? []),\n ];\n\n const hasNonText = allContent.some(\n (part) => part.type === \"file\" || part.type === \"image\",\n );\n const hasText = allContent.some((part) => part.type === \"text\");\n if (hasNonText && !hasText) {\n allContent.unshift({ type: \"text\", text: \" \" });\n }\n\n const content = allContent.map((part) => {\n const type = part.type;\n switch (type) {\n case \"text\":\n return { type: \"text\" as const, text: part.text };\n case \"image\":\n return { type: \"image_url\" as const, image_url: { url: part.image } };\n case \"file\": {\n const metadata = { filename: part.filename ?? \"file\" };\n if (httpUrlPattern.test(part.data)) {\n return {\n type: \"file\" as const,\n url: part.data,\n mime_type: part.mimeType,\n metadata,\n source_type: \"url\" as const,\n };\n }\n const parsed = parseDataUrl(part.data);\n return {\n type: \"file\" as const,\n data: parsed?.data ?? part.data,\n mime_type: parsed?.mimeType ?? part.mimeType,\n metadata,\n source_type: \"base64\" as const,\n };\n }\n case \"tool-call\":\n throw new Error(\"Tool call appends are not supported.\");\n default: {\n const _exhaustiveCheck:\n | \"reasoning\"\n | \"source\"\n | \"audio\"\n | \"data\"\n | \"generative-ui\" = type;\n throw new Error(\n `Unsupported append message part type: ${_exhaustiveCheck}`,\n );\n }\n }\n });\n\n if (content.length === 1 && content[0]?.type === \"text\") {\n return content[0].text ?? \"\";\n }\n return content;\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"convertMessages.js","names":["parts","_exhaustiveCheck"],"sources":["../src/convertMessages.ts"],"sourcesContent":["\"use client\";\n\nimport type { useExternalMessageConverter } from \"@assistant-ui/core/react\";\nimport type {\n AppendMessage,\n DataMessagePart,\n MessageTiming,\n} from \"@assistant-ui/core\";\nimport { httpUrlPattern, parseDataUrl } from \"@assistant-ui/core/internal\";\nimport type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type {\n LangChainBaseMessage,\n LangChainContentBlock,\n UIMessage,\n} from \"./types\";\n\ntype LangChainMessageConverterMetadata =\n useExternalMessageConverter.Metadata & {\n uiMessagesByParent?: Map<string, UIMessage[]>;\n messageTiming?: Record<string, MessageTiming>;\n };\n\nconst uiMessageToDataPart = (ui: UIMessage): DataMessagePart => ({\n type: \"data\",\n name: ui.name,\n data: ui.props,\n});\n\nexport const getMessageType = (message: LangChainBaseMessage): string => {\n if (typeof message._getType === \"function\") return message._getType();\n if (\"type\" in message)\n return (message as Record<string, unknown>).type as string;\n throw new Error(\"Cannot determine message type\");\n};\n\nconst contentToParts = (content: unknown) => {\n if (typeof content === \"string\")\n return [{ type: \"text\" as const, text: content }];\n\n const parts = content as readonly LangChainContentBlock[];\n return parts\n .map((part) => {\n const type = part.type;\n switch (type) {\n case \"text\":\n case \"text_delta\":\n return { type: \"text\" as const, text: part.text };\n case \"image_url\": {\n const image =\n typeof part.image_url === \"string\"\n ? part.image_url\n : part.image_url?.url;\n if (!image) return null;\n return { type: \"image\" as const, image };\n }\n case \"file\":\n return {\n type: \"file\" as const,\n filename: part.metadata?.filename ?? \"file\",\n data:\n part.source_type === \"url\"\n ? part.url\n : part.source_type === \"id\"\n ? part.id\n : part.data,\n mimeType: part.mime_type ?? \"application/octet-stream\",\n ...((part.source_type === \"url\" || part.source_type === \"id\") && {\n sourceType: part.source_type,\n }),\n };\n case \"audio\": {\n const mimeType = part.mime_type ?? \"application/octet-stream\";\n const subtype = mimeType.startsWith(\"audio/\")\n ? mimeType.slice(\"audio/\".length)\n : undefined;\n return {\n type: \"file\" as const,\n filename: subtype ? `audio.${subtype}` : \"audio\",\n data: part.data,\n mimeType,\n };\n }\n case \"thinking\":\n return { type: \"reasoning\" as const, text: part.thinking };\n case \"reasoning\":\n return {\n type: \"reasoning\" as const,\n text:\n part.summary?.map((s) => s?.text ?? \"\").join(\"\\n\\n\\n\") ??\n part.reasoning ??\n \"\",\n };\n case \"tool_use\":\n case \"input_json_delta\":\n return null;\n default:\n return null;\n }\n })\n .filter((p) => p !== null);\n};\n\nconst hasVisibleText = (text: unknown): boolean =>\n typeof text === \"string\" && text.trim() !== \"\";\n\n/**\n * Audio output arrives outside the content array: providers leave `content`\n * empty and put the spoken text in `additional_kwargs.audio.transcript`. The\n * audio bytes stay behind because no provider reports their media type, and a\n * streamed response carries raw PCM rather than a playable file.\n */\nconst withAudioTranscript = (\n parts: ReturnType<typeof contentToParts>,\n additionalKwargs: Record<string, unknown> | undefined,\n): ReturnType<typeof contentToParts> => {\n const audio = additionalKwargs?.audio as { transcript?: unknown } | undefined;\n const transcript = audio?.transcript;\n if (typeof transcript !== \"string\" || !hasVisibleText(transcript))\n return parts;\n if (parts.some((part) => part.type === \"text\" && hasVisibleText(part.text)))\n return parts;\n return [\n ...parts.filter((part) => part.type !== \"text\"),\n { type: \"text\" as const, text: transcript },\n ];\n};\n\nconst getCustomMetadata = (\n additionalKwargs: Record<string, unknown> | undefined,\n): Record<string, unknown> =>\n (additionalKwargs?.metadata as Record<string, unknown>) ?? {};\n\nconst getStringContent = (content: unknown): string => {\n if (typeof content === \"string\") return content;\n const parts = content as readonly LangChainContentBlock[];\n return parts\n .filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\");\n};\n\nexport const convertLangChainBaseMessage = (\n message: LangChainBaseMessage,\n metadata: LangChainMessageConverterMetadata = {},\n): useExternalMessageConverter.Message => {\n const type = getMessageType(message);\n\n switch (type) {\n case \"system\":\n return {\n role: \"system\",\n id: message.id,\n content: [{ type: \"text\", text: getStringContent(message.content) }],\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n },\n };\n\n case \"human\":\n return {\n role: \"user\",\n id: message.id,\n content: contentToParts(message.content),\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n },\n };\n\n case \"ai\": {\n const toolCallParts =\n message.tool_calls?.map((tc) => ({\n type: \"tool-call\" as const,\n toolCallId: tc.id,\n toolName: tc.name,\n args: tc.args as ReadonlyJSONObject,\n argsText: JSON.stringify(tc.args),\n })) ?? [];\n\n const assistantStatus =\n typeof message.status === \"object\" ? message.status : undefined;\n\n const uiDataParts =\n (message.id\n ? metadata.uiMessagesByParent\n ?.get(message.id)\n ?.map(uiMessageToDataPart)\n : undefined) ?? [];\n\n const timing = metadata.messageTiming?.[message.id ?? \"\"];\n\n return {\n role: \"assistant\",\n id: message.id,\n content: [\n ...withAudioTranscript(\n contentToParts(message.content),\n message.additional_kwargs,\n ),\n ...toolCallParts,\n ...uiDataParts,\n ],\n metadata: {\n custom: getCustomMetadata(message.additional_kwargs),\n ...(timing && { timing }),\n },\n ...(assistantStatus && { status: assistantStatus }),\n };\n }\n\n case \"tool\":\n return {\n role: \"tool\",\n toolName: message.name ?? \"\",\n toolCallId: message.tool_call_id ?? \"\",\n result: message.content,\n artifact: message.artifact,\n isError: message.status === \"error\",\n };\n\n default:\n return {\n role: \"system\",\n id: message.id,\n content: [\n {\n type: \"text\",\n text:\n typeof message.content === \"string\"\n ? message.content\n : JSON.stringify(message.content),\n },\n ],\n };\n }\n};\n\n/**\n * Audio media types that reach a provider's audio input through the LangChain\n * `audio` block. langchain-core derives OpenAI's `input_audio.format` by\n * splitting `mime_type` on `/`, and that format is a wav-or-mp3 enum, so\n * `audio/mpeg` passes the converter and is rejected at the provider.\n */\nconst audioBlockMimeTypes = new Map<string, \"audio/mp3\" | \"audio/wav\">([\n [\"audio/mp3\", \"audio/mp3\"],\n [\"audio/mpeg\", \"audio/mp3\"],\n [\"audio/wav\", \"audio/wav\"],\n [\"audio/wave\", \"audio/wav\"],\n [\"audio/x-wav\", \"audio/wav\"],\n]);\n\nexport const getMessageContent = (msg: AppendMessage) => {\n const allContent = [\n ...msg.content,\n ...(msg.attachments?.flatMap((a) => a.content) ?? []),\n ];\n\n const hasNonText = allContent.some(\n (part) =>\n part.type === \"file\" || part.type === \"image\" || part.type === \"audio\",\n );\n const hasText = allContent.some((part) => part.type === \"text\");\n if (hasNonText && !hasText) {\n allContent.unshift({ type: \"text\", text: \" \" });\n }\n\n const content = allContent.flatMap((part) => {\n const type = part.type;\n switch (type) {\n case \"text\":\n return { type: \"text\" as const, text: part.text };\n case \"image\":\n return { type: \"image_url\" as const, image_url: { url: part.image } };\n case \"file\": {\n const metadata = { filename: part.filename ?? \"file\" };\n if (part.sourceType === \"id\") {\n return {\n type: \"file\" as const,\n id: part.data,\n mime_type: part.mimeType,\n filename: metadata.filename,\n metadata,\n source_type: \"id\" as const,\n };\n }\n if (part.sourceType === \"url\" || httpUrlPattern.test(part.data)) {\n return {\n type: \"file\" as const,\n url: part.data,\n mime_type: part.mimeType,\n filename: metadata.filename,\n metadata,\n source_type: \"url\" as const,\n };\n }\n const parsed = parseDataUrl(part.data);\n const audioMimeType = audioBlockMimeTypes.get(\n (parsed?.mimeType ?? part.mimeType).toLowerCase(),\n );\n if (audioMimeType) {\n return {\n type: \"audio\" as const,\n data: parsed?.data ?? part.data,\n mime_type: audioMimeType,\n source_type: \"base64\" as const,\n };\n }\n return {\n type: \"file\" as const,\n data: parsed?.data ?? part.data,\n mime_type: parsed?.mimeType ?? part.mimeType,\n filename: metadata.filename,\n metadata,\n source_type: \"base64\" as const,\n };\n }\n case \"audio\": {\n const parsed = parseDataUrl(part.audio.data);\n return {\n type: \"audio\" as const,\n data: parsed?.data ?? part.audio.data,\n mime_type: `audio/${part.audio.format}`,\n source_type: \"base64\" as const,\n };\n }\n case \"data\":\n return [];\n case \"tool-call\":\n throw new Error(\"Tool call appends are not supported.\");\n default: {\n const _exhaustiveCheck: \"reasoning\" | \"source\" | \"generative-ui\" = type;\n throw new Error(\n `Unsupported append message part type: ${_exhaustiveCheck}`,\n );\n }\n }\n });\n\n if (content.length === 1 && content[0]?.type === \"text\") {\n return content[0].text ?? \"\";\n }\n return content;\n};\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { LangChainRuntimeExtras } from "./types.js";
2
2
  //#region src/runtimeExtras.d.ts
3
- declare const langChainExtras: import("@assistant-ui/core/internal").RuntimeExtras<LangChainRuntimeExtras>;
3
+ declare const langChainExtras: import("@assistant-ui/core/react").RuntimeExtras<LangChainRuntimeExtras>;
4
4
  //#endregion
5
5
  export { langChainExtras };
6
6
  //# sourceMappingURL=runtimeExtras.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtimeExtras.d.ts","names":[],"sources":["../src/runtimeExtras.ts"],"mappings":";;cAGa,uDAAe,cAAA"}
1
+ {"version":3,"file":"runtimeExtras.d.ts","names":[],"sources":["../src/runtimeExtras.ts"],"mappings":";;cAGa,oDAAe,cAAA"}
@@ -1,4 +1,4 @@
1
- import { createRuntimeExtras } from "@assistant-ui/core/internal";
1
+ import { createRuntimeExtras } from "@assistant-ui/core/react";
2
2
  //#region src/runtimeExtras.ts
3
3
  const langChainExtras = createRuntimeExtras("useStreamRuntime");
4
4
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"runtimeExtras.js","names":[],"sources":["../src/runtimeExtras.ts"],"sourcesContent":["import { createRuntimeExtras } from \"@assistant-ui/core/internal\";\nimport type { LangChainRuntimeExtras } from \"./types\";\n\nexport const langChainExtras =\n createRuntimeExtras<LangChainRuntimeExtras>(\"useStreamRuntime\");\n"],"mappings":";;AAGA,MAAa,kBACX,oBAA4C,kBAAkB"}
1
+ {"version":3,"file":"runtimeExtras.js","names":[],"sources":["../src/runtimeExtras.ts"],"sourcesContent":["import { createRuntimeExtras } from \"@assistant-ui/core/react\";\nimport type { LangChainRuntimeExtras } from \"./types\";\n\nexport const langChainExtras =\n createRuntimeExtras<LangChainRuntimeExtras>(\"useStreamRuntime\");\n"],"mappings":";;AAGA,MAAa,kBACX,oBAA4C,kBAAkB"}
@@ -1,6 +1,6 @@
1
1
  import { LangChainBaseMessage } from "./types.js";
2
- import { MessageTiming } from "@assistant-ui/core";
3
2
  import { StreamingTimingAccessors } from "@assistant-ui/core/react";
3
+ import { MessageTiming } from "@assistant-ui/core";
4
4
  //#region src/streamingTiming.d.ts
5
5
  declare const langChainStreamingTimingAccessors: StreamingTimingAccessors<LangChainBaseMessage>;
6
6
  /**
package/dist/types.d.ts CHANGED
@@ -48,6 +48,11 @@ type LangChainContentBlock = {
48
48
  metadata?: {
49
49
  filename?: string;
50
50
  };
51
+ } | {
52
+ type: "audio";
53
+ data: string;
54
+ mime_type: string;
55
+ source_type: "base64";
51
56
  } | {
52
57
  type: "tool_use" | "input_json_delta";
53
58
  };
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;KAoBY;EACN;EAAc;;EACd;EAAoB;;EACpB;EAAmB;IAAsB;;;EACzC;EAAkB;;EAElB;EACA,UAAU;IAAQ;IAAsB;;EACxC;;EAGA;EACA;EACA;EACA;EACA;IAAa;;;EAGb;EACA;EACA;EACA;EACA;IAAa;;;EAGb;EACA;EACA;EACA;EACA;IAAa;;;EAEb;;KAEM;EACV;EACA;EACA,MAAM;;;;;;;;;KAUI,UACV,+BACA,eAAe,0BAA0B;EAEzC;EACA;EACA,MAAM;EACN,OAAO;EACP;IACE;IACA;IACA;IACA;IACA;IACA;KACC;;;;;;;KAQO;EACV;EACA;;;;;;;;;KAUU;EACV;EACA;EACA;EACA;EACA,oBAAoB;;EAEpB,sBAAsB;;EAEtB;;EAEA,SAAS;;EAET;;KAGU;EACV;IAAa;;EACb;IAAuB;IAAa;;EACpC,oBAAoB;EACpB,WAAW,oBAAoB;EAC/B,WAAW,oBAAoB;EAC/B,QAAQ;EACR;EACA,SACE,QAAQ,4CACR,UAAU,4BACP;EACL,UACE,mBACA,UAAU,4BACP;EACL,aACE,eAAe,yBACf,UAAU,4BACP;EACL,QAAQ;EACR;;KAGU,+BAA+B;;;;;;;;;EASzC,qBAAqB;EACrB,QAAQ;EACR;IAEM,cAAc;IACd,SAAS;IACT,YAAY;IACZ,QAAQ;IACR,WAAW;;;;;;;;EASjB;;;;;EAKA;;;;;EAKA,6BAA6B;;EAE7B,gBAAgB;IAAU;;;EAE1B,WAAW,qBAAqB;;;;;;;EAOhC;;KAOU,0BAA0B,gCAA+B,KACjE,UAAU,mBACR,IAAI"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;;;KAoBY;EACN;EAAc;;EACd;EAAoB;;EACpB;EAAmB;IAAsB;;;EACzC;EAAkB;;EAElB;EACA,UAAU;IAAQ;IAAsB;;EACxC;;EAGA;EACA;EACA;EACA;EACA;IAAa;;;EAGb;EACA;EACA;EACA;EACA;IAAa;;;EAGb;EACA;EACA;EACA;EACA;IAAa;;;EAGb;EACA;EACA;EACA;;EAEA;;KAEM;EACV;EACA;EACA,MAAM;;;;;;;;;KAUI,UACV,+BACA,eAAe,0BAA0B;EAEzC;EACA;EACA,MAAM;EACN,OAAO;EACP;IACE;IACA;IACA;IACA;IACA;IACA;KACC;;;;;;;KAQO;EACV;EACA;;;;;;;;;KAUU;EACV;EACA;EACA;EACA;EACA,oBAAoB;;EAEpB,sBAAsB;;EAEtB;;EAEA,SAAS;;EAET;;KAGU;EACV;IAAa;;EACb;IAAuB;IAAa;;EACpC,oBAAoB;EACpB,WAAW,oBAAoB;EAC/B,WAAW,oBAAoB;EAC/B,QAAQ;EACR;EACA,SACE,QAAQ,4CACR,UAAU,4BACP;EACL,UACE,mBACA,UAAU,4BACP;EACL,aACE,eAAe,yBACf,UAAU,4BACP;EACL,QAAQ;EACR;;KAGU,+BAA+B;;;;;;;;;EASzC,qBAAqB;EACrB,QAAQ;EACR;IAEM,cAAc;IACd,SAAS;IACT,YAAY;IACZ,QAAQ;IACR,WAAW;;;;;;;;EASjB;;;;;EAKA;;;;;EAKA,6BAA6B;;EAE7B,gBAAgB;IAAU;;;EAE1B,WAAW,qBAAqB;;;;;;;EAOhC;;KAOU,0BAA0B,gCAA+B,KACjE,UAAU,mBACR,IAAI"}
@@ -5,9 +5,9 @@ import { foldUIUpdates, mergeUIMessages } from "./uiMessages.js";
5
5
  import { resolveForkCheckpoint } from "./resolveForkCheckpoint.js";
6
6
  import { useLangChainStreamingTiming } from "./streamingTiming.js";
7
7
  import { useAuiState } from "@assistant-ui/store";
8
+ import { useCloudThreadListAdapter, useExternalMessageConverter, useExternalStoreRuntime, useRemoteThreadListRuntime } from "@assistant-ui/core/react";
8
9
  import { useEffect, useMemo, useRef, useState } from "react";
9
10
  import { generateId, getExternalStoreMessages, pickExternalStoreSharedOptions } from "@assistant-ui/core";
10
- import { useCloudThreadListAdapter, useExternalMessageConverter, useExternalStoreRuntime, useRemoteThreadListRuntime } from "@assistant-ui/core/react";
11
11
  import { STREAM_CONTROLLER, useChannel, useStream } from "@langchain/react";
12
12
  //#region src/useStreamRuntime.ts
13
13
  const UI_CUSTOM_CHANNELS = ["custom"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-langchain",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "LangChain useStream adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "langchain",
@@ -29,10 +29,10 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@assistant-ui/core": "^0.3.0",
33
- "@assistant-ui/store": "^0.3.0",
32
+ "@assistant-ui/core": "^0.3.4",
33
+ "@assistant-ui/store": "^0.3.3",
34
34
  "assistant-cloud": "*",
35
- "assistant-stream": "^0.3.29"
35
+ "assistant-stream": "^0.3.32"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@langchain/react": "^1.0.2",
@@ -87,6 +87,7 @@ describe("convertLangChainBaseMessage file content parts", () => {
87
87
  filename: "file.pdf",
88
88
  data: "https://r2.example/u/abc/file.pdf",
89
89
  mimeType: "application/pdf",
90
+ sourceType: "url",
90
91
  },
91
92
  ]);
92
93
  });
@@ -103,6 +104,7 @@ describe("convertLangChainBaseMessage file content parts", () => {
103
104
  filename: "file",
104
105
  data: "file-abc123",
105
106
  mimeType: "application/octet-stream",
107
+ sourceType: "id",
106
108
  },
107
109
  ]);
108
110
  });
@@ -128,6 +130,7 @@ describe("getMessageContent file blocks", () => {
128
130
  type: "file",
129
131
  data: "ZmFrZQ==",
130
132
  mime_type: "application/pdf",
133
+ filename: "a.pdf",
131
134
  metadata: { filename: "a.pdf" },
132
135
  source_type: "base64",
133
136
  },
@@ -150,6 +153,7 @@ describe("getMessageContent file blocks", () => {
150
153
  type: "file",
151
154
  url: "https://r2.example/u/abc/file.pdf",
152
155
  mime_type: "application/pdf",
156
+ filename: "file.pdf",
153
157
  metadata: { filename: "file.pdf" },
154
158
  source_type: "url",
155
159
  },
@@ -173,6 +177,7 @@ describe("getMessageContent file blocks", () => {
173
177
  type: "file",
174
178
  data: "ZmFrZQ==",
175
179
  mime_type: "application/pdf",
180
+ filename: "a.pdf",
176
181
  metadata: { filename: "a.pdf" },
177
182
  source_type: "base64",
178
183
  },
@@ -195,11 +200,509 @@ describe("getMessageContent file blocks", () => {
195
200
  type: "file",
196
201
  data: "blob:https://app.example/123",
197
202
  mime_type: "application/pdf",
203
+ filename: "a.pdf",
204
+ metadata: { filename: "a.pdf" },
205
+ source_type: "base64",
206
+ },
207
+ ]);
208
+ });
209
+
210
+ it("emits an id source block with the value in the id key for sourceType id", () => {
211
+ const content = getMessageContent(
212
+ appendMessage({
213
+ type: "file",
214
+ data: "flx::storage:file_object:abc",
215
+ mimeType: "application/pdf",
216
+ filename: "invoice.pdf",
217
+ sourceType: "id",
218
+ }),
219
+ );
220
+
221
+ expect(content).toEqual([
222
+ { type: "text", text: " " },
223
+ {
224
+ type: "file",
225
+ id: "flx::storage:file_object:abc",
226
+ mime_type: "application/pdf",
227
+ filename: "invoice.pdf",
228
+ metadata: { filename: "invoice.pdf" },
229
+ source_type: "id",
230
+ },
231
+ ]);
232
+ expect(content[1]).not.toHaveProperty("data");
233
+ });
234
+
235
+ it("lets sourceType url override sniffing for non-http data", () => {
236
+ const content = getMessageContent(
237
+ appendMessage({
238
+ type: "file",
239
+ data: "s3://bucket/key.pdf",
240
+ mimeType: "application/pdf",
241
+ filename: "key.pdf",
242
+ sourceType: "url",
243
+ }),
244
+ );
245
+
246
+ expect(content).toEqual([
247
+ { type: "text", text: " " },
248
+ {
249
+ type: "file",
250
+ url: "s3://bucket/key.pdf",
251
+ mime_type: "application/pdf",
252
+ filename: "key.pdf",
253
+ metadata: { filename: "key.pdf" },
254
+ source_type: "url",
255
+ },
256
+ ]);
257
+ });
258
+
259
+ it("emits an id source block for attachment content parts", () => {
260
+ const content = getMessageContent({
261
+ content: [{ type: "text", text: "see attached" }],
262
+ attachments: [
263
+ {
264
+ content: [
265
+ {
266
+ type: "file",
267
+ data: "file-abc123",
268
+ mimeType: "application/pdf",
269
+ filename: "a.pdf",
270
+ sourceType: "id",
271
+ },
272
+ ],
273
+ },
274
+ ],
275
+ } as unknown as AppendMessage);
276
+
277
+ expect(content).toEqual([
278
+ { type: "text", text: "see attached" },
279
+ {
280
+ type: "file",
281
+ id: "file-abc123",
282
+ mime_type: "application/pdf",
283
+ filename: "a.pdf",
284
+ metadata: { filename: "a.pdf" },
285
+ source_type: "id",
286
+ },
287
+ ]);
288
+ });
289
+
290
+ it("round-trips an id source block through both converters", () => {
291
+ const converted = convertLangChainBaseMessage(
292
+ humanMessage([
293
+ {
294
+ type: "file",
295
+ id: "file-abc123",
296
+ mime_type: "application/pdf",
297
+ source_type: "id",
298
+ metadata: { filename: "a.pdf" },
299
+ },
300
+ ]),
301
+ {},
302
+ );
303
+
304
+ const content = getMessageContent(converted as unknown as AppendMessage);
305
+
306
+ expect(content).toEqual([
307
+ { type: "text", text: " " },
308
+ {
309
+ type: "file",
310
+ id: "file-abc123",
311
+ mime_type: "application/pdf",
312
+ filename: "a.pdf",
198
313
  metadata: { filename: "a.pdf" },
314
+ source_type: "id",
315
+ },
316
+ ]);
317
+ });
318
+
319
+ it("emits an audio block for a base64 file part with an audio mime type", () => {
320
+ const content = getMessageContent(
321
+ appendMessage({
322
+ type: "file",
323
+ data: "c291bmQ=",
324
+ mimeType: "audio/mp3",
325
+ filename: "memo.mp3",
326
+ }),
327
+ );
328
+
329
+ expect(content).toEqual([
330
+ { type: "text", text: " " },
331
+ {
332
+ type: "audio",
333
+ data: "c291bmQ=",
334
+ mime_type: "audio/mp3",
199
335
  source_type: "base64",
200
336
  },
201
337
  ]);
202
338
  });
339
+
340
+ it("normalizes audio/mpeg and audio/x-wav to the accepted spellings", () => {
341
+ expect(
342
+ getMessageContent(
343
+ appendMessage({
344
+ type: "file",
345
+ data: "c291bmQ=",
346
+ mimeType: "audio/mpeg",
347
+ }),
348
+ )[1],
349
+ ).toMatchObject({ type: "audio", mime_type: "audio/mp3" });
350
+
351
+ expect(
352
+ getMessageContent(
353
+ appendMessage({
354
+ type: "file",
355
+ data: "c291bmQ=",
356
+ mimeType: "audio/x-wav",
357
+ }),
358
+ )[1],
359
+ ).toMatchObject({ type: "audio", mime_type: "audio/wav" });
360
+ });
361
+
362
+ it("strips the data URL envelope from an audio file part", () => {
363
+ const content = getMessageContent(
364
+ appendMessage({
365
+ type: "file",
366
+ data: "data:audio/mpeg;base64,c291bmQ=",
367
+ mimeType: "audio/mp3",
368
+ }),
369
+ );
370
+
371
+ expect(content[1]).toEqual({
372
+ type: "audio",
373
+ data: "c291bmQ=",
374
+ mime_type: "audio/mp3",
375
+ source_type: "base64",
376
+ });
377
+ });
378
+
379
+ it("keeps url and id audio references as file blocks", () => {
380
+ expect(
381
+ getMessageContent(
382
+ appendMessage({
383
+ type: "file",
384
+ data: "https://cdn.example.com/memo.mp3",
385
+ mimeType: "audio/mp3",
386
+ filename: "memo.mp3",
387
+ }),
388
+ )[1],
389
+ ).toMatchObject({ type: "file", source_type: "url" });
390
+
391
+ expect(
392
+ getMessageContent(
393
+ appendMessage({
394
+ type: "file",
395
+ data: "file-abc123",
396
+ mimeType: "audio/mp3",
397
+ filename: "memo.mp3",
398
+ sourceType: "id",
399
+ }),
400
+ )[1],
401
+ ).toMatchObject({ type: "file", source_type: "id" });
402
+ });
403
+
404
+ it("does not treat inherited object keys as audio media types", () => {
405
+ for (const mimeType of ["__proto__", "constructor"]) {
406
+ expect(
407
+ getMessageContent(
408
+ appendMessage({
409
+ type: "file",
410
+ data: "ZmFrZQ==",
411
+ mimeType,
412
+ filename: "a.bin",
413
+ }),
414
+ )[1],
415
+ ).toMatchObject({ type: "file", mime_type: mimeType });
416
+ }
417
+ });
418
+
419
+ it("detects audio from the data URL envelope when the declared type is generic", () => {
420
+ expect(
421
+ getMessageContent(
422
+ appendMessage({
423
+ type: "file",
424
+ data: "data:audio/mpeg;base64,c291bmQ=",
425
+ mimeType: "application/octet-stream",
426
+ }),
427
+ )[1],
428
+ ).toEqual({
429
+ type: "audio",
430
+ data: "c291bmQ=",
431
+ mime_type: "audio/mp3",
432
+ source_type: "base64",
433
+ });
434
+ });
435
+
436
+ it("leaves non-audio file parts as file blocks", () => {
437
+ expect(
438
+ getMessageContent(
439
+ appendMessage({
440
+ type: "file",
441
+ data: "ZmFrZQ==",
442
+ mimeType: "application/pdf",
443
+ filename: "a.pdf",
444
+ }),
445
+ )[1],
446
+ ).toMatchObject({ type: "file", mime_type: "application/pdf" });
447
+ });
448
+ });
449
+
450
+ describe("getMessageContent audio and data parts", () => {
451
+ const appendMessage = (...parts: Record<string, unknown>[]) =>
452
+ ({ content: parts }) as unknown as AppendMessage;
453
+
454
+ it("emits a base64 audio block with the format MIME type for audio parts", () => {
455
+ const content = getMessageContent(
456
+ appendMessage({
457
+ type: "audio",
458
+ audio: { data: "c291bmQ=", format: "mp3" },
459
+ }),
460
+ );
461
+
462
+ expect(content).toEqual([
463
+ { type: "text", text: " " },
464
+ {
465
+ type: "audio",
466
+ data: "c291bmQ=",
467
+ mime_type: "audio/mp3",
468
+ source_type: "base64",
469
+ },
470
+ ]);
471
+ });
472
+
473
+ it("strips a data URL envelope from audio data", () => {
474
+ const content = getMessageContent(
475
+ appendMessage({
476
+ type: "audio",
477
+ audio: { data: "data:audio/wav;base64,d2F2", format: "wav" },
478
+ }),
479
+ );
480
+
481
+ expect(content).toEqual([
482
+ { type: "text", text: " " },
483
+ {
484
+ type: "audio",
485
+ data: "d2F2",
486
+ mime_type: "audio/wav",
487
+ source_type: "base64",
488
+ },
489
+ ]);
490
+ });
491
+
492
+ it("keeps the format-derived MIME when a data URL carries a divergent one", () => {
493
+ const content = getMessageContent(
494
+ appendMessage({
495
+ type: "audio",
496
+ audio: { data: "data:audio/mpeg;base64,c291bmQ=", format: "mp3" },
497
+ }),
498
+ );
499
+
500
+ expect(content).toEqual([
501
+ { type: "text", text: " " },
502
+ {
503
+ type: "audio",
504
+ data: "c291bmQ=",
505
+ mime_type: "audio/mp3",
506
+ source_type: "base64",
507
+ },
508
+ ]);
509
+ });
510
+
511
+ it("does not prepend a second placeholder when text accompanies audio", () => {
512
+ const content = getMessageContent(
513
+ appendMessage(
514
+ { type: "text", text: "listen" },
515
+ { type: "audio", audio: { data: "d2F2", format: "wav" } },
516
+ ),
517
+ );
518
+
519
+ expect(content).toEqual([
520
+ { type: "text", text: "listen" },
521
+ {
522
+ type: "audio",
523
+ data: "d2F2",
524
+ mime_type: "audio/wav",
525
+ source_type: "base64",
526
+ },
527
+ ]);
528
+ });
529
+
530
+ it("drops data parts while keeping the rest of the message", () => {
531
+ const content = getMessageContent(
532
+ appendMessage(
533
+ { type: "text", text: "hi" },
534
+ { type: "data", name: "chart", data: { values: [1, 2] } },
535
+ ),
536
+ );
537
+
538
+ expect(content).toBe("hi");
539
+ });
540
+
541
+ it("returns empty content for a data-only message", () => {
542
+ const content = getMessageContent(
543
+ appendMessage({ type: "data", name: "chart", data: { values: [1, 2] } }),
544
+ );
545
+
546
+ expect(content).toEqual([]);
547
+ });
548
+
549
+ it("still throws on assistant-only part types", () => {
550
+ expect(() =>
551
+ getMessageContent(appendMessage({ type: "reasoning", text: "hmm" })),
552
+ ).toThrow("Unsupported append message part type: reasoning");
553
+ });
554
+ });
555
+
556
+ describe("contentToParts audio blocks", () => {
557
+ const inboundAudioPart = (block: Record<string, unknown>) => {
558
+ const result = convertLangChainBaseMessage(
559
+ {
560
+ _getType: () => "human",
561
+ id: "h1",
562
+ content: [{ type: "audio", data: "c291bmQ=", ...block }],
563
+ },
564
+ {},
565
+ );
566
+ return contentOf(result)[0];
567
+ };
568
+
569
+ it("converts an inbound base64 audio block back to an audio part", () => {
570
+ const result = convertLangChainBaseMessage(
571
+ {
572
+ _getType: () => "human",
573
+ id: "h1",
574
+ content: [
575
+ {
576
+ type: "audio",
577
+ data: "c291bmQ=",
578
+ mime_type: "audio/mp3",
579
+ source_type: "base64",
580
+ },
581
+ ],
582
+ },
583
+ {},
584
+ );
585
+
586
+ expect(result).toMatchObject({
587
+ role: "user",
588
+ content: [
589
+ {
590
+ type: "file",
591
+ filename: "audio.mp3",
592
+ data: "c291bmQ=",
593
+ mimeType: "audio/mp3",
594
+ },
595
+ ],
596
+ });
597
+ });
598
+
599
+ it("keeps an inbound audio block whose mime type has no wire format", () => {
600
+ const result = convertLangChainBaseMessage(
601
+ {
602
+ _getType: () => "human",
603
+ id: "h1",
604
+ content: [
605
+ {
606
+ type: "audio",
607
+ data: "b2dn",
608
+ mime_type: "audio/ogg",
609
+ source_type: "base64",
610
+ },
611
+ ],
612
+ },
613
+ {},
614
+ );
615
+
616
+ expect(result).toMatchObject({
617
+ role: "user",
618
+ content: [
619
+ {
620
+ type: "file",
621
+ filename: "audio.ogg",
622
+ data: "b2dn",
623
+ mimeType: "audio/ogg",
624
+ },
625
+ ],
626
+ });
627
+ });
628
+
629
+ it("keeps an audio block on an assistant message", () => {
630
+ const result = convertLangChainBaseMessage(
631
+ {
632
+ _getType: () => "ai",
633
+ id: "ai-1",
634
+ content: [
635
+ {
636
+ type: "audio",
637
+ data: "c291bmQ=",
638
+ mime_type: "audio/mp3",
639
+ source_type: "base64",
640
+ },
641
+ { type: "text", text: "done" },
642
+ ],
643
+ },
644
+ {},
645
+ );
646
+
647
+ expect(result).toMatchObject({
648
+ role: "assistant",
649
+ content: [
650
+ {
651
+ type: "file",
652
+ filename: "audio.mp3",
653
+ data: "c291bmQ=",
654
+ mimeType: "audio/mp3",
655
+ },
656
+ { type: "text", text: "done" },
657
+ ],
658
+ });
659
+ });
660
+ it("round-trips an audio file part through both converters", () => {
661
+ const outbound = getMessageContent({
662
+ content: [
663
+ {
664
+ type: "file",
665
+ data: "data:audio/mpeg;base64,c291bmQ=",
666
+ mimeType: "audio/mpeg",
667
+ filename: "memo.mp3",
668
+ },
669
+ ],
670
+ } as unknown as AppendMessage);
671
+
672
+ const inbound = convertLangChainBaseMessage(
673
+ {
674
+ _getType: () => "human",
675
+ id: "h1",
676
+ content: outbound as never,
677
+ },
678
+ {},
679
+ );
680
+
681
+ expect(contentOf(inbound)).toEqual([
682
+ { type: "text", text: " " },
683
+ {
684
+ type: "file",
685
+ filename: "audio.mp3",
686
+ data: "c291bmQ=",
687
+ mimeType: "audio/mp3",
688
+ },
689
+ ]);
690
+
691
+ expect(
692
+ getMessageContent({
693
+ content: contentOf(inbound),
694
+ } as unknown as AppendMessage),
695
+ ).toEqual(outbound);
696
+ });
697
+ it("names an inbound audio attachment from its media subtype", () => {
698
+ expect(inboundAudioPart({ mime_type: "audio/wav" })).toMatchObject({
699
+ filename: "audio.wav",
700
+ });
701
+ expect(inboundAudioPart({})).toMatchObject({
702
+ filename: "audio",
703
+ mimeType: "application/octet-stream",
704
+ });
705
+ });
203
706
  });
204
707
 
205
708
  describe("convertLangChainBaseMessage reasoning content parts", () => {
@@ -346,3 +849,102 @@ describe("convertLangChainBaseMessage image content parts", () => {
346
849
  expect(contentOf(result)).toEqual([]);
347
850
  });
348
851
  });
852
+
853
+ describe("convertLangChainBaseMessage audio transcripts", () => {
854
+ const audioMessage = (
855
+ content: unknown,
856
+ audio: unknown,
857
+ ): LangChainBaseMessage => ({
858
+ _getType: () => "ai",
859
+ id: "msg-3",
860
+ content,
861
+ additional_kwargs: { audio },
862
+ });
863
+
864
+ it("surfaces the transcript when the provider leaves content empty", () => {
865
+ const result = convertLangChainBaseMessage(
866
+ audioMessage("", {
867
+ id: "audio_1",
868
+ data: "UklGRg==",
869
+ expires_at: 1,
870
+ transcript: "the secret number is four seven two",
871
+ }),
872
+ {},
873
+ );
874
+
875
+ expect(contentOf(result)).toEqual([
876
+ { type: "text", text: "the secret number is four seven two" },
877
+ ]);
878
+ });
879
+
880
+ it("treats a whitespace-only placeholder as no text", () => {
881
+ const result = convertLangChainBaseMessage(
882
+ audioMessage([{ type: "text", text: " " }], {
883
+ transcript: "spoken words",
884
+ }),
885
+ {},
886
+ );
887
+
888
+ expect(contentOf(result)).toEqual([{ type: "text", text: "spoken words" }]);
889
+ });
890
+
891
+ it("does not throw on a non-spec text block whose text is missing or not a string", () => {
892
+ for (const block of [{ type: "text" }, { type: "text", text: 42 }]) {
893
+ const result = convertLangChainBaseMessage(
894
+ audioMessage([block], { transcript: "spoken words" }),
895
+ {},
896
+ );
897
+
898
+ expect(contentOf(result)).toEqual([
899
+ { type: "text", text: "spoken words" },
900
+ ]);
901
+ }
902
+ });
903
+
904
+ it("keeps non-text parts when it substitutes the transcript", () => {
905
+ const result = convertLangChainBaseMessage(
906
+ audioMessage(
907
+ [
908
+ { type: "text", text: "" },
909
+ {
910
+ type: "image_url",
911
+ image_url: { url: "https://example.com/a.png" },
912
+ },
913
+ ],
914
+ { transcript: "spoken words" },
915
+ ),
916
+ {},
917
+ );
918
+
919
+ expect(contentOf(result)).toEqual([
920
+ { type: "image", image: "https://example.com/a.png" },
921
+ { type: "text", text: "spoken words" },
922
+ ]);
923
+ });
924
+
925
+ it("leaves existing text alone so the transcript is not duplicated", () => {
926
+ const result = convertLangChainBaseMessage(
927
+ audioMessage([{ type: "text", text: "written answer" }], {
928
+ transcript: "written answer",
929
+ }),
930
+ {},
931
+ );
932
+
933
+ expect(contentOf(result)).toEqual([
934
+ { type: "text", text: "written answer" },
935
+ ]);
936
+ });
937
+
938
+ it("ignores an absent, blank, or non-string transcript", () => {
939
+ for (const audio of [
940
+ undefined,
941
+ {},
942
+ { transcript: "" },
943
+ { transcript: " " },
944
+ { transcript: 42 },
945
+ ]) {
946
+ const result = convertLangChainBaseMessage(audioMessage("", audio), {});
947
+ expect(contentOf(result)).toEqual([{ type: "text", text: "" }]);
948
+ }
949
+ });
950
+ });
@@ -64,7 +64,22 @@ const contentToParts = (content: unknown) => {
64
64
  ? part.id
65
65
  : part.data,
66
66
  mimeType: part.mime_type ?? "application/octet-stream",
67
+ ...((part.source_type === "url" || part.source_type === "id") && {
68
+ sourceType: part.source_type,
69
+ }),
67
70
  };
71
+ case "audio": {
72
+ const mimeType = part.mime_type ?? "application/octet-stream";
73
+ const subtype = mimeType.startsWith("audio/")
74
+ ? mimeType.slice("audio/".length)
75
+ : undefined;
76
+ return {
77
+ type: "file" as const,
78
+ filename: subtype ? `audio.${subtype}` : "audio",
79
+ data: part.data,
80
+ mimeType,
81
+ };
82
+ }
68
83
  case "thinking":
69
84
  return { type: "reasoning" as const, text: part.thinking };
70
85
  case "reasoning":
@@ -85,6 +100,31 @@ const contentToParts = (content: unknown) => {
85
100
  .filter((p) => p !== null);
86
101
  };
87
102
 
103
+ const hasVisibleText = (text: unknown): boolean =>
104
+ typeof text === "string" && text.trim() !== "";
105
+
106
+ /**
107
+ * Audio output arrives outside the content array: providers leave `content`
108
+ * empty and put the spoken text in `additional_kwargs.audio.transcript`. The
109
+ * audio bytes stay behind because no provider reports their media type, and a
110
+ * streamed response carries raw PCM rather than a playable file.
111
+ */
112
+ const withAudioTranscript = (
113
+ parts: ReturnType<typeof contentToParts>,
114
+ additionalKwargs: Record<string, unknown> | undefined,
115
+ ): ReturnType<typeof contentToParts> => {
116
+ const audio = additionalKwargs?.audio as { transcript?: unknown } | undefined;
117
+ const transcript = audio?.transcript;
118
+ if (typeof transcript !== "string" || !hasVisibleText(transcript))
119
+ return parts;
120
+ if (parts.some((part) => part.type === "text" && hasVisibleText(part.text)))
121
+ return parts;
122
+ return [
123
+ ...parts.filter((part) => part.type !== "text"),
124
+ { type: "text" as const, text: transcript },
125
+ ];
126
+ };
127
+
88
128
  const getCustomMetadata = (
89
129
  additionalKwargs: Record<string, unknown> | undefined,
90
130
  ): Record<string, unknown> =>
@@ -152,7 +192,10 @@ export const convertLangChainBaseMessage = (
152
192
  role: "assistant",
153
193
  id: message.id,
154
194
  content: [
155
- ...contentToParts(message.content),
195
+ ...withAudioTranscript(
196
+ contentToParts(message.content),
197
+ message.additional_kwargs,
198
+ ),
156
199
  ...toolCallParts,
157
200
  ...uiDataParts,
158
201
  ],
@@ -191,6 +234,20 @@ export const convertLangChainBaseMessage = (
191
234
  }
192
235
  };
193
236
 
237
+ /**
238
+ * Audio media types that reach a provider's audio input through the LangChain
239
+ * `audio` block. langchain-core derives OpenAI's `input_audio.format` by
240
+ * splitting `mime_type` on `/`, and that format is a wav-or-mp3 enum, so
241
+ * `audio/mpeg` passes the converter and is rejected at the provider.
242
+ */
243
+ const audioBlockMimeTypes = new Map<string, "audio/mp3" | "audio/wav">([
244
+ ["audio/mp3", "audio/mp3"],
245
+ ["audio/mpeg", "audio/mp3"],
246
+ ["audio/wav", "audio/wav"],
247
+ ["audio/wave", "audio/wav"],
248
+ ["audio/x-wav", "audio/wav"],
249
+ ]);
250
+
194
251
  export const getMessageContent = (msg: AppendMessage) => {
195
252
  const allContent = [
196
253
  ...msg.content,
@@ -198,14 +255,15 @@ export const getMessageContent = (msg: AppendMessage) => {
198
255
  ];
199
256
 
200
257
  const hasNonText = allContent.some(
201
- (part) => part.type === "file" || part.type === "image",
258
+ (part) =>
259
+ part.type === "file" || part.type === "image" || part.type === "audio",
202
260
  );
203
261
  const hasText = allContent.some((part) => part.type === "text");
204
262
  if (hasNonText && !hasText) {
205
263
  allContent.unshift({ type: "text", text: " " });
206
264
  }
207
265
 
208
- const content = allContent.map((part) => {
266
+ const content = allContent.flatMap((part) => {
209
267
  const type = part.type;
210
268
  switch (type) {
211
269
  case "text":
@@ -214,33 +272,62 @@ export const getMessageContent = (msg: AppendMessage) => {
214
272
  return { type: "image_url" as const, image_url: { url: part.image } };
215
273
  case "file": {
216
274
  const metadata = { filename: part.filename ?? "file" };
217
- if (httpUrlPattern.test(part.data)) {
275
+ if (part.sourceType === "id") {
276
+ return {
277
+ type: "file" as const,
278
+ id: part.data,
279
+ mime_type: part.mimeType,
280
+ filename: metadata.filename,
281
+ metadata,
282
+ source_type: "id" as const,
283
+ };
284
+ }
285
+ if (part.sourceType === "url" || httpUrlPattern.test(part.data)) {
218
286
  return {
219
287
  type: "file" as const,
220
288
  url: part.data,
221
289
  mime_type: part.mimeType,
290
+ filename: metadata.filename,
222
291
  metadata,
223
292
  source_type: "url" as const,
224
293
  };
225
294
  }
226
295
  const parsed = parseDataUrl(part.data);
296
+ const audioMimeType = audioBlockMimeTypes.get(
297
+ (parsed?.mimeType ?? part.mimeType).toLowerCase(),
298
+ );
299
+ if (audioMimeType) {
300
+ return {
301
+ type: "audio" as const,
302
+ data: parsed?.data ?? part.data,
303
+ mime_type: audioMimeType,
304
+ source_type: "base64" as const,
305
+ };
306
+ }
227
307
  return {
228
308
  type: "file" as const,
229
309
  data: parsed?.data ?? part.data,
230
310
  mime_type: parsed?.mimeType ?? part.mimeType,
311
+ filename: metadata.filename,
231
312
  metadata,
232
313
  source_type: "base64" as const,
233
314
  };
234
315
  }
316
+ case "audio": {
317
+ const parsed = parseDataUrl(part.audio.data);
318
+ return {
319
+ type: "audio" as const,
320
+ data: parsed?.data ?? part.audio.data,
321
+ mime_type: `audio/${part.audio.format}`,
322
+ source_type: "base64" as const,
323
+ };
324
+ }
325
+ case "data":
326
+ return [];
235
327
  case "tool-call":
236
328
  throw new Error("Tool call appends are not supported.");
237
329
  default: {
238
- const _exhaustiveCheck:
239
- | "reasoning"
240
- | "source"
241
- | "audio"
242
- | "data"
243
- | "generative-ui" = type;
330
+ const _exhaustiveCheck: "reasoning" | "source" | "generative-ui" = type;
244
331
  throw new Error(
245
332
  `Unsupported append message part type: ${_exhaustiveCheck}`,
246
333
  );
@@ -1,4 +1,4 @@
1
- import { createRuntimeExtras } from "@assistant-ui/core/internal";
1
+ import { createRuntimeExtras } from "@assistant-ui/core/react";
2
2
  import type { LangChainRuntimeExtras } from "./types";
3
3
 
4
4
  export const langChainExtras =
package/src/types.ts CHANGED
@@ -49,6 +49,12 @@ export type LangChainContentBlock =
49
49
  source_type: "id";
50
50
  metadata?: { filename?: string };
51
51
  }
52
+ | {
53
+ type: "audio";
54
+ data: string;
55
+ mime_type: string;
56
+ source_type: "base64";
57
+ }
52
58
  | { type: "tool_use" | "input_json_delta" };
53
59
 
54
60
  export type LangChainToolCall = {