From 97f7b2e4a39ef57f198a857dac1d77304e495f5b Mon Sep 17 00:00:00 2001 From: Lianye Date: Tue, 28 Apr 2026 20:02:55 +0800 Subject: [PATCH] fix(openai): support leading think tags in content --- .../__tests__/channel/thinkTagParser.test.ts | 137 +++++++++++++ backend/modules/channel/StreamAccumulator.ts | 33 ++- backend/modules/channel/formatters/openai.ts | 42 +++- backend/modules/channel/thinkTagParser.ts | 189 ++++++++++++++++++ 4 files changed, 392 insertions(+), 9 deletions(-) create mode 100644 backend/__tests__/channel/thinkTagParser.test.ts create mode 100644 backend/modules/channel/thinkTagParser.ts diff --git a/backend/__tests__/channel/thinkTagParser.test.ts b/backend/__tests__/channel/thinkTagParser.test.ts new file mode 100644 index 00000000..99023a4f --- /dev/null +++ b/backend/__tests__/channel/thinkTagParser.test.ts @@ -0,0 +1,137 @@ +import { StreamAccumulator } from '../../modules/channel/StreamAccumulator' +import { OpenAIFormatter } from '../../modules/channel/formatters/openai' +import { ThinkTagParser, splitThinkTagsFromText } from '../../modules/channel/thinkTagParser' + +describe('leading think tag extraction', () => { + it('splits a leading block into a thought part', () => { + expect(splitThinkTagsFromText('reasoningfinal answer')).toEqual([ + { text: 'reasoning', thought: true }, + { text: 'final answer' } + ]) + }) + + it('allows whitespace before the leading proxy think block', () => { + expect(splitThinkTagsFromText('\n\nreasoningfinal answer')).toEqual([ + { text: 'reasoning', thought: true }, + { text: 'final answer' } + ]) + }) + + it('parses multiple leading think blocks before the normal answer starts', () => { + expect(splitThinkTagsFromText('first\nsecondanswer')).toEqual([ + { text: 'first\nsecond', thought: true }, + { text: 'answer' } + ]) + }) + + it('preserves tags that appear after normal text has started', () => { + expect(splitThinkTagsFromText('before example after')).toEqual([ + { text: 'before example after' } + ]) + }) + + it('preserves markdown/code examples instead of folding them', () => { + const markdown = 'Example:\n```xml\ndemo\n```' + expect(splitThinkTagsFromText(markdown)).toEqual([{ text: markdown }]) + }) + + it('does not leak partial leading tags while streaming', () => { + const parser = new ThinkTagParser() + + expect(parser.process('reason')).toEqual([{ text: 'reason', thought: true }]) + expect(parser.process('answer')).toEqual([{ text: 'answer' }]) + }) + + it('does not hide similar but non-tag text forever', () => { + const parser = new ThinkTagParser() + + expect(parser.process(' { + const parser = new ThinkTagParser() + + expect(parser.process('reason')).toEqual([{ text: 'reason', thought: true }]) + expect(parser.finalize()).toEqual([]) + }) +}) + +describe('OpenAI formatter think-tag compatibility', () => { + it('parses non-stream leading content with tags into collapsible thought parts', () => { + const formatter = new OpenAIFormatter() + const response = formatter.parseResponse({ + model: 'relay-model', + choices: [ + { + message: { + role: 'assistant', + content: 'relay reasoningvisible answer' + }, + finish_reason: 'stop' + } + ] + }) + + expect(response.content.parts).toEqual([ + { text: 'relay reasoning', thought: true }, + { text: 'visible answer' } + ]) + }) + + it('leaves non-leading tags in normal text', () => { + const formatter = new OpenAIFormatter() + const response = formatter.parseResponse({ + model: 'relay-model', + choices: [ + { + message: { + role: 'assistant', + content: 'Here is an example: demo' + }, + finish_reason: 'stop' + } + ] + }) + + expect(response.content.parts).toEqual([ + { text: 'Here is an example: demo' } + ]) + }) +}) + +describe('StreamAccumulator think-tag compatibility', () => { + it('normalizes streamed leading tags before they reach the UI', () => { + const accumulator = new StreamAccumulator('function_call', () => 'tool-id') + + expect(accumulator.add({ delta: [{ text: 'streamed reasoning' }], done: false })).toEqual([ + { text: 'streamed reasoning', thought: true } + ]) + expect(accumulator.add({ delta: [{ text: 'streamed answer' }], done: true })).toEqual([ + { text: 'streamed answer' } + ]) + + expect(accumulator.getContent().parts).toEqual([ + { text: 'streamed reasoning', thought: true }, + { text: 'streamed answer' } + ]) + }) + + it('does not fold streamed tags after normal text has started', () => { + const accumulator = new StreamAccumulator('function_call', () => 'tool-id') + + expect(accumulator.add({ delta: [{ text: 'Example: ' }], done: false })).toEqual([ + { text: 'Example: ' } + ]) + expect(accumulator.add({ delta: [{ text: 'demo' }], done: true })).toEqual([ + { text: 'demo' } + ]) + + expect(accumulator.getContent().parts).toEqual([ + { text: 'Example: demo' } + ]) + }) +}) diff --git a/backend/modules/channel/StreamAccumulator.ts b/backend/modules/channel/StreamAccumulator.ts index fbedf5e6..038ddd83 100644 --- a/backend/modules/channel/StreamAccumulator.ts +++ b/backend/modules/channel/StreamAccumulator.ts @@ -10,6 +10,7 @@ import type { StreamChunk, StreamUsageMetadata } from './types'; import type { ToolMode } from '../config/configs/base'; import { parseXMLToolCalls } from '../../tools/xmlFormatter'; import { IncrementalPromptToolParser } from '../../tools/promptToolParser'; +import { ThinkTagParser } from './thinkTagParser'; // JSON 工具调用边界标记 const TOOL_CALL_START = '<<>>'; @@ -92,6 +93,9 @@ export class StreamAccumulator { /** Prompt 模式下的增量工具解析器 */ private promptToolParser?: IncrementalPromptToolParser; + + /** 将内容中的 ... 标签流式转换为 thought parts */ + private readonly thinkTagParser = new ThinkTagParser(); constructor( toolMode: ToolMode = 'function_call', @@ -195,10 +199,24 @@ export class StreamAccumulator { } } + if (chunk.done) { + const trailingThinkParts = this.thinkTagParser.finalize(); + for (const part of trailingThinkParts) { + this.addPart(part, { + skipThinkTagParser: true, + visibleDelta + }); + } + } + if (chunk.done && this.promptToolParser) { const trailingParts = this.promptToolParser.flushIncompleteAsText(); for (const part of trailingParts) { - this.addPart(part, { skipPromptParser: true, visibleDelta }); + this.addPart(part, { + skipPromptParser: true, + skipThinkTagParser: true, + visibleDelta + }); } } @@ -252,14 +270,27 @@ export class StreamAccumulator { part: ContentPart, options?: { skipPromptParser?: boolean; + skipThinkTagParser?: boolean; visibleDelta?: ContentPart[]; } ): void { + if (!options?.skipThinkTagParser && part.text && !part.thought) { + const parsedThinkParts = this.thinkTagParser.process(part.text); + for (const parsedPart of parsedThinkParts) { + this.addPart(parsedPart, { + ...options, + skipThinkTagParser: true + }); + } + return; + } + if (!options?.skipPromptParser && this.promptToolParser && part.text && !part.thought) { const parsedParts = this.promptToolParser.appendText(part.text); for (const parsedPart of parsedParts) { this.addPart(parsedPart, { skipPromptParser: true, + skipThinkTagParser: true, visibleDelta: options?.visibleDelta }); } diff --git a/backend/modules/channel/formatters/openai.ts b/backend/modules/channel/formatters/openai.ts index 05785e75..785a4657 100644 --- a/backend/modules/channel/formatters/openai.ts +++ b/backend/modules/channel/formatters/openai.ts @@ -50,6 +50,7 @@ import type { ChannelError, ErrorType } from '../types'; +import { splitThinkTagsFromText } from '../thinkTagParser'; /** * OpenAI 格式转换器 @@ -623,7 +624,10 @@ export class OpenAIFormatter extends BaseFormatter { private parseResponseFunctionCallMode(message: any, parts: ContentPart[]): ContentPart[] { // 添加主要内容 if (message.content) { - parts.push({ text: message.content }); + parts.push(...splitThinkTagsFromText(message.content as string).filter(part => { + if (!('text' in part)) return true; + return (part.text || '').trim().length > 0; + })); } // 处理 tool_calls(函数调用) @@ -663,16 +667,38 @@ export class OpenAIFormatter extends BaseFormatter { const contentText = message.content as string; - const promptMode = detectPromptToolMode(contentText); - if (!promptMode) { - if (contentText.trim()) { - parts.push({ text: contentText }); + return [...parts, ...this.parseTextContentParts(contentText)]; + } + + /** + * 解析普通文本内容: + * 1. 先将 ... 标签转换为 thought parts; + * 2. 再仅对非思考文本检测 XML/JSON 工具调用。 + */ + private parseTextContentParts(contentText: string): ContentPart[] { + const parsedParts: ContentPart[] = []; + + for (const part of splitThinkTagsFromText(contentText)) { + if (!('text' in part) || !(part.text || '').trim()) { + continue; } - return parts; + + if (part.thought) { + parsedParts.push(part); + continue; + } + + const promptMode = detectPromptToolMode(part.text || ''); + if (!promptMode) { + parsedParts.push({ text: part.text }); + continue; + } + + const extracted = extractPromptToolParts(part.text || '', promptMode, { flushIncompleteTailAsText: true }); + parsedParts.push(...extracted.parts); } - const extracted = extractPromptToolParts(contentText, promptMode, { flushIncompleteTailAsText: true }); - return [...parts, ...extracted.parts]; + return parsedParts; } /** diff --git a/backend/modules/channel/thinkTagParser.ts b/backend/modules/channel/thinkTagParser.ts new file mode 100644 index 00000000..66f4d3b7 --- /dev/null +++ b/backend/modules/channel/thinkTagParser.ts @@ -0,0 +1,189 @@ +/** + * Utilities for extracting a leading ... block from model text. + * + * Some OpenAI-compatible proxy services do not expose reasoning_content as a + * dedicated field. Instead they prepend it to content as: + * + * reasoning...final answer... + * + * LimCode's UI already knows how to render ContentPart objects with + * `thought: true` as collapsible thinking blocks. This parser converts only + * those leading proxy-style tags into that internal representation. Later + * tags in Markdown/code/plain text are preserved as ordinary text. + */ + +import type { ContentPart } from '../conversation/types'; + +const OPENING_THINK_TAG = ''; +const CLOSING_THINK_TAG = ''; + +type ParserState = 'beforePrefix' | 'insideThink' | 'afterThink' | 'plainText'; + +function startsWithTag(source: string, tag: string): boolean { + return source.toLowerCase().startsWith(tag); +} + +function isPotentialTagPrefix(source: string, tag: string): boolean { + return source.length > 0 && tag.startsWith(source.toLowerCase()); +} + +/** + * Case-insensitive indexOf for fixed ASCII tags. + */ +function indexOfTag(source: string, tag: string): number { + return source.toLowerCase().indexOf(tag); +} + +/** + * Returns the longest suffix length of `source` that may be the beginning of + * `tag`. Used to avoid leaking partial streaming tags such as " 0; len--) { + if (tag.startsWith(lowerSource.slice(lowerSource.length - len))) { + return len; + } + } + + return 0; +} + +function leadingWhitespaceLength(text: string): number { + return text.match(/^\s*/)?.[0].length ?? 0; +} + +function appendTextPart(parts: ContentPart[], text: string, thought: boolean): void { + if (!text) return; + + const lastPart = parts[parts.length - 1]; + if ( + lastPart && + 'text' in lastPart && + !lastPart.functionCall && + (lastPart.thought === true) === thought + ) { + lastPart.text = `${lastPart.text || ''}${text}`; + return; + } + + parts.push(thought ? { text, thought: true } : { text }); +} + +/** + * Streaming-safe leading tag extractor. + * + * It only parses blocks before the first non-whitespace normal answer + * token. Once normal text starts, the parser switches to plainText mode and + * preserves any later tags exactly as text, which prevents Markdown or + * code examples from being folded accidentally. + */ +export class ThinkTagParser { + private buffer = ''; + private state: ParserState = 'beforePrefix'; + private hasParsedThinkPrefix = false; + + process(text: string): ContentPart[] { + if (!text) return []; + + this.buffer += text; + return this.drain(false); + } + + finalize(): ContentPart[] { + return this.drain(true); + } + + reset(): void { + this.buffer = ''; + this.state = 'beforePrefix'; + this.hasParsedThinkPrefix = false; + } + + private drain(final: boolean): ContentPart[] { + const parts: ContentPart[] = []; + + while (this.buffer.length > 0) { + if (this.state === 'plainText') { + appendTextPart(parts, this.buffer, false); + this.buffer = ''; + break; + } + + if (this.state === 'insideThink') { + const closingIndex = indexOfTag(this.buffer, CLOSING_THINK_TAG); + + if (closingIndex >= 0) { + appendTextPart(parts, this.buffer.slice(0, closingIndex), true); + this.buffer = this.buffer.slice(closingIndex + CLOSING_THINK_TAG.length); + this.state = 'afterThink'; + continue; + } + + if (final) { + appendTextPart(parts, this.buffer, true); + this.buffer = ''; + this.state = 'afterThink'; + break; + } + + const keepLen = potentialTagPrefixLength(this.buffer, CLOSING_THINK_TAG); + const emitLen = this.buffer.length - keepLen; + + if (emitLen > 0) { + appendTextPart(parts, this.buffer.slice(0, emitLen), true); + this.buffer = this.buffer.slice(emitLen); + } + + break; + } + + const whitespaceLen = leadingWhitespaceLength(this.buffer); + const afterWhitespace = this.buffer.slice(whitespaceLen); + + if (afterWhitespace.length === 0) { + if (final && !this.hasParsedThinkPrefix) { + appendTextPart(parts, this.buffer, false); + } + if (final) this.buffer = ''; + break; + } + + if (startsWithTag(afterWhitespace, OPENING_THINK_TAG)) { + // Drop whitespace before the first leading think block, but keep + // separators between multiple leading think blocks as thought + // content so separate reasoning chunks do not get glued together. + if (this.hasParsedThinkPrefix && whitespaceLen > 0) { + appendTextPart(parts, this.buffer.slice(0, whitespaceLen), true); + } + this.buffer = afterWhitespace.slice(OPENING_THINK_TAG.length); + this.state = 'insideThink'; + this.hasParsedThinkPrefix = true; + continue; + } + + if (!final && isPotentialTagPrefix(afterWhitespace, OPENING_THINK_TAG)) { + // Wait for more chunks before deciding whether this is really a + // leading tag or ordinary text starting with similar chars. + break; + } + + appendTextPart(parts, this.buffer, false); + this.buffer = ''; + this.state = 'plainText'; + break; + } + + return parts; + } +} + +/** + * Non-stream helper for complete content strings. + */ +export function splitThinkTagsFromText(text: string): ContentPart[] { + const parser = new ThinkTagParser(); + return [...parser.process(text), ...parser.finalize()]; +}