Spaces:
Paused
Paused
| 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 <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 } }, 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 = `<img class="news-item-image" src="${item.image}" alt="" loading="lazy" onerror="this.style.display='none'" />`; | |
| } | |
| newsItem.innerHTML = ` | |
| ${imageHtml} | |
| <div class="news-item-content"> | |
| <span class="news-index">${globalIdx}.</span> | |
| <span class="news-title">${item.title}</span> | |
| <span class="news-source">(${item.source})</span> | |
| </div> | |
| `; | |
| 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) => | |
| `<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"); | |
| } | |
| }); | |
| }); | |
| 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(); |