Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions backend/__tests__/channel/thinkTagParser.test.ts
Original file line number Diff line number Diff line change
@@ -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 <think> block into a thought part', () => {
expect(splitThinkTagsFromText('<think>reasoning</think>final answer')).toEqual([
{ text: 'reasoning', thought: true },
{ text: 'final answer' }
])
})

it('allows whitespace before the leading proxy think block', () => {
expect(splitThinkTagsFromText('\n\n<think>reasoning</think>final answer')).toEqual([
{ text: 'reasoning', thought: true },
{ text: 'final answer' }
])
})

it('parses multiple leading think blocks before the normal answer starts', () => {
expect(splitThinkTagsFromText('<think>first</think>\n<think>second</think>answer')).toEqual([
{ text: 'first\nsecond', thought: true },
{ text: 'answer' }
])
})

it('preserves <think> tags that appear after normal text has started', () => {
expect(splitThinkTagsFromText('before <think>example</think> after')).toEqual([
{ text: 'before <think>example</think> after' }
])
})

it('preserves markdown/code examples instead of folding them', () => {
const markdown = 'Example:\n```xml\n<think>demo</think>\n```'
expect(splitThinkTagsFromText(markdown)).toEqual([{ text: markdown }])
})

it('does not leak partial leading tags while streaming', () => {
const parser = new ThinkTagParser()

expect(parser.process('<th')).toEqual([])
expect(parser.process('ink>reason')).toEqual([{ text: 'reason', thought: true }])
expect(parser.process('</thi')).toEqual([])
expect(parser.process('nk>answer')).toEqual([{ text: 'answer' }])
})

it('does not hide similar but non-tag text forever', () => {
const parser = new ThinkTagParser()

expect(parser.process('<thi')).toEqual([])
expect(parser.process('s is ordinary text')).toEqual([{ text: '<this is ordinary text' }])
})

it('treats an unfinished leading think block as thought content when finalized', () => {
const parser = new ThinkTagParser()

expect(parser.process('<think>reason')).toEqual([{ text: 'reason', thought: true }])
expect(parser.finalize()).toEqual([])
})
})

describe('OpenAI formatter think-tag compatibility', () => {
it('parses non-stream leading content with <think> tags into collapsible thought parts', () => {
const formatter = new OpenAIFormatter()
const response = formatter.parseResponse({
model: 'relay-model',
choices: [
{
message: {
role: 'assistant',
content: '<think>relay reasoning</think>visible answer'
},
finish_reason: 'stop'
}
]
})

expect(response.content.parts).toEqual([
{ text: 'relay reasoning', thought: true },
{ text: 'visible answer' }
])
})

it('leaves non-leading <think> tags in normal text', () => {
const formatter = new OpenAIFormatter()
const response = formatter.parseResponse({
model: 'relay-model',
choices: [
{
message: {
role: 'assistant',
content: 'Here is an example: <think>demo</think>'
},
finish_reason: 'stop'
}
]
})

expect(response.content.parts).toEqual([
{ text: 'Here is an example: <think>demo</think>' }
])
})
})

describe('StreamAccumulator think-tag compatibility', () => {
it('normalizes streamed leading <think> tags before they reach the UI', () => {
const accumulator = new StreamAccumulator('function_call', () => 'tool-id')

expect(accumulator.add({ delta: [{ text: '<thi' }], done: false })).toEqual([])
expect(accumulator.add({ delta: [{ text: 'nk>streamed reasoning' }], done: false })).toEqual([
{ text: 'streamed reasoning', thought: true }
])
expect(accumulator.add({ delta: [{ text: '</think>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 <think> 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: '<think>demo</think>' }], done: true })).toEqual([
{ text: '<think>demo</think>' }
])

expect(accumulator.getContent().parts).toEqual([
{ text: 'Example: <think>demo</think>' }
])
})
})
33 changes: 32 additions & 1 deletion backend/modules/channel/StreamAccumulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<<<TOOL_CALL>>>';
Expand Down Expand Up @@ -92,6 +93,9 @@ export class StreamAccumulator {

/** Prompt 模式下的增量工具解析器 */
private promptToolParser?: IncrementalPromptToolParser;

/** 将内容中的 <think>...</think> 标签流式转换为 thought parts */
private readonly thinkTagParser = new ThinkTagParser();

constructor(
toolMode: ToolMode = 'function_call',
Expand Down Expand Up @@ -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
});
}
}

Expand Down Expand Up @@ -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
});
}
Expand Down
42 changes: 34 additions & 8 deletions backend/modules/channel/formatters/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
ChannelError,
ErrorType
} from '../types';
import { splitThinkTagsFromText } from '../thinkTagParser';

/**
* OpenAI 格式转换器
Expand Down Expand Up @@ -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(函数调用)
Expand Down Expand Up @@ -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. 先将 <think>...</think> 标签转换为 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;
}

/**
Expand Down
Loading