Spaces:
Paused
Paused
File size: 26,021 Bytes
f2cbc76 a5bba67 f2cbc76 99a33a4 f2cbc76 99a33a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | 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 Gemma. Say exactly: '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ị!' 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(); |