Skip to content

feat(reply-read-aloud): project speakable reply text - #557

Open
AtomsH4 wants to merge 32 commits into
CherryHQ:v0.2from
AtomsH4:codex/message-actions/read-aloud-projection
Open

feat(reply-read-aloud): project speakable reply text#557
AtomsH4 wants to merge 32 commits into
CherryHQ:v0.2from
AtomsH4:codex/message-actions/read-aloud-projection

Conversation

@AtomsH4

@AtomsH4 AtomsH4 commented Aug 17, 2026

Copy link
Copy Markdown

Summary

  • project speakable text from completed assistant messages without mutating persisted message data
  • exclude non-spoken parts, linked citations, URLs, code blocks, and unsupported content
  • scan equal-length backtick runs, structural Markdown links, balanced URL delimiters, and nested list indentation in linear passes
  • preserve ambiguous numeric bracket literals while omitting unambiguous linked citations
  • represent mixed original and block-translated content without forcing one language across the whole message
  • conservatively preserve Markdown literals while removing only paired inline delimiters
  • wait for the final image-aspect-ratio query state in the paintings hook test introduced by the latest base

Why

This extracts the pure read-aloud projection layer from #518 so it can be reviewed and merged independently of the toolbar and native TTS lifecycle. It addresses review feedback about language selection, Markdown boundary handling, URL projection, and destructive delimiter cleanup.

The paintings test adjustment follows the two-stage query contract introduced by #594 and prevents the full CI suite from asserting before the image ratio query resolves.

Validation

  • pnpm test:app -- src/frontend/features/chat/workspace/utils/__tests__/projectAssistantMessageReadAloud.test.ts src/frontend/features/chat/workspace/utils/__tests__/projectMarkdownInlineForSpeech.test.ts src/frontend/features/chat/workspace/utils/__tests__/resolveAssistantReadAloudLanguage.test.ts src/frontend/features/paintings/hooks/__tests__/usePaintings.test.tsx --runInBand — 102 tests passed
  • pnpm format:check
  • pnpm typecheck
  • pnpm lint — 0 errors; existing repository warnings only

This PR has no dependency on the toolbar PR. Native TTS and UI integration will follow only after both foundations merge.

@AtomsH4
AtomsH4 marked this pull request as ready for review August 17, 2026 01:50
@eeee0717

Copy link
Copy Markdown
Collaborator

这个 PR 不受 #565(已合并进 v0.2)影响——五个文件都是 messageRow/utils/ 下的纯函数,现在仍然是 CLEAN。两件事供后续参考。

朗读按钮的接法。 #565 给默认助手消息加了组合槽位:AssistantMessage 从模块根导出,children 渲染在正文之后。所以后续的 native TTS runtime PR 不需要再往 MessageList 加任何 prop——朗读按钮和复制、重新生成一起,放进 chat feature 自己的工具栏组件,朗读状态(哪条在播、是否 loading)走同一个 feature-owned Context:

<AssistantMessage message={message}>
  <AssistantMessageToolbar message={message} />
</AssistantMessage>

需要注意的是 renderAssistantMessage 必须是稳定引用:LegendList 只经 itemKey/data/extraData 刷新已挂载行,换 renderItem 身份推不进新状态。朗读状态变化频率高,这一点比复制反馈更要紧——它只能从 Context 或外部 store 在 leaf 里读,不能靠重建 render prop 往下推。我在 #556 也写了同样的说明。

投影函数的归属(非阻塞,可以合并后再动)。projectAssistantMessageReadAloudresolveAssistantReadAloudLanguage 目前放在共享的 messagePresentation 里,但使用者只有 chat 一个。src/frontend/components/README.md 的规则是「数独立 owner,不数 import 语句」——messagePresentation 自己也是等到 painting 成为第二个 owner 才抽出来的。既然朗读按钮按上面的方向本来就在 chat feature 里,这套投影跟着放进去会更一致;等真出现第二个 owner 再抽回共享模块。#556copyAssistantMessageText 是同样的情况,我在那边也提了一句。

投影本身的逻辑我这次没有重新逐行看,#518 review 提的两个问题——所有拉丁字母语言被判成 en-US*/_ 无条件删除破坏 snake_case2 * 3——从 PR 描述看方向是对的:不确定的脚本交给系统默认语音,只移除成对的行内定界符。

@eeee0717

Copy link
Copy Markdown
Collaborator

完整 review 了当前 head(7cdc3026)。先说好消息:两个历史缺陷确认真修了,而且是实测确认,不是看描述。

  • 语言识别:Bonjour, comment allez-vous ? / ¿Cómo estás? / Straße / Xin chào / 你好世界 / 东京大学 全部返回 undefined,こんにちはja-JP안녕하세요ko-KR。旧的「拉丁语系全给 en-US、纯汉字给 zh-CN」彻底消失。
  • Markdown 清洗:snake_casea_b_c_d2 * 3 = 62 ** 8\*literal\* 全部完好。实现换成了真正的 CommonMark 定界符配对,拿 33 条规范用例对拍,31 条逐字一致,2 条差异是有意取舍且有测试覆盖。
  • 上轮 review 说的「测试把错误结果钉成期望值」没有复发:把语言判定改回旧缺陷、把清洗改回「全局删 * _」,分别有 4 条和 15 条测试变红。
  • 无隐私泄漏:构造了含 reasoning / data-code / data-compact / data-error / data-video / file / source-* / tool-* / dynamic-tool 的完整 parts 数组,每个埋哨兵串,输出里哨兵零出现。

不过实测中发现三个建议合并前处理的问题。

P1

1. 链接正则灾难性回溯,能冻住 JS 线程。 projectAssistantMessageReadAloud.ts:54(同形问题在 :51:53)。

(?:\\.|[^)])* 的两个分支在反斜杠上重叠——\\. 吃 2 个字符,[^)] 吃 1 个再吃 1 个,到达同一位置有两条路径,匹配失败时路径数 2^k。[^)] 还能吃换行,所以搜索范围是整条消息。实测曲线:

反斜杠 30 个(输入 34 字符):    8ms
反斜杠 34 个(输入 38 字符):   43ms
反斜杠 36 个(输入 40 字符):  117ms
反斜杠 38 个(输入 42 字符):  334ms
反斜杠 40 个(输入 44 字符):  943ms      每 +2 个翻约 3 倍

触发条件很宽:消息里出现任意 [标签](,其后到消息结尾没有 ),且后面跟着一串反斜杠。行内 LaTeX(\alpha \beta \gamma)、Windows 路径、正则示例都算。注意 \(...\) 的移除在 :59,晚于 :54,所以这条正则跑的时候行内 LaTeX 还在。内容由模型输出控制,而这是同步跑在 JS 线程上的纯函数。

修复是把两个分支改成互斥:

-  text = text.replace(/!\[[^\]]*\]\((?:\\.|[^)])*\)/g, '');
+  text = text.replace(/!\[[^\]]*\]\((?:[^()\\]|\\.)*\)/g, '');
   text = text.replace(/!\[[^\]]*\]\[[^\]]*\]/g, '');
-  text = text.replace(/\[\d+\]\((?:\\.|[^)])*\)/g, '');
-  text = text.replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, '$1');
+  text = text.replace(/\[\d+\]\((?:[^()\\]|\\.)*\)/g, '');
+  text = text.replace(/\[([^\]]+)\]\((?:[^()\\]|\\.)*\)/g, '$1');

我把这个改动打进去跑了 PR 自带的 50 个测试,全过、行为不变,同一 payload 从 943ms 降到 0.00ms。

2. 行内代码里的标识符仍然被吃掉——正是本 PR 声称修掉的缺陷,只是换到了代码跨度里。 :66 先剥掉反引号,:69 才做强调投影,于是代码跨度里的内容被当成 Markdown 强调处理。实测:

"Call `train_test_split` then `__init__` and `a*b*c`."
  → "Call train_test_split then init and abc."
"Path `C:\Users\_temp` is used."
  → "Path C:\Users_temp is used."
"Bold **outside** and `**inside**` code."
  → "Bold outside and inside code."

代码跨度恰恰是标识符密度最高的地方。裸文本里的 2*3*4 被保护了,代码块里的 a*b*c 反而被吃掉,这个不一致挺刺眼。现有两个测试看起来覆盖了这里,但用的代码内容都不含定界符(`answer``inline value`),属于典型的假安全感。修法是把代码跨度先抽成占位符,投影后回填。

3. $ 当货币符号时会静默删掉整段文字。 :63/\$([^$\n]+)\$/g 没有 flanking 检查,一行里有两个 $ 就当行内数学。实测:

"It costs $50 for the C:\path option and $70 in total."
  → "It costs 70 in total."          ← 整个从句静默消失
"Budget is $5 and the premium tier is $10 per month."
  → "Budget is 5 and the premium tier is 10 per month."
"Set $HOME first, then $PATH is used."
  → "Set HOME first, then PATH is used."

第一条最严重:跨度里含 \path,命中复杂 LaTeX 判定,于是整段被替换成空串。价格、shell 变量都是聊天里的高频内容,当前零测试覆盖。渲染器侧走的是 md4c 的 latexMath,它对 $ 有 flanking 约束(开定界符后不能是空白、闭定界符前不能是空白),比这里严格,建议对齐。

P2

4. 列表项 / 引用块里的围栏代码不会被移除。 :77/^ {0,3}({3,}|~{3,})/u只认缩进 ≤3 空格的围栏,而removeFencedCodeBlocks` 跑在 :46、块标记剥离跑在 :68,顺序反了。实测「安装步骤」这种很常见的排版:

Steps:

1. Install it:

    ```bash
    export API_KEY=sk-secret
    ```

2. Done.

围栏行被识别到的数量是 0,代码内容原样进朗读,还带一个残留反引号。引用块里的 > ```js 同样漏。对照:顶层围栏和 3 空格缩进围栏都能正确移除。另外 4 空格缩进代码块完全没有处理,同样会被念出来。

5. 248 行的定界符引擎没有自己的测试文件。 projectMarkdownInlineForSpeech.ts 是这个 PR 里最难的一块(自己实现了 flanking、rule-of-three、opener 桶索引、版本失效),却只能透过 projectAssistantMessageReadAloud 间接测。5 个存活 mutation 里有 3 个在这儿:关闭「三的倍数」规则 → 20000 样本 fuzz 有 845 条输出不同(如 *a**b*a**bab);invalidateOpenersAfter 空转 → 544 条不同;~ 长度守卫关闭 → ~~~triple~~~ 从原样保留变成 ~triple~。三者测试都不红。覆盖率工具独立佐证了同样的行号。这也违反 docs/guides/testing-and-ci.md 的「测试放在拥有该行为的最低层」。

6. 有约 30 行复杂度可能是死的。 opener bucket 的 canClose 维度和 version 失效检查,在 2×20000 样本、最长 33 个 token 的 fuzz 下一条输出都不变。建议给出一个能区分的输入,给不出就删掉。

7. 逐块翻译会替换整条消息。 :17-34 一旦找到非空翻译就整条丢掉原文,但 data-translationsourceBlockId,翻译是针对单个块的。实测两段正文 + 一条针对第二段的翻译 → 第一段被静默丢弃。UI 侧原文译文是同时渲染的,会出现「屏幕两段、朗读一段」。

8. 单个假名/谚文字符会翻转整条回复的语音。 The Japanese particle は is pronounced "wa".ja-JP;这款游戏叫做ドラゴンクエスト,很有名。ja-JP。建议加一个最低占比阈值。这个函数目前零调用方,改动成本最低。

两点说明

桌面用 franc-min 而这里没用,我确认不是缺陷。 桌面的 franc-min 只出现在 src/renderer/hooks/translate/useDetectLang.ts,服务的是翻译功能的源语言检测;桌面全仓 grep aloud / speechSynthesis / TTSService 没有朗读功能。按仓库的双端对齐判据,朗读投影的产物是瞬时 UI 状态、不会被序列化,属于能力差异而非数据契约,不必对齐。

这三个模块目前全仓零调用方(diff 是纯新增 796 行、零删除),所以上述缺陷现在都还不可达,要等 TTS runtime 接线才生效——但也意味着现在改的成本最低。顺带一个接线时会撞上的问题:测试 fixture 用的是大写区域码('en-US''en-GB''fr-FR'),而仓库的语言码契约是小写(PersistedLangCodeSchema/^[a-z]{2,3}(-[a-z]{2,4})?$/,唯一真正产出 data-translationsrc/backend/data/fixtures/chat.ts:372 用的是 'en')。建议补一个组合测试:译文 part → 投影 → 解析出最终语言,并用仓库真实的小写码当 fixture。

文件归属搬得很干净:全仓 grep ReadAloud|ForSpeech 只命中新目录,messagePresentation 下无残留,import 方向也对。

写得好的部分点名:projects only original text parts 一条测试塞了 8 种非文本 part 并在两侧夹文本验证顺序,两种泄漏 mutation 都被它抓住,而且对新增 part 类型天然抗性,是全套最好的一条;uses only the last non-empty translation… 一条同时钉住四个性质;pairs emphasis after an even number of backslashes 钉的是转义计数奇偶,极易写错又极难靠 code review 发现。

验证方式:三个函数脱离 jest 单独执行做行为刻画,22 个 mutation 逐个实测,2×20000 样本 fuzz 差分,并把 PR 自带的 51 条断言过了一遍同一 harness 确认走的是真实代码路径。

@AtomsH4

AtomsH4 commented Aug 20, 2026

Copy link
Copy Markdown
Author

@eeee0717 感谢这轮完整 review,已按 这条评论 逐项处理,当前 head 为 4fd9ac9a

  • P1-1:三条 link/image regex 改成互斥分支,并覆盖 40 个反斜杠的失败输入;新实现低于 100ms。
  • P1-2:inline code span 在线性 token 保护后再做其他 Markdown 投影,覆盖标识符、路径、强调、link/math 和 token 标记字符。
  • P1-3:$...$ 增加 flanking 限制,价格、预算和 $HOME / $PATH 保持原文,简单数学行为不变。
  • P2-4:先归一化 quote/list marker,再处理容器缩进 fence,并移除四空格/tab 缩进代码。
  • P2-5:新增最低 owner 的 inline projector suite,覆盖 rule-of-three、opener invalidation、tilde 长度、转义奇偶、flanking 和跨行隔离。
  • P2-6:做了确定性 20 万样本差分;canClose bucket 和 version 无输出差异且结构上冗余,已从每字符 6 bucket 简化为 3 并移除 version;保留能被反例杀掉的 invalidation/rule-of-three。
  • P2-7:block-scoped translation 现在只替换最近的对应正文块,不再丢弃其他正文。SDK 的 TextUIPart 没有 block ID,只有 translation data 有 sourceBlockId,所以按当前 UI owner 的 parts 顺序匹配最近正文块。
  • P2-8:Kana/Hangul 信号要求至少 2 个字符且占全部字母至少 50%,单字符和混合句不再翻转,纯日/韩文仍返回对应 locale。
  • 补了小写 ja 的 translation part → projection → language resolver 组合测试。最新 base 已删除无 mobile consumer 的 PersistedLangCodeSchema,因此没有在 chat owner 重建共享 schema。

最终验证:3 suites / 81 tests;pnpm format:checkpnpm lintpnpm typecheck 均退出 0(lint 仅仓库既存 warning)。

@AtomsH4

AtomsH4 commented Aug 21, 2026

Copy link
Copy Markdown
Author

@eeee0717 补充处理本轮 read-aloud projection 边界问题,已推送至 27ce2fe5

  • inline code span 改为线性扫描反引号 run,仅由等长 run 闭合;
  • inline link、image 和数字引用的 destination 改为线性平衡括号扫描,裸 URL 不再跨越 code-span token;
  • 缩进代码按 list container 的 content indent 相对判断,保留合法的四空格续行;
  • block-scoped translation 仅在所有输出块共享同一目标语言时返回显式 language,混合原文/译文时交给系统默认语音;
  • 增加普通链接、图片、数字引用、含反引号 code span、相邻 code span、列表续行和混合语言回归测试。

本地验证:

  • focused Jest:2 suites / 76 tests
  • pnpm format:check
  • pnpm lint(仅仓库既有 warning)
  • pnpm typecheck

CI 已由新提交触发。

@AtomsH4

AtomsH4 commented Aug 21, 2026

Copy link
Copy Markdown
Author

@eeee0717 最终补充:后续边界修复与 CI 稳定性处理已推送,当前 head 为 d8cefeae

  • code span 现在跨软换行扫描,仅由等长反引号 run 闭合;
  • Markdown link/image/数字引用改为单调结构扫描,覆盖未转义与 \( / \) destination、嵌套/转义 label、HTTP(S) autolink,并保留裸 URL 内的平衡括号;
  • [2] 作为有歧义正文保留,只有 linked numeric citation 等明确形式被移除;
  • 深层列表 marker 按活动 content indent 识别,续行与“再缩进四列”的代码块不再混淆;
  • 混合原文与局部译文返回 known-mixed language 状态,resolver 不再用局部译文语言覆盖整条消息;
  • message type import 已切到 @/shared/data/types/message,符合 mobile data boundary;
  • 合并最新 v0.2 后,修正 perf(paintings): virtualize gallery and fix sidebar overlay #594 两阶段 paintings query 测试的等待条件:等待最终 outputAspectRatio,不再在第一阶段 data 到达时提前断言。

验证:相关 4 suites / 102 tests;pnpm format:checkpnpm typecheckpnpm lint 均退出 0(lint 仅仓库既有 warning)。最新 PR CI 的 lint、typecheck、test 三项均已通过。

…/read-aloud-projection

# Conflicts:
#	src/frontend/features/paintings/hooks/__tests__/usePaintings.test.tsx
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.

2 participants