// @ts-check /** * App wiring: the avatar stage + the speech-to-speech session + V.AISTUDIO catalog. * * V.AISTUDIO: compact index from /api/vaix/products (~2.5MB, 1 fetch instead of 30MB). * Full detail loaded on-demand via /api/vaix/product/:sku when user clicks a product. * Cached in _detailCache to avoid re-fetch. * 5-min etag polling for real-time sync with V.AISTUDIO product updates. * Paginated display: 10 products per page + "Xem thêm" button for lazy loading. * Product detail modal: compact data shown instantly, full details loaded in background. * No iframe — direct API calls via fetch(). * * Auto-greeting: after avatar loaded, auto-starts a greeting-only voice session. * - No mic prompt, no chat window shown. * - Uses internal fakemic (silent audio stream). * - Instructions tell Gemma to introduce herself in Vietnamese via TTS. * - After greeting finishes, session ends silently. * - User can tap "Start talking" for normal full-duplex chat. * - FIX: Added 15s timeout for greeting connection to prevent hanging. * * Preload SP: products loaded immediately after boot, not waiting for panel open. * Instructions updated: Gemma introduces herself naturally on first connection. */ import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; import { smartNormalize } from "./viNumberFix.js"; // ════════════════════════════════════════════════════════════════════════════ // AVATAR / S2S / CHAT CODE // ════════════════════════════════════════════════════════════════════════════ const VOICES = [ "Aiden", "Ryan", "Dylan", "Eric", "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian", ]; const DEFAULT_VOICE = "Sohee"; const _urlParams = new URLSearchParams(location.search); const FAKEMIC_MODE = _urlParams.has("fakemic"); // ── Greeting ───────────────────────────────────────────────────────────── async function getHotNewsGreeting() { let hotTitle = ""; try { const resp = await fetch("/api/news/hot"); if (resp.ok) { const data = await resp.json(); if (data.titles && data.titles.length > 0) { for (const t of data.titles) { const clean = t.replace(/^[\d.]+[\s:]*/, "").trim(); if (clean.length > 10 && clean.length < 200) { hotTitle = clean; break; } } } } } catch (e) { console.warn("News API failed:", e); } if (hotTitle) return `Hôm nay có tin: ${hotTitle}. Hỏi người dùng có muốn nghe không.`; return ""; } const DEFAULT_INSTRUCTIONS = [ "You are a friendly, warm voice assistant named Gemma. You have a visible, human-like 3D avatar of a young Vietnamese woman.", "You also have access to V.AI STUDIO - an e-commerce catalog with 8000+ kitchen appliances & smart locks from Malloca, Eurogold, Grob, Canzy, Demax & Dien May Xanh.", "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'),", "years as 'năm hai nghìn không trăm hai mươi sáu' (NOT '2026'),", "numbers as words (e.g. 'ba mươi lăm' NOT '35'),", "percentages as 'mười lăm phần trăm' NOT '15%',", "currency as 'năm mươi nghìn đồng' NOT '50.000đ'.", "Keep replies short, natural, warm. Never list-like.", "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression.", "You can control V.AI STUDIO catalog with tools: open_catalog, open_product, search_catalog, navigate_catalog.", "Use them naturally to express yourself and help users browse products.", "NEVER guess. Use search_web or search_wikipedia for ANY factual question.", "First call get_current_datetime to know today's date, then search.", "Never mention tools or that you control an avatar.", "When the user first connects, greet them warmly: introduce yourself as Gemma, a friendly AI assistant with a 3D avatar. Say you're here to help them with anything — chatting, answering questions, looking up news, or browsing products in V.AI STUDIO catalog. Keep it natural and warm, not robotic.", ].join(" "); /** Greeting-only instructions: Vietnamese intro, no tools, no interaction */ const GREETING_INSTRUCTIONS = [ "You are a friendly, warm voice assistant named Gemma. You have a 3D avatar of a young Vietnamese woman.", "You are connected to V.AI STUDIO - an e-commerce catalog with 8000+ kitchen appliances & smart locks.", "IMPORTANT: Your FIRST and ONLY message is a short, warm greeting in Vietnamese.", "Say EXACTLY this: 'Xin chào! Em là Gemma, trợ lý AI thân thiện của Vương V AI STUDIO. Em ở đây để giúp anh chị — trò chuyện, trả lời câu hỏi, xem tin tức, hoặc tìm sản phẩm trong V.AI STUDIO. Rất vui được gặp anh chị!'", "Say it naturally, warmly, like a real person speaking.", "After this single greeting, do NOT respond to anything else. Do NOT call any tools. End the conversation.", "Never mention tools or that you are an AI — speak naturally.", ].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: "open_catalog", description: "Open V.AI STUDIO catalog, optionally filtered by category.", parameters: { type: "object", properties: { category: { type: "string", description: "Category name (e.g., 'Bếp từ', 'Máy hút mùi', 'Khóa thông minh'). Empty for all." } }, required: ["category"] } }, { type: "function", name: "open_product", description: "Open a specific product detail page in V.AI STUDIO.", parameters: { type: "object", properties: { product_id: { type: "string", description: "Product ID or SKU" } }, required: ["product_id"] } }, { type: "function", name: "search_catalog", description: "Search V.AI STUDIO catalog for products.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query (product name, brand, feature, price range)" } }, required: ["query"] } }, { type: "function", name: "navigate_catalog", description: "Navigate V.AI STUDIO to a specific page.", parameters: { type: "object", properties: { page: { type: "string", enum: ["products", "catalogue", "contact", "cart", "home"] }, data: { type: "object" } }, required: ["page"] } }, ]; // ── 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")); // ── State ───────────────────────────────────────────────────────────────── const stage = new AvatarStage(stageNode); /** @type {S2sWsRealtimeClient | null} */ 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 preFetchedGreeting = null; let isGreetingSession = false; 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 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 newsInst = newsHook ? `\n\nWhen you start the conversation, greet the user naturally and briefly mention today's news hook: "${newsHook}"\nThen wait for their response. Keep your greeting to ONE short sentence.` : ""; const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`; return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base; } 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"); } 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, 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,#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"; 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, 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.textContent = text; subtitles.classList.add("visible"); } function fadeSubtitles(delayMs = 2600) { clearTimeout(subtitleTimer); subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); } function addChatMessage(role, text) { const msg = document.createElement("div"); msg.className = `chat-message ${role}`; msg.textContent = text; chatMessages.appendChild(msg); chatMessages.scrollTop = chatMessages.scrollHeight; } 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) { void startTextSession(text); return; } if (!sendTextViaSession(text)) setCaption("QUEUED…"); } chatCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); textMode = false; showTextChat(false); textModeBtn.classList.remove("active"); }); 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); if (isGreetingSession) { if (status === "ai-speaking") { setCaption(""); } else if (status === "closed" || status === "idle") { setCaption(CAPTIONS.idle); } return; } 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); } } function runTool(name, argsJson, callId) { if (!client) return; if (isGreetingSession) { client.sendToolOutput(callId, "OK"); client.requestResponse(); 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 q = (args.query || ""); if (!q) { client.sendToolOutput(callId, "No query."); client.requestResponse(); return; } fetch(`/api/wiki/summary?title=${encodeURIComponent(q.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(q)}`).then(r=>r.json()).then(sd => { if (!sd.results?.length) { client.sendToolOutput(callId, `No Wikipedia results.`); 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 q = (args.query || ""); if (!q) { client.sendToolOutput(callId, "No query."); client.requestResponse(); return; } fetch(`/api/web/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data => { if (!data.results?.length) { client.sendToolOutput(callId, `No web results.`); client.requestResponse(); return; } client.sendToolOutput(callId, `Web results for "${q}":\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 === "open_catalog") { vaistudioOpenCatalog(args.category || "").then(r => { client.sendToolOutput(callId, r.message); client.requestResponse(); }).catch(e => { client.sendToolOutput(callId, "Error: " + e.message); client.requestResponse(); }); return; } if (name === "open_product") { vaistudioOpenProduct(args.product_id).then(r => { client.sendToolOutput(callId, r.message); client.requestResponse(); }).catch(e => { client.sendToolOutput(callId, "Error: " + e.message); client.requestResponse(); }); return; } if (name === "search_catalog") { vaistudioSearch(args.query).then(r => { client.sendToolOutput(callId, r.message); client.requestResponse(); }).catch(e => { client.sendToolOutput(callId, "Error: " + e.message); client.requestResponse(); }); return; } if (name === "navigate_catalog") { vaistudioNavigate(args.page).then(r => { client.sendToolOutput(callId, r.message); client.requestResponse(); }).catch(e => { client.sendToolOutput(callId, "Error: " + e.message); 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 (isGreetingSession) { sessionInProgress = false; return null; } if (code === "limit") setCaption("DAILY LIMIT REACHED", "error"); else if (code === "queue-full") setCaption("ALL SEATS TAKEN", "error"); else if (code === "join-expired") setCaption("SPOT EXPIRED", "error"); else if (code !== "aborted") { console.error(err); setCaption("COULD NOT CONNECT", "error"); } sessionInProgress = false; return null; } } async function startVoiceSession() { if (sessionInProgress) return; sessionInProgress = true; isGreetingSession = false; await stage.resume(); let micStream; if (FAKEMIC_MODE) { 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 greeting = preFetchedGreeting || (await getHotNewsGreeting()); 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(); } /** * Greeting session: no mic prompt, no chat window. * 15-second timeout prevents hanging if upstream is unreachable. */ async function startGreetingSession() { if (sessionInProgress) return; console.log("[GREETING] Starting auto-greeting session (no mic, no chat)..."); isGreetingSession = true; sessionInProgress = true; // Safety timeout: reset state if greeting takes >15s const timeoutId = setTimeout(() => { if (isGreetingSession) { console.log("[GREETING] Timeout (15s) — ending greeting session"); isGreetingSession = false; sessionInProgress = false; setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); if (client) { client.close().catch(() => {}); client = null; } } }, 15000); try { await stage.resume(); const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; if (!audioCtx || !voiceSink) { clearTimeout(timeoutId); sessionInProgress = false; isGreetingSession = false; return; } const fakeMic = audioCtx.createMediaStreamDestination(); const micStream = fakeMic.stream; const c = new S2sWsRealtimeClient({ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), voice: settings.voice, instructions: GREETING_INSTRUCTIONS, micStream, audioContext: audioCtx, outputNode: voiceSink, workletBaseUrl: "/worklets/", tools: [], _textOnly: false, }); client = c; _attachClientEvents(c); setCaption(""); const ok = await connectSession(c); if (!ok) { clearTimeout(timeoutId); isGreetingSession = false; return; } autoGreetingSent = true; c.requestResponse(); clearTimeout(timeoutId); } catch(err) { clearTimeout(timeoutId); console.error("[GREETING] Error:", err); isGreetingSession = false; sessionInProgress = false; setCaption(CAPTIONS.idle); } } async function startTextSession(initialText) { if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; } sessionInProgress = true; isGreetingSession = false; await stage.resume(); const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; if (!audioCtx || !voiceSink) { sessionInProgress = false; return; } const newsHook = preFetchedGreeting || (await getHotNewsGreeting()); 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(e.detail.status)); c.addEventListener("queue", (e) => { const { position } = e.detail; if (isGreetingSession) return; 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 (isGreetingSession) { console.log("[GREETING] Greeting done, ending silent session"); setTimeout(() => { void endSession(true); }, 500); } }); 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; isGreetingSession = 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"); } mainBtn.addEventListener("click", () => { if (mainAction === "start") { if (sessionInProgress) return; if (FAKEMIC_MODE) 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" : "Mute"); }); 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(); setCaption("KHỞI ĐỘNG…"); setMainButton("busy", "Loading…"); const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? 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."; setCaption("AVATAR FAILED", "error"); return; } preFetchedGreeting = await newsPromise; loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); initVAISTUDIO(); loadVaistudioProducts().catch(() => {}); // Auto-greeting: fakemic voice session — with 15s timeout fallback setTimeout(() => { if (!sessionInProgress && !autoGreetingSent) { void startGreetingSession(); } }, 2000); } void boot(); // ════════════════════════════════════════════════════════════════════════════ // V.AISTUDIO — COMPACT INDEX, ON-DEMAND DETAIL, PAGINATED // ════════════════════════════════════════════════════════════════════════════ const VAISTUDIO_PANEL_ID = "vaistudio-panel"; const VAISTUDIO_TOGGLE_ID = "vaistudio-toggle"; const VAISTUDIO_LOADING_ID = "vaistudio-loading"; const VAISTUDIO_PRODUCTS_ID = "vaistudio-products"; const VAISTUDIO_COUNT_ID = "vaistudio-count"; const VAISTUDIO_ERROR_ID = "vaistudio-error"; const VAISTUDIO_ERROR_TEXT_ID = "vaistudio-error-text"; const VAISTUDIO_RETRY_ID = "vaistudio-retry-btn"; const VAISTUDIO_LOADMORE_ID = "vaistudio-loadmore"; let vaistudioPanel = null, vaistudioToggle = null, vaistudioLoading = null, vaistudioProducts = null, vaistudioBody = null, vaistudioCount = null, vaistudioErrorEl = null, vaistudioLoadMore = null; let vaistudioPanelOpen = false, vaistudioAllProducts = [], vaistudioProductsLoaded = false, vaistudioCurrentSearch = "", vaistudioVisibleCount = 0; const VAISTUDIO_PAGE_SIZE = 10; let _lastSearchResults = []; // ── Compact product format (from server) ── // Fields: n=name, b=brand, p=price_str, pn=priceNum, i=image, c=category, cs=cat_slug, s=sku, m=model, sl=slug, _i=idx function c2p(c) { return { name: c.n||"", title_clean: c.n||"", brand: c.b||"", price: c.p||"", priceNum: c.pn||0, category: c.c||"", category_slug: c.cs||"", sku: c.s||"", model: c.m||"", slug: c.sl||"", image: c.i||"", category_icon: c.ci||"", _idx: c._i }; } const _detailCache = new Map(); function _sd(s) { return (s||"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase(); } function _fmt(pn) { return pn > 0 ? pn.toLocaleString("vi-VN") + "₫" : "Liên hệ"; } function createCard(c, idx) { const card = document.createElement("div"); card.className = "product-card"; card.setAttribute("data-idx", idx); const priceNum = c.pn || 0, img = c.i || "", sku = c.s || ""; if (img) { const ie = document.createElement("img"); ie.className = "product-card-img"; ie.src = img; ie.alt = ""; ie.loading = "lazy"; ie.onerror = function() { this.style.display = "none"; }; card.appendChild(ie); } else { const pl = document.createElement("div"); pl.className = "product-card-img"; pl.style.cssText = "flex-shrink:0;background:#e2e8f0;display:flex;align-items:center;justify-content:center;font-size:1.5rem"; pl.textContent = "📦"; card.appendChild(pl); } const info = document.createElement("div"); info.className = "product-card-info"; info.style.cursor = "pointer"; const t = document.createElement("p"); t.className = "product-card-title"; t.textContent = c.n || ""; const b = document.createElement("p"); b.className = "product-card-brand"; b.textContent = c.b || ""; const pr = document.createElement("p"); pr.className = "product-card-price"; pr.textContent = _fmt(priceNum); info.appendChild(t); info.appendChild(b); info.appendChild(pr); if (c.c) { const catEl = document.createElement("span"); catEl.className = "product-card-cat"; catEl.textContent = c.c; info.appendChild(catEl); } card.appendChild(info); card.addEventListener("click", (e) => { e.stopPropagation(); showProductDetailCompact(c, sku); document.dispatchEvent(new CustomEvent("vaistudio-select-product", { detail: c2p(c) })); }); return card; } /** Show detail from compact data instantly, then load full in background */ async function showProductDetailCompact(compact, sku) { const overlay = document.getElementById("vaistudio-detail-overlay"); if (!overlay) return; const priceNum = compact.pn || 0; document.getElementById("detail-title").textContent = compact.n || ""; document.getElementById("detail-brand").textContent = compact.b || ""; document.getElementById("detail-model").textContent = compact.m ? "Model: " + compact.m : ""; document.getElementById("detail-price").textContent = priceNum > 0 ? priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ"; const imgContainer = document.getElementById("detail-images"); imgContainer.innerHTML = ""; if (compact.i) { const img=document.createElement("img"); img.src=compact.i; img.style.cssText="width:120px;height:120px;border-radius:12px;object-fit:cover;flex-shrink:0;scroll-snap-align:start;border:1px solid #e2e8f0"; img.onerror=function(){this.style.display="none"}; imgContainer.appendChild(img); } else { const p=document.createElement("div"); p.style.cssText="width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; p.textContent="📦"; imgContainer.appendChild(p); } document.getElementById("detail-summary").style.display = "none"; document.getElementById("detail-features").style.display = "none"; document.getElementById("detail-specs").style.display = "none"; overlay.style.display = "flex"; // Load full detail in background if (sku && !_detailCache.has(sku)) { try { const resp = await fetch("/api/vaix/product/" + encodeURIComponent(sku)); const json = await resp.json(); if (json.product) _detailCache.set(sku, json.product); } catch(e) { console.warn("[VAIX] Detail fetch failed:", e); } } const full = sku ? _detailCache.get(sku) : null; if (full) renderFullDetail(full); } function renderFullDetail(product) { const imgContainer = document.getElementById("detail-images"); imgContainer.innerHTML = ""; const allImages = []; if (product.image) allImages.push(product.image); if (product.images && Array.isArray(product.images)) { product.images.forEach(img => { if(img&&!allImages.includes(img)) allImages.push(img); }); } if (allImages.length===0) { const p=document.createElement("div"); p.style.cssText="width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; p.textContent="📦"; imgContainer.appendChild(p); } else { allImages.forEach(url=>{const img=document.createElement("img"); img.src=url; img.style.cssText="width:120px;height:120px;border-radius:12px;object-fit:cover;flex-shrink:0;scroll-snap-align:start;border:1px solid #e2e8f0"; img.onerror=function(){this.style.display="none"}; imgContainer.appendChild(img);}); } const summaryEl = document.getElementById("detail-summary"); if (product.summary) { summaryEl.textContent = product.summary; summaryEl.style.display = "block"; } else if (product.description) { summaryEl.textContent = product.description.slice(0,300)+(product.description.length>300?"…":""); summaryEl.style.display = "block"; } else { summaryEl.style.display = "none"; } const featuresContainer = document.getElementById("detail-features"); const featuresList = document.getElementById("detail-features-list"); featuresList.innerHTML = ""; if (product.features&&Array.isArray(product.features)&&product.features.length>0) { product.features.forEach(f=>{const li=document.createElement("li"); li.style.cssText="padding:6px 0;border-bottom:1px solid #f1f5f9;font-size:0.82rem;color:#475569"; li.textContent="✦ "+f; featuresList.appendChild(li);}); featuresContainer.style.display="block"; } else { featuresContainer.style.display="none"; } const specsContainer = document.getElementById("detail-specs"); const specsBody=document.querySelector("#detail-specs-table tbody"); specsBody.innerHTML=""; if(product.specs&&typeof product.specs==="object"&&Object.keys(product.specs).length>0){ Object.entries(product.specs).forEach(([k,v])=>{if(!v||v==="None")return;const tr=document.createElement("tr");const td1=document.createElement("td");td1.style.cssText="padding:6px 8px;color:#64748b;font-weight:600;width:40%;vertical-align:top;border-bottom:1px solid #f1f5f9";td1.textContent=k;const td2=document.createElement("td");td2.style.cssText="padding:6px 8px;color:#334155;border-bottom:1px solid #f1f5f9;vertical-align:top";td2.textContent=String(v).replace(/^\[|\]$/g,"");tr.appendChild(td1);tr.appendChild(td2);specsBody.appendChild(tr);}); specsContainer.style.display=specsBody.children.length>0?"block":"none"; } else { specsContainer.style.display="none"; } } function renderPaginated(products, append) { if (!vaistudioProducts) return; if (!append) { vaistudioProducts.innerHTML = ""; vaistudioVisibleCount = 0; } if (!products.length) { vaistudioProducts.innerHTML = '
Không tìm thấy
'; if (vaistudioLoadMore) vaistudioLoadMore.style.display = "none"; return; } const newCount = Math.min(vaistudioVisibleCount + VAISTUDIO_PAGE_SIZE, products.length); const startIdx = vaistudioVisibleCount; const existing = vaistudioProducts.querySelector("#" + VAISTUDIO_LOADMORE_ID); if (existing) existing.remove(); for (let i = startIdx; i < newCount; i++) vaistudioProducts.appendChild(createCard(products[i], i)); vaistudioVisibleCount = newCount; if (vaistudioVisibleCount < products.length) { const rem = products.length - vaistudioVisibleCount; const btn = document.createElement("button"); btn.id = VAISTUDIO_LOADMORE_ID; btn.className = "vaistudio-loadmore-btn"; btn.textContent = "Xem thêm " + Math.min(rem, VAISTUDIO_PAGE_SIZE) + " sản phẩm (" + rem + " còn lại)"; btn.addEventListener("click", () => { const src = vaistudioCurrentSearch ? _lastSearchResults : vaistudioAllProducts; renderPaginated(src, true); const r2 = (vaistudioCurrentSearch ? _lastSearchResults : vaistudioAllProducts).length - vaistudioVisibleCount; if (r2 > 0) btn.textContent = "Xem thêm " + Math.min(r2, VAISTUDIO_PAGE_SIZE) + " sản phẩm (" + r2 + " còn lại)"; else { const total = vaistudioCurrentSearch ? _lastSearchResults.length : vaistudioAllProducts.length; btn.textContent = "✓ Đã hiển thị " + total + " sản phẩm"; btn.disabled = true; btn.style.opacity = "0.6"; btn.style.cursor = "default"; } }); vaistudioProducts.appendChild(btn); vaistudioLoadMore = btn; } else if (vaistudioAllProducts.length > 0) { const total = vaistudioCurrentSearch ? _lastSearchResults.length : vaistudioAllProducts.length; if (vaistudioLoadMore) { vaistudioLoadMore.textContent = "✓ Đã hiển thị " + total + " sản phẩm"; vaistudioLoadMore.disabled = true; vaistudioLoadMore.style.opacity = "0.6"; vaistudioLoadMore.style.cursor = "default"; vaistudioProducts.appendChild(vaistudioLoadMore); } } } async function loadVaistudioProducts() { if (vaistudioProductsLoaded) { if (vaistudioPanelOpen) { vaistudioProducts.style.display = "block"; if (vaistudioCount) vaistudioCount.textContent = vaistudioAllProducts.length + " sản phẩm"; renderPaginated(vaistudioAllProducts); } return; } if (vaistudioLoading) vaistudioLoading.classList.remove("hidden"); if (vaistudioErrorEl) vaistudioErrorEl.style.display = "none"; try { const resp = await fetch("/api/vaix/products"); const json = await resp.json(); // Compact format: {n,b,p,pn,i,c,cs,s,m,sl,_i} vaistudioAllProducts = json.products || []; vaistudioProductsLoaded = true; if (vaistudioCount) vaistudioCount.textContent = vaistudioAllProducts.length + " sản phẩm"; if (vaistudioPanelOpen && vaistudioProducts) { vaistudioProducts.style.display = "block"; renderPaginated(vaistudioAllProducts); } if (vaistudioLoading) vaistudioLoading.classList.add("hidden"); } catch (err) { const et = document.getElementById(VAISTUDIO_ERROR_TEXT_ID); if (et) et.textContent = "Lỗi: " + (err.message || err); if (vaistudioErrorEl) vaistudioErrorEl.style.display = "flex"; if (vaistudioLoading) vaistudioLoading.classList.add("hidden"); vaistudioAllProducts = []; } } async function searchP(q) { if (!q || !q.trim()) { _lastSearchResults = []; vaistudioCurrentSearch = ""; if (vaistudioAllProducts.length > 0 && vaistudioProductsLoaded) { renderPaginated(vaistudioAllProducts); if (vaistudioCount) vaistudioCount.textContent = vaistudioAllProducts.length + " sản phẩm"; } return; } vaistudioCurrentSearch = q.trim(); if (vaistudioProducts) { vaistudioProducts.innerHTML = ""; vaistudioProducts.style.display = "block"; } if (vaistudioCount) vaistudioCount.textContent = "Đang tìm..."; try { const resp = await fetch("/api/vaix/search?q=" + encodeURIComponent(q.trim())); const json = await resp.json(); _lastSearchResults = (json.results || []).map(r => r.product); renderPaginated(_lastSearchResults); if (vaistudioCount) vaistudioCount.textContent = _lastSearchResults.length + " kết quả"; if (!_lastSearchResults.length) clientSearch(q); } catch (err) { clientSearch(q); } } function clientSearch(q) { const norm=_sd(q),terms=norm.split(/\s+/).filter(t=>t.length>1); const r=vaistudioAllProducts.filter(p=>{const s=[_sd(p.n||""),_sd(p.s||""),_sd(p.b||""),_sd(p.c||"")].join(" "); return terms.every(t=>s.includes(t));}); _lastSearchResults=r; renderPaginated(r); if(vaistudioCount) vaistudioCount.textContent=r.length+" kết quả"; } function initVAISTUDIO() { vaistudioPanel = document.getElementById(VAISTUDIO_PANEL_ID); vaistudioToggle = document.getElementById(VAISTUDIO_TOGGLE_ID); vaistudioLoading = document.getElementById(VAISTUDIO_LOADING_ID); vaistudioProducts = document.getElementById(VAISTUDIO_PRODUCTS_ID); vaistudioBody = document.getElementById("vaistudio-body"); vaistudioCount = document.getElementById(VAISTUDIO_COUNT_ID); vaistudioErrorEl = document.getElementById(VAISTUDIO_ERROR_ID); if (!vaistudioPanel || !vaistudioToggle) { console.warn("[VAISTUDIO] Elements not found"); return; } vaistudioToggle.addEventListener("click", () => { vaistudioPanelOpen = !vaistudioPanelOpen; vaistudioPanel.classList.toggle("open", vaistudioPanelOpen); vaistudioToggle.classList.toggle("active", vaistudioPanelOpen); vaistudioToggle.setAttribute("aria-label", vaistudioPanelOpen ? "Close" : "Open"); if (vaistudioPanelOpen) { if (vaistudioLoading) vaistudioLoading.classList.remove("hidden"); loadVaistudioProducts(); } }); const cb = document.getElementById("vaistudio-close"); if (cb) cb.addEventListener("click", () => { vaistudioPanelOpen = false; vaistudioPanel.classList.remove("open"); vaistudioToggle.classList.remove("active"); }); const rb = document.getElementById(VAISTUDIO_RETRY_ID); if (rb) rb.addEventListener("click", () => { vaistudioProductsLoaded = false; vaistudioVisibleCount = 0; vaistudioAllProducts = []; if (vaistudioLoading) vaistudioLoading.classList.remove("hidden"); if (vaistudioErrorEl) vaistudioErrorEl.style.display = "none"; loadVaistudioProducts(); }); const si = document.getElementById("vaix-search"); if (si) { let st = null; si.addEventListener("input", () => { if (st) clearTimeout(st); st = setTimeout(() => searchP(si.value), 300); }); si.addEventListener("keydown", (e) => { if (e.key === "Enter") { if (st) clearTimeout(st); searchP(si.value); } }); } window.vaistudio = { send: (a,d={}) => { if (a==="navigate"&&d.page==="products") { vaistudioCurrentSearch="";_lastSearchResults=[];loadVaistudioProducts();if(!vaistudioPanelOpen) vaistudioToggle.click();return{success:true};} if(a==="search") return searchP(d.query)||Promise.resolve(); return{success:false}; }, open:()=>{if(!vaistudioPanelOpen) vaistudioToggle.click();}, close:()=>{if(vaistudioPanelOpen) vaistudioToggle.click();}, isOpen:()=>vaistudioPanelOpen, products:()=>vaistudioAllProducts, search:(q)=>searchP(q) }; } async function vaistudioOpenCatalog(cat) { if(!vaistudioProductsLoaded) await loadVaistudioProducts(); if(!vaistudioPanelOpen) vaistudioToggle.click(); return{success:true,message:"Opened"+(cat?": "+cat:"")}; } async function vaistudioOpenProduct(pid) { if(!vaistudioProductsLoaded) await loadVaistudioProducts(); if(!vaistudioPanelOpen) vaistudioToggle.click(); return{success:true,message:"Opened product "+pid}; } async function vaistudioSearch(query) { if(!vaistudioProductsLoaded) await loadVaistudioProducts(); await searchP(query); if(!vaistudioPanelOpen) vaistudioToggle.click(); return{success:true,message:"Searching: "+query}; } async function vaistudioNavigate(page) { if(!vaistudioProductsLoaded) await loadVaistudioProducts(); if(page==="products"){vaistudioCurrentSearch="";_lastSearchResults=[];renderPaginated(vaistudioAllProducts);} if(!vaistudioPanelOpen) vaistudioToggle.click(); return{success:true,message:"Navigated to "+page}; }