From f1222a03e3916d25c3273b9a4ee4da6dbe77a308 Mon Sep 17 00:00:00 2001 From: godLei6 Date: Wed, 29 Jul 2026 15:19:30 +0000 Subject: [PATCH] fix(kosong): repair inline newlines from Alibaba gateway streams Some models (e.g. Qwen) served through Alibaba token-plan / dashscope gateways emit spurious newlines inside Chinese text at phrase boundaries. These break Markdown list-item lazy continuation and split table rows, so the TUI renders lists and tables as broken plain text. Detect Alibaba gateways by baseUrl and, only for those requests, buffer streamed text deltas and replace non-structural newlines with spaces while preserving real structural breaks (blank lines, headings, list markers, blockquotes, table pipes, code fences, horizontal rules). Non-streaming responses are repaired with the same rules. Other providers are unaffected. Adds unit tests for the helper predicates and full-text repair, plus streaming provider-integration tests covering both Alibaba and non-Alibaba base URLs. --- .../kosong/src/providers/openai-legacy.ts | 146 ++++++++++++++++- .../test/openai-legacy-newline-repair.test.ts | 154 ++++++++++++++++++ 2 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 packages/kosong/test/openai-legacy-newline-repair.test.ts diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index f40e59df7d..9d038a764e 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -59,6 +59,64 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { maxLength: 64, }; +// --------------------------------------------------------------------------- +// Alibaba gateway newline repair +// --------------------------------------------------------------------------- +// 部分模型(如 Qwen)经阿里 token plan / dashscope 网关返回的流式响应,会在 +// 中文行内短语边界插入多余换行符,破坏 Markdown 列表项与表格结构(列表续行 +// 丢失缩进、表格行被拆散)。此处仅对阿里系网关启用修复:把“非结构性换行” +// 替换为空格,保留真正的结构性换行。其他渠道不受影响。 + +/** 判断 baseUrl 是否为阿里系网关(token plan / dashscope 等)。 */ +export function isAlibabaGateway(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + return /aliyuncs\.com|dashscope/i.test(baseUrl); +} + +/** + * 判断两行之间的换行是否为“结构性换行”(应保留)。 + * 结构性换行包括:空行(段落分隔)、后行以块级标记开头(标题/列表/引用/表格/ + * 代码围栏/水平线)、前行是标题。其余视为行内多余换行,应替换为空格。 + */ +export function isStructuralNewline(prevLine: string, nextLine: string): boolean { + if (prevLine === '' || nextLine === '') return true; + if (/^(?:#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|\s*>|\||`{3,}|~{3,})/.test(nextLine)) return true; + if (/^#{1,6}\s/.test(prevLine)) return true; + // 前行是代码围栏(开始或结束)时,其后换行必须保留,否则围栏会与相邻行粘连。 + if (/^(`{3,}|~{3,})/.test(prevLine.trim())) return true; + if (/^\s*([-*_])\s*\1\s*\1/.test(nextLine)) return true; + return false; +} + +/** 非流式完整文本修复:逐行合并非结构性换行,保留代码块与块级结构。 */ +export function repairFullText(text: string): string { + const lines = text.split('\n'); + if (lines.length <= 1) return text; + const result: string[] = [lines[0]!]; + let inCodeBlock = false; + if (/^(`{3,}|~{3,})/.test(lines[0]!.trim())) inCodeBlock = true; + for (let i = 1; i < lines.length; i++) { + const prev = result[result.length - 1]!; + const curr = lines[i]!; + if (inCodeBlock) { + result.push(curr); + if (/^(`{3,}|~{3,})\s*$/.test(curr.trim())) inCodeBlock = false; + continue; + } + if (/^(`{3,}|~{3,})/.test(curr.trim())) { + result.push(curr); + inCodeBlock = true; + continue; + } + if (isStructuralNewline(prev, curr)) { + result.push(curr); + } else { + result[result.length - 1] = prev + ' ' + curr; + } + } + return result.join('\n'); +} + function responseFormatToOpenAI(format: ResponseFormat): Record { if (format.type === 'json_object') { return { type: 'json_object' }; @@ -326,12 +384,18 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { private _finishReason: FinishReason | null = null; private _rawFinishReason: string | null = null; private readonly _iter: AsyncGenerator; + // 阿里网关换行修复状态:仅当 _needsNlRepair 为 true 时使用。 + private readonly _needsNlRepair: boolean; + private _nlBuffer = ''; + private _nlInCodeBlock = false; constructor( response: OpenAI.Chat.ChatCompletion | AsyncIterable, isStream: boolean, reasoningKeyDialect: ReasoningKeyDialect, + needsNlRepair = false, ) { + this._needsNlRepair = needsNlRepair; if (isStream) { this._iter = this._convertStreamResponse( response as AsyncIterable, @@ -345,6 +409,60 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } } + /** + * 对单个 text delta 做行缓冲换行修复。 + * 流式 delta 可能在任意位置切断一行,因此遇到位于 buffer 末尾的换行时先 + * 暂存,等下一个 chunk 到来再判断该换行是否结构性。返回可立即 yield 的文本。 + */ + private _processNlRepair(content: string): string { + this._nlBuffer += content; + let output = ''; + while (true) { + const nlIdx = this._nlBuffer.indexOf('\n'); + if (nlIdx === -1) break; + const before = this._nlBuffer.slice(0, nlIdx); + const after = this._nlBuffer.slice(nlIdx + 1); + // 换行落在 buffer 末尾,下一行尚未到达,暂存待后续 chunk 判断。 + if (after === '') { + this._nlBuffer = before + '\n'; + break; + } + if (this._nlInCodeBlock) { + output += before + '\n'; + if (/^(`{3,}|~{3,})\s*$/.test(before.trim())) this._nlInCodeBlock = false; + this._nlBuffer = after; + continue; + } + if (/^(`{3,}|~{3,})/.test(before.trim())) { + output += before + '\n'; + this._nlInCodeBlock = true; + this._nlBuffer = after; + continue; + } + if (/^(`{3,}|~{3,})/.test(after.trim())) { + output += before + '\n'; + this._nlInCodeBlock = true; + this._nlBuffer = after; + continue; + } + if (isStructuralNewline(before, after)) { + output += before + '\n'; + } else { + output += before + ' '; + } + this._nlBuffer = after; + } + return output; + } + + /** 流结束时 flush 残留 buffer(不再等待后续 chunk)。 */ + private _flushNlRepair(): string | null { + if (this._nlBuffer === '') return null; + const result = this._nlBuffer; + this._nlBuffer = ''; + return result; + } + get id(): string | null { return this._id; } @@ -392,7 +510,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } if (message.content) { - yield { type: 'text', text: message.content } satisfies StreamedMessagePart; + // 阿里网关可能在非流式响应文本中插入行内多余换行,按需修复。 + const text = this._needsNlRepair ? repairFullText(message.content) : message.content; + yield { type: 'text', text } satisfies StreamedMessagePart; } if (message.tool_calls) { @@ -448,7 +568,15 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // text content if (delta.content) { - yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; + if (this._needsNlRepair) { + // 阿里网关行内多余换行修复:经行缓冲判断后产出可立即下发的文本。 + const repaired = this._processNlRepair(delta.content); + if (repaired) { + yield { type: 'text', text: repaired } satisfies StreamedMessagePart; + } + } else { + yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; + } } // tool calls — preserve `index` on every yielded part so the generate @@ -459,6 +587,13 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } } } + // 流结束:flush 行缓冲中残留的文本(含此前暂存的末尾换行)。 + if (this._needsNlRepair) { + const flushed = this._flushNlRepair(); + if (flushed) { + yield { type: 'text', text: flushed } satisfies StreamedMessagePart; + } + } } catch (error: unknown) { throw convertOpenAIError(error); } @@ -640,7 +775,12 @@ export class OpenAILegacyChatProvider implements ChatProvider { createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, options?.signal ? { signal: options.signal } : undefined, )) as unknown as OpenAI.Chat.ChatCompletion | AsyncIterable; - return new OpenAILegacyStreamedMessage(response, this._stream, this._reasoningKeyDialect); + return new OpenAILegacyStreamedMessage( + response, + this._stream, + this._reasoningKeyDialect, + isAlibabaGateway(this._baseUrl), + ); } catch (error: unknown) { throw convertOpenAIError(error); } diff --git a/packages/kosong/test/openai-legacy-newline-repair.test.ts b/packages/kosong/test/openai-legacy-newline-repair.test.ts new file mode 100644 index 0000000000..44b0c424b9 --- /dev/null +++ b/packages/kosong/test/openai-legacy-newline-repair.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { StreamedMessagePart, TextPart } from '#/message'; +import { + isAlibabaGateway, + isStructuralNewline, + OpenAILegacyChatProvider, + repairFullText, +} from '#/providers/openai-legacy'; + +const ALIBABA_BASE_URL = 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'; + +describe('isAlibabaGateway', () => { + it('matches token-plan maas gateway', () => { + expect(isAlibabaGateway(ALIBABA_BASE_URL)).toBe(true); + }); + it('matches dashscope gateway', () => { + expect(isAlibabaGateway('https://dashscope.aliyuncs.com/compatible-mode/v1')).toBe(true); + }); + it('does not match openai', () => { + expect(isAlibabaGateway('https://api.openai.com/v1')).toBe(false); + }); + it('does not match deepseek', () => { + expect(isAlibabaGateway('https://api.deepseek.com')).toBe(false); + }); + it('returns false for undefined', () => { + expect(isAlibabaGateway(undefined)).toBe(false); + }); +}); + +describe('isStructuralNewline', () => { + it('empty prev or next line is structural', () => { + expect(isStructuralNewline('', 'hello')).toBe(true); + expect(isStructuralNewline('hello', '')).toBe(true); + }); + it('next line heading / list / quote / table / fence is structural', () => { + expect(isStructuralNewline('text', '## Title')).toBe(true); + expect(isStructuralNewline('text', '- item')).toBe(true); + expect(isStructuralNewline('text', ' * item')).toBe(true); + expect(isStructuralNewline('text', '1. item')).toBe(true); + expect(isStructuralNewline('text', '> quote')).toBe(true); + expect(isStructuralNewline('text', '| col |')).toBe(true); + expect(isStructuralNewline('text', '```ts')).toBe(true); + }); + it('prev line heading is structural', () => { + expect(isStructuralNewline('## Title', 'Some text')).toBe(true); + }); + it('horizontal rule is structural', () => { + expect(isStructuralNewline('text', '---')).toBe(true); + expect(isStructuralNewline('text', '***')).toBe(true); + }); + it('plain inline continuation is NOT structural', () => { + expect(isStructuralNewline('- 裁剪决策已写入文件,', '后续同步遇到新增')).toBe(false); + expect(isStructuralNewline('| ur/ee2/saas.git |', '✓ cacd2b |')).toBe(false); + }); +}); + +describe('repairFullText', () => { + it('leaves single line unchanged', () => { + expect(repairFullText('hello world')).toBe('hello world'); + }); + it('merges list item continuation lines', () => { + expect(repairFullText('- 裁剪决策已写入文件,\n后续同步遇到新增\n应用可直接查阅')) + .toBe('- 裁剪决策已写入文件, 后续同步遇到新增 应用可直接查阅'); + }); + it('merges a broken table row', () => { + const input = '| 仓库 | 状态 |\n|------|------|\n| ur/ee2/saas.git |\n✓ cacd2b |'; + const expected = '| 仓库 | 状态 |\n|------|------|\n| ur/ee2/saas.git | ✓ cacd2b |'; + expect(repairFullText(input)).toBe(expected); + }); + it('preserves normal list items', () => { + const input = '- item1\n- item2\n- item3'; + expect(repairFullText(input)).toBe(input); + }); + it('preserves paragraph separation', () => { + expect(repairFullText('para1\n\npara2')).toBe('para1\n\npara2'); + }); + it('preserves heading followed by body', () => { + expect(repairFullText('## Title\nSome text')).toBe('## Title\nSome text'); + }); + it('preserves fenced code block', () => { + const input = '```\nline1\nline2\n```'; + expect(repairFullText(input)).toBe(input); + }); + it('preserves code block embedded in text', () => { + const input = 'before\n```js\nconst a = 1\nconst b = 2\n```\nafter'; + expect(repairFullText(input)).toBe(input); + }); + it('preserves ordered list', () => { + const input = '1. first\n2. second\n3. third'; + expect(repairFullText(input)).toBe(input); + }); + it('handles mixed heading and broken table', () => { + const input = '## 推送结果\n\n| 仓库 | 状态 |\n|------|------|\n| saas.git |\n✓ done |'; + const expected = '## 推送结果\n\n| 仓库 | 状态 |\n|------|------|\n| saas.git | ✓ done |'; + expect(repairFullText(input)).toBe(expected); + }); +}); + +describe('Alibaba gateway newline repair (provider integration)', () => { + function mockStream(provider: OpenAILegacyChatProvider, deltas: string[]): void { + async function* mockedStream(): AsyncIterable> { + for (const content of deltas) { + yield { id: 'c1', choices: [{ index: 0, delta: { content } }] }; + } + yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }; + } + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + } + + async function collectText(provider: OpenAILegacyChatProvider): Promise { + const stream = await provider.generate('', [], [ + { role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }, + ]); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + return parts.filter((p): p is TextPart => p.type === 'text').map((p) => p.text).join(''); + } + + it('repairs inline newlines for Alibaba baseUrl (streaming)', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'qwen3.8-max-preview', + apiKey: 'test-key', + baseUrl: ALIBABA_BASE_URL, + stream: true, + }); + mockStream(provider, ['- 裁剪决策已写入文件,\n', '后续同步遇到新增\n', '应用可直接查阅']); + expect(await collectText(provider)).toBe('- 裁剪决策已写入文件, 后续同步遇到新增 应用可直接查阅'); + }); + + it('does NOT repair for non-Alibaba baseUrl (streaming)', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'gpt-4.1', + apiKey: 'test-key', + baseUrl: 'https://api.openai.com/v1', + stream: true, + }); + mockStream(provider, ['- 裁剪决策已写入文件,\n', '后续同步遇到新增\n', '应用可直接查阅']); + expect(await collectText(provider)).toBe('- 裁剪决策已写入文件,\n后续同步遇到新增\n应用可直接查阅'); + }); + + it('preserves structural newlines for Alibaba baseUrl (streaming)', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'qwen3.8-max-preview', + apiKey: 'test-key', + baseUrl: ALIBABA_BASE_URL, + stream: true, + }); + mockStream(provider, ['- item1\n', '- item2\n', '- item3']); + expect(await collectText(provider)).toBe('- item1\n- item2\n- item3'); + }); +});