Skip to content

fix(chat): normalize qwen38 leaked thinking and whitespace text parts - #145

Open
grapeot wants to merge 5 commits into
masterfrom
fix/whitespace-text-parts
Open

fix(chat): normalize qwen38 leaked thinking and whitespace text parts#145
grapeot wants to merge 5 commits into
masterfrom
fix/whitespace-text-parts

Conversation

@grapeot

@grapeot grapeot commented Aug 23, 2026

Copy link
Copy Markdown
Owner

背景

iOS 客户端用 qwen38(RadixArk/Qwen3.8-27B-NVFP4,SGLang --reasoning-parser qwen3)时,回复出现大量多余空行,且偶见 thinking 内容 / 字面 think 标签泄漏进正文。Web / Android 渲染同一份 server 数据均正常——根因在 iOS 渲染层缺归一化。完整排查记录(含 SGLang reasoning parser 源码交叉验证与数据核验)见 docs/qwen38_rendering_fix.md。

根因

  • 多余空行:qwen38 的 text part 普遍带前导/尾随换行,tool-only 轮次还会产生纯空白 text part;iOS 之前原样渲染(纯空白 part 走原生 Text,每个换行变成可见空行)。
  • thinking 泄漏:SGLang 的 Qwen3Detector 是一次性状态机——只剥第一个 think 开始标签;reasoning 期间遇到的第一个 think 结束标签(包括模型思考时自己写出的字面结束标签)会永久结束 reasoning,其余 thinking + 真结束标签全部进入 content 流,被 OpenCode 原样存成 text part。本 session 189 条 assistant 消息中 33% 的 text part 含泄漏标签,干净 session 为 0——与“模型思考中讨论这些标签”的概率直接相关。

改动

  1. 第一批buildAssistantBlocks 跳过纯空白 text part;markdownText 渲染前 trim。
  2. 第二批:新增 MessageRowView.normalizedText(fence-aware:``` / ~~~ 围栏识别,闭围栏行必须是纯围栏字符且长度不小于开围栏),只作用于 assistant text part:
    • 从文本开头切到最后一个独立成行的 think 结束标签(含)——之前整体视为泄漏的 thinking 尾巴;
    • 剩余部分若还有独立成行的 think 开始标签(流式截断),从该行切到末尾;
    • trim 首尾空白;行内标签与代码围栏内标签一律不动。
      应用于渲染(markdownText)、复制/划词(copyableText)、块构建(buildAssistantBlocks)。用户消息永不归一化,仅修掉纯空白 part 在用户气泡里留下 20pt padding 的缺口。
  3. 新增 9 个单测 MessageThinkLeakNormalizationTests,含顺序回归(尾巴里夹字面 open/close 示例块时,仍只保留最后一个 close 之后的内容)。
  4. docs/qwen38_rendering_fix.md:排查记录、SGLang 源码交叉验证、最终方案与取舍。

验证

  • 定向测试:MessageRenderingHeuristicTests + MessageThinkLeakNormalizationTests 共 20/20 通过(iPhone 17 Pro 模拟器)。
  • 全量 xcodebuild build(同一模拟器):BUILD SUCCEEDED。
  • 全量 suite 有 2 个既有 UI 测试失败(testSettingsShowsThreeSpeechStrategiesAndRealtimePrompt、testToolCardsFixtureRendersFileCardsAndMergedToolCalls),与本改动无关。

已知取舍

模型若在普通回复里用独立成行标签引用示例 thinking 块(不在代码围栏内),从文本开头到该示例 close 的内容会被切掉——该 case 已明确不在本次 concern 范围;代码围栏内的示例不受影响。

注:本分支不包含 chore(chat): log part classification in loadMessages(排查期临时 debug log,rebase 时已剔除)。

Add Qwen 3.8 27B (NVFP4, 512k context, YaRN) as the last model preset
in the model picker. The model is served via SGLang on none:8002
via the qwen38 provider configured in opencode.json.
qwen38 (SGLang) emits text parts with leading/trailing newlines and pure-newline parts, which the plain-Text fallback rendered as visible blank lines. Filter non-renderable text parts in buildAssistantBlocks and trim in markdownText so empty parts collapse like the web/android renderers.
Assistant text parts can carry thinking leaked by the one-shot qwen3
reasoning parser in SGLang: the first think-close token the model emits
ends reasoning early, so the remaining thinking plus the real close
token land in the content stream. Add a fence-aware normalizedText()
that cuts the leaked tail at the last standalone-line close, drops a
dangling standalone open, and trims. Applied to assistant rendering,
copy/selection text, and block building; user messages stay verbatim
(whitespace-only parts are still skipped there, closing the padding gap).

Adds 9 unit tests in MessageThinkLeakNormalizationTests; targeted suite
is 20/20 green on the iOS simulator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant