mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-27 02:16:01 +00:00
fix(frontend): strip unclosed <think> tags from streaming AI content (#3218)
* fix(frontend): strip unclosed <think> tags from streaming AI content During streaming, an opening <think> tag may arrive in one chunk while the matching </think> arrives in a later chunk. The existing splitInlineReasoning regex only matched fully closed pairs, so the mid-flight reasoning was left in message.content and rendered into the chat bubble via the markdown pipeline's rehypeRaw plugin until the closing tag landed. Extend splitInlineReasoning with a second pass: after stripping every closed <think>...</think> pair, route any remaining content from a lone opener to the reasoning slot and leave only the preceding preamble in content. Closed-tag behavior is unchanged. Covers every provider whose stream emits reasoning inline as <think> tags (MiniMax streaming path, MindIE, PatchedChatOpenAI, and any gateway-served DeepSeek/OpenAI-compatible model). * style(frontend): apply prettier formatting to streaming reasoning tests * fix(frontend): skip <think> split for literal think tags in inline code Treats a `<think>` opener immediately preceded by a backtick as part of markdown inline code rather than a streaming reasoning marker. Prevents permanent content truncation when an AI message documents the `<think>` tag literally (e.g. ``Use `<think>` markers``), where the streaming-safe fallback would otherwise route the rest of the answer into the reasoning panel because no `</think>` ever arrives. Adds regression tests for both the post-stream and mid-stream cases.
This commit is contained in:
@@ -266,22 +266,42 @@ export function extractTextFromMessage(message: Message) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const THINK_OPEN_TAG = "<think>";
|
||||
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
|
||||
|
||||
function splitInlineReasoning(content: string) {
|
||||
const reasoningParts: string[] = [];
|
||||
const cleaned = content
|
||||
.replace(THINK_TAG_RE, (_, reasoning: string) => {
|
||||
const normalized = reasoning.trim();
|
||||
if (normalized) {
|
||||
reasoningParts.push(normalized);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.trim();
|
||||
|
||||
// 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 "";
|
||||
});
|
||||
|
||||
// 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
|
||||
// reasoning slot instead of letting it render as message content (the
|
||||
// raw-HTML markdown pipeline would otherwise paint the inner text on
|
||||
// screen until the closing tag lands).
|
||||
//
|
||||
// Skip when the opener sits right after a backtick — that is the model
|
||||
// talking about `<think>` literally inside markdown inline code, not
|
||||
// actually streaming reasoning.
|
||||
const openTagIndex = cleaned.indexOf(THINK_OPEN_TAG);
|
||||
if (openTagIndex !== -1 && cleaned[openTagIndex - 1] !== "`") {
|
||||
const tail = cleaned.slice(openTagIndex + THINK_OPEN_TAG.length).trim();
|
||||
if (tail) {
|
||||
reasoningParts.push(tail);
|
||||
}
|
||||
cleaned = cleaned.slice(0, openTagIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
content: cleaned,
|
||||
content: cleaned.trim(),
|
||||
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user