mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-22 14:08:28 +00:00
fix(frontend): render full content for multi-part AI messages (#3649)
* fix(frontend): render full content for multi-part AI messages
Gemini streams the first content block as a {type:text} object carrying
the thinking signature, then emits continuation deltas as bare strings.
The content extractors only kept {type:text} objects and dropped the
string parts, truncating each AI message to its first block.
Handle string elements in extractContentFromMessage, extractTextFromMessage,
and textOfMessage so the full message renders.
Fixes #1000
* test(frontend): cover multi-part AI message content extraction
Add a regression test for the #1000 truncation: an AI message whose
content is [{type:text, signature}, "bare string"] (Gemini's shape after
LangChain merge_content). Fails on main, passes with the extractor fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover textOfMessage multi-part content extraction
Add tests for the bare-string continuation case and the null-when-empty
contract, and document why textOfMessage joins flat ("") while the body
extractors join with "\n". Addresses review feedback on #3649.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7a03e5243
commit
654f5e1c66
@ -259,7 +259,13 @@ export function extractTextFromMessage(message: Message) {
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => (content.type === "text" ? content.text : ""))
|
||||
.map((content) =>
|
||||
typeof content === "string"
|
||||
? content
|
||||
: content.type === "text"
|
||||
? content.text
|
||||
: "",
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
@ -323,6 +329,9 @@ export function extractContentFromMessage(message: Message) {
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((content) => {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return content.text;
|
||||
|
||||
@ -43,11 +43,14 @@ export function textOfMessage(message: Message) {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
// Flat join ("") for single-line consumers (input box, titles); the rendered
|
||||
// body uses extractContentFromMessage, which joins multi-part content with "\n".
|
||||
const text = message.content
|
||||
.map((part) =>
|
||||
typeof part === "string" ? part : part.type === "text" ? part.text : "",
|
||||
)
|
||||
.join("");
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractTextFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
getAssistantTurnCopyData,
|
||||
getAssistantTurnUsageMessages,
|
||||
@ -508,3 +509,35 @@ test("keeps streaming assistant hidden when a hidden control message follows it"
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("multi-part content with bare-string continuations", () => {
|
||||
// Gemini streams the first content block as a {type:"text"} object carrying
|
||||
// the thinking signature, then emits continuation deltas as plain strings.
|
||||
// LangChain's Python merge_content preserves these as bare-string elements,
|
||||
// so the finalized message content is [{type:"text", ...}, "...rest..."].
|
||||
const geminiMessage = {
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "First block carrying the signature.",
|
||||
extras: { signature: "abc123" },
|
||||
index: 0,
|
||||
},
|
||||
"Continuation streamed as a bare string.",
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
test("extractContentFromMessage includes the bare-string parts", () => {
|
||||
expect(extractContentFromMessage(geminiMessage)).toBe(
|
||||
"First block carrying the signature.\nContinuation streamed as a bare string.",
|
||||
);
|
||||
});
|
||||
|
||||
test("extractTextFromMessage includes the bare-string parts", () => {
|
||||
expect(extractTextFromMessage(geminiMessage)).toBe(
|
||||
"First block carrying the signature.\nContinuation streamed as a bare string.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import { channelSourceOfThread, pathOfThread } from "@/core/threads/utils";
|
||||
import {
|
||||
channelSourceOfThread,
|
||||
pathOfThread,
|
||||
textOfMessage,
|
||||
} from "@/core/threads/utils";
|
||||
|
||||
test("uses standard chat route when thread has no agent context", () => {
|
||||
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
||||
@ -81,3 +86,35 @@ test("ignores threads without valid IM channel source metadata", () => {
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("textOfMessage concatenates object and bare-string content parts", () => {
|
||||
// Gemini's finalized shape: first signed {type:text} block + bare-string
|
||||
// continuation. textOfMessage joins flat ("") for single-line consumers.
|
||||
const message = {
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "First block.",
|
||||
extras: { signature: "abc123" },
|
||||
index: 0,
|
||||
},
|
||||
" Continuation as a bare string.",
|
||||
],
|
||||
} as unknown as Message;
|
||||
|
||||
expect(textOfMessage(message)).toBe(
|
||||
"First block. Continuation as a bare string.",
|
||||
);
|
||||
});
|
||||
|
||||
test("textOfMessage returns null when array content has no text", () => {
|
||||
const message = {
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: [{ type: "image_url", image_url: "https://example.com/x.png" }],
|
||||
} as unknown as Message;
|
||||
|
||||
expect(textOfMessage(message)).toBeNull();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user