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

Delete src/app.js with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/app.js +0 -390
src/app.js DELETED
@@ -1,390 +0,0 @@
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 V, a helpful AI assistant from V.AI STUDIO with a 3D talking head avatar.",
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 V. Say exactly: 'Xin chào! Em là V, trợ lý AI đến từ 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
- // Safe getter: uses getElementById (all our HTML IDs) with null-safety guard
50
- function g(id) { try { return document.getElementById(id) } catch(e) { return null } }
51
-
52
- let stageNode=g("stage"), mainBtn=g("main-btn"), mainBtnLabel=g("main-btn-label");
53
- let muteBtn=g("mute-btn"), textModeBtn=g("text-mode-btn"), caption=g("caption");
54
- let subtitles=g("subtitles"), loadingEl=g("loading"), settingsBtn=g("settings-btn");
55
- let settingsDialog=g("settings"), inputVoice=g("voice"), inputInstructions=g("instructions");
56
- let inputDirectUrl=g("direct-url"), inputSubtitles=g("subtitles-toggle"), directUrlRow=g("direct-url-row");
57
- let textChat=g("text-chat"), chatHeader=g("chat-header"), chatMessages=g("chat-messages");
58
- let chatInput=g("chat-input"), chatSendBtn=g("chat-send-btn"), chatCloseBtn=g("chat-close-btn");
59
- let chatResizeHandle=g("chat-resize-handle"), chatAvatarSelect=g("chat-avatar-select"), settingsAvatarSelect=g("settings-avatar-select");
60
- let chatAudioToggleBtn=g("chat-audio-toggle-btn");
61
- let welcomeModal=g("welcome-modal"), welcomeChatBtn=g("welcome-chat-btn"), welcomeVoiceBtn=g("welcome-voice-btn");
62
- const loading = loadingEl;
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, avatarAudioMuted = 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() { if(!welcomeModal)return;
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() { if(!welcomeModal)return;
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
- const RELOAD_AVATAR_TIMEOUT = 60000; // 60s timeout
110
- async function reloadAvatar(){if(!stage.head)return;if(loading)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 reload timeout after ${Math.round(RELOAD_AVATAR_TIMEOUT/1000)}s`)), RELOAD_AVATAR_TIMEOUT))])}catch(e){const avatarPath = settings.avatar ? `/avatars/${settings.avatar}` : "/avatars/vuong.glb (default)"; console.error("[reloadAvatar] Failed:", { avatarUrl: avatarPath, error: e?.message || String(e), stageHead: !!stage.head, stageLastError: stage.lastError });}if(loading)loading.classList.add("done")}
111
- 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"}
112
- function makeDraggable(){
113
- let d=false,sx,sy,sl,st;
114
- function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}}
115
- 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()}
116
- 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()}
117
- function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()}
118
- if(chatHeader)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);
119
- }
120
- function makeResizable(){
121
- let r=false,sx,sy,sw,sh;
122
- function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}}
123
- 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()}
124
- 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()}
125
- function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")}
126
- if(chatResizeHandle)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);
127
- }
128
- function setCaption(t,k=""){caption.textContent=t;caption.className=k}
129
- function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")}
130
- function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)}
131
-
132
- // ── Enhanced addChatMessage with product card support ──
133
- let _pendingProductCards = null;
134
- function addChatMessage(r,t){
135
- const m=document.createElement("div");m.className="chat-message "+r;
136
- if(r==="assistant" && _pendingProductCards && t){
137
- const mt=document.createElement("div");mt.textContent=t;m.appendChild(mt);
138
- const pc=document.createElement("div");pc.innerHTML=_pendingProductCards;m.appendChild(pc);
139
- if(window.vaix) window.vaix.attachChatCardHandlers(pc);
140
- _pendingProductCards = null;
141
- } else {
142
- m.textContent=t;
143
- }
144
- chatMessages.appendChild(m);chatMessages.scrollTop=chatMessages.scrollHeight;
145
- setTimeout(()=>{chatMessages.scrollTop=chatMessages.scrollHeight},50);
146
- }
147
- function setPendingProductCards(html){_pendingProductCards=html;}
148
-
149
- function showTextChat(s){textChat.hidden=!s;if(s)chatInput.focus()}
150
- 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}
151
-
152
- // ✨ Pure text chat client — uses S2S in text-only mode, so it has ALL same tools as voice
153
- // (query_catalog, show_product, search_web, search_wikipedia, etc.)
154
- function sendTextMessage(t){
155
- const msg = t || (chatInput && chatInput.value ? chatInput.value.trim() : '');
156
- if(!msg) return;
157
- if(chatInput) chatInput.value = "";
158
- addChatMessage("user", msg);
159
- setCaption("V ĐANG VIẾT…", "live");
160
-
161
- // Try text-session-first: send via S2S client (full tools support)
162
- if (sendTextViaSession(msg)) {
163
- // ✅ Sent via S2S — will get tool calls, product cards, etc.
164
- return;
165
- }
166
- // S2S not connected — fallback to /api/chat (simple text, no tools)
167
- fetch("/api/chat", {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json" },
170
- body: JSON.stringify({ message: msg }),
171
- })
172
- .then(async (resp) => {
173
- const data = await resp.json();
174
- let reply = data.transcript || data.error || "Không nhận được phản hồi. Thử lại.";
175
- setCaption(reply.split("\n")[0].slice(0, 50), "");
176
- addChatMessage("assistant", reply);
177
- })
178
- .catch(err => {
179
- console.error("[text-chat] Error:", err);
180
- setCaption("LỖI KẾT NỐI", "error");
181
- addChatMessage("assistant", "❌ Không thể kết nối. Vui lòng thử lại sau.");
182
- })
183
- .finally(() => {
184
- chatInput.focus();
185
- });
186
- }
187
-
188
- if(chatCloseBtn)chatCloseBtn.addEventListener("click",e=>{e.stopPropagation();textMode=false;showTextChat(false);textModeBtn.classList.remove("active")});
189
- let mainAction="start";
190
- 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}
191
- 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"};
192
- function onStatus(s){
193
- stage.setConversationState(s);
194
- if(isGreetingSession){if(s==="ai-speaking")setCaption("");else if(s==="closed"||s==="idle")setCaption(CAPTIONS.idle);return}
195
- setCaption(CAPTIONS[s]??s,s==="error"?"error":s==="idle"||s==="closed"?"":"live");
196
- 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}
197
- if(s==="user-speaking"){subtitles.classList.remove("visible");showTextChat(textMode)}
198
- }
199
- function runTool(name,argsJson,callId){
200
- if(!client)return;if(isGreetingSession){client.sendToolOutput(callId,"OK");client.requestResponse();return}
201
- let args={};try{args=JSON.parse(argsJson||"{}")}catch(e){}
202
- const send=r=>{
203
- client.sendToolOutput(callId,r);
204
- client.requestResponse();
205
- };
206
- 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}
207
- 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}
208
- 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}
209
- 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}
210
- 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}
211
- if(name==="search_catalog"){send("Searching...");return}
212
- if(name==="query_catalog"){
213
- const result = window.vaix?.queryCatalog(args.query||"")||"Catalog not loaded yet.";
214
- const results = window.vaix?.getLastSearchResults();
215
- if(results && results.length){
216
- let html = '<div class="chat-product-cards">';
217
- const limit = Math.min(results.length, 5);
218
- for(let i=0;i<limit;i++){
219
- html += window.vaix.createChatProductCard(results[i]);
220
- }
221
- html += '</div>';
222
- setPendingProductCards(html);
223
- }
224
- send(result);
225
- return;
226
- }
227
- if(name==="show_product"){
228
- const result = window.vaix?.showProduct(args.product_name||"")||"Product not found.";
229
- const p = window.vaix?.getLastShownProduct();
230
- if(p){
231
- const html = '<div class="chat-product-cards">' + window.vaix.createChatProductCard(p) + '</div>';
232
- setPendingProductCards(html);
233
- }
234
- send(result);
235
- return;
236
- }
237
- const r=stage.runTool(name,args)??`Unknown: ${name}`;send(r);
238
- }
239
- 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}}
240
- 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()}
241
- 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}}
242
- 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()}}
243
- function _a(c){
244
- c.addEventListener("status",e=>onStatus(e.detail.status));
245
- c.addEventListener("queue",e=>{const{position}=e.detail;if(isGreetingSession)return;setCaption(position>0?`#${position} IN LINE…`:"ALMOST THERE…","live")});
246
- c.addEventListener("transcript",e=>{const{role,text}=e.detail;if(role==="assistant"&&text){const n=smartNormalize(text);showSubtitles(n);addChatMessage("assistant",n)}});
247
- c.addEventListener("response-finished",()=>{autoGreetingSent=true;fadeSubtitles();if(isGreetingSession)setTimeout(()=>void endSession(true),500)});
248
- c.addEventListener("toolcall",e=>{const{name,arguments:a,callId}=e.detail;runTool(name,a,callId)});
249
- c.addEventListener("server-error",e=>console.warn("err:",e.detail.error));
250
- c.addEventListener("error",()=>void endSession());
251
- }
252
- 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");avatarAudioMuted=false;updateChatAudioToggleBtn();if(!silent)setCaption(CAPTIONS.idle);setMainButton("start","Start talking")}
253
-
254
- // ✨ Welcome button handlers
255
- if (welcomeChatBtn) {
256
- if(welcomeChatBtn)welcomeChatBtn.addEventListener("click", () => {
257
- welcomeMode = "text";
258
- hideWelcome();
259
- // ✨ Create S2S text session so we have tools: query_catalog, show_product, etc.
260
- startTextSession("Xin chào! Tôi muốn trò chuyện.");
261
- });
262
- }
263
-
264
- if (welcomeVoiceBtn) {
265
- if(welcomeVoiceBtn)welcomeVoiceBtn.addEventListener("click", () => {
266
- welcomeMode = "voice";
267
- hideWelcome();
268
- startGreetingSession().then(ok => {
269
- if (ok) client?.requestResponse();
270
- });
271
- });
272
- }
273
-
274
- if(mainBtn)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()});
275
- if(muteBtn)muteBtn.addEventListener("click",()=>{muted=!muted;client?.setMuted(muted);if(muteBtn) muteBtn.classList.toggle("active",muted)});
276
- if(textModeBtn)textModeBtn.addEventListener("click",()=>{textMode=!textMode;showTextChat(textMode);if(textModeBtn) textModeBtn.classList.toggle("active",textMode)});
277
- if(chatSendBtn)chatSendBtn.addEventListener("click",()=>sendTextMessage());if(chatInput)chatInput.addEventListener("keypress",e=>{if(e.key==="Enter")sendTextMessage()});
278
- if(chatAvatarSelect)chatAvatarSelect.addEventListener("change",()=>{setAvatarFromSelect(chatAvatarSelect.value||'');void reloadAvatar()});
279
- if(settingsAvatarSelect)settingsAvatarSelect.addEventListener("change",()=>{if(settingsAvatarSelect.value){setAvatarFromSelect(settingsAvatarSelect.value)}if(chatAvatarSelect)chatAvatarSelect.value=settingsAvatarSelect.value||'';void reloadAvatar()});
280
- if(settingsBtn)settingsBtn.addEventListener("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;settingsDialog?.showModal()});
281
- if(settingsDialog)settingsDialog.addEventListener("close",()=>{const v=(inputVoice&&(inputVoice.value||''))||DEFAULT_VOICE,a=(settingsAvatarSelect&&(settingsAvatarSelect.value||'')),i=inputInstructions?inputInstructions.value:'',d=inputDirectUrl?(inputDirectUrl.value||'').trim():'',c=inputSubtitles?inputSubtitles.checked:false;settings={voice:v,avatar:a,instructions:i,directUrl:d,subtitles:c};if(settings.avatar!=="vuong.glb")autoGreetingSent=false;saveSettings();if(chatAvatarSelect)chatAvatarSelect.value=settings.avatar;if(!settings.subtitles)subtitles?.classList.remove("visible");client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")})});
282
- window.addEventListener("beforeunload",()=>client?.close());
283
-
284
- async function boot(){
285
- // Debug: show on-screen logging
286
- window._debugEl = document.getElementById("debug-output");
287
- window._debugLog = function(tag, msg) {
288
- if (window._debugEl) {
289
- window._debugEl.textContent = "🔍 " + tag + ": " + msg;
290
- window._debugEl.style.display = "block";
291
- }
292
- console.log("[DEBUG]", tag, msg);
293
- };
294
-
295
- if(inputVoice){for(const v of VOICES){const o=document.createElement("option");o.value=v;o.textContent=v.replaceAll("_"," ");inputVoice.appendChild(o)}}
296
- try{const r=await fetch("api/config");if(r.ok)config={...config,...(await r.json())}}catch{}
297
- directUrlRow.hidden=!config.allowDirect;
298
- await fetchAvatarList();populateAvatarSelects(settings.avatar);
299
- makeDraggable();makeResizable();
300
- setCaption("KHỞI ĐỘNG…");setMainButton("busy","Loading…");
301
- const newsPromise=(settings.avatar==="vuong.glb"||!settings.avatar)?getHotNewsGreeting().catch(()=>null):Promise.resolve(null);
302
- const BOOT_AVATAR_TIMEOUT = 60000; // 60s timeout for avatar init (large GLB + TalkingHead parsing)
303
- try {
304
- await Promise.race([
305
- 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))}%`}}),
306
- new Promise((_,rej)=>setTimeout(()=>rej(new Error(`Avatar init timeout after ${Math.round(BOOT_AVATAR_TIMEOUT/1000)}s`)), BOOT_AVATAR_TIMEOUT))
307
- ]);
308
- } catch (e) {
309
- const avatarPath = settings.avatar ? `/avatars/${settings.avatar}` : "/avatars/vuong.glb (default)";
310
- console.error(`[boot] Avatar init failed after ${BOOT_AVATAR_TIMEOUT/1000}s`, {
311
- avatarUrl: avatarPath,
312
- avatarSetting: settings.avatar,
313
- error: e?.message || String(e),
314
- stack: e?.stack?.split("\n").slice(0, 5).join(" | "),
315
- stageHead: !!stage.head,
316
- stageLastError: stage.lastError,
317
- stageStaticModel: stage.isStaticModel,
318
- });
319
- (async()=>{try{await fetch("/api/logs/client",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:"browser",message:"avatar boot failed",details:JSON.stringify({avatarUrl:avatarPath,error:e?.message||String(e),stageHead:!!stage.head,stageLastError:stage.lastError,stageStaticModel:stage.isStaticModel,avatarSetting:settings.avatar}),url:location.href})});}catch{}})();
320
-
321
- // Show error on banner (visible to phone users)
322
- try {
323
- var bannerEl = document.getElementById("avatar-error-banner");
324
- if (bannerEl) {
325
- bannerEl.style.display = "block";
326
- bannerEl.style.position = "fixed";
327
- bannerEl.style.top = "0";
328
- bannerEl.style.left = "0";
329
- bannerEl.style.right = "0";
330
- bannerEl.style.background = "rgba(220,38,38,0.95)";
331
- bannerEl.style.color = "#fff";
332
- bannerEl.style.padding = "8px";
333
- bannerEl.style.fontSize = "14px";
334
- bannerEl.style.fontWeight = "bold";
335
- bannerEl.style.zIndex = "10000";
336
- bannerEl.style.textAlign = "center";
337
- bannerEl.style.fontFamily = "sans-serif";
338
- bannerEl.textContent = "⚠ Avatar failed: " + (e?.message || String(e)).slice(0,120);
339
- }
340
- } catch(e) {}
341
-
342
- loading.textContent = "Avatar failed — continuing...";
343
- setCaption("AVATAR FAILED", "error");
344
- // DON'T return — let vaix-rag init and continue booting
345
- }
346
- preFetchedGreeting=await newsPromise;
347
- if(loading)loading.classList.add("done");
348
-
349
- // ✨ Show welcome modal instead of greeting + idle state
350
- setCaption(CAPTIONS.idle);setMainButton("start","Start talking");
351
-
352
- // Init VAIX panel (defined in vaix-rag.js loaded earlier from index.html)
353
- const panel=document.getElementById("vaistudio-panel");
354
- const toggle=document.getElementById("vaistudio-toggle");
355
- const closeBtn=document.getElementById("vaistudio-close");
356
- const retryBtn=document.getElementById("vaistudio-retry-btn");
357
- 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()})});
358
- if(closeBtn)closeBtn.addEventListener("click",()=>{panel.classList.remove("open");panel.style.display="none";toggle?.classList.remove("active")});
359
- if(retryBtn)retryBtn.addEventListener("click",()=>{window.vaix?.load().then(()=>renderAll())});
360
- 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)}};
361
- let tries=0;(function wait(){if(window.vaix?.isLoaded()){renderAll();return}if(++tries>50)return;setTimeout(wait,200)})();
362
-
363
- // ✨ Show welcome modal after loading completes
364
- setTimeout(()=>{ setLoading(false); showWelcome(); }, 1000);
365
- }
366
-
367
- function setLoading(show){
368
- if(show){ loading.style.display=""; if(loading)loading.classList.remove("done"); }
369
- else { if(loading)loading.classList.add("done"); }
370
- }
371
-
372
- // ── Chat audio toggle (mute/unmute avatar playback) ──
373
- function updateChatAudioToggleBtn(){
374
- if(!chatAudioToggleBtn)return;
375
- if(!client || !client._playbackNode){ chatAudioToggleBtn.style.display="none"; return; }
376
- chatAudioToggleBtn.style.display="grid";
377
- var snd=document.getElementById("audio-icon-sound");
378
- var mtc=document.getElementById("audio-icon-muted");
379
- if(snd) snd.style.display = avatarAudioMuted ? "none" : "";
380
- if(mtc) mtc.style.display = avatarAudioMuted ? "" : "none";
381
- chatAudioToggleBtn.setAttribute("aria-label", avatarAudioMuted ? "Unmute avatar audio" : "Mute avatar audio");
382
- }
383
- chatAudioToggleBtn && chatAudioToggleBtn.addEventListener("click",()=>{
384
- avatarAudioMuted=!avatarAudioMuted;
385
- // Toggle by setting gain on the session's output node or use setMuted
386
- if(client){ client.setMuted(avatarAudioMuted); }
387
- updateChatAudioToggleBtn();
388
- });
389
-
390
- void boot();