mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-06 21:08:43 +00:00
fix(frontend): keep inline-code <think> pairs in content and restore copy for reasoning-only turns (#4647)
Two related fixes in the reasoning extraction path of core/messages/utils.ts: 1. splitInlineReasoning's first pass stripped every closed <think>...</think> pair unconditionally, so a message that discusses the tag literally in markdown inline code (e.g. "Wrap your reasoning in `<think>...</think>`") had its code span hollowed out and the inner text shipped to the Reasoning panel. The streaming pass already guards backtick-adjacent openers; apply the same guard to the closed-pair pass so both passes agree on what counts as literal tag talk. 2. getAssistantTurnCopyData fell back to reasoning via `content ?? reasoning`, but extractContentFromMessage never returns null, so the fallback was dead code and a reasoning-only turn (e.g. stopped mid-thinking) rendered no copy button at all - inconsistent with getMessageCopyData, which does copy reasoning in that case. Use the same empty-string check it uses.
This commit is contained in:
parent
da282811b8
commit
91d7f4e4fa
@ -431,8 +431,13 @@ export function getAssistantTurnCopyData(
|
||||
.reverse()
|
||||
.filter((message) => message.type === "ai")
|
||||
.map((message) => {
|
||||
// extractContentFromMessage never returns null, so fall back to
|
||||
// reasoning on empty text (same rule as getMessageCopyData) —
|
||||
// otherwise a reasoning-only turn loses its copy button entirely.
|
||||
const content = extractContentFromMessage(message);
|
||||
return content ?? extractReasoningContentFromMessage(message) ?? "";
|
||||
return content.length > 0
|
||||
? content
|
||||
: (extractReasoningContentFromMessage(message) ?? "");
|
||||
})
|
||||
.find((content) => content.length > 0) ?? null
|
||||
);
|
||||
@ -483,14 +488,23 @@ function splitInlineReasoning(content: string): InlineReasoningSplit {
|
||||
const reasoningParts: string[] = [];
|
||||
|
||||
// First pass: strip every fully closed `<think>...</think>` pair and
|
||||
// collect its body as reasoning.
|
||||
let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
// collect its body as reasoning. A pair whose opener sits right after a
|
||||
// backtick is the model talking about the tag literally inside markdown
|
||||
// inline code (same guard as the streaming pass below) — leave it in the
|
||||
// rendered content instead of hollowing out the code span.
|
||||
let cleaned = content.replace(
|
||||
THINK_TAG_RE,
|
||||
(match: string, reasoning: string, offset: number) => {
|
||||
if (content[offset - 1] === "`") {
|
||||
return match;
|
||||
}
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
},
|
||||
);
|
||||
|
||||
// Streaming-safe pass: a `<think>` opener whose `</think>` has not arrived
|
||||
// yet means the rest of the chunk is reasoning in flight. Route it into the
|
||||
|
||||
@ -456,6 +456,30 @@ describe("inline <think> tag splitting", () => {
|
||||
expect(hasReasoning(message)).toBe(false);
|
||||
});
|
||||
|
||||
test("a closed <think> pair inside markdown inline code stays in content", () => {
|
||||
// The model talking about the tag literally, e.g. when explaining prompt
|
||||
// engineering. Hollowing the code span out into the reasoning panel
|
||||
// corrupts the visible answer.
|
||||
const message = aiMessage(
|
||||
"Wrap your reasoning in `<think>your reasoning</think>` before answering.",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe(
|
||||
"Wrap your reasoning in `<think>your reasoning</think>` before answering.",
|
||||
);
|
||||
expect(extractReasoningContentFromMessage(message)).toBeNull();
|
||||
expect(hasReasoning(message)).toBe(false);
|
||||
});
|
||||
|
||||
test("a closed <think> pair outside code is still stripped when another sits in inline code", () => {
|
||||
const message = aiMessage(
|
||||
"<think>real reasoning</think>Use `<think>text</think>` markers.",
|
||||
);
|
||||
expect(extractContentFromMessage(message)).toBe(
|
||||
"Use `<think>text</think>` markers.",
|
||||
);
|
||||
expect(extractReasoningContentFromMessage(message)).toBe("real reasoning");
|
||||
});
|
||||
|
||||
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
|
||||
// Simulates the moment the model has emitted the opening backtick and
|
||||
// `<think>` for a literal documentation reference, before the closing
|
||||
@ -713,6 +737,22 @@ test("hides assistant copy data while that turn is streaming", () => {
|
||||
expect(getAssistantTurnCopyData(messages, { isStreaming: true })).toBeNull();
|
||||
});
|
||||
|
||||
test("falls back to reasoning for a reasoning-only assistant turn's copy data", () => {
|
||||
// A turn can end with reasoning but no answer text (e.g. stopped during
|
||||
// thinking). getMessageCopyData already copies the reasoning in that case;
|
||||
// the turn-level copy button must not disappear instead.
|
||||
const messages = [
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { reasoning_content: "the actual reasoning" },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(getAssistantTurnCopyData(messages)).toBe("the actual reasoning");
|
||||
});
|
||||
|
||||
test("marks the latest assistant message as streaming", () => {
|
||||
const messages = [
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user