const statusEl = document.getElementById("status"); const outputEl = document.getElementById("output"); const healthBtn = document.getElementById("healthBtn"); const offerBtn = document.getElementById("offerBtn"); const resetBtn = document.getElementById("resetBtn"); const sendBtn = document.getElementById("sendBtn"); const chatInput = document.getElementById("chatInput"); const remoteVideo = document.getElementById("remoteVideo"); remoteVideo.muted = false; remoteVideo.volume = 1.0; const messagesEl = document.getElementById("messages"); const connBadge = document.getElementById("connBadge"); const mPeers = document.getElementById("mPeers"); const mBusy = document.getElementById("mBusy"); const mLatency = document.getElementById("mLatency"); const mAsrLatency = document.getElementById("mAsrLatency"); const mLlmLatency = document.getElementById("mLlmLatency"); const mLlmSource = document.getElementById("mLlmSource"); const mLlmHistory = document.getElementById("mLlmHistory"); const mTtsLatency = document.getElementById("mTtsLatency"); const mTtsFirst = document.getElementById("mTtsFirst"); const mTts = document.getElementById("mTts"); const mBargeIn = document.getElementById("mBargeIn"); const mInputMode = document.getElementById("mInputMode"); const mVadCount = document.getElementById("mVadCount"); const dConn = document.getElementById("dConn"); const dVad = document.getElementById("dVad"); const dAsr = document.getElementById("dAsr"); const dLlm = document.getElementById("dLlm"); const dTts = document.getElementById("dTts"); let pc = null; let evt = null; let subtitleWs = null; let localMicStream = null; let lastSeenRun = 0; let lastBusy = false; let aiStreamingEl = null; const AUTO_RECONNECT_KEY = "visual_chat_auto_reconnect"; const stateMap = { "SessionState.IDLE": "空闲", "SessionState.USER_SPEAKING": "用户说话", "SessionState.THINKING": "思考中", "SessionState.AVATAR_SPEAKING": "数字人说话", idle: "空闲", user_speaking: "用户说话", thinking: "思考中", avatar_speaking: "数字人说话", }; function fmtTime() { return new Date().toLocaleTimeString("zh-CN", { hour12: false }); } function toCnState(raw) { return stateMap[raw] || raw; } function addMessage(role, text) { const row = document.createElement("div"); row.className = "msg-row"; const div = document.createElement("div"); div.className = `msg ${role}`; div.textContent = text; const time = document.createElement("div"); time.className = `msg-time ${role}`; time.textContent = fmtTime(); row.appendChild(div); row.appendChild(time); messagesEl.appendChild(row); messagesEl.scrollTop = messagesEl.scrollHeight; return div; } function setConnBadge(label, kind = "neutral") { connBadge.textContent = label; connBadge.className = `badge ${kind}`; } function setDot(el, kind) { el.className = `dot ${kind}`; } function updateMetrics(data) { mPeers.textContent = String(data.peers ?? 0); mBusy.textContent = data.pipeline_busy ? "是" : "否"; mLatency.textContent = `${data.last_latency_ms ?? 0} ms`; mAsrLatency.textContent = `${data.last_asr_latency_ms ?? 0} ms`; mLlmLatency.textContent = `${data.last_llm_latency_ms ?? 0} ms`; mLlmSource.textContent = data.last_llm_source === "online" ? "在线" : "兜底"; mLlmHistory.textContent = String(data?.llm?.history_items ?? 0); mTtsLatency.textContent = `${data.last_tts_latency_ms ?? 0} ms`; mTtsFirst.textContent = `${data.last_tts_first_chunk_ms ?? 0} ms`; mTts.textContent = data?.tts?.ready ? "就绪" : "未就绪"; mBargeIn.textContent = `${data.last_barge_in_ms ?? 0} ms (${data.barge_in_count ?? 0})`; mInputMode.textContent = data.last_input_mode === "voice" ? "语音" : data.last_input_mode === "text" ? "文本" : "none"; mVadCount.textContent = `${data.vad_start_count ?? 0}/${data.vad_end_count ?? 0}`; setDot(dConn, (data.peers ?? 0) > 0 ? "ok" : "warn"); setDot(dVad, (data.vad_start_count ?? 0) > 0 ? "ok" : "warn"); setDot(dAsr, data?.asr?.ready ? "ok" : "warn"); setDot(dLlm, data?.llm?.ready ? "ok" : "bad"); setDot(dTts, data?.tts?.ready ? "ok" : "warn"); } async function checkHealth() { const res = await fetch("/health"); const data = await res.json(); statusEl.textContent = `状态:${toCnState(data.state)}`; outputEl.textContent = JSON.stringify(data, null, 2); sendBtn.disabled = !!data.pipeline_busy; updateMetrics(data); } function subscribeEvents() { if (evt) return; evt = new EventSource("/events"); evt.onmessage = (event) => { try { const data = JSON.parse(event.data); statusEl.textContent = `状态:${toCnState(data.state)}`; outputEl.textContent = JSON.stringify(data, null, 2); sendBtn.disabled = !!data.pipeline_busy; updateMetrics(data); lastBusy = !!data.pipeline_busy; if (typeof data.pipeline_runs === "number" && data.pipeline_runs > lastSeenRun) lastSeenRun = data.pipeline_runs; } catch (_) { // no-op } }; } function subscribeSubtitles() { if (subtitleWs && (subtitleWs.readyState === WebSocket.OPEN || subtitleWs.readyState === WebSocket.CONNECTING)) return; const scheme = location.protocol === "https:" ? "wss" : "ws"; subtitleWs = new WebSocket(`${scheme}://${location.host}/ws/subtitles`); subtitleWs.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.role === "user") { aiStreamingEl = null; addMessage(data.role, data.text || ""); } else if (data.role === "ai") { if (data.partial) { if (!aiStreamingEl) aiStreamingEl = addMessage("ai", ""); aiStreamingEl.textContent = `${aiStreamingEl.textContent}${data.text || ""}`; if (data.final) aiStreamingEl = null; } else { aiStreamingEl = null; addMessage("ai", data.text || ""); } } } catch (_) { // no-op } }; subtitleWs.onclose = () => { setTimeout(subscribeSubtitles, 1000); }; } async function connectWebRTC() { if (pc) { outputEl.textContent = "WebRTC 已连接或正在连接"; return; } setConnBadge("连接中...", "warn"); offerBtn.disabled = true; const metaRes = await fetch("/meta"); const meta = await metaRes.json(); pc = new RTCPeerConnection({ iceServers: [{ urls: [meta.stun_url || "stun:stun.l.google.com:19302"] }], }); const currentPc = pc; let seenConnected = false; currentPc.onconnectionstatechange = () => { const st = currentPc.connectionState; if (st === "connected") { seenConnected = true; setConnBadge("已连接", "ok"); localStorage.setItem(AUTO_RECONNECT_KEY, "1"); } else if (st === "connecting") setConnBadge("连接中...", "warn"); else if (st === "failed" || st === "disconnected" || st === "closed") { setConnBadge(`连接${st}`, "warn"); if (seenConnected && (st === "disconnected" || st === "failed")) { addMessage("system", "连接已断开,可能被新的客户端连接接管。"); } if (localMicStream) { localMicStream.getTracks().forEach((t) => t.stop()); localMicStream = null; } if (pc === currentPc) { pc = null; } offerBtn.disabled = false; } }; // Explicitly request remote media m-lines so server can attach tracks. pc.addTransceiver("video", { direction: "recvonly" }); pc.ontrack = (event) => { if (event.streams && event.streams[0]) { remoteVideo.srcObject = event.streams[0]; remoteVideo.play().catch(() => { addMessage("system", "远端音视频已到达,如未自动播放请点击视频区域后重试。"); }); } }; let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); localMicStream = stream; } catch (err) { statusEl.textContent = "状态:麦克风权限失败"; outputEl.textContent = `getUserMedia 失败:${err?.message || err}\n提示:跨机器访问请使用 HTTPS 或浏览器安全例外。`; setConnBadge("麦克风失败", "warn"); offerBtn.disabled = false; throw err; } stream.getTracks().forEach((track) => pc.addTrack(track, stream)); const sendAudioTrack = stream.getAudioTracks()[0]; if (sendAudioTrack) { sendAudioTrack.enabled = true; } const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const res = await fetch("/webrtc/offer", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sdp: pc.localDescription.sdp, type: pc.localDescription.type, }), }); const answer = await res.json(); await pc.setRemoteDescription(answer); statusEl.textContent = "状态:WebRTC 已连接"; outputEl.textContent = JSON.stringify( { connected: true, iceConnectionState: pc.iceConnectionState }, null, 2 ); setConnBadge("已连接", "ok"); addMessage("system", "WebRTC 已连接,可开始对话。"); if (answer.replaced_previous) { addMessage("system", "检测到已有旧连接,已自动断开旧连接并由当前页面接管。"); } subscribeEvents(); } async function sendText() { const text = (chatInput.value || "").trim(); if (!text) return; if (sendBtn.disabled) return; sendBtn.disabled = true; const res = await fetch("/chat/text", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); const data = await res.json(); outputEl.textContent = JSON.stringify(data, null, 2); if (data.ok) { chatInput.value = ""; } else if (data.message) { addMessage("system", `发送失败:${data.message}`); } sendBtn.disabled = false; } async function resetChat() { await fetch("/chat/reset", { method: "POST" }); messagesEl.innerHTML = ""; addMessage("system", "会话已重置。"); await checkHealth(); } function tryAutoReconnect() { if (localStorage.getItem(AUTO_RECONNECT_KEY) !== "1") return; setTimeout(() => { if (!pc) connectWebRTC().catch(() => { // no-op: user can reconnect manually if permission/policy blocks auto flow }); }, 300); } healthBtn.addEventListener("click", checkHealth); offerBtn.addEventListener("click", connectWebRTC); resetBtn.addEventListener("click", resetChat); sendBtn.addEventListener("click", sendText); chatInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); sendText(); } }); checkHealth(); subscribeEvents(); subscribeSubtitles(); setConnBadge("未连接", "neutral"); tryAutoReconnect();