feat(mobile): voice-first master replies with WeChat-style unread dot and 转文字
客户 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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,55 +42,72 @@
|
||||
|
||||
<view v-else class="bubble-row left">
|
||||
<view class="avatar master-avatar">师</view>
|
||||
<view class="bubble master">
|
||||
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
|
||||
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
|
||||
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
|
||||
{{ index + 1 }}. {{ cleanOrderedText(point) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="msg.result.cautions?.length" class="bubble-cautions">
|
||||
<text v-for="item in msg.result.cautions" :key="item" class="bubble-caution">⚠ {{ item }}</text>
|
||||
</view>
|
||||
<text v-if="msg.result.noEvidence" class="bubble-noevidence">当前授权空间内没有足够依据。</text>
|
||||
|
||||
<view class="bubble-actions">
|
||||
<button class="bubble-action" @click="toggleAnswerSpeech(msg)">{{ speechButtonLabel(`answer-${msg.id}`) }}</button>
|
||||
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||||
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||||
</button>
|
||||
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
|
||||
<button
|
||||
class="bubble-action"
|
||||
:class="{ active: msg.feedbackVerdict === 'up' }"
|
||||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||||
@click="feedback('up', msg)"
|
||||
<view class="master-col">
|
||||
<view v-if="msg.voiceStatus !== 'unavailable'" class="voice-line">
|
||||
<view
|
||||
class="bubble master voice-bubble"
|
||||
:class="{ playing: isVoicePlaying(msg) }"
|
||||
@click="playVoice(msg)"
|
||||
>
|
||||
有帮助
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action"
|
||||
:class="{ negative: msg.feedbackVerdict === 'down' }"
|
||||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||||
@click="feedback('down', msg)"
|
||||
>
|
||||
没解决
|
||||
</button>
|
||||
<text v-if="msg.feedbackVerdict" class="feedback-note">
|
||||
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
|
||||
</text>
|
||||
<text class="voice-icon">{{ isVoicePlaying(msg) ? '◼' : '🔊' }}</text>
|
||||
<text class="voice-duration">{{ voiceLabel(msg) }}</text>
|
||||
</view>
|
||||
<view v-if="msg.unread" class="voice-dot" />
|
||||
<button v-if="!msg.transcriptOpen" class="transcript-toggle" @click="msg.transcriptOpen = true">转文字</button>
|
||||
</view>
|
||||
|
||||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||||
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
|
||||
<text class="citation-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
|
||||
<view v-if="msg.voiceStatus === 'unavailable' || msg.transcriptOpen" class="bubble master transcript-bubble">
|
||||
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
|
||||
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
|
||||
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
|
||||
{{ index + 1 }}. {{ cleanOrderedText(point) }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="msg.result.cautions?.length" class="bubble-cautions">
|
||||
<text v-for="item in msg.result.cautions" :key="item" class="bubble-caution">⚠ {{ item }}</text>
|
||||
</view>
|
||||
<text v-if="msg.result.noEvidence" class="bubble-noevidence">当前授权空间内没有足够依据。</text>
|
||||
|
||||
<view class="bubble-actions">
|
||||
<button v-if="msg.voiceStatus === 'unavailable'" class="bubble-action" @click="toggleAnswerSpeech(msg)">
|
||||
{{ speechButtonLabel(`answer-${msg.id}`) }}
|
||||
</button>
|
||||
<button v-if="msg.result.snippets?.length" class="bubble-action" @click="msg.citationsOpen = !msg.citationsOpen">
|
||||
引用 {{ msg.result.snippets.length }} 条 {{ msg.citationsOpen ? '▲' : '▼' }}
|
||||
</button>
|
||||
<button v-if="msg.toolCode === ''" class="bubble-action" :disabled="summaryLoading" @click="buildSummaryCard(msg)">
|
||||
{{ summaryLoading ? '生成中' : '总结卡' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="msg.toolCode === ''" class="bubble-actions feedback-row">
|
||||
<button
|
||||
class="bubble-action"
|
||||
:class="{ active: msg.feedbackVerdict === 'up' }"
|
||||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||||
@click="feedback('up', msg)"
|
||||
>
|
||||
有帮助
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action"
|
||||
:class="{ negative: msg.feedbackVerdict === 'down' }"
|
||||
:disabled="msg.feedbackSubmitting || Boolean(msg.feedbackVerdict)"
|
||||
@click="feedback('down', msg)"
|
||||
>
|
||||
没解决
|
||||
</button>
|
||||
<text v-if="msg.feedbackVerdict" class="feedback-note">
|
||||
{{ msg.feedbackVerdict === 'up' ? '已记录' : '已进入复核' }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="msg.citationsOpen && msg.result.snippets?.length" class="bubble-citations">
|
||||
<view v-for="(snippet, index) in msg.result.snippets" :key="`${snippet.docTitle}-${index}`" class="citation">
|
||||
<text class="citation-title">{{ snippet.docTitle }}</text>
|
||||
<text v-if="snippet.spaceCode" class="citation-meta">知识空间:{{ snippet.spaceCode }}</text>
|
||||
<text class="citation-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="citation-meta">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<string | null> => {
|
||||
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<number | null> =>
|
||||
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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user