import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; import { smartNormalize, prepareForTTS } from "./viNumberFix.js"; const VOICES = [ "Aiden", "Ryan", "Dylan", "Eric", "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian", ]; const DEFAULT_VOICE = "Sohee"; // ── News fetching ─────────────────────────────────────────────────────── async function getHotNews(page = 1, pageSize = 10) { let items = []; try { const resp = await fetch(`/api/news/hot?page=${page}&pageSize=${pageSize}`); if (resp.ok) { const data = await resp.json(); if (data.items && data.items.length > 0) { items = data.items; newsHasMore = data.hasMore || false; } } } catch (e) { console.warn("News API failed:", e); } return items; } const DEFAULT_INSTRUCTIONS = [ "You are a friendly voice assistant with a visible, human-like 3D avatar.", "CRITICAL: Always respond in the SAME LANGUAGE that the user writes or speaks.", "If Vietnamese, respond in Vietnamese. If English, respond in English.", "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),", "CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35'). This ensures the TTS voice reads them correctly.", "Keep replies short, natural, warm. Never list-like.", "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression. Use them naturally.", "NEVER guess. Use search_web or search_wikipedia for ANY factual question.", "Never mention tools or that you control an avatar.", "Source links should be formatted as popup links with tags.", ].join(" "); const STORAGE_KEYS = { voice: "avatar.voice", avatar: "avatar.model", instructions: "avatar.instructions", directUrl: "avatar.directUrl", subtitles: "avatar.subtitles", }; const TOOL_DEFS = [ { type: "function", name: "set_mood", description: "Change your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS } }, required: ["mood"] } }, { type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES } }, required: ["gesture"] } }, { type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string" } }, required: ["emoji"] } }, { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {} } }, { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } }, { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } }, ]; // ── DOM elements ───────────────────────────────────────────────────────── const $ = (sel) => document.querySelector(sel); const stageNode = $("#stage"); const mainBtn = $("#main-btn"); const mainBtnLabel = $("#main-btn-label"); const muteBtn = $("#mute-btn"); const textModeBtn = $("#text-mode-btn"); const newsToggleFooter = $("#news-toggle-footer"); const caption = $("#caption"); const subtitles = $("#subtitles"); const loading = $("#loading"); const settingsBtn = $("#settings-btn"); const settingsDialog = $("#settings"); const inputVoice = $("#voice"); const inputInstructions = $("#instructions"); const inputDirectUrl = $("#direct-url"); const inputSubtitles = $("#subtitles-toggle"); const directUrlRow = $("#direct-url-row"); const textChat = $("#text-chat"); const chatHeader = $("#chat-header"); const chatMessages = $("#chat-messages"); const chatInput = $("#chat-input"); const chatSendBtn = $("#chat-send-btn"); const chatCloseBtn = $("#chat-close-btn"); const chatResizeHandle = $("#chat-resize-handle"); const chatAvatarSelect = $("#chat-avatar-select"); const settingsAvatarSelect = $("#settings-avatar-select"); const newsPanel = $("#news-panel"); const newsList = $("#news-list"); const newsListContainer = $("#news-list-container"); const newsCloseBtn = $("#news-close-btn"); const newsToggleBtn = $("#news-toggle-btn"); const newsChevronUp = $("#news-chevron-up"); const newsChevronDown = $("#news-chevron-down"); const newsLoadMoreBtn = $("#news-load-more"); // ── State ─────────────────────────────────────────────────────────────── const stage = new AvatarStage(stageNode); let client = null; let muted = false; let subtitleTimer = 0; let textMode = false; let config = { lb: false, allowDirect: true }; let avatarList = []; let sessionInProgress = false; let autoGreetingSent = false; let preFetchedNews = []; let latestNewsUrl = null; let latestNewsSource = null; let newsCollapsed = false; let newsPage = 1; const NEWS_PAGE_SIZE = 10; let newsLoading = false; let newsHasMore = true; function loadSettings() { return { voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE, avatar: localStorage.getItem(STORAGE_KEYS.avatar) || "vuong.glb", instructions: localStorage.getItem(STORAGE_KEYS.instructions) || "", directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "", subtitles: localStorage.getItem(STORAGE_KEYS.subtitles) === "1", }; } let settings = loadSettings(); function saveSettings() { localStorage.setItem(STORAGE_KEYS.voice, settings.voice); localStorage.setItem(STORAGE_KEYS.avatar, settings.avatar); localStorage.setItem(STORAGE_KEYS.instructions, settings.instructions); localStorage.setItem(STORAGE_KEYS.directUrl, settings.directUrl); localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0"); } function getSourceName(hostname) { const name = hostname.replace(/^www\./, "").toLowerCase(); const known = { "vnexpress.net": "VnExpress", "dantri.com.vn": "Dân trí", "tuoitre.vn": "Tuổi Trẻ", "thanhnien.vn": "Thanh Niên", "vietnamnet.vn": "VietNamNet", "zingnews.vn": "ZingNews", "cafef.vn": "CafeF", "genk.vn": "GenK", "kenh14.vn": "Kênh 14", "afamily.vn": "Afamily", "plo.vn": "PLO", "vtc.vn": "VTC", }; return known[name] || name; } // Format date/time in Vietnamese function formatDateTimeVN(date) { const weekdays = ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"]; const months = ["tháng 1", "tháng 2", "tháng 3", "tháng 4", "tháng 5", "tháng 6", "tháng 7", "tháng 8", "tháng 9", "tháng 10", "tháng 11", "tháng 12"]; const day = date.getDate(); const month = months[date.getMonth()]; const year = date.getFullYear(); const weekday = weekdays[date.getDay()]; const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return { dateStr: `${weekday}, ngày ${day} ${month} năm ${year}`, timeStr: `${hours}:${minutes}` }; } function effectiveInstructions(newsItems) { const now = new Date(); const { dateStr, timeStr } = formatDateTimeVN(now); const dateLine = `Hôm nay là ${dateStr}. Bây giờ là ${timeStr}.`; const extra = settings.instructions.trim(); const introLine = "Tôi tên là Vương đến V.AI STUDIO."; let newsInst = ""; if (newsItems && newsItems.length > 0) { const topNews = newsItems[0]; const sourceName = getSourceName(new URL(topNews.link).hostname); newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}.\nTin HOT nhất hôm nay: ${topNews.title} (nguồn ${sourceName})"\nInclude the full title and source name in parentheses. After greeting, offer to help with questions.`; } else { newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}."\nThen offer to help with questions.`; } const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`; return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base; } // ── News Panel ───────────────────────────────────────────────────────── function toggleNewsPanel() { if (!newsPanel) return; if (newsPanel.hidden) { newsPanel.hidden = false; newsToggleFooter?.classList.add("active"); loadNewsPage(1, true); } else { newsPanel.hidden = true; newsToggleFooter?.classList.remove("active"); } } function toggleNewsCollapse() { if (!newsPanel) return; newsCollapsed = !newsCollapsed; newsPanel.classList.toggle("collapsed", newsCollapsed); if (newsChevronUp && newsChevronDown) { newsChevronUp.hidden = newsCollapsed; newsChevronDown.hidden = !newsCollapsed; } } async function loadNewsPage(page = 1, reset = false) { if (newsLoading) return; newsLoading = true; if (newsLoadMoreBtn) { newsLoadMoreBtn.textContent = "Đang tải..."; newsLoadMoreBtn.disabled = true; } try { const items = await getHotNews(page); if (reset && newsList) { newsList.innerHTML = ""; preFetchedNews = []; } if (items.length > 0) { preFetchedNews = [...preFetchedNews, ...items]; appendNewsItems(items); newsPage = page; } if (newsLoadMoreBtn) { newsLoadMoreBtn.hidden = !newsHasMore; newsLoadMoreBtn.textContent = "Xem thêm tin tức ↓"; newsLoadMoreBtn.disabled = false; } } catch (e) { console.warn("Failed to load news page:", e); if (newsLoadMoreBtn) { newsLoadMoreBtn.textContent = "Lỗi tải tin. Thử lại ↓"; newsLoadMoreBtn.disabled = false; } } newsLoading = false; } function appendNewsItems(items) { if (!newsList) return; items.forEach((item, idx) => { const newsItem = document.createElement("div"); newsItem.className = "news-item"; const globalIdx = preFetchedNews.length - items.length + idx + 1; let imageHtml = ""; if (item.image) { imageHtml = ``; } newsItem.innerHTML = ` ${imageHtml}
${globalIdx}. ${item.title} (${item.source})
`; newsItem.addEventListener("click", () => { summarizeArticle(item.link, item.title); }); newsList.appendChild(newsItem); }); } async function summarizeArticle(url, title) { const summaryText = `Hãy tóm tắt ngắn gọn và đầy đủ nhất nội dung bài viết này: "${title}" (${url})`; if (!client || !sessionInProgress) { void startTextSession(summaryText); return; } addChatMessage("user", `📰 ${title}`); client.sendUserText(summaryText); client.requestResponse(); } // ── Avatar list ───────────────────────────────────────────────────────── async function fetchAvatarList() { try { const resp = await fetch("/api/avatars"); if (resp.ok) avatarList = (await resp.json()).avatars || []; } catch {} } function populateAvatarSelects(selectedName) { for (const sel of [chatAvatarSelect, settingsAvatarSelect]) { sel.innerHTML = ""; const def = document.createElement("option"); def.value = ""; def.textContent = "(Default - Brunette)"; sel.appendChild(def); for (const name of avatarList) { const opt = document.createElement("option"); opt.value = name; opt.textContent = name.replace(/\.glb$/i, "").replace(/_/g, " ") + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : ""); sel.appendChild(opt); } if (selectedName && avatarList.includes(selectedName)) { sel.value = selectedName; } } } function setAvatarFromSelect(value) { settings.avatar = value || ""; saveSettings(); } async function reloadAvatar() { if (!stage.head) return; loading.classList.remove("done"); loading.textContent = "Loading avatar..."; try { await stage.init({ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, onprogress: (ev) => { if (ev.lengthComputable) { loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; } } }); } catch (err) { console.error(err); } loading.classList.add("done"); } // ── Chat / captions ──────────────────────────────────────────────────── function clampRect() { const vw = window.innerWidth, vh = window.innerHeight; const r = textChat.getBoundingClientRect(); let l = r.left, t = r.top; l = Math.max(10, Math.min(l, vw - r.width - 10)); t = Math.max(10, Math.min(t, vh - r.height - 10)); textChat.style.left = `${l}px`; textChat.style.top = `${t}px`; } function makeDraggable() { let dragging = false; let startX, startY, startLeft, startTop; function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; } function onStart(e) { if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) { return; } const p = getPos(e); dragging = true; const rect = textChat.getBoundingClientRect(); startX = p.x; startY = p.y; startLeft = rect.left; startTop = rect.top; textChat.classList.add("dragging"); e.preventDefault(); } function onMove(e) { if (!dragging) return; const p = getPos(e); textChat.style.left = `${startLeft + p.x - startX}px`; textChat.style.top = `${startTop + p.y - startY}px`; textChat.style.right = "auto"; textChat.style.bottom = "auto"; textChat.style.width = ""; textChat.style.height = ""; e.preventDefault(); } function onEnd() { if (!dragging) return; dragging = false; textChat.classList.remove("dragging"); clampRect(); } chatHeader.addEventListener("mousedown", onStart); document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onEnd); chatHeader.addEventListener("touchstart", onStart, { passive: false }); document.addEventListener("touchmove", onMove, { passive: false }); document.addEventListener("touchend", onEnd); } function makeResizable() { let resizing = false; let startX, startY, startW, startH; function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; } function onStart(e) { resizing = true; const rect = textChat.getBoundingClientRect(); const p = getPos(e); startX = p.x; startY = p.y; startW = rect.width; startH = rect.height; textChat.classList.add("resizing"); e.preventDefault(); e.stopPropagation(); } function onMove(e) { if (!resizing) return; const p = getPos(e); textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`; textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`; textChat.style.right = "auto"; textChat.style.bottom = "auto"; e.preventDefault(); } function onEnd() { if (!resizing) return; resizing = false; textChat.classList.remove("resizing"); } chatResizeHandle.addEventListener("mousedown", onStart); document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onEnd); chatResizeHandle.addEventListener("touchstart", onStart, { passive: false }); document.addEventListener("touchmove", onMove, { passive: false }); document.addEventListener("touchend", onEnd); } function setCaption(text, kind = "") { caption.textContent = text; caption.className = kind; } function showSubtitles(text) { if (!settings.subtitles) return; clearTimeout(subtitleTimer); subtitles.innerHTML = text; subtitles.classList.add("visible"); subtitles.querySelectorAll("a[href]").forEach((a) => { a.addEventListener("click", (e) => { e.preventDefault(); const href = a.getAttribute("href"); if (href) { window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); } }); }); } function fadeSubtitles(delayMs = 2600) { clearTimeout(subtitleTimer); subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function addChatMessage(role, text) { const msg = document.createElement("div"); msg.className = `chat-message ${role}`; let displayText = text; if (role === "assistant" && latestNewsUrl && latestNewsSource) { const sourceEscaped = escapeRegex(latestNewsSource); const patterns = [ new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'), new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'), ]; for (const pattern of patterns) { if (pattern.test(displayText)) { displayText = displayText.replace(pattern, (match) => `
${match}` ); break; } } } msg.innerHTML = displayText; chatMessages.appendChild(msg); chatMessages.scrollTop = chatMessages.scrollHeight; msg.querySelectorAll("a[href]").forEach((a) => { a.addEventListener("click", (e) => { e.preventDefault(); const href = a.getAttribute("href"); if (href) { window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); } }); }); if (role === "assistant" && preFetchedNews.length > 0) { showNewsPanel(preFetchedNews.slice(0, 10)); } } function showTextChat(show) { textChat.hidden = !show; if (show) chatInput.focus(); } function sendTextViaSession(text) { if (!client) return false; const st = client._status; if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") { return false; } setCaption("SENDING…"); client.sendUserText(text); client.requestResponse(); return true; } function sendTextMessage() { const text = chatInput.value.trim(); if (!text) return; addChatMessage("user", text); chatInput.value = ""; if (!client || !sessionInProgress) { const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href); void startTextSession(text); return; } if (!sendTextViaSession(text)) { setCaption("QUEUED…"); } } // ── Event listeners for news panel ───────────────────────────────────── if (newsCloseBtn) { newsCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); newsPanel.hidden = true; newsToggleFooter?.classList.remove("active"); }); } if (newsToggleBtn) { newsToggleBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleNewsCollapse(); }); } if (newsPanel) { newsPanel.addEventListener("click", (e) => { if (e.target.closest("#news-header") && !e.target.closest("#news-header-actions")) { toggleNewsCollapse(); } }); } if (newsLoadMoreBtn) { newsLoadMoreBtn.addEventListener("click", () => { loadNewsPage(newsPage + 1); }); } if (newsToggleFooter) { newsToggleFooter.addEventListener("click", () => { toggleNewsPanel(); }); } if (chatCloseBtn) { chatCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); textMode = false; showTextChat(false); textModeBtn.classList.remove("active"); }); } // ── Button logic ──────────────────────────────────────────────────────── let mainAction = "start"; function setMainButton(action, label) { mainAction = action; mainBtnLabel.textContent = label; mainBtn.disabled = action === "busy"; mainBtn.classList.toggle("live", action === "stop"); muteBtn.hidden = action !== "stop"; textModeBtn.hidden = false; newsToggleFooter.hidden = false; } const CAPTIONS = { idle: "TAP TO TALK", "creating-session": "REQUESTING A SLOT…", queued: "WAITING IN LINE…", "your-turn": "YOUR TURN, TAP TO JOIN", connecting: "CONNECTING…", connected: "GO AHEAD, I'M LISTENING", "user-speaking": "LISTENING", processing: "THINKING…", "ai-speaking": "SPEAKING", closed: "TAP TO TALK", error: "SOMETHING BROKE, TAP TO RETRY", }; function onStatus(status) { stage.setConversationState(status); setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live"); switch (status) { case "idle": case "closed": setMainButton("start", "Start talking"); break; case "error": setMainButton("start", "Retry"); break; case "creating-session": case "connecting": setMainButton("busy", "Connecting…"); break; case "queued": setMainButton("stop", "Leave queue"); break; case "your-turn": setMainButton("join", "Join now"); break; default: setMainButton("stop", "End conversation"); break; } if (status === "user-speaking") { subtitles.classList.remove("visible"); showTextChat(textMode); } } // ── Tool runner ───────────────────────────────────────────────────────── function runTool(name, argsJson, callId) { if (!client) return; let args = {}; try { args = JSON.parse(argsJson || "{}"); } catch {} if (name === "get_current_datetime") { const now = new Date(); const { dateStr, timeStr } = formatDateTimeVN(now); client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Bây giờ là ${timeStr}.`); client.requestResponse(); return; } if (name === "search_wikipedia") { const query = args.query || ""; if (!query) { client.sendToolOutput(callId, `No query.`); client.requestResponse(); return; } fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`) .then(r => r.json()) .then(data => { if (data.extract) { client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`); client.requestResponse(); return; } fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`) .then(r => r.json()) .then(sd => { if (!sd.results?.length) { client.sendToolOutput(callId, `No Wikipedia results for "${query}".`); client.requestResponse(); return; } fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`) .then(r => r.json()) .then(sum => { client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0,3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`); client.requestResponse(); }); }); }) .catch(() => { client.sendToolOutput(callId, `Wikipedia search failed.`); client.requestResponse(); }); return; } if (name === "search_web") { const query = args.query || ""; if (!query) { client.sendToolOutput(callId, `No query.`); client.requestResponse(); return; } fetch(`/api/web/search?q=${encodeURIComponent(query)}`) .then(r => r.json()) .then(data => { if (!data.results?.length) { client.sendToolOutput(callId, `No web results for "${query}".`); client.requestResponse(); return; } client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0,3).map((r,i) => `${i+1}. ${r.title}\n ${r.snippet}`).join("\n")}`); client.requestResponse(); }) .catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return; } const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`; client.sendToolOutput(callId, result); client.requestResponse(); } async function connectSession(c) { try { await c.connect(); return c; } catch (err) { const code = err?.code; if (code === "limit") { setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error"); } else if (code === "queue-full") { setCaption("ALL SEATS TAKEN, TRY AGAIN", "error"); } else if (code === "join-expired") { setCaption("SPOT EXPIRED, TAP TO RETRY", "error"); } else if (code !== "aborted") { console.error(err); setCaption("COULD NOT CONNECT, TAP TO RETRY", "error"); } await endSession(true); return null; } } async function startVoiceSession() { if (sessionInProgress) return; sessionInProgress = true; await stage.resume(); let micStream; if (new URLSearchParams(location.search).has("fakemic")) { const ctx = stage.audioCtx; micStream = ctx.createMediaStreamDestination().stream; } else { try { micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); } catch { setCaption("MIC BLOCKED", "error"); sessionInProgress = false; return; } } const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; if (!audioCtx || !voiceSink) { sessionInProgress = false; return; } const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews()); if (!preFetchedNews.length) { preFetchedNews = newsItems; } if (newsItems.length > 0) { latestNewsUrl = newsItems[0].link; try { latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname); } catch { latestNewsSource = "VnExpress"; } } const c = new S2sWsRealtimeClient({ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), voice: settings.voice, instructions: effectiveInstructions(newsItems), micStream, audioContext: audioCtx, outputNode: voiceSink, workletBaseUrl: "/worklets/", tools: TOOL_DEFS, _textOnly: false, }); client = c; _attachClientEvents(c); setCaption("REQUESTING A SLOT…"); const ok = await connectSession(c); if (!ok) return; if (!autoGreetingSent) { c.requestResponse(); } } async function startTextSession(initialText) { if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; } sessionInProgress = true; await stage.resume(); const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; if (!audioCtx || !voiceSink) { sessionInProgress = false; return; } const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews()); if (!preFetchedNews.length) { preFetchedNews = newsItems; } if (newsItems.length > 0) { latestNewsUrl = newsItems[0].link; try { latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname); } catch { latestNewsSource = "VnExpress"; } } const c = new S2sWsRealtimeClient({ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), voice: settings.voice, instructions: effectiveInstructions(newsItems), audioContext: audioCtx, outputNode: voiceSink, workletBaseUrl: "/worklets/", tools: TOOL_DEFS, _textOnly: true, }); client = c; _attachClientEvents(c); textMode = true; showTextChat(true); textModeBtn.classList.add("active"); const ok = await connectSession(c); if (!ok) return; if (!autoGreetingSent) { c.requestResponse(); } if (initialText) { c.sendUserText(initialText); c.requestResponse(); } } function _attachClientEvents(c) { c.addEventListener("status", (e) => { onStatus(e.detail.status); }); c.addEventListener("queue", (e) => { const { position } = e.detail; setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); }); c.addEventListener("transcript", (e) => { const { role, text } = e.detail; if (role === "assistant" && text) { const normalized = smartNormalize(text); showSubtitles(normalized); addChatMessage("assistant", normalized); } }); c.addEventListener("response-finished", () => { autoGreetingSent = true; fadeSubtitles(); if (preFetchedNews.length > 0 && !newsPanel.hidden) { } }); c.addEventListener("toolcall", (e) => { const { name, arguments: args, callId } = e.detail; runTool(name, args, callId); }); c.addEventListener("server-error", (e) => { console.warn("server error:", e.detail.error); }); c.addEventListener("error", () => { void endSession(); }); } async function endSession(silent = false) { const c = client; client = null; sessionInProgress = false; autoGreetingSent = false; if (c) { if (c.options.micStream) { for (const track of c.options.micStream?.getTracks() ?? []) { track.stop(); } } await c.close().catch(() => {}); } stage.setConversationState("idle"); subtitles.classList.remove("visible"); if (!silent) { setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); } } // ── UI events ─────────────────────────────────────────────────────────── mainBtn.addEventListener("click", () => { if (mainAction === "start") { if (sessionInProgress) return; if (new URLSearchParams(location.search).has("fakemic")) { void startTextSession(); } else { void startVoiceSession(); } } else if (mainAction === "join") { stage.resume(); client?.join(); } else if (mainAction === "stop") { void endSession(); } }); muteBtn.addEventListener("click", () => { muted = !muted; client?.setMuted(muted); muteBtn.classList.toggle("active", muted); muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); }); textModeBtn.addEventListener("click", () => { textMode = !textMode; showTextChat(textMode); textModeBtn.classList.toggle("active", textMode); }); chatSendBtn.addEventListener("click", () => sendTextMessage()); chatInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { sendTextMessage(); } }); chatAvatarSelect.addEventListener("change", () => { setAvatarFromSelect(chatAvatarSelect.value); void reloadAvatar(); }); settingsAvatarSelect.addEventListener("change", () => { setAvatarFromSelect(settingsAvatarSelect.value); chatAvatarSelect.value = settingsAvatarSelect.value; void reloadAvatar(); }); settingsBtn.addEventListener("click", () => { inputVoice.value = settings.voice; inputInstructions.value = settings.instructions; inputDirectUrl.value = settings.directUrl; inputSubtitles.checked = settings.subtitles; settingsAvatarSelect.value = chatAvatarSelect.value; settingsDialog.showModal(); }); settingsDialog.addEventListener("close", () => { settings = { voice: inputVoice.value || DEFAULT_VOICE, avatar: settingsAvatarSelect.value || "", instructions: inputInstructions.value, directUrl: inputDirectUrl.value.trim(), subtitles: inputSubtitles.checked }; if (settings.avatar !== "vuong.glb") { autoGreetingSent = false; } saveSettings(); chatAvatarSelect.value = settings.avatar; if (!settings.subtitles) { subtitles.classList.remove("visible"); } client?.updateSession({ voice: settings.voice, instructions: effectiveInstructions(preFetchedNews) }); }); window.addEventListener("beforeunload", () => { client?.close(); }); // ── Boot ──────────────────────────────────────────────────────────────── async function boot() { for (const v of VOICES) { const o = document.createElement("option"); o.value = v; o.textContent = v.replaceAll("_", " "); inputVoice.append(o); } try { const resp = await fetch("api/config"); if (resp.ok) { config = { ...config, ...(await resp.json()) }; } } catch {} directUrlRow.hidden = !config.allowDirect; await fetchAvatarList(); populateAvatarSelects(settings.avatar); makeDraggable(); makeResizable(); const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href); setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…"); const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews(1).catch(() => []) : Promise.resolve([]); try { await stage.init({ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, onprogress: (ev) => { if (ev.lengthComputable) { loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; } } }); } catch (err) { console.error(err); loading.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; } preFetchedNews = await newsPromise; loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); if (newsToggleFooter) { newsToggleFooter.hidden = false; } } void boot();