Spaces:
Paused
Paused
| import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; | |
| import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; | |
| import { smartNormalize } from "./viNumberFix.js"; | |
| 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"); | |
| async function getHotNewsGreeting() { | |
| let hotTitle = ""; | |
| try { | |
| const resp = await fetch("/api/news/hot"); | |
| if (resp.ok) { const data = await resp.json(); if(data.titles?.length){for(const t of data.titles){const c = t.replace(/^[\d.]+[\s:]*/,"").trim(); if(c.length>10&&c.length<200){hotTitle=c;break;}}} } | |
| } catch {} | |
| return hotTitle ? `Hôm nay có tin: ${hotTitle}. Hỏi người dùng có muốn nghe không.` : ""; | |
| } | |
| const DEFAULT_INSTRUCTIONS = [ | |
| "You are Gemma, a friendly warm voice assistant with a 3D avatar of a young Vietnamese woman.", | |
| "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).", | |
| "CRITICAL: Same language as user. VN numbers as words (ba mươi lăm not 35), dates as 'ngày 9 tháng 7 năm 2026', currency as 'năm mươi nghìn đồng'.", | |
| "Keep replies short, natural, warm.", | |
| "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, get_current_datetime, search_wikipedia, search_web, set_mood, make_hand_gesture, make_facial_expression.", | |
| "PRODUCT RULES: When user asks about ANY product — FIRST call query_catalog(query). This searches ALL products AND shows them in the panel AND returns similar product suggestions. After query_catalog, ALWAYS mention similar products and ask if user wants to see them. If user wants details, call show_product(name/SKU) to open the detail modal which also shows similar products below.", | |
| "SIMILAR PRODUCTS: query_catalog() and show_product() both automatically recommend related products by same category/brand/price range. Use this to cross-sell: 'Chị có muốn xem thêm sản phẩm tương tự không?'", | |
| "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.", | |
| "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.", | |
| ].join(" "); | |
| const GREETING_INSTRUCTIONS = "You are V, trợ lý AI đến từ V.AI STUDIO. Say exactly: 'Xin chào! Em là V, trợ lý AI đến từ V.AI STUDIO. Em có thể giúp anh chị tìm kiếm sản phẩm, trò chuyện, tra cứu thông tin. Anh chị nhấn nút tròn để nói chuyện hoặc nhấn icon chat để nhắn tin với em nhé!' Then end. No tools."; | |
| 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 avatar mood.", parameters:{type:"object", properties:{mood:{type:"string", enum:AVATAR_MOODS}}, required:["mood"]}}, | |
| { type:"function", name:"make_hand_gesture", description:"Hand gesture.", parameters:{type:"object", properties:{gesture:{type:"string", enum:AVATAR_GESTURES}}, required:["gesture"]}}, | |
| { type:"function", name:"make_facial_expression", description:"Face emoji.", parameters:{type:"object", properties:{emoji:{type:"string"}}, required:["emoji"]}}, | |
| { type:"function", name:"get_current_datetime", description:"Get date/time.", parameters:{type:"object", properties:{}, required:[]}}, | |
| { type:"function", name:"search_wikipedia", description:"Search Wikipedia.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"search_web", description:"Search the web.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"open_catalog", description:"Open V.AI STUDIO panel.", parameters:{type:"object", properties:{category:{type:"string"}}, required:["category"]}}, | |
| { type:"function", name:"search_catalog", description:"Search catalog UI.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}}, | |
| { type:"function", name:"query_catalog", description:"SEARCH all products + show in panel + return details + suggest similar products. ALWAYS use FIRST for product questions.", parameters:{type:"object", properties:{query:{type:"string", description:"Product name, brand, SKU, category, or feature"}}, required:["query"]}}, | |
| { type:"function", name:"show_product", description:"Open product detail modal + show similar products. Use AFTER query_catalog. Pass name/SKU.", parameters:{type:"object", properties:{product_name:{type:"string", description:"Product name or SKU"}}, required:["product_name"]}}, | |
| ]; | |
| const $ = s => document.querySelector(s); | |
| const stageNode = $("#stage"), mainBtn = $("#main-btn"), mainBtnLabel = $("#main-btn-label"); | |
| const muteBtn = $("#mute-btn"), textModeBtn = $("#text-mode-btn"), caption = $("#caption"); | |
| const subtitles = $("#subtitles"), loading = $("#loading"), settingsBtn = $("#settings-btn"); | |
| const settingsDialog = $("#settings"), inputVoice = $("#voice"), inputInstructions = $("#instructions"); | |
| const inputDirectUrl = $("#direct-url"), inputSubtitles = $("#subtitles-toggle"), directUrlRow = $("#direct-url-row"); | |
| const textChat = $("#text-chat"), chatHeader = $("#chat-header"), chatMessages = $("#chat-messages"); | |
| const chatInput = $("#chat-input"), chatSendBtn = $("#chat-send-btn"), chatCloseBtn = $("#chat-close-btn"); | |
| const chatResizeHandle = $("#chat-resize-handle"), chatAvatarSelect = $("#chat-avatar-select"), settingsAvatarSelect = $("#settings-avatar-select"); | |
| const stage = new AvatarStage(stageNode); | |
| let client = null, muted = false, subtitleTimer = 0, textMode = false; | |
| let config = { lb:false, allowDirect:true }, avatarList = [], sessionInProgress = false, autoGreetingSent = false, preFetchedGreeting = null, 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() { for(const[k,v]of Object.entries(settings)) localStorage.setItem(STORAGE_KEYS[k],String(v)); } | |
| function effectiveInstructions(newsHook) { | |
| const n = new Date(); | |
| return `${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}\n\n${DEFAULT_INSTRUCTIONS}${newsHook?`\n\nGreeting: "${newsHook}"`:""}${settings.instructions.trim()?"\n\nUser: "+settings.instructions.trim():""}`; | |
| } | |
| async function fetchAvatarList(){try{const r=await fetch("/api/avatars");if(r.ok)avatarList=(await r.json()).avatars||[]}catch{}} | |
| function populateAvatarSelects(sn){ | |
| for(const sel of[chatAvatarSelect,settingsAvatarSelect]){ | |
| sel.innerHTML=""; const d=document.createElement("option"); d.value=""; d.textContent="(Default)"; sel.appendChild(d); | |
| for(const n of avatarList){const o=document.createElement("option"); o.value=n; o.textContent=n.replace(/\.glb$/i,"").replace(/_/g," ")+(n.toLowerCase()==="vuong.glb"?" 🎙️":""); sel.appendChild(o);} | |
| if(sn&&avatarList.includes(sn)) sel.value=sn; | |
| } | |
| } | |
| function setAvatarFromSelect(v){settings.avatar=v||"";saveSettings()} | |
| async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}})}catch(e){console.error(e)}loading.classList.add("done")} | |
| function clampRect(){const vw=innerWidth,vh=innerHeight,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 d=false,sx,sy,sl,st; | |
| function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} | |
| function os(e){if(e.target.closest("#chat-header-actions,#chat-avatar-select"))return;const p=gp(e);d=true;const r=textChat.getBoundingClientRect();sx=p.x;sy=p.y;sl=r.left;st=r.top;textChat.classList.add("dragging");e.preventDefault()} | |
| function om(e){if(!d)return;const p=gp(e);textChat.style.left=(sl+p.x-sx)+"px";textChat.style.top=(st+p.y-sy)+"px";textChat.style.right="auto";textChat.style.bottom="auto";e.preventDefault()} | |
| function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()} | |
| chatHeader.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatHeader.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); | |
| } | |
| function makeResizable(){ | |
| let r=false,sx,sy,sw,sh; | |
| function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} | |
| function os(e){r=true;const rc=textChat.getBoundingClientRect(),p=gp(e);sx=p.x;sy=p.y;sw=rc.width;sh=rc.height;textChat.classList.add("resizing");e.preventDefault();e.stopPropagation()} | |
| function om(e){if(!r)return;const p=gp(e);textChat.style.width=Math.max(260,sw+p.x-sx)+"px";textChat.style.height=Math.max(120,sh+p.y-sy)+"px";e.preventDefault()} | |
| function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")} | |
| chatResizeHandle.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatResizeHandle.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); | |
| } | |
| function setCaption(t,k=""){caption.textContent=t;caption.className=k} | |
| function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")} | |
| function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)} | |
| // ── Enhanced addChatMessage with product card support ── | |
| let _pendingProductCards = null; | |
| function addChatMessage(r,t){ | |
| const m=document.createElement("div");m.className="chat-message "+r; | |
| if(r==="assistant" && _pendingProductCards && t){ | |
| // Render text + product cards together | |
| const mt=document.createElement("div");mt.textContent=t;m.appendChild(mt); | |
| const pc=document.createElement("div");pc.innerHTML=_pendingProductCards;m.appendChild(pc); | |
| if(window.vaix) window.vaix.attachChatCardHandlers(pc); | |
| _pendingProductCards = null; | |
| } else { | |
| m.textContent=t; | |
| } | |
| chatMessages.appendChild(m);chatMessages.scrollTop=chatMessages.scrollHeight; | |
| // Also scroll to show the new message | |
| setTimeout(()=>{chatMessages.scrollTop=chatMessages.scrollHeight},50); | |
| } | |
| function setPendingProductCards(html){_pendingProductCards=html;} | |
| function showTextChat(s){textChat.hidden=!s;if(s)chatInput.focus()} | |
| function sendTextViaSession(t){if(!client)return false;const s=client._status;if(s!=="connected"&&s!=="ai-speaking"&&s!=="processing"&&s!=="user-speaking")return false;setCaption("SENDING…");client.sendUserText(t);client.requestResponse();return true} | |
| function sendTextMessage(){const t=chatInput.value.trim();if(!t)return;addChatMessage("user",t);chatInput.value="";if(!client||!sessionInProgress){void startTextSession(t);return}if(!sendTextViaSession(t))setCaption("QUEUED…")} | |
| chatCloseBtn.addEventListener("click",e=>{e.stopPropagation();textMode=false;showTextChat(false);textModeBtn.classList.remove("active")}); | |
| let mainAction="start"; | |
| function setMainButton(a,l){mainAction=a;mainBtnLabel.textContent=l;mainBtn.disabled=a==="busy";mainBtn.classList.toggle("live",a==="stop");muteBtn.hidden=a!=="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(s){ | |
| stage.setConversationState(s); | |
| if(isGreetingSession){if(s==="ai-speaking")setCaption("");else if(s==="closed"||s==="idle")setCaption(CAPTIONS.idle);return} | |
| setCaption(CAPTIONS[s]??s,s==="error"?"error":s==="idle"||s==="closed"?"":"live"); | |
| switch(s){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(s==="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(e){} | |
| const send=r=>{ | |
| client.sendToolOutput(callId,r); | |
| client.requestResponse(); | |
| }; | |
| if(name==="get_current_datetime"){const n=new Date();send(`Date: ${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} Time: ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",second:"2-digit"})}.`);return} | |
| if(name==="search_wikipedia"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(q.replace(/\s+/g,"_"))}`).then(r=>r.json()).then(d=>{if(d.extract){send(`Wikipedia (${d.title}): ${d.extract.slice(0,1000)}\n${d.url}`);return}fetch(`/api/wiki/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(sd=>{if(!sd.results?.length){send("No results.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`).then(r=>r.json()).then(s=>{send(s.extract?`Wikipedia (${s.title}): ${s.extract.slice(0,1000)}`:`${sd.results.slice(0,3).map(r=>r.title+": "+r.snippet).join("\n")}`)})})}).catch(()=>send("Wikipedia failed."));return} | |
| if(name==="search_web"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/web/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(d=>{if(!d.results?.length){send("No results.");return}send(d.results.slice(0,3).map((r,i)=>`${i+1}. ${r.title}\n ${r.snippet}`).join("\n"))}).catch(()=>send("Web search failed."));return} | |
| if(name==="open_catalog"){const p=document.getElementById("vaistudio-panel"),t=document.getElementById("vaistudio-toggle");if(p){p.classList.add("open");p.style.display="flex"}if(t)t.classList.add("active");send("Catalog opened.");return} | |
| if(name==="open_product"){const p=window.vaix?.findProduct(args.product_id||"");send(p?`Product: ${p.title_clean}\nPrice: ${(p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ")}\nBrand: ${p.brand||""}\nModel: ${p.model||p.sku||""}`:"Product not found.");return} | |
| if(name==="search_catalog"){send("Searching...");return} | |
| if(name==="query_catalog"){ | |
| const result = window.vaix?.queryCatalog(args.query||"")||"Catalog not loaded yet."; | |
| // Prepare product cards for chat | |
| const results = window.vaix?.getLastSearchResults(); | |
| if(results && results.length){ | |
| let html = '<div class="chat-product-cards">'; | |
| const limit = Math.min(results.length, 5); | |
| for(let i=0;i<limit;i++){ | |
| html += window.vaix.createChatProductCard(results[i]); | |
| } | |
| html += '</div>'; | |
| setPendingProductCards(html); | |
| } | |
| send(result); | |
| return; | |
| } | |
| if(name==="show_product"){ | |
| const result = window.vaix?.showProduct(args.product_name||"")||"Product not found."; | |
| // Prepare single product card for chat | |
| const p = window.vaix?.getLastShownProduct(); | |
| if(p){ | |
| const html = '<div class="chat-product-cards">' + window.vaix.createChatProductCard(p) + '</div>'; | |
| setPendingProductCards(html); | |
| } | |
| send(result); | |
| return; | |
| } | |
| const r=stage.runTool(name,args)??`Unknown: ${name}`;send(r); | |
| } | |
| async function connectSession(c){try{await c.connect();return c}catch(e){const code=e?.code;if(isGreetingSession){sessionInProgress=false;return null}if(code==="limit")setCaption("DAILY LIMIT","error");else if(code==="queue-full")setCaption("ALL SEATS","error");else if(code==="join-expired")setCaption("EXPIRED","error");else if(code!=="aborted"){console.error(e);setCaption("NO CONNECTION","error")}sessionInProgress=false;return null}} | |
| async function startVoiceSession(){if(sessionInProgress)return;sessionInProgress=true;isGreetingSession=false;await stage.resume();let ms;if(FAKEMIC_MODE){ms=stage.audioCtx.createMediaStreamDestination().stream}else{try{ms=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}catch{setCaption("MIC BLOCKED","error");sessionInProgress=false;return}}const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;return}const g=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(g),micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:false});client=c;_a(c);setCaption("REQUESTING A SLOT…");(await connectSession(c))&&!autoGreetingSent&&c.requestResponse()} | |
| async function startGreetingSession(){if(sessionInProgress)return;isGreetingSession=true;sessionInProgress=true;await stage.resume();const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;isGreetingSession=false;return}const fm=ac.createMediaStreamDestination(),ms=fm.stream;const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:GREETING_INSTRUCTIONS,micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:[],_textOnly:false});client=c;_a(c);setCaption("");const ok=await connectSession(c);if(!ok){isGreetingSession=false;return}autoGreetingSent=true;c.requestResponse()} | |
| async function startTextSession(t){if(sessionInProgress){if(client&&t){client.sendUserText(t);client.requestResponse()}return}sessionInProgress=true;isGreetingSession=false;await stage.resume();const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;return}const nh=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(nh),audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:true});client=c;_a(c);textMode=true;showTextChat(true);textModeBtn.classList.add("active");const ok=await connectSession(c);if(!ok)return;if(!autoGreetingSent)c.requestResponse();if(t){c.sendUserText(t);c.requestResponse()}} | |
| function _a(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 n=smartNormalize(text);showSubtitles(n);addChatMessage("assistant",n)}}); | |
| c.addEventListener("response-finished",()=>{autoGreetingSent=true;fadeSubtitles();if(isGreetingSession)setTimeout(()=>void endSession(true),500)}); | |
| c.addEventListener("toolcall",e=>{const{name,arguments:a,callId}=e.detail;runTool(name,a,callId)}); | |
| c.addEventListener("server-error",e=>console.warn("err:",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 t of c.options.micStream?.getTracks()??[])t.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)}); | |
| 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()); | |
| 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 r=await fetch("api/config");if(r.ok)config={...config,...(await r.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}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading avatar ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}})}catch(e){console.error(e);loading.textContent="Avatar failed.";setCaption("AVATAR FAILED","error");return} | |
| preFetchedGreeting=await newsPromise; | |
| loading.classList.add("done");setCaption(CAPTIONS.idle);setMainButton("start","Start talking"); | |
| // Init VAIX panel (defined in vaix-rag.js loaded earlier from index.html) | |
| const panel=document.getElementById("vaistudio-panel"),toggle=document.getElementById("vaistudio-toggle"),loadingEl=document.getElementById("vaistudio-loading"),closeBtn=document.getElementById("vaistudio-close"),retryBtn=document.getElementById("vaistudio-retry-btn"); | |
| if(toggle)toggle.addEventListener("click",()=>{const o=!panel.classList.contains("open");panel.classList.toggle("open",o);panel.style.display=o?"flex":"none";toggle.classList.toggle("active",o);if(o)window.vaix?.load().then(()=>{panel.classList.contains("open")&&renderAll()})}); | |
| if(closeBtn)closeBtn.addEventListener("click",()=>{panel.classList.remove("open");panel.style.display="none";toggle?.classList.remove("active")}); | |
| if(retryBtn)retryBtn.addEventListener("click",()=>{window.vaix?.load().then(()=>renderAll())}); | |
| function renderAll(){const p=document.getElementById("vaistudio-panel"),l=document.getElementById("vaistudio-loading"),pe=document.getElementById("vaistudio-products"),ce=document.getElementById("vaistudio-count"),sc=document.getElementById("vaix-suggestions");if(!p||!p.classList.contains("open"))return;if(l)l.hidden=true;if(sc)sc.style.display="none";if(!pe)return;pe.innerHTML="";pe.style.display="block";const prods=window.vaix?.allProducts()||[];if(ce)ce.textContent=prods.length+" sản phẩm";for(let i=0;i<Math.min(20,prods.length);i++){const x=prods[i];const c=document.createElement("div");c.className="product-card";c.innerHTML=(x.image?`<img class="product-card-img" src="${x.image}" alt="" loading="lazy">`:'<div class="product-card-img" style="background:#e2e8f0;display:flex;align-items:center;justify-content:center;font-size:1.5rem">📦</div>')+`<div class="product-card-info"><p class="product-card-title">${x.title_clean||x.name}</p><p class="product-card-brand">${x.brand||""}</p><p class="product-card-price">${x.priceNum>0?x.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"}</p></div>`;c.addEventListener("click",e=>{e.stopPropagation();window.vaix?.showProduct(x.title_clean)});pe.appendChild(c)}} | |
| // Wait for vaix to be ready | |
| let tries=0;(function wait(){if(window.vaix?.isLoaded()){renderAll();return}if(++tries>50)return;setTimeout(wait,200)})(); | |
| setTimeout(()=>{if(!sessionInProgress&&!autoGreetingSent)void startGreetingSession()},2000); | |
| } | |
| void boot(); |