Spaces:
Running
Running
[FIX] Null-safety, chat mute button, greeting voice flow
Browse files- 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();
|