From f7eff14342c9c2c1e5b7b3988499788a7cfa79ad Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 18 Jul 2026 04:59:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20voice-first=20master=20replies?= =?UTF-8?q?=20with=20WeChat-style=20unread=20dot=20and=20=E8=BD=AC?= =?UTF-8?q?=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户 2026-07-18 追加要求:师傅回复先以语音消息呈现(微信式), 未读带红点,点击播放,可选「转文字」展开全文;同时优化长文刷屏布局。 speech.ts: - 播放控制器新增 preload(text):预合成 TTS 并写入源缓存(不播放), 供语音气泡在首次点击前显示时长;失败返回 null - 新增 measureAudioDuration(source):经 metadata 探测音频秒长(8s 超时兜底) sop/index.vue: - MasterMsg 增加 voiceStatus(preparing/ready/unavailable)/voiceDuration/ unread/transcriptOpen - 答案到达即后台预合成语音(说话内容=直接答案 answer 字段), 就绪后气泡显示「🔊 N"」+未读红点;点击播放(再点停止,播放态变色), 播放即清红点;「转文字」灰 chip 展开完整结构化内容 (要点/提醒/引用/总结卡/反馈),微信同款交互 - TTS 不可用或答案为空时自动回退为纯文本气泡(播报按钮保留), 训练概况等数据型回复直接文本呈现 - 布局优化:头部压缩,长文默认收进「转文字」,线程不再整段刷屏 验证: - 本地:时长 13" 真实测得、红点播放后消失、转文字展开全文,3 张截图目检 - 生产冒烟:语音气泡+时长+红点+转文字 chip 全部就位 - H5 已发布(备份 h5-before-20260718045801) 说明:每条答案一次 TTS 合成调用(有缓存),试点量级成本可忽略; 练页会话化(批次二)将复用同一套语音气泡形态。 Co-Authored-By: Claude Fable 5 --- mobile-uni/src/pages/user/sop/index.vue | 251 +++++++++++++++++++----- mobile-uni/src/services/speech.ts | 56 +++++- 2 files changed, 257 insertions(+), 50 deletions(-) diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index e1e82bfb..09c9c11f 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -42,55 +42,72 @@ 师 - - {{ msg.result.answer || '未生成直接答案,请查看引用依据。' }} - - - {{ index + 1 }}. {{ cleanOrderedText(point) }} - - - - ⚠ {{ item }} - - 当前授权空间内没有足够依据。 - - - - - - - + + - - - {{ snippet.docTitle }} - 知识空间:{{ snippet.spaceCode }} - {{ snippetExcerpt(snippet.content) }} - 片段 #{{ snippet.fragmentId }} + + {{ msg.result.answer || '未生成直接答案,请查看引用依据。' }} + + + {{ index + 1 }}. {{ cleanOrderedText(point) }} + + + + ⚠ {{ item }} + + 当前授权空间内没有足够依据。 + + + + + + + + + + + {{ snippet.docTitle }} + 知识空间:{{ snippet.spaceCode }} + {{ snippetExcerpt(snippet.content) }} + 片段 #{{ snippet.fragmentId }} + @@ -237,7 +254,7 @@ import { } from '@/services/auth'; import { resetPageScroll } from '@/services/navigation'; import { ensureEmployeePosition } from '@/services/position'; -import { chooseSpeechAudio, createSpeechPlaybackController, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; +import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; import { downloadSummaryCardImage } from '@/services/summary-card-image'; import { computed } from 'vue'; @@ -260,6 +277,10 @@ interface MasterMsg { feedbackVerdict: AnswerFeedbackVerdict | ''; feedbackSubmitting: boolean; citationsOpen: boolean; + voiceStatus: 'preparing' | 'ready' | 'unavailable'; + voiceDuration: number | null; + unread: boolean; + transcriptOpen: boolean; } interface SystemMsg { @@ -394,6 +415,7 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) }) : await searchKnowledge(text); if (generation !== knowledgeSearchGeneration) return; + const spoken = (nextResult.answer || '').trim(); replaceMsg(pending.id, { id: pending.id, role: 'master', @@ -401,8 +423,13 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) toolCode: '', feedbackVerdict: '', feedbackSubmitting: false, - citationsOpen: false + citationsOpen: false, + voiceStatus: spoken ? 'preparing' : 'unavailable', + voiceDuration: null, + unread: Boolean(spoken), + transcriptOpen: false }); + if (spoken) void prepareVoice(pending.id, spoken); if (!nextResult.answer && !nextResult.snippets?.length) { pushSystem('未检索到可引用内容,换个问法试试'); } @@ -454,7 +481,11 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM toolCode: effectiveToolCode, feedbackVerdict: '', feedbackSubmitting: false, - citationsOpen: false + citationsOpen: false, + voiceStatus: 'unavailable', + voiceDuration: null, + unread: false, + transcriptOpen: false }); } catch (error) { if (generation !== knowledgeSearchGeneration) return; @@ -546,6 +577,47 @@ const pickVoiceAudio = async () => { } }; +const findMasterMsg = (id: number): MasterMsg | null => { + const found = messages.value.find((item) => item.id === id); + return found && found.role === 'master' ? found : null; +}; + +const spokenTextOf = (msg: MasterMsg) => (msg.result.answer || '').trim(); + +/** Pre-synthesize the reply audio so the voice bubble can show a duration + * before the first tap, WeChat-style. Falls back to a plain text bubble + * when remote TTS is unavailable. */ +const prepareVoice = async (id: number, spoken: string) => { + const source = await speechPlayback.preload(spoken); + const msg = findMasterMsg(id); + if (!msg) return; + if (!source) { + msg.voiceStatus = 'unavailable'; + msg.unread = false; + return; + } + msg.voiceDuration = await measureAudioDuration(source); + msg.voiceStatus = 'ready'; +}; + +const isVoicePlaying = (msg: MasterMsg) => + speechState.value.key === `answer-${msg.id}` && speechState.value.status === 'playing'; + +const voiceLabel = (msg: MasterMsg) => { + if (speechState.value.key === `answer-${msg.id}` && speechState.value.status === 'loading') return '…'; + if (msg.voiceStatus === 'preparing') return '…'; + return msg.voiceDuration ? `${Math.min(msg.voiceDuration, 60)}"` : '语音'; +}; + +const playVoice = (msg: MasterMsg) => { + if (msg.voiceStatus === 'preparing') { + uni.showToast({ title: '语音生成中,请稍候', icon: 'none' }); + return; + } + msg.unread = false; + void speechPlayback.toggle(`answer-${msg.id}`, spokenTextOf(msg)); +}; + const snippetExcerpt = (text = '') => { const value = text.trim(); return value.length > 260 ? `${value.slice(0, 260)}...` : value; @@ -2018,4 +2090,85 @@ onUnload(() => { .pending-bubble .bubble-text { color: #8a93a0; } + +/* ===== voice-first master replies (WeChat voice message style) ===== */ +.master-col { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + max-width: 82%; +} + +.master-col .bubble { + max-width: 100%; +} + +.voice-line { + display: flex; + align-items: center; + gap: 8px; +} + +.voice-bubble { + display: flex; + align-items: center; + gap: 8px; + min-width: 96px; + padding: 11px 14px; + cursor: pointer; +} + +.voice-bubble.playing { + background: #ffe9eb; +} + +.voice-icon { + font-size: 15px; + line-height: 1; +} + +.voice-duration { + color: #232a33; + font-size: 15px; + font-weight: 600; +} + +.voice-dot { + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 50%; + background: #f43b30; +} + +.transcript-toggle { + flex-shrink: 0; + margin: 0; + padding: 4px 12px; + border: none; + border-radius: 8px; + background: rgba(20, 24, 31, 0.06); + color: #6b7480; + font-size: 12px; + line-height: 1.5; +} + +/* compact header */ +.chat-head { + padding: 8px 12px 6px; +} + +.chat-head-row { + margin-bottom: 6px; +} + +.chat-title { + font-size: 16px; +} + +.chat-source-switch .source-option { + padding: 5px 0; + font-size: 13px; +} diff --git a/mobile-uni/src/services/speech.ts b/mobile-uni/src/services/speech.ts index 9059cc4a..db6cc2c9 100644 --- a/mobile-uni/src/services/speech.ts +++ b/mobile-uni/src/services/speech.ts @@ -210,10 +210,64 @@ export const createSpeechPlaybackController = ( } }; + /** + * Synthesize (and cache) audio for `text` without playing it, so a + * WeChat-style voice bubble can show duration before the first tap. + * Returns the audio source URL, or null when remote TTS is unavailable. + */ + const preload = async (text: string): Promise => { + const value = text.trim(); + if (!value) return null; + const cached = sourceCache.get(value); + if (cached) return cached; + try { + const result = await synthesizeSpeech(value); + const source = result.inlineAudioUrl || result.audioUrl; + if (!source) return null; + sourceCache.set(value, source); + while (sourceCache.size > maxCachedSources) { + const oldestKey = sourceCache.keys().next().value as string | undefined; + if (!oldestKey) break; + sourceCache.delete(oldestKey); + } + return source; + } catch { + return null; + } + }; + const destroy = () => { stop(); sourceCache.clear(); }; - return { toggle, stop, destroy }; + return { toggle, stop, destroy, preload }; }; + +export const measureAudioDuration = (source: string): Promise => + new Promise((resolve) => { + if (typeof Audio === 'undefined') { + resolve(null); + return; + } + const probe = new Audio(); + let settled = false; + const finish = (value: number | null) => { + if (settled) return; + settled = true; + probe.src = ''; + resolve(value); + }; + const timer = setTimeout(() => finish(null), 8000); + probe.preload = 'metadata'; + probe.onloadedmetadata = () => { + clearTimeout(timer); + const duration = probe.duration; + finish(Number.isFinite(duration) && duration > 0 ? Math.max(1, Math.round(duration)) : null); + }; + probe.onerror = () => { + clearTimeout(timer); + finish(null); + }; + probe.src = source; + });