@assistant-ui/react-langchain 0.0.22 → 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.
@@ -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,51 @@ 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;
52
73
  url?: never;
53
74
  } | {
54
75
  type: "audio";
@@ -57,8 +78,10 @@ declare const getMessageContent: (msg: AppendMessage) => string | ({
57
78
  source_type: "base64";
58
79
  text?: never;
59
80
  image_url?: never;
60
- url?: never;
81
+ id?: never;
82
+ filename?: never;
61
83
  metadata?: never;
84
+ url?: never;
62
85
  })[];
63
86
  //#endregion
64
87
  export { convertLangChainBaseMessage, getMessageContent, getMessageType };
@@ -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;cAuF3B,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"}
@@ -11,7 +11,7 @@ const getMessageType = (message) => {
11
11
  if ("type" in message) return message.type;
12
12
  throw new Error("Cannot determine message type");
13
13
  };
14
- const contentToParts = (content, role) => {
14
+ const contentToParts = (content) => {
15
15
  if (typeof content === "string") return [{
16
16
  type: "text",
17
17
  text: content
@@ -35,18 +35,17 @@ const contentToParts = (content, role) => {
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
  };
40
41
  case "audio": {
41
- if (role !== "user") return null;
42
- const format = part.mime_type === "audio/wav" ? "wav" : part.mime_type === "audio/mp3" ? "mp3" : null;
43
- if (!format) return null;
42
+ const mimeType = part.mime_type ?? "application/octet-stream";
43
+ const subtype = mimeType.startsWith("audio/") ? mimeType.slice(6) : void 0;
44
44
  return {
45
- type: "audio",
46
- audio: {
47
- data: part.data,
48
- format
49
- }
45
+ type: "file",
46
+ filename: subtype ? `audio.${subtype}` : "audio",
47
+ data: part.data,
48
+ mimeType
50
49
  };
51
50
  }
52
51
  case "thinking": return {
@@ -63,6 +62,22 @@ const contentToParts = (content, role) => {
63
62
  }
64
63
  }).filter((p) => p !== null);
65
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
+ };
66
81
  const getCustomMetadata = (additionalKwargs) => additionalKwargs?.metadata ?? {};
67
82
  const getStringContent = (content) => {
68
83
  if (typeof content === "string") return content;
@@ -82,7 +97,7 @@ const convertLangChainBaseMessage = (message, metadata = {}) => {
82
97
  case "human": return {
83
98
  role: "user",
84
99
  id: message.id,
85
- content: contentToParts(message.content, "user"),
100
+ content: contentToParts(message.content),
86
101
  metadata: { custom: getCustomMetadata(message.additional_kwargs) }
87
102
  };
88
103
  case "ai": {
@@ -100,7 +115,7 @@ const convertLangChainBaseMessage = (message, metadata = {}) => {
100
115
  role: "assistant",
101
116
  id: message.id,
102
117
  content: [
103
- ...contentToParts(message.content, "assistant"),
118
+ ...withAudioTranscript(contentToParts(message.content), message.additional_kwargs),
104
119
  ...toolCallParts,
105
120
  ...uiDataParts
106
121
  ],
@@ -129,6 +144,19 @@ const convertLangChainBaseMessage = (message, metadata = {}) => {
129
144
  };
130
145
  }
131
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
+ ]);
132
160
  const getMessageContent = (msg) => {
133
161
  const allContent = [...msg.content, ...msg.attachments?.flatMap((a) => a.content) ?? []];
134
162
  const hasNonText = allContent.some((part) => part.type === "file" || part.type === "image" || part.type === "audio");
@@ -150,18 +178,35 @@ const getMessageContent = (msg) => {
150
178
  };
151
179
  case "file": {
152
180
  const metadata = { filename: part.filename ?? "file" };
153
- 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 {
154
190
  type: "file",
155
191
  url: part.data,
156
192
  mime_type: part.mimeType,
193
+ filename: metadata.filename,
157
194
  metadata,
158
195
  source_type: "url"
159
196
  };
160
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
+ };
161
205
  return {
162
206
  type: "file",
163
207
  data: parsed?.data ?? part.data,
164
208
  mime_type: parsed?.mimeType ?? part.mimeType,
209
+ filename: metadata.filename,
165
210
  metadata,
166
211
  source_type: "base64"
167
212
  };
@@ -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, role: \"user\" | \"assistant\") => {\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 \"audio\": {\n if (role !== \"user\") return null;\n const format =\n part.mime_type === \"audio/wav\"\n ? (\"wav\" as const)\n : part.mime_type === \"audio/mp3\"\n ? (\"mp3\" as const)\n : null;\n if (!format) return null;\n return {\n type: \"audio\" as const,\n audio: { data: part.data, format },\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 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, \"user\"),\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, \"assistant\"),\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) =>\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 (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 \"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
+ {"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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-langchain",
3
- "version": "0.0.22",
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.2",
33
- "@assistant-ui/store": "^0.3.2",
32
+ "@assistant-ui/core": "^0.3.4",
33
+ "@assistant-ui/store": "^0.3.3",
34
34
  "assistant-cloud": "*",
35
- "assistant-stream": "^0.3.31"
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,251 @@ 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
+ });
203
448
  });
204
449
 
205
450
  describe("getMessageContent audio and data parts", () => {
@@ -309,6 +554,18 @@ describe("getMessageContent audio and data parts", () => {
309
554
  });
310
555
 
311
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
+
312
569
  it("converts an inbound base64 audio block back to an audio part", () => {
313
570
  const result = convertLangChainBaseMessage(
314
571
  {
@@ -328,11 +585,18 @@ describe("contentToParts audio blocks", () => {
328
585
 
329
586
  expect(result).toMatchObject({
330
587
  role: "user",
331
- content: [{ type: "audio", audio: { data: "c291bmQ=", format: "mp3" } }],
588
+ content: [
589
+ {
590
+ type: "file",
591
+ filename: "audio.mp3",
592
+ data: "c291bmQ=",
593
+ mimeType: "audio/mp3",
594
+ },
595
+ ],
332
596
  });
333
597
  });
334
598
 
335
- it("drops an inbound audio block with an unrepresentable mime type", () => {
599
+ it("keeps an inbound audio block whose mime type has no wire format", () => {
336
600
  const result = convertLangChainBaseMessage(
337
601
  {
338
602
  _getType: () => "human",
@@ -349,10 +613,20 @@ describe("contentToParts audio blocks", () => {
349
613
  {},
350
614
  );
351
615
 
352
- expect(result).toMatchObject({ role: "user", content: [] });
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
+ });
353
627
  });
354
628
 
355
- it("drops an audio block on an assistant message", () => {
629
+ it("keeps an audio block on an assistant message", () => {
356
630
  const result = convertLangChainBaseMessage(
357
631
  {
358
632
  _getType: () => "ai",
@@ -372,7 +646,61 @@ describe("contentToParts audio blocks", () => {
372
646
 
373
647
  expect(result).toMatchObject({
374
648
  role: "assistant",
375
- content: [{ type: "text", text: "done" }],
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",
376
704
  });
377
705
  });
378
706
  });
@@ -521,3 +849,102 @@ describe("convertLangChainBaseMessage image content parts", () => {
521
849
  expect(contentOf(result)).toEqual([]);
522
850
  });
523
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
+ });
@@ -33,7 +33,7 @@ export const getMessageType = (message: LangChainBaseMessage): string => {
33
33
  throw new Error("Cannot determine message type");
34
34
  };
35
35
 
36
- const contentToParts = (content: unknown, role: "user" | "assistant") => {
36
+ const contentToParts = (content: unknown) => {
37
37
  if (typeof content === "string")
38
38
  return [{ type: "text" as const, text: content }];
39
39
 
@@ -64,19 +64,20 @@ const contentToParts = (content: unknown, role: "user" | "assistant") => {
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
  };
68
71
  case "audio": {
69
- if (role !== "user") return null;
70
- const format =
71
- part.mime_type === "audio/wav"
72
- ? ("wav" as const)
73
- : part.mime_type === "audio/mp3"
74
- ? ("mp3" as const)
75
- : null;
76
- if (!format) return null;
72
+ const mimeType = part.mime_type ?? "application/octet-stream";
73
+ const subtype = mimeType.startsWith("audio/")
74
+ ? mimeType.slice("audio/".length)
75
+ : undefined;
77
76
  return {
78
- type: "audio" as const,
79
- audio: { data: part.data, format },
77
+ type: "file" as const,
78
+ filename: subtype ? `audio.${subtype}` : "audio",
79
+ data: part.data,
80
+ mimeType,
80
81
  };
81
82
  }
82
83
  case "thinking":
@@ -99,6 +100,31 @@ const contentToParts = (content: unknown, role: "user" | "assistant") => {
99
100
  .filter((p) => p !== null);
100
101
  };
101
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
+
102
128
  const getCustomMetadata = (
103
129
  additionalKwargs: Record<string, unknown> | undefined,
104
130
  ): Record<string, unknown> =>
@@ -134,7 +160,7 @@ export const convertLangChainBaseMessage = (
134
160
  return {
135
161
  role: "user",
136
162
  id: message.id,
137
- content: contentToParts(message.content, "user"),
163
+ content: contentToParts(message.content),
138
164
  metadata: {
139
165
  custom: getCustomMetadata(message.additional_kwargs),
140
166
  },
@@ -166,7 +192,10 @@ export const convertLangChainBaseMessage = (
166
192
  role: "assistant",
167
193
  id: message.id,
168
194
  content: [
169
- ...contentToParts(message.content, "assistant"),
195
+ ...withAudioTranscript(
196
+ contentToParts(message.content),
197
+ message.additional_kwargs,
198
+ ),
170
199
  ...toolCallParts,
171
200
  ...uiDataParts,
172
201
  ],
@@ -205,6 +234,20 @@ export const convertLangChainBaseMessage = (
205
234
  }
206
235
  };
207
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
+
208
251
  export const getMessageContent = (msg: AppendMessage) => {
209
252
  const allContent = [
210
253
  ...msg.content,
@@ -229,20 +272,43 @@ export const getMessageContent = (msg: AppendMessage) => {
229
272
  return { type: "image_url" as const, image_url: { url: part.image } };
230
273
  case "file": {
231
274
  const metadata = { filename: part.filename ?? "file" };
232
- 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)) {
233
286
  return {
234
287
  type: "file" as const,
235
288
  url: part.data,
236
289
  mime_type: part.mimeType,
290
+ filename: metadata.filename,
237
291
  metadata,
238
292
  source_type: "url" as const,
239
293
  };
240
294
  }
241
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
+ }
242
307
  return {
243
308
  type: "file" as const,
244
309
  data: parsed?.data ?? part.data,
245
310
  mime_type: parsed?.mimeType ?? part.mimeType,
311
+ filename: metadata.filename,
246
312
  metadata,
247
313
  source_type: "base64" as const,
248
314
  };