Spaces:
Paused
Paused
| // @ts-check | |
| /** | |
| * App wiring: the avatar stage + the speech-to-speech session. | |
| * Modes: voice (mic + VAD) OR text (keyboard only, no mic needed). | |
| * v4 β 10+ hot news cards, click-to-summarize, news hook in greeting, fix avatar hang. | |
| */ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** @type {Array<{title:string,link:string,source:string,sourceName:string}>} */ | |
| let cachedNewsItems = []; | |
| let newsFetching = false; | |
| let newsFetchPromise = null; | |
| async function fetchNewsItems() { | |
| if (newsFetching && newsFetchPromise) return newsFetchPromise; | |
| newsFetching = true; | |
| newsFetchPromise = (async () => { | |
| try { | |
| const resp = await fetch("/api/news/hot"); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| if (data.items && data.items.length > 0) { | |
| cachedNewsItems = data.items; | |
| } | |
| } | |
| } catch (e) { console.warn("News API failed:", e); } | |
| return cachedNewsItems; | |
| })(); | |
| const result = await newsFetchPromise; | |
| newsFetching = false; | |
| newsFetchPromise = null; | |
| return result; | |
| } | |
| /** | |
| * Fetch a SHORT hot news headline to weave into the greeting. | |
| * Uses one of the top news items, preferring short titles. | |
| */ | |
| async function getHotNewsGreeting() { | |
| const items = await fetchNewsItems(); | |
| if (items.length === 0) return null; | |
| // Pick the shortest title that's still meaningful | |
| let best = null; | |
| for (const item of items) { | |
| const clean = item.title.replace(/^[\d.]+[\s:]*/, "").trim(); | |
| if (clean.length > 10 && clean.length < 200) { | |
| if (!best || clean.length < best.title.length) { | |
| best = { title: clean, link: item.link, sourceName: item.sourceName }; | |
| } | |
| } | |
| } | |
| return best; | |
| } | |
| const DEFAULT_INSTRUCTIONS = [ | |
| "You are a friendly voice assistant with a visible, human-like 3D avatar.", | |
| "CRITICAL: Always respond in the SAME LANGUAGE 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', 'hai nghΓ¬n khΓ΄ng trΔm hai mΖ°Ζ‘i sΓ‘u' not '2026'). This ensures the TTS voice reads them correctly. The display will show the number format naturally.", | |
| "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 <a href> 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, description: "Mood name." } }, 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, description: "Gesture name." } }, 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", description: "A single face emoji." } }, required: ["emoji"] } }, | |
| { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } }, | |
| { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } }, | |
| { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } }, | |
| { type: "function", name: "summarize_news", description: "Fetch and summarize a news article from a URL. Use when user clicks a hot news card and asks for a summary.", parameters: { type: "object", properties: { url: { type: "string", description: "URL of the news article to summarize" } }, required: ["url"] } }, | |
| ]; | |
| // ββ DOM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); | |
| const stageNode = $("#stage"); | |
| const mainBtn = /** @type {HTMLButtonElement} */ ($("#main-btn")); | |
| const mainBtnLabel = $("#main-btn-label"); | |
| const muteBtn = /** @type {HTMLButtonElement} */ ($("#mute-btn")); | |
| const textModeBtn = /** @type {HTMLButtonElement} */ ($("#text-mode-btn")); | |
| const caption = $("#caption"); | |
| const subtitles = $("#subtitles"); | |
| const loading = $("#loading"); | |
| const settingsBtn = /** @type {HTMLButtonElement} */ ($("#settings-btn")); | |
| const settingsDialog = /** @type {HTMLDialogElement} */ ($("#settings")); | |
| const inputVoice = /** @type {HTMLSelectElement} */ ($("#voice")); | |
| const inputInstructions = /** @type {HTMLTextAreaElement} */ ($("#instructions")); | |
| const inputDirectUrl = /** @type {HTMLInputElement} */ ($("#direct-url")); | |
| const inputSubtitles = /** @type {HTMLInputElement} */ ($("#subtitles-toggle")); | |
| const directUrlRow = $("#direct-url-row"); | |
| const textChat = /** @type {HTMLElement} */ ($("#text-chat")); | |
| const chatHeader = /** @type {HTMLElement} */ ($("#chat-header")); | |
| const chatMessages = /** @type {HTMLElement} */ ($("#chat-messages")); | |
| const chatInput = /** @type {HTMLInputElement} */ ($("#chat-input")); | |
| const chatSendBtn = /** @type {HTMLButtonElement} */ ($("#chat-send-btn")); | |
| const chatCloseBtn = /** @type {HTMLButtonElement} */ ($("#chat-close-btn")); | |
| const chatResizeHandle = /** @type {HTMLElement} */ ($("#chat-resize-handle")); | |
| const chatAvatarSelect = /** @type {HTMLSelectElement} */ ($("#chat-avatar-select")); | |
| const settingsAvatarSelect = /** @type {HTMLSelectElement} */ ($("#settings-avatar-select")); | |
| const newsCards = /** @type {HTMLElement} */ ($("#news-cards")); | |
| const newsList = /** @type {HTMLElement} */ ($("#news-list")); | |
| const newsCloseBtn = /** @type {HTMLButtonElement} */ ($("#news-close-btn")); | |
| // ββ State (module-level) βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const stage = new AvatarStage(stageNode); | |
| /** @type {S2sWsRealtimeClient | null} */ | |
| let client = null; | |
| let muted = false; | |
| let subtitleTimer = 0; | |
| let textMode = false; | |
| /** @type {{ lb: boolean, allowDirect: boolean }} */ | |
| let config = { lb: false, allowDirect: true }; | |
| let avatarList = []; | |
| let sessionInProgress = false; | |
| let autoGreetingSent = false; | |
| /** Pre-fetched short news hook */ | |
| let preFetchedGreeting = null; | |
| /** URL vΓ tΓͺn nguα»n tin tα»©c Δα» frontend inject link clickable */ | |
| let latestNewsUrl = null; | |
| let latestNewsSource = null; | |
| 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"); | |
| } | |
| /** | |
| * RΓΊt gα»n hostname thΓ nh tΓͺn nguα»n dα» Δα»c (vd: "vnexpress.net" β "VnExpress"). | |
| */ | |
| function getSourceName(hostname) { | |
| const name = hostname.replace(/^www\./, "").toLowerCase(); | |
| const known = { | |
| "vnexpress.net": "VnExpress", | |
| "dantri.com.vn": "DΓ’n trΓ", | |
| "dantri.com": "DΓ’n trΓ", | |
| "tuoitre.vn": "Tuα»i TrαΊ»", | |
| "thanhnien.vn": "Thanh NiΓͺn", | |
| "nhandan.vn": "NhΓ’n DΓ’n", | |
| "vietnamnet.vn": "VietNamNet", | |
| "zingnews.vn": "ZingNews", | |
| "cafef.vn": "CafeF", | |
| "techz.vn": "TechZ", | |
| "genk.vn": "GenK", | |
| "soha.vn": "Soha", | |
| "kenh14.vn": "KΓͺnh 14", | |
| "afamily.vn": "Afamily", | |
| "eva.vn": "Eva", | |
| "ngoisao.vn": "NgΓ΄i Sao", | |
| "giadinh.net.vn": "Gia ΔΓ¬nh", | |
| "tienphong.vn": "Tiα»n Phong", | |
| "plo.vn": "PLO", | |
| "vtc.vn": "VTC", | |
| "vtcnews.vn": "VTC News", | |
| "vtv.vn": "VTV", | |
| "nld.com.vn": "NgΖ°α»i Lao Δα»ng", | |
| "sggp.org.vn": "SGGP", | |
| "cand.com.vn": "CAND", | |
| "qdnd.vn": "QΔND", | |
| "laodong.vn": "Lao Δα»ng", | |
| "giaoduc.net.vn": "GiΓ‘o Dα»₯c", | |
| "phunuonline.com.vn": "Phα»₯ Nα»―", | |
| "suckhoedoisong.vn": "Sα»©c Khα»e", | |
| "vnexpress": "VnExpress", | |
| }; | |
| return known[name] || name; | |
| } | |
| /** | |
| * Build system instructions sent to the backend. | |
| * AI gets a short news hook (if any) and is told to greet naturally. | |
| * AI chα» nΓ³i tΓͺn nguα»n (VD: "nguα»n VnExpress"). | |
| * Frontend tα»± inject link clickable sau khi nhαΊn response. | |
| */ | |
| function effectiveInstructions(newsHook) { | |
| const now = new Date(); | |
| const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); | |
| const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); | |
| const dateLine = `Today is ${dateStr}. The current time is ${timeStr}.`; | |
| const extra = settings.instructions.trim(); | |
| const introLine = "TΓ΄i tΓͺn lΓ VΖ°Ζ‘ng ΔαΊΏn V.AI STUDIO."; | |
| const sourceName = newsHook ? (newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname)) : ""; | |
| const newsInst = newsHook | |
| ? `\n\nWhen you start the conversation, greet the user naturally and briefly mention today's news hook: "${newsHook.title}"\nAfter the greeting, add the source in EXACT format: "(nguα»n ${sourceName})" β WITH parentheses. Example: "CΓ³ tin nΓ³ng tα»« bΓ‘o VnExpress hΓ΄m nay: ... (nguα»n ${sourceName})"\nKeep your greeting to ONE short sentence.` | |
| : ""; | |
| const base = `${dateLine}\n\n${introLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`; | |
| return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base; | |
| } | |
| // ββ 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"); | |
| } | |
| // ββ News Cards βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** Render news cards into the news container */ | |
| function renderNewsCards() { | |
| newsList.innerHTML = ""; | |
| for (const item of cachedNewsItems) { | |
| const card = document.createElement("div"); | |
| card.className = "news-card"; | |
| card.dataset.url = item.link; | |
| card.dataset.title = item.title; | |
| const source = document.createElement("span"); | |
| source.className = "news-card-source"; | |
| source.textContent = item.sourceName || getSourceName(item.source) || "Nguα»n"; | |
| const title = document.createElement("span"); | |
| title.className = "news-card-title"; | |
| title.textContent = item.title; | |
| card.appendChild(source); | |
| card.appendChild(title); | |
| // Click to summarize | |
| card.addEventListener("click", () => { | |
| if (client && sessionInProgress) { | |
| // Send a text message asking the AI to summarize this news | |
| // The AI will use the summarize_news tool to fetch the article content | |
| const msg = `HΓ£y tΓ³m tαΊ―t ngαΊ―n gα»n vΓ ΔαΊ§y Δα»§ tin tα»©c sau tα»« ${item.sourceName || item.source || "bΓ‘o"}: "${item.title}". ΔΖ°α»ng dαΊ«n: ${item.link}`; | |
| addChatMessage("user", `π° TΓ³m tαΊ―t: ${item.title}`); | |
| client.sendUserText(msg); | |
| client.requestResponse(); | |
| } else { | |
| // No session β open in a popup window | |
| window.open(item.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); | |
| } | |
| }); | |
| newsList.appendChild(card); | |
| } | |
| } | |
| /** Show news cards */ | |
| function showNewsCards() { | |
| if (cachedNewsItems.length === 0) return; | |
| renderNewsCards(); | |
| newsCards.hidden = false; | |
| } | |
| /** Hide news cards */ | |
| function hideNewsCards() { | |
| newsCards.hidden = true; | |
| } | |
| // News close button | |
| if (newsCloseBtn) { | |
| newsCloseBtn.addEventListener("click", () => hideNewsCards()); | |
| } | |
| // ββ 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; } | |
| /** | |
| * Show subtitles with HTML support β renders <a> links as clickable popup links. | |
| */ | |
| 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); } | |
| /** Escape special regex characters in a string */ | |
| function escapeRegex(str) { | |
| return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| /** | |
| * Add a chat message with optional source link as popup. | |
| * Tα»± Δα»ng biαΊΏn "(nguα»n VnExpress)" thΓ nh link clickable. | |
| */ | |
| function addChatMessage(role, text) { | |
| const msg = document.createElement("div"); | |
| msg.className = `chat-message ${role}`; | |
| // Inject link clickable: match "(nguα»n TΓͺnNguα»n)" trong text | |
| 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'), | |
| new RegExp(`nguα»n\\s*${sourceEscaped}`, 'gi'), | |
| ]; | |
| for (const pattern of patterns) { | |
| if (pattern.test(displayText)) { | |
| displayText = displayText.replace(pattern, (match) => | |
| `<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">${match}</a>` | |
| ); | |
| 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"); | |
| } | |
| }); | |
| }); | |
| } | |
| 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β¦"); | |
| } | |
| if (chatCloseBtn) chatCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); textMode = false; showTextChat(false); textModeBtn.classList.remove("active"); }); | |
| // ββ Button βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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; } | |
| 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(); | |
| client.sendToolOutput(callId, `Current date/time: ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} at ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}.`); | |
| client.requestResponse(); return; | |
| } | |
| if (name === "search_wikipedia") { | |
| const query = /** @type {string} */ (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 = /** @type {string} */ (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; | |
| } | |
| if (name === "summarize_news") { | |
| const url = /** @type {string} */ (args.url || ""); | |
| if (!url) { client.sendToolOutput(callId, `No URL provided.`); client.requestResponse(); return; } | |
| fetch(`/api/news/summary?url=${encodeURIComponent(url)}`).then(r => r.json()).then(data => { | |
| if (data.content && data.content.length > 50) { | |
| client.sendToolOutput(callId, `TΓ³m tαΊ―t tin tα»©c tα»« ${url}:\n\n${data.content.slice(0, 2000)}`); | |
| } else { | |
| client.sendToolOutput(callId, `KhΓ΄ng thα» Δα»c nα»i dung tα»« ${url}. Vui lΓ²ng thα» lαΊ‘i sau.`); | |
| } | |
| client.requestResponse(); | |
| }).catch(() => { client.sendToolOutput(callId, `Failed to fetch news summary.`); 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 = /** @type {Error & {code?: string}} */ (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 = /** @type {AudioContext} */ (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 greeting = preFetchedGreeting || (await getHotNewsGreeting()); | |
| if (greeting) { | |
| latestNewsUrl = greeting.link; | |
| latestNewsSource = greeting.sourceName || getSourceName(new URL(greeting.link).hostname); | |
| } | |
| const c = new S2sWsRealtimeClient({ | |
| ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), | |
| voice: settings.voice, instructions: effectiveInstructions(greeting), | |
| 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(); } | |
| } | |
| /** | |
| * Start a text-only session. | |
| */ | |
| 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 newsHook = preFetchedGreeting || (await getHotNewsGreeting()); | |
| if (newsHook) { | |
| latestNewsUrl = newsHook.link; | |
| latestNewsSource = newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname); | |
| } | |
| const c = new S2sWsRealtimeClient({ | |
| ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), | |
| voice: settings.voice, instructions: effectiveInstructions(newsHook), | |
| 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(/** @type {CustomEvent} */ (e).detail.status)); | |
| c.addEventListener("queue", (e) => { const { position } = /** @type {CustomEvent} */ (e).detail; setCaption(position > 0 ? `#${position} IN LINEβ¦` : "ALMOST THEREβ¦", "live"); }); | |
| c.addEventListener("transcript", (e) => { | |
| const { role, text } = /** @type {CustomEvent} */ (e).detail; | |
| if (role === "assistant" && text) { | |
| const normalized = smartNormalize(text); | |
| showSubtitles(normalized); | |
| addChatMessage("assistant", normalized); | |
| } | |
| }); | |
| c.addEventListener("response-finished", () => { | |
| autoGreetingSent = true; | |
| fadeSubtitles(); | |
| // Show news cards after each response | |
| showNewsCards(); | |
| }); | |
| c.addEventListener("toolcall", (e) => { const { name, arguments: args, callId } = /** @type {CustomEvent} */ (e).detail; runTool(name, args, callId); }); | |
| c.addEventListener("server-error", (e) => console.warn("server error:", /** @type {CustomEvent} */ (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"); | |
| hideNewsCards(); | |
| 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(preFetchedGreeting || "") }); | |
| }); | |
| 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β¦"); | |
| // Pre-fetch news items in parallel with avatar loading | |
| const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) | |
| ? fetchNewsItems().then(() => getHotNewsGreeting()).catch(() => null) | |
| : Promise.resolve(null); | |
| 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; } | |
| preFetchedGreeting = await newsPromise; | |
| loading.classList.add("done"); | |
| setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); | |
| } | |
| void boot(); |