fix: 语音输出恢复"开始播报"、消除加粗文本重复朗读;输入框真正对齐

真机复测又发现三个问题,逐一定位修复:

1. 流式播报接上后"开始播报"提示语丢了。TtsPlayer 新增 announceStart(),
   只入队播放不碰流式缓冲区状态,beginQuery() 里 stop()+配置同步之后
   调用。真机验证:连续问答里从第2轮开始稳定播报"开始播报"(第1轮偶发
   缺失,是启动时批量测试自动开始跟语音配置同步的时序竞争,非本次改动
   引入的逻辑问题)。

2. 加粗文本(**xxx**)被朗读两遍。根因:markdownToPlain 每次都从头
   重新解析整段 rawText,** 闭合符号到达前后同一段文字的纯文本渲染
   结果不一样(未闭合时星号当普通字符、闭合后星号被去掉),提前把这段
   不稳定文字提交为 lastSpokenPlain 会在闭合符号到达后被重新判定成
   "待朗读"造成重复。新增 findStableBoundary(),只处理到最后一个已闭合
   的 ** 为止,未闭合的尾部留到下次 append 再处理;flush() 收尾时因为
   生成已结束,不再有这个顾虑,直接处理全部剩余内容。真机验证:连续
   3 轮问答,每轮都有加粗小标题,全部按顺序推进朗读新内容,无重复。

3. 输入框和按钮行的对不齐问题:之前一直在改 .q-input(文本框本身)的
   高度,但真实 DOM 测量显示文本框本身一直是精确的 96px,真正超高的
   是它的父容器 .q-input-wrap(渲染成 102.4px,比 96px 多 6.4px),
   文本框贴容器顶部导致整体被顶高。给 .q-input-wrap 也显式定死
   height:96px。真机像素测量确认对齐。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ssurui 2026-09-03 15:54:34 +08:00
parent 48a9fb0f6d
commit 8054fcdad1
2 changed files with 52 additions and 2 deletions

View File

@ -180,6 +180,7 @@ function beginQuery(): void {
startWaitTimer()
ttsPlayer.stop()
syncTtsConfig()
if (ttsEnabled.value) ttsPlayer.announceStart()
}
async function sendQuery(): Promise<void> {
@ -865,7 +866,7 @@ onUnmounted(() => {
display: flex; align-items: flex-end; gap: 14px;
}
.q-input-wrap { flex: 1; min-width: 0; position: relative; }
.q-input-wrap { flex: 1; min-width: 0; height: 96px; position: relative; }
.q-input {
width: 100%; box-sizing: border-box;

View File

@ -148,7 +148,15 @@ export class TtsPlayer {
this.rawText += text
const thinkFree = this.stripThinkContent(this.rawText)
const plain = this.markdownToPlain(thinkFree)
// 只处理到"安全边界"为止:排除末尾可能还没闭合的 ** 加粗标记。
// markdownToPlain 每次都是从头重新解析整段 rawText 的,闭合符号到达前后,
// 同一段文字的纯文本渲染结果会不一样(比如未闭合时星号被当普通字符处理,
// 闭合后星号被去掉、文字保留)。如果提前把这段还不稳定的文字提交为
// lastSpokenPlain,闭合符号一到,plain 文本变化,会被 computePendingDelta
// 重新判定成"待朗读",导致这段文字被朗读两遍。
const stableBoundary = this.findStableBoundary(thinkFree)
const stableRaw = thinkFree.slice(0, stableBoundary)
const plain = this.markdownToPlain(stableRaw)
const pending = this.computePendingDelta(plain)
if (!pending) return
@ -167,6 +175,15 @@ export class TtsPlayer {
*/
flush(): void {
if (!this.options.enabled) return
// 收尾:答案已经生成完毕,不用再担心格式标记后续会变化,
// 把 append() 因 findStableBoundary 搁置的尾部(如收尾正好停在未闭合的 ** 上)也一并处理掉
const thinkFree = this.stripThinkContent(this.rawText)
const plain = this.markdownToPlain(thinkFree)
const pending = this.computePendingDelta(plain)
if (pending) {
this.lastSpokenPlain = plain
this.buffer += pending
}
if (this.buffer.trim()) {
this.enqueue(this.buffer.trim())
this.buffer = ''
@ -179,6 +196,17 @@ export class TtsPlayer {
* 分段规则:"。"、";"、"!"、"?" 后长停顿,"、"、":" 后短停顿(约 0.2s~0.3s),
* 每段一行发给主进程,由主进程统一合成并在段间插入对应时长的静音。
*/
/**
* 播报"开始播报"提示音,用在流式问答开始时(配合 append()/flush())。
* 只入队播放,不碰 rawText/buffer 等流式缓冲区状态,调用前应已经
* 通过 stop() 清空过一轮的状态。
*/
announceStart(): void {
if (!this.options.enabled) return
this.introSpoken = true
this.enqueue('开始播报')
}
speakWithAnnouncement(rawText: string): void {
if (!this.options.enabled) return
const plain = this.stripThinkAndMarkdown(rawText)
@ -331,6 +359,27 @@ export class TtsPlayer {
* 用“后缀-前缀对齐”计算真正新增的纯文本。
* 当 cleanMarkdown/marked 把 HTML 实体或格式符号压缩时,按内容对齐可避免丢字或重复。
*/
/**
* 找到文本里可以安全处理的边界,排除末尾可能还没闭合的 ** 加粗标记
* (出现次数为奇数,说明最后一个 ** 还没等到它的另一半)。
* 只处理 ** 是因为这是目前唯一被实测复现"闭合前后纯文本渲染不一致"问题的标记;
* 其它行内标记(*斜体*、`代码`、~~删除线~~)出现频率低很多,先不处理。
*/
private findStableBoundary(text: string): number {
let count = 0
let lastUnclosedIndex = -1
let from = 0
while (true) {
const found = text.indexOf('**', from)
if (found === -1) break
count++
lastUnclosedIndex = found
from = found + 2
}
if (count % 2 === 1) return lastUnclosedIndex
return text.length
}
private computePendingDelta(newPlain: string): string {
const prev = this.decodeHtmlEntities(this.lastSpokenPlain)
if (!prev) return newPlain