Compare commits

..

No commits in common. "main" and "v9.0.0" have entirely different histories.
main ... v9.0.0

4 changed files with 21 additions and 178 deletions

View File

@ -203,44 +203,17 @@ const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))
- **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。 - **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。
- **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。 - **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。
- **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。 - **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。
- **生成中途才打开 TTS,会漏掉开关之前已生成的正文**。`append()` 开头是 `if (!this.options.enabled) return`,TTS 关闭期间每次 `onDelta` 调用都被直接挡掉,内部 `rawText`/`lastSpokenPlain` 完全不累积;中途打开后下一次 `onDelta` 只送来新增量,等于从打开那一刻"之后"才开始播。`ChatPanel.vue` 的 `toggleTts()` 因此在 `store.querying` 为真时,除了 `announceStart()` 还要补一次 `ttsPlayer.append(store.rawAnswer)`,把已生成的全文"补喂"一遍,相当于告诉播放器"这就是从头到现在的内容",之后的增量在这个基础上正常追加、不会重复。
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 monkey-patch `window.electronAPI.xxx` 来拦截调用做验证是无效的——`contextBridge.exposeInMainWorld` 暴露的对象默认被冻结,赋值会静默失败、不报错,很容易误判成"这条代码路径没走到"。真正能验证的办法是去主进程对应的 `ipcMain.handle(...)` 里临时加 `console.log`(验证完记得删),或者用 CDP 的 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读 DOM/计算样式——这两种都是从"实际发生的效果"验证,不依赖能否篡改渲染进程里的 API 对象。
### 长时间运行后语音输入输出一起失效(systemTts.ts)
真机反馈:pad 跑了一两周之后语音输入输出全部失效。现场诊断:pulseaudio 占 49% CPU,系统里积压了 70 个从几天前就存在的 `aplay -t raw` 残留进程。
**根因跟 `WAKE_IDLE_THRESHOLD_MS` 那条注释里提到的"PulseAudio 大约 5 秒空闲就挂起音频设备"是同一件事**:`speakWithVits`/`speakWithPiperStreaming` 每播一个分句就 spawn 一个 aplay,写完数据后调用 `stdin.end()`,然后完全依赖 aplay 自己触发 `'exit'` 事件才清理 `isSpeaking` 状态——没有任何超时兜底(合成侧 `vitsWorker` 倒是本来就有 60 秒超时)。长时间运行后一旦某次"唤醒音频通路"没成功,aplay 会卡在往挂死的 PulseAudio 写数据,`'exit'` 永远不触发,`isSpeaking` 永久卡 `true`,`playNext()` 不再处理队列,TTS 直接哑掉。**ASR 走的是渲染进程 `getUserMedia`/`AudioContext`,和 TTS 共用同一个被拖垮的 PulseAudio 实例**,所以语音输入也一起失效。更麻烦的是 `systemTtsStop()` 原来只发 `SIGTERM`,卡在系统调用里的 aplay 根本杀不掉——每次打断播报(每次提问前都会调一次)都可能攒下一个杀不死的僵尸进程,日积月累到 70 个,把 PulseAudio 彻底拖垮。
**修复**:播放阶段加 `PLAYER_EXIT_TIMEOUT_MS`(8 秒)看门狗,`stdin.end()` 后超时未触发 `'exit'` 就判定通路卡死,`SIGKILL` 强杀并 `done(error)` 收尾;`systemTtsStop()` 加 `killWithEscalation()`,先 `SIGTERM`,1.5 秒宽限期后还没退出就 `SIGKILL`。
**验证方法**:真机上拿 `kill -STOP <pulseaudio_pid>` 故意冻结 PulseAudio,模拟"音频通路卡死"的真实场景,再用 CDP 触发一次播报,确认看门狗在约 8 秒后正确强杀卡住的 aplay、队列自动继续、最终无残留进程,`kill -CONT` 解冻收尾。**这个冻结/解冻手法可以复用**:任何"子进程偶发挂死"的 bug,不必真的等它在真机自然复现,直接 `SIGSTOP` 它依赖的下游服务就能可控地造出卡死场景。
### 系统提示词:查不到内容时的答复 ### 系统提示词:查不到内容时的答复
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。 提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导,让输入本身产生差异。 **光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导追加到系统提示词末尾,让输入本身产生差异。
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。 **没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
**`withToneHint()` 的插入位置踩过一次真实的坑**:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。真机复测发现:同一个检索到充分相关内容、理应直接作答的问题,连续追问 3 次,有 1 次被模型硬套上"手册里未查到…"的开场白,即便后面还是照常给出了详细步骤——答案读起来却像没查到,用户也确实这样反馈过。根因是这条指令脱离了它原本所属的"仅在没有足够信息时才用"的条件分句,变成了提示词末尾一条独立显眼的指令,模型会倾向"找机会用上它"。**现在改为把引导语嵌入到原本那条"如果没有足够信息"分句内部**(`NO_MATCH_BULLET_MARKER` 定位插入点,在 `config.ts` 的默认提示词里找 `以便更准确地定位;` 这句),位置和从属语气都跟原本不带随机性的版本保持一致。用户在设置页改写过这句话、不含该标记时,`withToneHint()` 原样返回不插入,不强行拼接导致语义错位。
**排查这个 bug 时的一个教训**:之前测试这个功能时没能发现问题,是因为测试脚本直接拿纯净的 `systemPrompt` 调用生成接口,没有复现 `withToneHint()` 在真实请求里的行为——以后验证跟 prompt 拼接相关的改动,测试必须经过完整的 `streamGenerate()`/`withToneHint()` 调用链,不能绕开直接测底层生成接口。
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。 另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
### 触屏 `:hover` 粘滞导致状态色被覆盖(ChatPanel.vue 语音按钮)
`.voice-btn`(语音输入/语音输出/拍照提问)点击切换"开启"状态后,按钮背景该变成纯 `--green` 填充,真机上却只有边框变绿、背景还是浅灰——看起来像凭空多了一个"焦点框"。
**排查弯路**:一开始以为是浏览器原生 focus outline,加了 `outline:none` + `:active` 反馈,真机复测毫无变化。
**真正根因是 CSS 优先级,跟 outline/focus 完全无关**:`.voice-btn.on`(2 个选择器)想把背景铺满 `--green`,但 `.voice-btn:not(:disabled):hover`(3 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",`:hover` 状态会一直粘在刚点过的按钮上不消失(触屏设备通病),于是 hover 规则的浅灰 `background` 把 `.on` 的绿色背景盖掉,只剩下没被 hover 碰到的 `border-color: var(--green)` 还在——视觉上就是一个孤零零的绿框。修复:hover 规则加 `:not(.on)` 排除已开启状态。
**排查方法论**:真机截图只能看到表象("好像是焦点框"),容易被误导。改用 CDP(Chrome DevTools Protocol)——启动时加 `--remote-debugging-port=9222`,写个不依赖 `ws` 包的最小原生 WebSocket 客户端(`net`+`crypto` 手搓握手帧)连上去,用 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读点击后的 `getComputedStyle()`,直接看到 `background` 实际渲染值是 `--panel-3` 不是 `--green`,才一次性定位到真正被覆盖的是哪条规则。**以后排查"样式明明写了但没生效"类问题,CDP 读计算样式比盯着截图猜更可靠。**
### Vue Proxy IPC 序列化 ### Vue Proxy IPC 序列化
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**: Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:

View File

@ -27,39 +27,22 @@ interface ChatMessage {
// 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里 // 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里
// 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求 // 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求
// 随机挑一条措辞引导,让输入本身产生差异,答复自然就不会千篇一律。 // 随机挑一条措辞引导追加到系统提示词末尾,让输入本身产生差异,答复自然就不会千篇一律。
// //
// 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册 // 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册
// 问答更看重稳定准确。 // 问答更看重稳定准确。这些引导只在"查不到内容"时才起作用,正常回答不受影响。
//
// 踩过的坑:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高
// 的位置),结果它从"仅在查不到内容时才用"的次要条件,变成了一条独立显眼的指令——真机
// 复测发现同一个检索到充分相关内容的问题,连续追问 3 次,有 1 次被模型硬套上了"手册里
// 未查到…"的开场白,即使后面还是照常给出了详细步骤,答案读起来却像没查到。原因是这条
// 指令脱离了它所属的条件分句、又处于 prompt 末尾最显眼的位置,模型会"找机会用上它"。
//
// 现在改为嵌入到原本那条"如果没有足够信息"分句的内部(MARKER 之后、下一条分句之前),
// 并把措辞明确改成"仅当确实需要使用这句话时"——位置和从属语气都跟原本那句不带随机性的
// 版本一致,只是具体措辞有随机性。真机复测:同一个问题连跑 5 次全部正常作答,真正查不到
// 内容的问题也依然能正确触发(且带上变化后的措辞)。
const NO_MATCH_TONE_HINTS = [ const NO_MATCH_TONE_HINTS = [
'(仅当确实需要使用这句话时,才以"很抱歉"起头组织这句话。)', '若本次需要说明手册中未查到相关内容,请以"很抱歉"起头组织这句话。',
'(仅当确实需要使用这句话时,才以"手册里"起头组织这句话。)', '若本次需要说明手册中未查到相关内容,请以"手册里"起头组织这句话。',
'(仅当确实需要使用这句话时,才以"目前检索到的资料"起头组织这句话。)', '若本次需要说明手册中未查到相关内容,请以"目前检索到的资料"起头组织这句话。',
'(仅当确实需要使用这句话时,才以"这部分内容"起头组织这句话。)', '若本次需要说明手册中未查到相关内容,请以"这部分内容"起头组织这句话。',
'(仅当确实需要使用这句话时,才用两句话表达:先说明情况,再给一条补充信息的建议。)', '若本次需要说明手册中未查到相关内容,请用两句话表达:先说明情况,再给一条补充信息的建议。',
'(仅当确实需要使用这句话时,才先简短致歉,再说明可以如何把问题描述得更具体。)' '若本次需要说明手册中未查到相关内容,请先简短致歉,再说明可以如何把问题描述得更具体。'
] ]
// 必须跟 config.ts 里 DEFAULT_CONFIG.rag.systemPrompt 那条"没有足够信息"分句的结尾保持一致,
// 用于定位插入点。用户在设置页改写过这句话、不含这个标记时,原样返回不插入——
// 不强行拼接导致语义错位,顶多是少一点措辞变化,不会把提示词弄坏。
const NO_MATCH_BULLET_MARKER = '以便更准确地定位;'
function withToneHint(systemPrompt: string): string { function withToneHint(systemPrompt: string): string {
if (!systemPrompt.includes(NO_MATCH_BULLET_MARKER)) return systemPrompt
const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)] const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)]
return systemPrompt.replace(NO_MATCH_BULLET_MARKER, `${NO_MATCH_BULLET_MARKER}\n ${hint}`) return `${systemPrompt}\n${hint}`
} }
// 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式) // 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式)

View File

@ -207,18 +207,6 @@ const WAKE_TIMEOUT_MS = WAKE_SILENCE_MS + 3000
const PAUSE_LONG_MS = 200 const PAUSE_LONG_MS = 200
const PAUSE_SHORT_MS = 100 const PAUSE_SHORT_MS = 100
/**
* stdin 写完(EOF)后等待播放器自行退出的最长时间。正常情况下 aplay 把缓冲里剩余的
* 音频播完就会退出,通常远小于 1 秒。真机长时间运行后发现过 PulseAudio 音频通路挂起
* 唤醒失败的情况(见上面 WAKE_IDLE_THRESHOLD_MS 的注释——这台设备的 PulseAudio 大约
* 5 秒空闲就会把音频设备挂起),aplay 卡在给挂死的通路写数据,stdin.end() 之后永远不
* 触发 'exit':没有这个兜底的话,isSpeaking 会永久卡在 true,playNext() 再也不会处理
* 队列里后续的句子,相当于整个 TTS 直接哑掉;ASR 共用同一个 PulseAudio 实例,通路卡死
* 后录音也会跟着失败,这正是"长时间运行后语音输入输出都不行"的根因。超时后强制
* SIGKILL 并以 done(error) 收尾,让队列能继续推进而不是永久卡死。
*/
const PLAYER_EXIT_TIMEOUT_MS = 8000
/** 生成指定时长的裸静音 PCM(s16le 单声道) */ /** 生成指定时长的裸静音 PCM(s16le 单声道) */
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer { function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2) return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
@ -623,17 +611,9 @@ function speakWithPiperStreaming(
const fullText = segments.join('') const fullText = segments.join('')
let resolved = false let resolved = false
let playerExitTimer: NodeJS.Timeout | null = null
const clearPlayerExitTimer = () => {
if (playerExitTimer) {
clearTimeout(playerExitTimer)
playerExitTimer = null
}
}
const done = (error?: string) => { const done = (error?: string) => {
if (resolved) return if (resolved) return
resolved = true resolved = true
clearPlayerExitTimer()
cleanup() cleanup()
try { try {
rmSync(segDir, { recursive: true, force: true }) rmSync(segDir, { recursive: true, force: true })
@ -764,19 +744,6 @@ function speakWithPiperStreaming(
} catch { } catch {
// ignore // ignore
} }
// 见 PLAYER_EXIT_TIMEOUT_MS 注释:音频通路卡死时 aplay 可能永远不触发 'exit',
// 这里兜底强制结束,避免 isSpeaking 卡死导致后续队列全部哑掉。
const stuckPlayer = currentPlayer
playerExitTimer = setTimeout(() => {
if (!isAlive()) return
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
try {
stuckPlayer?.kill('SIGKILL')
} catch {
// ignore
}
done('player exit timeout')
}, PLAYER_EXIT_TIMEOUT_MS)
} }
})() })()
@ -944,18 +911,10 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-')) const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-'))
let resolved = false let resolved = false
let playerExitTimer: NodeJS.Timeout | null = null
const clearPlayerExitTimer = () => {
if (playerExitTimer) {
clearTimeout(playerExitTimer)
playerExitTimer = null
}
}
const isAlive = () => myToken === ttsStreamToken && !resolved const isAlive = () => myToken === ttsStreamToken && !resolved
const done = (error?: string) => { const done = (error?: string) => {
if (resolved) return if (resolved) return
resolved = true resolved = true
clearPlayerExitTimer()
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出) // 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
try { try {
vitsWorker?.send({ cmd: 'cancel' }) vitsWorker?.send({ cmd: 'cancel' })
@ -1056,20 +1015,6 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
} catch { } catch {
// ignore // ignore
} }
// 数据已全部写完,正常情况下 aplay 播完缓冲就会触发上面的 'exit' 处理器。
// 若音频通路卡死(见 PLAYER_EXIT_TIMEOUT_MS 注释),'exit' 永远不会来,
// 这里兜底强制结束本轮播报,避免 isSpeaking 卡死导致后续队列全部哑掉。
const stuckPlayer = currentPlayer
playerExitTimer = setTimeout(() => {
if (!isAlive()) return
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
try {
stuckPlayer?.kill('SIGKILL')
} catch {
// ignore
}
done('player exit timeout')
}, PLAYER_EXIT_TIMEOUT_MS)
} }
})() })()
@ -1190,23 +1135,7 @@ function speakWithPiper(
}) })
} }
/**
* 每轮播报结束时统一调用。之前只是把 currentProcess/currentPlayer 引用置空,
* 没有保证对应的系统进程真的退出——真机复测时发现合成卡死分支(见上面
* "synth timeout" 处)只杀了 vitsWorker,没杀已经 spawn 出来、stdin 还开着
* 干等数据的 aplay,这个进程会永远卡在那等一个再也不会来的 EOF,形成和
* PLAYER_EXIT_TIMEOUT_MS 要解决的问题同一性质的泄漏,只是触发路径不同。
* 与其在每个 done() 调用点各自记得去杀 currentPlayer,不如在 cleanup()
* 这个唯一的收尾出口统一兜底:无论从哪条路径走到这里,都先尝试杀掉还活着
* 的进程,再清空引用。
*/
function cleanup(): void { function cleanup(): void {
if (currentProcess) {
killWithEscalation(currentProcess)
}
if (currentPlayer) {
killWithEscalation(currentPlayer)
}
currentProcess = null currentProcess = null
currentPlayer = null currentPlayer = null
isSpeaking = false isSpeaking = false
@ -1250,33 +1179,6 @@ export async function systemTtsSpeak(text: string): Promise<void> {
}) })
} }
/**
* 先 SIGTERM,一段宽限期后如果进程还没退出再 SIGKILL。
* 音频通路卡死时 aplay 可能卡在不可中断的系统调用里,单发 SIGTERM 不一定能把它
* 真正杀掉——这里只是把 JS 里的引用清空了、不代表对应的系统进程真的退出了,不加
* 兜底的话每次中途打断播报都可能在后台攒下一个杀不掉的僵尸 aplay,长时间运行下来
* 越积越多,最终把 PulseAudio 拖垮(见 PLAYER_EXIT_TIMEOUT_MS 注释)。
*/
function killWithEscalation(proc: ChildProcess, graceMs = 1500): void {
let exited = false
proc.once('exit', () => {
exited = true
})
try {
proc.kill('SIGTERM')
} catch {
// ignore
}
setTimeout(() => {
if (exited) return
try {
proc.kill('SIGKILL')
} catch {
// ignore
}
}, graceMs)
}
export function systemTtsStop(): void { export function systemTtsStop(): void {
// 递增代际标记,进行中的分段喂流循环会据此中止 // 递增代际标记,进行中的分段喂流循环会据此中止
ttsStreamToken++ ttsStreamToken++
@ -1288,7 +1190,11 @@ export function systemTtsStop(): void {
} catch { } catch {
// ignore // ignore
} }
killWithEscalation(currentProcess) try {
currentProcess.kill('SIGTERM')
} catch {
// ignore
}
currentProcess = null currentProcess = null
} }
if (currentPlayer) { if (currentPlayer) {
@ -1297,7 +1203,11 @@ export function systemTtsStop(): void {
} catch { } catch {
// ignore // ignore
} }
killWithEscalation(currentPlayer) try {
currentPlayer.kill('SIGTERM')
} catch {
// ignore
}
currentPlayer = null currentPlayer = null
} }
queue = [] queue = []

View File

@ -503,18 +503,6 @@ async function toggleTts(): Promise<void> {
} else if (store.rawAnswer && !store.querying) { } else if (store.rawAnswer && !store.querying) {
// 答案已显示完毕时打开 TTS,立即从头播 // 答案已显示完毕时打开 TTS,立即从头播
ttsPlayer.speakWithAnnouncement(store.rawAnswer) ttsPlayer.speakWithAnnouncement(store.rawAnswer)
} else if (store.querying) {
// 提问还在流式生成中途打开 TTS:ttsPlayer 内部的 rawText 此前因为
// enabled=false 一直没有累积(append() 一开头就 return),直接等后续
// onDelta 的话只会从"此刻之后的新增量"开始朗读,把已经显示在屏幕上但
// 开 TTS 之前生成的那部分正文漏掉。这里先播"开始播报"提示音(跟正常
// 提问开始时一致),再用 append() 把已生成的全文"补喂"一遍,相当于
// 告诉播放器"这就是从头到现在的内容",让它从头开始排队朗读;之后
// onDelta 送来的增量会在此基础上正常追加,衔接不会重复。此刻还没有
// 任何输出(刚发问、第一个 delta 还没到)时 append 传空字符串会被
// append() 内部的 if (!text) return 挡掉,不会误触发。
ttsPlayer.announceStart()
ttsPlayer.append(store.rawAnswer)
} }
} }
@ -972,19 +960,8 @@ onUnmounted(() => {
font-weight: 700; font-weight: 700;
font-family: inherit; font-family: inherit;
transition: all .15s; transition: all .15s;
/* 这三个按钮之前没有 :active 态,点下去没有任何即时反馈。加上即时反馈的同时顺手
关掉原生 outline(这是纯触屏界面,不依赖 Tab 键盘导航,不需要保留原生焦点框)。*/
outline: none;
} }
/* 真正的"绿框"根因不在 outline/focus,而是 CSS 优先级:.voice-btn:not(:disabled):hover .voice-btn:not(:disabled):hover { background: var(--panel-3); }
(3 个选择器)比 .voice-btn.on(2 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",
:hover 状态会一直粘在刚点过的按钮上不消失(触屏设备的通病),于是 hover 规则的浅灰
background 把 .on 本该铺满的纯绿色背景盖掉了,只剩下没被 hover 碰到的绿色边框还在——
看起来就像按钮周围凭空多出一个绿框。真机用 CDP 直接读取点击后的计算样式实锤:
className 带 "on",但 background 实际渲染出的是 --panel-3 的浅灰色,不是 --green。
加 :not(.on) 让 hover 规则在"已开启"状态下让位,背景就能正常显示成绿色。 */
.voice-btn:not(:disabled):not(.on):hover { background: var(--panel-3); }
.voice-btn:not(:disabled):active { filter: brightness(.92); }
.voice-btn.on { .voice-btn.on {
background: var(--green); background: var(--green);
border-color: var(--green); border-color: var(--green);