bep40 commited on
Commit
fcf5333
·
verified ·
1 Parent(s): fa1f687

[FIX] Null-safety, chat mute button, greeting voice flow

Browse files
Files changed (1) hide show
  1. src/app.js +376 -0
src/app.js ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
2
+ import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
3
+ import { smartNormalize } from "./viNumberFix.js";
4
+
5
+ const VOICES = ["Aiden","Ryan","Dylan","Eric","Ono_Anna","Serena","Sohee","Uncle_Fu","Vivian"];
6
+ const DEFAULT_VOICE = "Sohee";
7
+ const _urlParams = new URLSearchParams(location.search);
8
+ const FAKEMIC_MODE = _urlParams.has("fakemic");
9
+
10
+ async function getHotNewsGreeting() {
11
+ let hotTitle = "";
12
+ try {
13
+ const resp = await fetch("/api/news/hot");
14
+ 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;}}} }
15
+ } catch {}
16
+ return hotTitle ? `Hôm nay có tin: ${hotTitle}. Hỏi người dùng có muốn nghe không.` : "";
17
+ }
18
+
19
+ const DEFAULT_INSTRUCTIONS = [
20
+ "You are Gemma, a friendly warm voice assistant with a 3D avatar of a young Vietnamese woman.",
21
+ "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).",
22
+ "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'.",
23
+ "Keep replies short, natural, warm.",
24
+ "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.",
25
+ "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.",
26
+ "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?'",
27
+ "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.",
28
+ "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.",
29
+ ].join(" ");
30
+
31
+ 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.";
32
+
33
+ const STORAGE_KEYS = { voice:"avatar.voice", avatar:"avatar.model", instructions:"avatar.instructions", directUrl:"avatar.directUrl", subtitles:"avatar.subtitles" };
34
+
35
+ const TOOL_DEFS = [
36
+ { type:"function", name:"set_mood", description:"Change avatar mood.", parameters:{type:"object", properties:{mood:{type:"string", enum:AVATAR_MOODS}}, required:["mood"]}},
37
+ { type:"function", name:"make_hand_gesture", description:"Hand gesture.", parameters:{type:"object", properties:{gesture:{type:"string", enum:AVATAR_GESTURES}}, required:["gesture"]}},
38
+ { type:"function", name:"make_facial_expression", description:"Face emoji.", parameters:{type:"object", properties:{emoji:{type:"string"}}, required:["emoji"]}},
39
+ { type:"function", name:"get_current_datetime", description:"Get date/time.", parameters:{type:"object", properties:{}, required:[]}},
40
+ { type:"function", name:"search_wikipedia", description:"Search Wikipedia.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}},
41
+ { type:"function", name:"search_web", description:"Search the web.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}},
42
+ { type:"function", name:"open_catalog", description:"Open V.AI STUDIO panel.", parameters:{type:"object", properties:{category:{type:"string"}}, required:["category"]}},
43
+ { type:"function", name:"search_catalog", description:"Search catalog UI.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}},
44
+ { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}},
45
+ { 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"]}},
46
+ { 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"]}},
47
+ ];
48
+
49
+ const $ = s => document.querySelector(s);
50
+ const stageNode = $("#stage"), mainBtn = $("#main-btn"), mainBtnLabel = $("#main-btn-label");
51
+ const muteBtn = $("#mute-btn"), textModeBtn = $("#text-mode-btn"), caption = $("#caption");
52
+ const subtitles = $("#subtitles"), loading = $("#loading"), settingsBtn = $("#settings-btn");
53
+ const settingsDialog = $("#settings"), inputVoice = $("#voice"), inputInstructions = $("#instructions");
54
+ const inputDirectUrl = $("#direct-url"), inputSubtitles = $("#subtitles-toggle"), directUrlRow = $("#direct-url-row");
55
+ const textChat = $("#text-chat"), chatHeader = $("#chat-header"), chatMessages = $("#chat-messages");
56
+ const chatInput = $("#chat-input"), chatSendBtn = $("#chat-send-btn"), chatCloseBtn = $("#chat-close-btn");
57
+ const chatResizeHandle = $("#chat-resize-handle"), chatAvatarSelect = $("#chat-avatar-select"), settingsAvatarSelect = $("#settings-avatar-select");
58
+
59
+ // ✨ Welcome Modal elements
60
+ const welcomeModal = $("#welcome-modal");
61
+ const welcomeChatBtn = $("#welcome-chat-btn");
62
+ const welcomeVoiceBtn = $("#welcome-voice-btn");
63
+
64
+ // ✨ Welcome mode tracking: null | 'text' | 'voice'
65
+ let welcomeMode = null;
66
+
67
+ const stage = new AvatarStage(stageNode);
68
+ let client = null, muted = false, subtitleTimer = 0, textMode = false;
69
+ let config = { lb:false, allowDirect:true }, avatarList = [], sessionInProgress = false, autoGreetingSent = false, preFetchedGreeting = null, isGreetingSession = false;
70
+
71
+ function loadSettings() {
72
+ 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" };
73
+ }
74
+ let settings = loadSettings();
75
+ function saveSettings() { for(const[k,v]of Object.entries(settings)) localStorage.setItem(STORAGE_KEYS[k],String(v)); }
76
+
77
+ function effectiveInstructions(newsHook) {
78
+ const n = new Date();
79
+ 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():""}`;
80
+ }
81
+
82
+ // ✨ Welcome modal show/hide
83
+ function showWelcome() {
84
+ welcomeModal.classList.add("show");
85
+ // Hide the main app UI while welcome is showing
86
+ document.getElementById("topbar").style.display = "none";
87
+ document.getElementById("controls").style.display = "none";
88
+ document.getElementById("subtitles").style.display = "none";
89
+ document.getElementById("vaistudio-toggle").style.display = "none";
90
+ }
91
+ function hideWelcome() {
92
+ welcomeModal.classList.remove("show");
93
+ welcomeModal.style.display = "none";
94
+ document.getElementById("topbar").style.display = "";
95
+ document.getElementById("controls").style.display = "";
96
+ document.getElementById("subtitles").style.display = "";
97
+ document.getElementById("vaistudio-toggle").style.display = "";
98
+ }
99
+
100
+ async function fetchAvatarList(){try{const r=await fetch("/api/avatars");if(r.ok)avatarList=(await r.json()).avatars||[]}catch{}}
101
+ function populateAvatarSelects(sn){
102
+ for(const sel of[chatAvatarSelect,settingsAvatarSelect]){
103
+ sel.innerHTML=""; const d=document.createElement("option"); d.value=""; d.textContent="(Default)"; sel.appendChild(d);
104
+ 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);}
105
+ if(sn&&avatarList.includes(sn)) sel.value=sn;
106
+ }
107
+ }
108
+ function setAvatarFromSelect(v){settings.avatar=v||"";saveSettings()}
109
+ async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await Promise.race([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))}%`}}),new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),15000))])}catch(e){console.error(e)}loading.classList.add("done")}
110
+ 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"}
111
+ function makeDraggable(){
112
+ let d=false,sx,sy,sl,st;
113
+ function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}}
114
+ 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()}
115
+ 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()}
116
+ function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()}
117
+ 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);
118
+ }
119
+ function makeResizable(){
120
+ let r=false,sx,sy,sw,sh;
121
+ function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}}
122
+ 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()}
123
+ 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()}
124
+ function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")}
125
+ 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);
126
+ }
127
+ function setCaption(t,k=""){caption.textContent=t;caption.className=k}
128
+ function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")}
129
+ function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)}
130
+
131
+ // ── Enhanced addChatMessage with product card support ──
132
+ let _pendingProductCards = null;
133
+ function addChatMessage(r,t){
134
+ const m=document.createElement("div");m.className="chat-message "+r;
135
+ if(r==="assistant" && _pendingProductCards && t){
136
+ const mt=document.createElement("div");mt.textContent=t;m.appendChild(mt);
137
+ const pc=document.createElement("div");pc.innerHTML=_pendingProductCards;m.appendChild(pc);
138
+ if(window.vaix) window.vaix.attachChatCardHandlers(pc);
139
+ _pendingProductCards = null;
140
+ } else {
141
+ m.textContent=t;
142
+ }
143
+ chatMessages.appendChild(m);chatMessages.scrollTop=chatMessages.scrollHeight;
144
+ setTimeout(()=>{chatMessages.scrollTop=chatMessages.scrollHeight},50);
145
+ }
146
+ function setPendingProductCards(html){_pendingProductCards=html;}
147
+
148
+ function showTextChat(s){textChat.hidden=!s;if(s)chatInput.focus()}
149
+ 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}
150
+
151
+ // ✨ Pure text chat client — uses S2S in text-only mode, so it has ALL same tools as voice
152
+ // (query_catalog, show_product, search_web, search_wikipedia, etc.)
153
+ function sendTextMessage(t){
154
+ const msg = t || chatInput.value.trim();
155
+ if(!msg) return;
156
+ chatInput.value = "";
157
+ addChatMessage("user", msg);
158
+ setCaption("GEMMA ĐANG VIẾT…", "live");
159
+
160
+ // Try text-session-first: send via S2S client (full tools support)
161
+ if (sendTextViaSession(msg)) {
162
+ // ✅ Sent via S2S — will get tool calls, product cards, etc.
163
+ return;
164
+ }
165
+ // S2S not connected — fallback to /api/chat (simple text, no tools)
166
+ fetch("/api/chat", {
167
+ method: "POST",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({ message: msg }),
170
+ })
171
+ .then(async (resp) => {
172
+ const data = await resp.json();
173
+ let reply = data.transcript || data.error || "Không nhận được phản hồi. Thử lại.";
174
+ setCaption(reply.split("\n")[0].slice(0, 50), "");
175
+ addChatMessage("assistant", reply);
176
+ })
177
+ .catch(err => {
178
+ console.error("[text-chat] Error:", err);
179
+ setCaption("LỖI KẾT NỐI", "error");
180
+ addChatMessage("assistant", "❌ Không thể kết nối. Vui lòng thử lại sau.");
181
+ })
182
+ .finally(() => {
183
+ chatInput.focus();
184
+ });
185
+ }
186
+
187
+ // Safe event binding helpers (null-safety)
188
+ function on(el,evt,fn){if(el)el.addEventListener(evt,fn)}
189
+ function toggleClass(el,cls,val){if(el)el.classList.toggle(cls,val)}
190
+
191
+ on(chatCloseBtn,"click",e=>{e.stopPropagation();textMode=false;showTextChat(false);toggleClass(textModeBtn,"active",false)});
192
+ let mainAction="start";
193
+ function setMainButton(a,l){mainAction=a;mainBtnLabel.textContent=l;mainBtn.disabled=a==="busy";mainBtn.classList.toggle("live",a==="stop");if(muteBtn)muteBtn.hidden=a!=="stop";if(textModeBtn)textModeBtn.hidden=false}
194
+ 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"};
195
+ function onStatus(s){
196
+ stage.setConversationState(s);
197
+ if(isGreetingSession){if(s==="ai-speaking")setCaption("");else if(s==="closed"||s==="idle")setCaption(CAPTIONS.idle);return}
198
+ setCaption(CAPTIONS[s]??s,s==="error"?"error":s==="idle"||s==="closed"?"":"live");
199
+ 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}
200
+ if(s==="user-speaking"){if(subtitles)subtitles.classList.remove("visible");showTextChat(textMode)}
201
+ // Show chat audio toggle whenever there's playback activity
202
+ updateChatAudioToggleBtn();
203
+ }
204
+ function runTool(name,argsJson,callId){
205
+ if(!client)return;if(isGreetingSession){client.sendToolOutput(callId,"OK");client.requestResponse();return}
206
+ let args={};try{args=JSON.parse(argsJson||"{}")}catch(e){}
207
+ const send=r=>{
208
+ client.sendToolOutput(callId,r);
209
+ client.requestResponse();
210
+ };
211
+ 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}
212
+ 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}
213
+ 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}
214
+ 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}
215
+ 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}
216
+ if(name==="search_catalog"){send("Searching...");return}
217
+ if(name==="query_catalog"){
218
+ const result = window.vaix?.queryCatalog(args.query||"")||"Catalog not loaded yet.";
219
+ const results = window.vaix?.getLastSearchResults();
220
+ if(results && results.length){
221
+ let html = '<div class="chat-product-cards">';
222
+ const limit = Math.min(results.length, 5);
223
+ for(let i=0;i<limit;i++){
224
+ html += window.vaix.createChatProductCard(results[i]);
225
+ }
226
+ html += '</div>';
227
+ setPendingProductCards(html);
228
+ }
229
+ send(result);
230
+ return;
231
+ }
232
+ if(name==="show_product"){
233
+ const result = window.vaix?.showProduct(args.product_name||"")||"Product not found.";
234
+ const p = window.vaix?.getLastShownProduct();
235
+ if(p){
236
+ const html = '<div class="chat-product-cards">' + window.vaix.createChatProductCard(p) + '</div>';
237
+ setPendingProductCards(html);
238
+ }
239
+ send(result);
240
+ return;
241
+ }
242
+ const r=stage.runTool(name,args)??`Unknown: ${name}`;send(r);
243
+ }
244
+ 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}}
245
+ 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()}
246
+ 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()}
247
+ 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);toggleClass(textModeBtn,"active",true);const ok=await connectSession(c);if(!ok)return;if(!autoGreetingSent)c.requestResponse();if(t){c.sendUserText(t);c.requestResponse()}}
248
+ function _a(c){
249
+ c.addEventListener("status",e=>onStatus(e.detail.status));
250
+ c.addEventListener("queue",e=>{const{position}=e.detail;if(isGreetingSession)return;setCaption(position>0?`#${position} IN LINE…`:"ALMOST THERE…","live")});
251
+ c.addEventListener("transcript",e=>{const{role,text}=e.detail;if(role==="assistant"&&text){const n=smartNormalize(text);showSubtitles(n);addChatMessage("assistant",n)}});
252
+ c.addEventListener("response-finished",()=>{
253
+ autoGreetingSent=true;
254
+ fadeSubtitles();
255
+ if(isGreetingSession){
256
+ // Keep session alive after greeting — user can now speak
257
+ setTimeout(()=>{
258
+ isGreetingSession=false;
259
+ sessionInProgress=false;
260
+ setCaption(CAPTIONS.idle);
261
+ setMainButton("start","Start talking");
262
+ updateChatAudioToggleBtn();
263
+ },1500);
264
+ }
265
+ });
266
+ c.addEventListener("toolcall",e=>{const{name,arguments:a,callId}=e.detail;runTool(name,a,callId)});
267
+ c.addEventListener("server-error",e=>console.warn("err:",e.detail.error));
268
+ c.addEventListener("error",()=>void endSession());
269
+ }
270
+ 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")}
271
+
272
+ // ── Chat audio toggle (mute/unmute avatar voice in chat) ──
273
+ let avatarAudioMuted = false;
274
+ let chatAudioToggleBtn = null;
275
+ function initChatAudioToggle(){
276
+ chatAudioToggleBtn = $("#chat-audio-toggle-btn");
277
+ if(!chatAudioToggleBtn) return;
278
+ chatAudioToggleBtn.addEventListener("click",()=>{
279
+ avatarAudioMuted=!avatarAudioMuted;
280
+ client?.setMuted(avatarAudioMuted);
281
+ var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted");
282
+ if(snd) snd.style.display=avatarAudioMuted?"none":"";
283
+ if(mtc) mtc.style.display=avatarAudioMuted?"":"none";
284
+ chatAudioToggleBtn.setAttribute("aria-label",avatarAudioMuted?"Unmute avatar audio":"Mute avatar audio");
285
+ if(client&&client._playbackNode){
286
+ var n=client._playbackNode;
287
+ if(n.port)n.port.postMessage({kind:avatarAudioMuted?"mute":"unmute"});
288
+ }
289
+ });
290
+ }
291
+ function updateChatAudioToggleBtn(){
292
+ if(!chatAudioToggleBtn) return;
293
+ var show=(client&&(client._playbackNode||client._status==="ai-speaking"||client._status==="connected"||client._status==="processing"));
294
+ chatAudioToggleBtn.style.display=show?"grid":"none";
295
+ }
296
+
297
+ // ✨ Welcome button handlers
298
+ if (welcomeChatBtn) {
299
+ welcomeChatBtn.addEventListener("click", () => {
300
+ welcomeMode = "text";
301
+ hideWelcome();
302
+ // ✨ Create S2S text session so we have tools: query_catalog, show_product, etc.
303
+ startTextSession("Xin chào! Tôi muốn trò chuyện.");
304
+ });
305
+ }
306
+
307
+ if (welcomeVoiceBtn) {
308
+ welcomeVoiceBtn.addEventListener("click", () => {
309
+ welcomeMode = "voice";
310
+ hideWelcome();
311
+ startGreetingSession();
312
+ });
313
+ }
314
+
315
+ // Safe DOM helper — same as $() but used for one-off reads
316
+ function g(s){try{return document.querySelector(s)}catch{return null}}
317
+
318
+ on(mainBtn,"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()});
319
+ on(muteBtn,"click",()=>{muted=!muted;client?.setMuted(muted);toggleClass(muteBtn,"active",muted)});
320
+ on(textModeBtn,"click",()=>{textMode=!textMode;showTextChat(textMode);toggleClass(textModeBtn,"active",textMode)});
321
+ on(chatSendBtn,"click",()=>sendTextMessage());
322
+ on(chatInput,"keypress",e=>{if(e.key==="Enter")sendTextMessage()});
323
+ on(chatAvatarSelect,"change",()=>{setAvatarFromSelect(chatAvatarSelect.value);void reloadAvatar()});
324
+ on(settingsAvatarSelect,"change",()=>{setAvatarFromSelect(settingsAvatarSelect.value);if(chatAvatarSelect)chatAvatarSelect.value=settingsAvatarSelect.value;void reloadAvatar()});
325
+ on(settingsBtn,"click",()=>{if(inputVoice)inputVoice.value=settings.voice;if(inputInstructions)inputInstructions.value=settings.instructions;if(inputDirectUrl)inputDirectUrl.value=settings.directUrl;if(inputSubtitles)inputSubtitles.checked=settings.subtitles;if(settingsAvatarSelect&&chatAvatarSelect)settingsAvatarSelect.value=chatAvatarSelect.value||"";if(settingsDialog)settingsDialog.showModal()});
326
+ settingsDialog.addEventListener("close",()=>{
327
+ var iv=(inputVoice&&(inputVoice.value||""))||DEFAULT_VOICE;
328
+ var sa=(settingsAvatarSelect&&settingsAvatarSelect.value)||"";
329
+ var ii=(inputInstructions&&inputInstructions.value)||"";
330
+ var du=inputDirectUrl?(inputDirectUrl.value||"").trim():settings.directUrl||"";
331
+ var cb=(inputSubtitles&&inputSubtitles.checked)||false;
332
+ settings={voice:iv,avatar:sa,instructions:ii,directUrl:du,subtitles:cb};
333
+ if(settings.avatar!=="vuong.glb")autoGreetingSent=false;
334
+ saveSettings();
335
+ if(chatAvatarSelect)chatAvatarSelect.value=settings.avatar;
336
+ if(subtitles&&!settings.subtitles)subtitles.classList.remove("visible");
337
+ client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")});
338
+ });
339
+ window.addEventListener("beforeunload",()=>client?.close());
340
+ initChatAudioToggle();
341
+
342
+ async function boot(){
343
+ for(const v of VOICES){const o=document.createElement("option");o.value=v;o.textContent=v.replaceAll("_"," ");inputVoice.append(o)}
344
+ try{const r=await fetch("api/config");if(r.ok)config={...config,...(await r.json())}}catch{}
345
+ directUrlRow.hidden=!config.allowDirect;
346
+ await fetchAvatarList();populateAvatarSelects(settings.avatar);
347
+ makeDraggable();makeResizable();
348
+ setCaption("KHỞI ĐỘNG…");setMainButton("busy","Loading…");
349
+ const newsPromise=(settings.avatar==="vuong.glb"||!settings.avatar)?getHotNewsGreeting().catch(()=>null):Promise.resolve(null);
350
+ try{await Promise.race([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))}%`}}),new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),8000))])}catch(e){console.error(e);loading.textContent="Avatar failed.";setCaption("AVATAR FAILED","error");return welcome}
351
+ preFetchedGreeting=await newsPromise;
352
+ loading.classList.add("done");
353
+
354
+ // ✨ Show welcome modal instead of greeting + idle state
355
+ setCaption(CAPTIONS.idle);setMainButton("start","Start talking");
356
+
357
+ // Init VAIX panel (defined in vaix-rag.js loaded earlier from index.html)
358
+ const panel=document.getElementById("vaistudio-panel");
359
+ const toggle=document.getElementById("vaistudio-toggle");
360
+ const closeBtn=document.getElementById("vaistudio-close");
361
+ const retryBtn=document.getElementById("vaistudio-retry-btn");
362
+ 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()})});
363
+ if(closeBtn)closeBtn.addEventListener("click",()=>{panel.classList.remove("open");panel.style.display="none";toggle?.classList.remove("active")});
364
+ if(retryBtn)retryBtn.addEventListener("click",()=>{window.vaix?.load().then(()=>renderAll())});
365
+ 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)}};
366
+ let tries=0;(function wait(){if(window.vaix?.isLoaded()){renderAll();return}if(++tries>50)return;setTimeout(wait,200)})();
367
+
368
+ // ✨ Show welcome modal after loading completes
369
+ setTimeout(()=>{ setLoading(false); showWelcome(); }, 1000);
370
+ }
371
+
372
+ function setLoading(show){
373
+ if(show){ loading.style.display=""; loading.classList.remove("done"); }
374
+ else { loading.classList.add("done"); }
375
+ }
376
+ void boot();