diff --git a/www/app.js b/www/app.js index a3959a5..c304c7f 100644 --- a/www/app.js +++ b/www/app.js @@ -53,9 +53,11 @@ let backendConfig = null; // 从API获取的配置 let currentUser = null; // 当前登录用户 { username, password, registeredAt } // TTS 语音播放状态 -let enableTTS = false; // 是否启用语音播放 +let enableTTS = false; // 是否启用语音播放(默认关闭) let currentPlayingAudio = null; // 当前播放的音频对象 let ttsVoice = 'zh-CN-XiaoxiaoNeural'; // TTS 语音 +let ttsQueue = []; // TTS 待播放队列 +let isTTSPlaying = false; // 是否正在播放队列 // 每日使用统计(未登录用户) let dailyUsage = { @@ -3137,6 +3139,10 @@ async function openAgent(agentId) { } } + // 重置 TTS 状态(新对话默认关闭) + enableTTS = false; + stopTTSQueue(); + // 创建新对话并设置智能体 const newConv = { id: backendId || Date.now().toString(), @@ -3159,6 +3165,9 @@ async function openAgent(agentId) { function showAgentChatPage() { if (!currentAgent || !currentConversation) return; + // 停止之前的 TTS 播放 + stopTTSQueue(); + const chatHtml = `
@@ -3616,6 +3625,10 @@ async function createNewConversation() { return; } + // 重置 TTS 状态(新对话默认关闭) + enableTTS = false; + stopTTSQueue(); + // 如果用户已登录,先在 backend 创建对话 let backendId = null; if (currentUser && currentUser.id) { @@ -3653,6 +3666,9 @@ async function createNewConversation() { // 打开对话 function openConversation(id) { + // 停止之前的 TTS 播放 + stopTTSQueue(); + currentConversation = conversations.find(c => c.id === id); if (!currentConversation) { showConversationList(); @@ -4088,6 +4104,8 @@ async function streamGenerate(userMsgIndex) { let buffer = ''; let thinkingOutputStarted = false; // 正式内容是否开始输出 let abortController = new AbortController(); // 用于中断流 + let ttsAccumulatedText = ''; // TTS 累计文本 + let ttsLastPlayedIndex = 0; // 上次播放的文本位置 // 绑定停止按钮事件 const stopBtn = document.getElementById('stopGenerateBtn'); @@ -4157,6 +4175,32 @@ async function streamGenerate(userMsgIndex) { currentConversation.messages[aiMessageIndex].content += delta.content; contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content) + ''; + + // 流式TTS播放:积累文本并播放 + if (enableTTS) { + ttsAccumulatedText += delta.content; + // 每80字符播放一段(避免太短频繁请求) + if (ttsAccumulatedText.length >= 80) { + // 找到合适的断句点(句子结束) + const breakPoints = ['。', '!', '?', ';', '.', '!', '?', ';', '\n']; + let breakIndex = -1; + for (let i = ttsAccumulatedText.length - 1; i >= ttsLastPlayedIndex + 40; i--) { + if (breakPoints.includes(ttsAccumulatedText[i])) { + breakIndex = i; + break; + } + } + // 如果找到断句点,或者积累超过120字符强制播放 + if (breakIndex >= 0 || ttsAccumulatedText.length - ttsLastPlayedIndex >= 120) { + const textToPlay = ttsAccumulatedText.slice(ttsLastPlayedIndex, breakIndex >= 0 ? breakIndex + 1 : ttsAccumulatedText.length); + if (textToPlay.trim()) { + addToTTSQueue(textToPlay.trim()); + } + ttsLastPlayedIndex = breakIndex >= 0 ? breakIndex + 1 : ttsAccumulatedText.length; + } + } + } + scrollToBottom(); } } @@ -4170,11 +4214,21 @@ async function streamGenerate(userMsgIndex) { thinkingEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].thinking); } contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content); + + // 播放剩余未播放的文本(流式TTS) + if (enableTTS && ttsAccumulatedText && ttsLastPlayedIndex < ttsAccumulatedText.length) { + const remainingText = ttsAccumulatedText.slice(ttsLastPlayedIndex).trim(); + if (remainingText) { + addToTTSQueue(remainingText); + } + } } catch (error) { console.error('Error:', error); currentConversation.messages[aiMessageIndex].content = `抱歉,出现了错误:${error.message}\n\n请检查网络连接后重试。`; contentEl.innerHTML = renderMarkdown(currentConversation.messages[aiMessageIndex].content); + // 出错时停止TTS队列 + stopTTSQueue(); } finally { isLoading = false; sendBtn.disabled = false; @@ -4187,12 +4241,6 @@ async function streamGenerate(userMsgIndex) { // 记录统计到 backend logStatsToBackend('llm_call', currentConversation.agentId || 'chat', 1); - // 自动播放 TTS - const lastMsg = currentConversation.messages[aiMessageIndex]; - if (enableTTS && lastMsg && lastMsg.content) { - autoPlayTTS(lastMsg.content); - } - // 自动总结标题:第一次对话和每隔5次对话 const totalMessages = currentConversation.messages.length; // 第一次对话(用户+AI=2条)或每5次对话(10条) @@ -4740,7 +4788,78 @@ function renderMessages() { scrollToBottom(); } -// TTS 语音播放 +// ==================== TTS 队列播放 ==================== + +// 添加文本到 TTS 队列 +async function addToTTSQueue(text) { + if (!text || !enableTTS) return; + + ttsQueue.push(text); + + // 如果没有在播放,开始播放队列 + if (!isTTSPlaying) { + playTTSQueue(); + } +} + +// 播放 TTS 队列 +async function playTTSQueue() { + if (ttsQueue.length === 0) { + isTTSPlaying = false; + return; + } + + isTTSPlaying = true; + + while (ttsQueue.length > 0 && enableTTS) { + const text = ttsQueue.shift(); + + try { + const response = await fetch('/api/tts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, voice: ttsVoice }) + }); + + if (!response.ok || !enableTTS) break; + + const audioBlob = await response.blob(); + const audioUrl = URL.createObjectURL(audioBlob); + + // 播放音频 + currentPlayingAudio = new Audio(audioUrl); + await currentPlayingAudio.play(); + + // 等待播放完成 + await new Promise(resolve => { + currentPlayingAudio.onended = resolve; + currentPlayingAudio.onerror = resolve; + }); + + URL.revokeObjectURL(audioUrl); + currentPlayingAudio = null; + + } catch (e) { + console.error('TTS播放失败:', e); + break; + } + } + + isTTSPlaying = false; + ttsQueue = []; // 清空队列 +} + +// 停止 TTS 队列播放 +function stopTTSQueue() { + ttsQueue = []; + isTTSPlaying = false; + if (currentPlayingAudio) { + currentPlayingAudio.pause(); + currentPlayingAudio = null; + } +} + +// TTS 手动播放(点击消息上的播放按钮) async function playTTS(index) { if (!currentConversation || index < 0) return; @@ -4753,10 +4872,9 @@ async function playTTS(index) { return; } - // 如果正在播放,停止播放 - if (currentPlayingAudio) { - currentPlayingAudio.pause(); - currentPlayingAudio = null; + // 如果正在播放或队列中有内容,停止 + if (currentPlayingAudio || isTTSPlaying) { + stopTTSQueue(); showToast('已停止播放'); return; } @@ -4783,7 +4901,7 @@ async function playTTS(index) { // 播放音频 currentPlayingAudio = new Audio(audioUrl); - currentPlayingAudio.play(); + await currentPlayingAudio.play(); showToast('开始播放'); @@ -4799,43 +4917,6 @@ async function playTTS(index) { } } -// 自动播放 TTS(AI回复完成后) -function autoPlayTTS(text) { - if (!enableTTS || !text) return; - - // 延迟一点时间,让用户先看到内容 - setTimeout(async () => { - try { - const response = await fetch('/api/tts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, voice: ttsVoice }) - }); - - if (!response.ok) return; - - const audioBlob = await response.blob(); - const audioUrl = URL.createObjectURL(audioBlob); - - // 停止之前的播放 - if (currentPlayingAudio) { - currentPlayingAudio.pause(); - } - - currentPlayingAudio = new Audio(audioUrl); - currentPlayingAudio.play(); - - currentPlayingAudio.onended = () => { - URL.revokeObjectURL(audioUrl); - currentPlayingAudio = null; - }; - - } catch (e) { - console.error('自动TTS播放失败:', e); - } - }, 500); -} - // 折叠/展开思考内容 function toggleThinking(block) { block.classList.toggle('expanded');