let typewriterTextNode = null; let lastRenderedAgentText = ''; let typewriterFrameId = null; window.agentOutputAwaitingSpeechCompletion = false; window.agentOutputHasAnswerStream = false; window.typewriterState = { text: '', position: 0, isActive: false, frameId: null }; /** * 取消當前的打字機動畫 */ function cancelTypewriterAnimation() { if (window.typewriterState.frameId) { cancelAnimationFrame(window.typewriterState.frameId); window.typewriterState.frameId = null; } typewriterFrameId = null; window.typewriterState.isActive = false; } function setTypewriterContent(text) { const nextText = String(text || ''); if (lastRenderedAgentText === nextText) { return; } lastRenderedAgentText = nextText; renderAgentMarkdown(nextText); } function typewriterEffect(text, speed = 40, enableTTS = true, options = {}) { const sourceText = String(text || '').trim(); // 如果新文字是舊文字的延續,且正在打字中,我們不重頭開始,而是更新目標 if (window.typewriterState.isActive && sourceText.startsWith(window.typewriterState.text)) { window.typewriterState.text = sourceText; return; } cancelTypewriterAnimation(); window.typewriterState = { text: sourceText, position: 0, isActive: true, frameId: null }; const awaitSpeechCompletion = options.awaitSpeechCompletion === true || !!enableTTS; window.agentOutputAwaitingSpeechCompletion = awaitSpeechCompletion; setAgentOutputMode('final'); if (!agentOutput.dataset.temporary) { agentOutput.dataset.temporary = 'false'; } agentOutput.classList.add('active'); agentOutput.classList.add('typing-active'); agentOutput.classList.remove('typing-done'); if (enableTTS && typeof speakText === 'function') { speakText(sourceText); } setState('speaking'); const charsPerMs = speed > 0 ? 1 / speed : 2; let startTime = null; function step(timestamp) { if (startTime === null) startTime = timestamp; const elapsed = timestamp - startTime; // 計算理論上應該打到哪裡 let nextPos = Math.floor(elapsed * charsPerMs); // 如果落後目標太多,僅進行溫和追趕,避免瞬間跳轉 const lag = window.typewriterState.text.length - nextPos; if (lag > 20) { // 每幀額外追趕一些字元,而不是直接跳到底 startTime -= (lag * 0.3) * (1 / charsPerMs); nextPos = Math.floor((timestamp - startTime) * charsPerMs); } window.typewriterState.position = Math.min(window.typewriterState.text.length, nextPos); setTypewriterContent(window.typewriterState.text.slice(0, window.typewriterState.position)); agentOutput.scrollTop = agentOutput.scrollHeight; if (window.typewriterState.position < window.typewriterState.text.length) { window.typewriterState.frameId = requestAnimationFrame(step); typewriterFrameId = window.typewriterState.frameId; return; } // 結束打字 completeTypewriter(); } window.typewriterState.frameId = requestAnimationFrame(step); typewriterFrameId = window.typewriterState.frameId; } function completeTypewriter() { window.typewriterState.isActive = false; window.typewriterState.frameId = null; typewriterFrameId = null; agentOutput.classList.remove('typing-active'); agentOutput.classList.add('typing-done'); setTypewriterContent(window.typewriterState.text); agentOutput.scrollTop = agentOutput.scrollHeight; if (agentOutput.dataset.temporary === 'true') { return; } if (typeof hasPendingStreamingSpeech === 'function' && hasPendingStreamingSpeech()) { window.agentOutputAwaitingSpeechCompletion = true; return; } if (window.agentOutputAwaitingSpeechCompletion) { return; } setState('idle', {clearCards: false}); } const agentProgressSteps = []; function renderAgentProgressStep(text) { const safeText = text || '正在處理...'; const nextIndex = agentProgressSteps.length + 1; agentProgressSteps.push(safeText); if (agentProgressSteps.length > 4) { agentProgressSteps.shift(); } const rows = agentProgressSteps.map((step, index) => { const isLatest = index === agentProgressSteps.length - 1; const className = isLatest ? 'agent-progress-row current' : 'agent-progress-row'; return `
${escapeHtml(step)}
`; }); return `
${rows.join('')}
`; } function setAgentOutputMode(mode) { agentOutput.classList.remove('progress-mode', 'output-mode-processing', 'output-mode-streaming', 'output-mode-final'); if (mode === 'processing') { agentOutput.classList.add('progress-mode', 'output-mode-processing'); } else if (mode === 'streaming') { agentOutput.classList.add('output-mode-streaming'); } else if (mode === 'final') { agentOutput.classList.add('output-mode-final'); } } function setAgentOutputContent(html) { let shell = agentOutput.querySelector('.agent-output-shell'); if (!shell) { agentOutput.innerHTML = '
'; shell = agentOutput.querySelector('.agent-output-shell'); } const contentNode = shell.querySelector('.agent-output-content'); // 在打字機模式下,我們加上一個光標元素 const isTyping = agentOutput.classList.contains('typing-active'); const cursorHtml = isTyping ? '' : ''; contentNode.innerHTML = html + cursorHtml; typewriterTextNode = null; agentOutput.scrollTop = agentOutput.scrollHeight; } function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function renderInlineMarkdown(text) { return escapeHtml(text) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/_([^_]+)_/g, '$1') .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1') .replace(/(https?:\/\/[^\s<]+)/g, '$1'); } function renderAgentMarkdown(markdown) { const text = String(markdown || '').replace(/\r\n/g, '\n'); const blocks = []; const codePattern = /```([\s\S]*?)```/g; let cursor = 0; let match; while ((match = codePattern.exec(text)) !== null) { blocks.push({type: 'markdown', value: text.slice(cursor, match.index)}); blocks.push({type: 'code', value: match[1].replace(/^\w+\n/, '')}); cursor = match.index + match[0].length; } blocks.push({type: 'markdown', value: text.slice(cursor)}); const rendered = blocks.map(block => { if (block.type === 'code') { return `
${escapeHtml(block.value.trim())}
`; } return renderMarkdownBlock(block.value); }).join(''); setAgentOutputContent(rendered); } function renderMarkdownBlock(value) { const lines = String(value || '').split('\n'); const html = []; let listItems = []; function flushList() { if (!listItems.length) return; html.push(``); listItems = []; } lines.forEach(line => { const trimmed = line.trim(); if (!trimmed) { flushList(); return; } const heading = trimmed.match(/^(#{1,3})\s+(.+)$/); if (heading) { flushList(); const level = heading[1].length; html.push(`${renderInlineMarkdown(heading[2])}`); return; } const bullet = trimmed.match(/^([-*]|\d+\.)\s+(.+)$/); if (bullet) { listItems.push(bullet[2]); return; } flushList(); html.push(`

${renderInlineMarkdown(trimmed)}

`); }); flushList(); return html.join(''); } function editAgentOutput(text, temporary = false, options = {}) { const nextText = String(text || '').trim(); if (temporary) { if (options.progress === true && window.agentOutputHasAnswerStream) { return; } if (options.progress !== true) { window.agentOutputHasAnswerStream = true; } agentOutput.dataset.temporary = 'true'; // 串流模式:使用增量打字效果 typewriterEffect(nextText, 30, false); if (options.progress === true) { setAgentOutputMode('processing'); } else { setAgentOutputMode('streaming'); } } else { // 非串流模式(例如狀態更新):直接更新文字 cancelTypewriterAnimation(); setTypewriterContent(nextText); agentOutput.dataset.temporary = 'false'; agentOutput.classList.add('active'); setAgentOutputMode('final'); } } function finishAgentOutput(text, enableTTS = true, options = {}) { // 結束時不需要重啟打字機,只需確保目標文字是最新的,並完成剩餘部分 window.agentOutputHasAnswerStream = false; agentOutput.dataset.temporary = 'false'; typewriterEffect(text || '', 24, enableTTS, options); } function hideAgentOutput() { cancelTypewriterAnimation(); window.agentOutputHasAnswerStream = false; agentOutput.classList.remove('active'); agentOutput.classList.remove('typing-active'); agentOutput.classList.add('typing-done'); setAgentOutputMode(null); agentOutput.innerHTML = ''; typewriterTextNode = null; lastRenderedAgentText = ''; } function initEmotionSelector() { document.getElementById('emotion-select').addEventListener('change', (e) => { const emotion = e.target.value; background.className = `voice-immersive-background emotion-${emotion} active`; emotionIndicator.textContent = `當前情緒: ${emotionEmojis[emotion]}`; }); } function initTranscriptControls() { document.getElementById('transcript-provisional').addEventListener('click', () => { transcript.textContent = '今天天氣怎麼樣'; transcript.className = 'voice-transcript provisional'; }); document.getElementById('transcript-final').addEventListener('click', () => { transcript.textContent = '今天天氣怎麼樣?'; transcript.className = 'voice-transcript final'; }); } function initLoginButton() { const googleLoginBtn = document.getElementById('googleLoginBtn'); if (googleLoginBtn) { googleLoginBtn.addEventListener('click', handleGoogleLogin); } } function initLogoutButton() { const logoutBtn = document.getElementById('logoutBtn'); if (logoutBtn) { logoutBtn.addEventListener('click', handleLogout); } } function handleLogout() { localStorage.removeItem('jwt_token'); if (typeof ws !== 'undefined' && ws) { ws.close(); } if (typeof stopSpeaking === 'function') { stopSpeaking(true, 'logout'); } window.location.href = '/login/'; } let isTextInputMode = false; // 當前是否為文字輸入模式 let textInputElement = null; // 文字輸入框元素 function initChatIcon() { const chatIcon = document.getElementById('chatIcon'); if (chatIcon) { chatIcon.addEventListener('click', toggleInputMode); } } function toggleInputMode() { isTextInputMode = !isTextInputMode; const transcript = document.getElementById('transcript'); if (!transcript) { console.error('❌ 找不到 transcript 元素'); return; } if (isTextInputMode) { const originalContent = transcript.textContent; transcript.className = 'voice-transcript text-input-mode'; transcript.innerHTML = ''; textInputElement = document.createElement('textarea'); textInputElement.placeholder = '請輸入訊息...'; textInputElement.id = 'text-input-box'; textInputElement.addEventListener('keydown', handleTextInput); transcript.appendChild(textInputElement); setTimeout(() => textInputElement.focus(), 100); } else { if (textInputElement) { textInputElement.removeEventListener('keydown', handleTextInput); textInputElement = null; } transcript.className = 'voice-transcript provisional'; transcript.textContent = '請說話...'; } } function handleTextInput(event) { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); const text = textInputElement.value.trim(); if (!text) { console.warn('⚠️ 訊息為空,不送出'); return; } if (typeof wsManager !== 'undefined' && wsManager) { const chatId = window.currentChatId || null; wsManager.sendUserMessage(text, chatId); textInputElement.value = ''; if (typeof setState === 'function') { setState('thinking'); } // 確保重置錄音與波形,允許接著交互 if (typeof stopRealAudioAnalysis === 'function') { stopRealAudioAnalysis(); } if (typeof isRecording !== 'undefined') { isRecording = false; } toggleInputMode(); } else { console.error('❌ WebSocket 未初始化'); } } }