mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 05:18:53 +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()
|
.reverse()
|
||||||
.filter((message) => message.type === "ai")
|
.filter((message) => message.type === "ai")
|
||||||
.map((message) => {
|
.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);
|
const content = extractContentFromMessage(message);
|
||||||
return content ?? extractReasoningContentFromMessage(message) ?? "";
|
return content.length > 0
|
||||||
|
? content
|
||||||
|
: (extractReasoningContentFromMessage(message) ?? "");
|
||||||
})
|
})
|
||||||
.find((content) => content.length > 0) ?? null
|
.find((content) => content.length > 0) ?? null
|
||||||
);
|
);
|
||||||
@ -483,14 +488,23 @@ function splitInlineReasoning(content: string): InlineReasoningSplit {
|
|||||||
const reasoningParts: string[] = [];
|
const reasoningParts: string[] = [];
|
||||||
|
|
||||||
// First pass: strip every fully closed `<think>...</think>` pair and
|
// First pass: strip every fully closed `<think>...</think>` pair and
|
||||||
// collect its body as reasoning.
|
// collect its body as reasoning. A pair whose opener sits right after a
|
||||||
let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
// backtick is the model talking about the tag literally inside markdown
|
||||||
const normalized = reasoning.trim();
|
// inline code (same guard as the streaming pass below) — leave it in the
|
||||||
if (normalized) {
|
// rendered content instead of hollowing out the code span.
|
||||||
reasoningParts.push(normalized);
|
let cleaned = content.replace(
|
||||||
}
|
THINK_TAG_RE,
|
||||||
return "";
|
(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
|
// 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
|
// 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);
|
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", () => {
|
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
|
||||||
// Simulates the moment the model has emitted the opening backtick and
|
// Simulates the moment the model has emitted the opening backtick and
|
||||||
// `<think>` for a literal documentation reference, before the closing
|
// `<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();
|
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", () => {
|
test("marks the latest assistant message as streaming", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user