diff --git a/src/renderer/src/components/ChatPanel.vue b/src/renderer/src/components/ChatPanel.vue index 832a0c7..25ab552 100644 --- a/src/renderer/src/components/ChatPanel.vue +++ b/src/renderer/src/components/ChatPanel.vue @@ -180,6 +180,7 @@ function beginQuery(): void { startWaitTimer() ttsPlayer.stop() syncTtsConfig() + if (ttsEnabled.value) ttsPlayer.announceStart() } async function sendQuery(): Promise { @@ -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; diff --git a/src/renderer/src/utils/tts.ts b/src/renderer/src/utils/tts.ts index 5f29ce7..4ad252d 100644 --- a/src/renderer/src/utils/tts.ts +++ b/src/renderer/src/utils/tts.ts @@ -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