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:
@@ -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