Spaces:
Paused
Paused
| import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; | |
| import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; | |
| import { smartNormalize } from "./viNumberFix.js"; | |
| import { VoiceTyper } from "./voiceTyper.js"; | |
| const VOICES = ["Aiden","Ryan","Dylan","Eric","Ono_Anna","Serena","Sohee","Uncle_Fu","Vivian"]; | |
| const DEFAULT_VOICE = "Sohee"; | |
| const _urlParams = new URLSearchParams(location.search); | |
| const FAKEMIC_MODE = _urlParams.has("fakemic"); | |
| async function getHotNewsGreeting() { | |
| let hotTitle = ""; | |
| try { | |
| const resp = await fetch("/api/news/hot"); | |
| if (resp.ok) { const data = await resp.json(); if(data.titles?.length){for(const t of data.titles){const c = t.replace(/^[\d.]+[\s:]*/,"").trim(); if(c.length>10&&c.length<200){hotTitle=c;break;}}} } | |
| } catch {} | |
| return hotTitle ? `Hôm nay có tin: ${hotTitle}. Hỏi người dùng có muốn nghe không.` : ""; | |
| } | |
| const DEFAULT_INSTRUCTIONS = [ | |
| "You are Gemma, a friendly warm voice assistant with a 3D avatar of a young Vietnamese woman.", | |
| "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).", | |
| "CRITICAL: Same language as user. VN numbers as words (ba mươi lăm not 35), dates as 'ngày 9 tháng 7 năm 2026', currency as 'năm mươi nghìn đồng'.", | |
| "Keep replies short, natural, warm.", | |
| "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, combo_suggest (gợi ý combo gian bếp: bếp từ + máy hút mùi + chậu rửa + vòi rửa, lọc theo thương hiệu/giá/chất liệu/màu sắc), get_current_datetime, search_wikipedia, search_web, search_news (search real-time Vietnamese news), set_mood, make_hand_gesture, make_facial_expression.", | |
| "PRODUCT RULES: When user asks about ANY product — FIRST call query_catalog(query). This searches ALL products AND shows them in the panel AND returns similar product suggestions. After query_catalog, ALWAYS mention similar products and ask if user wants to see them. If user wants details, call show_product(name/SKU) to open the detail modal which also shows similar products below.", | |
| "COMBO RULES: When user asks for a combo/set gian bếp (e.g. 'combo bếp từ + máy hút mùi + chậu + vòi', 'bộ nồi bếp', or asks to change combo by giá tiền / chất liệu / màu sắc / thương hiệu) — call combo_suggest and pass the criteria (brand, minPrice/maxPrice in VND, material like inox/kính/gốm, color like đen/trắng/bạc). combo_suggest will show product cards and a text summary.", | |
| "SIMILAR PRODUCTS: query_catalog() and show_product() both automatically recommend related products by same category/brand/price range. Use this to cross-sell: 'Chị có muốn xem thêm sản phẩm tương tự không?'", | |
| "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.", | |
| "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.", | |
| "RELIABILITY RULES (mUST follow 100%): Answer exactly what the user asks, in their language and requested format. When the user asks about news, a campaign, a promotion, a product, or any current event: ALWAYS call the most specific search tool (search_news for Vietnamese news/promos/campaigns, search_web for general/web, query_catalog for products) FIRST and use the returned results to answer. If the first search returns little, RETRY with a second, broader or different keyword (e.g. add the brand name, rename 'chiến dịch' to the brand, try English) before ever saying you couldn't find anything. NEVER reply with a vague 'Em xin lỗi, chưa tìm thấy' / 'không tìm thấy thông tin' refusal. If you genuinely find no exact match, still give a useful answer: summarize the closest info you did find, tell the user what you searched, and offer 2-3 concrete alternatives (e.g. search another keyword, check a specific product, or ask them to clarify). Always deliver a real, substantive answer — never stop at an apology. Follow the user's exact request (quantity, format, detail level, tone).", | |
| ].join(" "); | |
| const GREETING_INSTRUCTIONS = "You are Gemma. Say exactly: 'Xin chào! Em là Gemma, trợ lý AI thân thiện của Vương V AI STUDIO. Em ở đây để giúp anh chị — trò chuyện, trả lời câu hỏi, xem tin tức, hoặc tìm sản phẩm trong V.AI STUDIO. Rất vui được gặp anh chị!' Then end. No tools."; | |
| const STORAGE_KEYS = { voice:"avatar.voice", avatar:"avatar.model", instructions:"avatar.instructions", directUrl:"avatar.directUrl", subtitles:"avatar.subtitles" }; | |
| const TOOL_DEFS = [ | |
| { type:"function", name:"set_mood", description:"Change avatar mood.", parameters:{type:"object", properties:{mood:{type:"string", enum:AVATAR_MOODS}}, required:["mood"]}}, | |
| { type:"function", name:"make_hand_gesture", description:"Hand gesture.", parameters:{type:"object", properties:{gesture:{type:"string", enum:AVATAR_GESTURES}}, required:["gesture"]}}, | |
| { type:"function", name:"make_facial_expression", description:"Face emoji.", parameters:{type:"object", properties:{emoji:{type:"string"}}, required:["emoji"]}}, | |
| { type:"function", name:"get_current_datetime", description:"Get date/time.", parameters:{type:"object", properties:{}, required:[]}}, | |
| { type:"function", name:"search_wikipedia", description:"Search Wikipedia.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"search_web", description:"Search the web.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"search_news", description:"Search real-time Vietnamese news on a specific topic. Use this when user asks about current events, hot topics, or news.", parameters:{type:"object", properties:{query:{type:"string", description:"News topic or keyword to search for"}}, required:["query"]}}, | |
| { type:"function", name:"open_catalog", description:"Open V.AI STUDIO panel.", parameters:{type:"object", properties:{category:{type:"string"}}, required:["category"]}}, | |
| { type:"function", name:"search_catalog", description:"Search catalog UI.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, | |
| { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}}, | |
| { type:"function", name:"query_catalog", description:"SEARCH all products + show in panel + return details + suggest similar products. ALWAYS use FIRST for product questions.", parameters:{type:"object", properties:{query:{type:"string", description:"Product name, brand, SKU, category, or feature"}}, required:["query"]}}, | |
| { type:"function", name:"show_product", description:"Open product detail modal + show similar products. Use AFTER query_catalog. Pass name/SKU.", parameters:{type:"object", properties:{product_name:{type:"string", description:"Product name or SKU"}}, required:["product_name"]}}, | |
| { type:"function", name:"combo_suggest", description:"Suggest a kitchen COMBO (bếp từ + máy hút mùi + chậu rửa + vòi rửa, ideally same brand). Filter by brand, price range (minPrice/maxPrice VND), material (e.g. inox/kính/gốm), or color. Call when user wants a combo or a set of kitchen appliances matching price/material/color/brand.", parameters:{type:"object", properties:{brand:{type:"string", description:"Preferred brand e.g. Malloca, Eurogold, Grob"}, minPrice:{type:"number", description:"Minimum price in VND"}, maxPrice:{type:"number", description:"Maximum price in VND"}, material:{type:"string", description:"Material e.g. inox, kính, gốm"}, color:{type:"string", description:"Color e.g. đen, trắng, bạc"}, categories:{type:"array", items:{type:"string"}, description:"Which appliance categories to include, e.g. ['bếp từ','máy hút mùi','chậu rửa','vòi rửa']"}}, required:[]}}, | |
| ]; | |
| const $ = s => document.querySelector(s); | |
| const stageNode = $("#stage"), mainBtn = $("#main-btn"), mainBtnLabel = $("#main-btn-label"); | |
| const muteBtn = $("#mute-btn"), textModeBtn = $("#text-mode-btn"), caption = $("#caption"); | |
| const subtitles = $("#subtitles"), loading = $("#loading"), settingsBtn = $("#settings-btn"); | |
| const settingsDialog = $("#settings"), inputVoice = $("#voice"), inputInstructions = $("#instructions"); | |
| const inputDirectUrl = $("#direct-url"), inputSubtitles = $("#subtitles-toggle"), directUrlRow = $("#direct-url-row"); | |
| const textChat = $("#text-chat"), chatHeader = $("#chat-header"), chatMessages = $("#chat-messages"); | |
| const chatInput = $("#chat-input"), chatSendBtn = $("#chat-send-btn"), chatCloseBtn = $("#chat-close-btn"); | |
| const chatResizeHandle = $("#chat-resize-handle"), chatAvatarSelect = $("#chat-avatar-select"), settingsAvatarSelect = $("#settings-avatar-select"); | |
| const chatMicBtn = $("#chat-mic-btn"); | |
| // ✨ Welcome Modal elements | |
| const welcomeModal = $("#welcome-modal"); | |
| const welcomeChatBtn = $("#welcome-chat-btn"); | |
| const welcomeVoiceBtn = $("#welcome-voice-btn"); | |
| // ✨ Welcome mode tracking: null | 'text' | 'voice' | |
| let welcomeMode = null; | |
| let voiceTyper = null; | |
| let voiceTyperMode = null; // "chat" | "subtitle" | |
| const stage = new AvatarStage(stageNode); | |
| let client = null, muted = false, subtitleTimer = 0, textMode = false; | |
| let config = { lb:false, allowDirect:true }, avatarList = [], sessionInProgress = false, autoGreetingSent = false, preFetchedGreeting = null, isGreetingSession = false; | |
| // Variable để theo dõi context cho gợi ý | |
| var currentContextForSuggestions = null; | |
| function loadSettings() { | |
| // Subtitles default ON: unless the user has explicitly set the toggle to "0", | |
| // subtitles are enabled so the user always sees what they say + what she says. | |
| var subVal = localStorage.getItem(STORAGE_KEYS.subtitles); | |
| var subOn = subVal === null ? true : subVal === "1"; | |
| 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:subOn }; | |
| } | |
| let settings = loadSettings(); | |
| function saveSettings() { for(const[k,v]of Object.entries(settings)) localStorage.setItem(STORAGE_KEYS[k],String(v)); } | |
| function effectiveInstructions(newsHook) { | |
| const n = new Date(); | |
| return `${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}\n\n${DEFAULT_INSTRUCTIONS}${newsHook?`\n\nGreeting: "${newsHook}"`:""}${settings.instructions.trim()?"\n\nUser: "+settings.instructions.trim():""}`; | |
| } | |
| // ✨ Welcome modal show/hide | |
| function showWelcome() { | |
| welcomeModal.classList.add("show"); | |
| document.getElementById("topbar").style.display = "none"; | |
| document.getElementById("controls").style.display = "none"; | |
| document.getElementById("subtitles").style.display = "none"; | |
| document.getElementById("vaistudio-toggle").style.display = "none"; | |
| } | |
| function hideWelcome() { | |
| welcomeModal.classList.remove("show"); | |
| welcomeModal.style.display = "none"; | |
| document.getElementById("topbar").style.display = ""; | |
| document.getElementById("controls").style.display = ""; | |
| document.getElementById("subtitles").style.display = ""; | |
| document.getElementById("vaistudio-toggle").style.display = ""; | |
| } | |
| async function fetchAvatarList(){try{const r=await fetch("/api/avatars");if(r.ok)avatarList=(await r.json()).avatars||[]}catch{}} | |
| function populateAvatarSelects(sn){ | |
| for(const sel of[chatAvatarSelect,settingsAvatarSelect]){ | |
| sel.innerHTML=""; const d=document.createElement("option"); d.value=""; d.textContent="(Default)"; sel.appendChild(d); | |
| for(const n of avatarList){const o=document.createElement("option"); o.value=n; o.textContent=n.replace(/\.glb$/i,"").replace(/_/g," ")+(n.toLowerCase()==="vuong.glb"?" 🎙️":""); sel.appendChild(o);} | |
| if(sn&&avatarList.includes(sn)) sel.value=sn; | |
| } | |
| } | |
| function setAvatarFromSelect(v){settings.avatar=v||"";saveSettings()} | |
| async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await 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")} | |
| // ✨ Ensure the avatar is initialized before starting a session. Called AFTER | |
| // the user picks a character in the welcome modal, so the loading % bar only | |
| // appears once a character is chosen (matches the new startup flow). | |
| let __avatarReadyPromise = null; | |
| function ensureAvatarReady(){ | |
| if(__avatarReadyPromise) return __avatarReadyPromise; | |
| __avatarReadyPromise=(async()=>{ | |
| if(stage.head){ loading.classList.add("done"); return; } | |
| loading.classList.remove("done"); | |
| 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")),45000)), | |
| ]); | |
| }catch(e){ console.error("[AvatarStage] init failed:", e?.message||e); } | |
| loading.classList.add("done"); | |
| if(stage.resume) try{ await stage.resume(); }catch(_){} | |
| })(); | |
| return __avatarReadyPromise; | |
| } | |
| function clampRect(){const vw=innerWidth,vh=innerHeight,r=textChat.getBoundingClientRect();let l=r.left,t=r.top;l=Math.max(10,Math.min(l,vw-r.width-10));t=Math.max(10,Math.min(t,vh-r.height-10));textChat.style.left=l+"px";textChat.style.top=t+"px"} | |
| function makeDraggable(){ | |
| let d=false,sx,sy,sl,st; | |
| function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} | |
| function os(e){if(e.target.closest("#chat-header-actions,#chat-avatar-select"))return;const p=gp(e);d=true;const r=textChat.getBoundingClientRect();sx=p.x;sy=p.y;sl=r.left;st=r.top;textChat.classList.add("dragging");e.preventDefault} | |
| function om(e){if(!d)return;const p=gp(e);textChat.style.left=(sl+p.x-sx)+"px";textChat.style.top=(st+p.y-sy)+"px";textChat.style.right="auto";textChat.style.bottom="auto";e.preventDefault} | |
| function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()} | |
| chatHeader.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatHeader.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); | |
| } | |
| function makeResizable(){ | |
| let r=false,sx,sy,sw,sh; | |
| function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} | |
| function os(e){r=true;const rc=textChat.getBoundingClientRect(),p=gp(e);sx=p.x;sy=p.y;sw=rc.width;sh=rc.height;textChat.classList.add("resizing");e.preventDefault;e.stopPropagation} | |
| function om(e){if(!r)return;const p=gp(e);textChat.style.width=Math.max(260,sw+p.x-sx)+"px";textChat.style.height=Math.max(120,sh+p.y-sy)+"px";e.preventDefault} | |
| function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")} | |
| chatResizeHandle.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatResizeHandle.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); | |
| } | |
| function setCaption(t,k=""){caption.textContent=t;caption.className=k} | |
| function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")} | |
| function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)} | |
| // ═══════════════════════════════════════════════════════════ | |
| // POST-MESSAGE CONTEXT (suggestions + hot tags + source cards) | |
| // ═══════════════════════════════════════════════════════════ | |
| // ── Dynamic HOT topics extracted from Google News RSS in real-time ── | |
| // No fixed topics — every fetch pulls fresh keywords from actual news headlines | |
| let HOT_TOPIC_KEYWORDS = []; // Populated dynamically by fetchHotTags | |
| /** | |
| * Extract trending keywords from Google News RSS titles. | |
| * Returns array of {label, query, keyword} objects — fully dynamic, changes every fetch. | |
| */ | |
| /** | |
| * Extract trending keywords from Google News RSS titles. | |
| * Labels are derived from the actual RSS titles - fully dynamic. | |
| * Returns array of {label, query, keyword} objects. | |
| */ | |
| function extractTrendingKeywords(titles) { | |
| // Category patterns: each maps to a label generator that creates | |
| // dynamic labels based on the actual RSS title content | |
| const categoryPatterns = [ | |
| // AI / Tech models - extract specific model name from title | |
| { | |
| regex: /\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i, | |
| labelFromTitle: function(title) { | |
| const m = title.match(/\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i); | |
| if (!m) return null; | |
| const kw = m[1].replace(/\s+/g, ''); | |
| return '#' + kw.charAt(0).toUpperCase() + kw.slice(1); | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' tin tức mới nhất hôm nay'; | |
| } | |
| }, | |
| // Finance - extract specific topic | |
| { | |
| regex: /\b(chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán|gold|vàng|giá\s+vàng)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán/i.test(title)) | |
| return '#ChứngKhoán'; | |
| if (/gold|vàng|giá\s+vàng/i.test(title)) | |
| return '#GiáVàng'; | |
| return null; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' Việt Nam hôm nay'; | |
| } | |
| }, | |
| // Sports - extract specific event/team | |
| { | |
| regex: /\b(bóng\s+đá|football|premier\s+league|world\s+cup|chuyển\s+nhượng|transfer|tuyển\s+việt\s+nam|u23|asian\s+cup|đội\s+tuyển|fifa|world\s+cup)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/fifa/i.test(title)) return '#Fifa'; | |
| if (/tuyển\s+việt\s+nam|đội\s+tuyển\s+việt\s+nam/i.test(title)) return '#ĐộiTuyểnViệtNam'; | |
| if (/chuyển\s+nhượng|transfer/i.test(title)) return '#ChuyểnNhượng'; | |
| if (/asian\s+cup|u23/i.test(title)) return '#U23'; | |
| if (/world\s+cup/i.test(title)) return '#WorldCup'; | |
| if (/premier\s+league/i.test(title)) return '#PremierLeague'; | |
| return '#BóngĐá'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' tin tức mới nhất'; | |
| } | |
| }, | |
| // Mobile / Tech - extract specific device | |
| { | |
| regex: /\b(iphone|apple|galaxy|smartphone|pixel|xiaomi|oppo|vivo|samsong)\b/i, | |
| labelFromTitle: function(title) { | |
| const m = title.match(/\b(iphone|galaxy|pixel|xiaomi|oppo|vivo)\b/i); | |
| if (m) return '#' + m[1].charAt(0).toUpperCase() + m[1].slice(1); | |
| return '#iPhone'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' thông tin mới nhất 2026'; | |
| } | |
| }, | |
| // Entertainment - extract specific content | |
| { | |
| regex: /\b(phim|movie|netflix|disney|box\s+office|phim\s+hay|phim\s+mới)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/netflix/i.test(title)) return '#Netflix'; | |
| if (/disney/i.test(title)) return '#Disney'; | |
| if (/phim\s+mới|phim\s+hay/i.test(title)) return '#PhimMới'; | |
| return '#Phim'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' hay nhất 2026'; | |
| } | |
| }, | |
| // Weather | |
| { | |
| regex: /\b(bão|thời\s+tiết|weather|nắng\s+nóng|lũ\s+lụt|mưa\s+lớn|bão\s+số)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/bão\s+số/i.test(title)) return '#BãoSố'; | |
| if (/nắng\s+nóng/i.test(title)) return '#NắngNóng'; | |
| if (/lũ\s+lụt|ngập\s+úng/i.test(title)) return '#LũLụt'; | |
| return '#ThờiTiết'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' Việt Nam hôm nay'; | |
| } | |
| }, | |
| // Economy | |
| { | |
| regex: /\b(nông\s+sản|giá\s+điện|inflation|gdp|kinh\s+tế)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/nông\s+sản/i.test(title)) return '#NôngSản'; | |
| if (/giá\s+điện/i.test(title)) return '#GiáĐiện'; | |
| return '#KinhTế'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' Việt Nam tin tức mới nhất'; | |
| } | |
| }, | |
| // Politics | |
| { | |
| regex: /\b(chính\s+trị|đảng|chính\s+phủ|pháp\s+luật|thời\s+sự)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/thời\s+sự/i.test(title)) return '#ThờiSự'; | |
| return '#ChínhTrị'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' nóng nhất hôm nay'; | |
| } | |
| }, | |
| // Education | |
| { | |
| regex: /\b(giáo\s+dục|đại\s+học|tuyển\s+sinh|học\s+sinh|sinh\s+viên)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/tuyển\s+sinh/i.test(title)) return '#TuyểnSinh'; | |
| if (/đại\s+học/i.test(title)) return '#ĐạiHọc'; | |
| return '#GiáoDục'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' mới nhất hôm nay'; | |
| } | |
| }, | |
| // Health | |
| { | |
| regex: /\b(sức\s+khoẻ|bệnh|y\s+tế|epidemic|dịch|vaccine)\b/i, | |
| labelFromTitle: function(title) { | |
| if (/vaccine/i.test(title)) return '#Vaccine'; | |
| if (/dịch|epidemic/i.test(title)) return '#DịchBệnh'; | |
| return '#SứcKhỏe'; | |
| }, | |
| queryFromLabel: function(label) { | |
| return label + ' nóng nhất hôm nay'; | |
| } | |
| }, | |
| ]; | |
| const matched = []; | |
| const seenLabels = new Set(); | |
| for (const title of titles) { | |
| for (const cat of categoryPatterns) { | |
| if (cat.regex.test(title)) { | |
| const label = cat.labelFromTitle(title); | |
| if (!label || seenLabels.has(label)) continue; | |
| seenLabels.add(label); | |
| matched.push({ | |
| label: label, | |
| query: cat.queryFromLabel(label), | |
| keyword: cat.regex.source, | |
| }); | |
| if (matched.length >= 10) return matched; | |
| break; // Only match one category per title | |
| } | |
| } | |
| } | |
| return matched; | |
| } | |
| let currentHotTags = []; | |
| let hotTagsLastFetch = 0; | |
| const HOT_TAGS_TTL = 60 * 60 * 1000; // 1 hour cache | |
| async function fetchHotTags() { | |
| const now = Date.now(); | |
| if (currentHotTags.length > 0 && (now - hotTagsLastFetch) < HOT_TAGS_TTL) { | |
| return currentHotTags; | |
| } | |
| const realTitles = []; | |
| // ── Step 1: Fetch real-time news titles via the SERVER → /api/news/hot ── | |
| // (Google News RSS does NOT send CORS headers, so fetching it directly from the | |
| // browser always fails and yields an empty HOT list. The server proxies it.) | |
| try { | |
| const resp = await fetch('/api/news/hot'); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| if (Array.isArray(data.titles)) { | |
| for (const t of data.titles) { | |
| const c = t.replace(/^[\d.]+[\s:]*/, '').trim(); | |
| if (c) realTitles.push(c); | |
| if (realTitles.length >= 15) break; | |
| } | |
| } | |
| } else { | |
| console.warn('[HOT] /api/news/hot returned', resp.status); | |
| } | |
| } catch (e) { | |
| console.warn('[HOT] /api/news/hot fetch failed:', e.message); | |
| } | |
| // ── Step 2: Extract trending keywords dynamically from real news titles ── | |
| HOT_TOPIC_KEYWORDS = extractTrendingKeywords(realTitles); | |
| // ── Step 3: Build hot tags from extracted keywords ── | |
| const tags = HOT_TOPIC_KEYWORDS.map(kw => ({ | |
| label: kw.label, | |
| query: kw.query, | |
| keyword: kw.keyword, | |
| })); | |
| // If we have fewer than 10 tags, pad with topic keywords extracted from the | |
| // headline titles so the HOT list always has at least 10 entries. These are | |
| // created only from REAL trending titles (never hard-coded), so they stay HOT-relevant. | |
| if (tags.length < 10 && realTitles.length > 0) { | |
| for (const t of realTitles) { | |
| if (tags.length >= 10) break; | |
| let short = t.replace(/^[\d.]+[\s:]*/, '').trim(); | |
| // Drop trailing source "- SourceName" | |
| short = short.replace(/\s*-\s*[^-\s]+(?:\s+[^-\s]+)?\s*$/, '').trim(); | |
| if (short.length < 6) continue; | |
| // Pick 2-3 meaningful words (skip numbers & common connectors) to form a compact tag | |
| const stopwords = ['của', 'và', 'với', 'cho', 'các', 'trong', 'một', 'những', 'đã', 'sẽ', 'đang', 'có', 'tại', 'theo', 'về', 'ngày', 'hôm']; | |
| const words = short.split(/\s+/).filter(w => w.length > 2 && !/^[\d.,]+$/.test(w) && !stopwords.includes(w.toLowerCase().replace(/[.,]/g,'').toLowerCase())); | |
| const picked = words.slice(0, 3); | |
| if (picked.length === 0) continue; | |
| const label = '#' + picked.join('').replace(/[^\wÀ-ỹà-ỹ]/gi, '').slice(0, 20); | |
| if (label.length > 3 && !tags.find(t => t.label === label)) { | |
| tags.push({ | |
| label, | |
| query: picked.length > 2 ? picked.slice(0, 2).join(' ') + ' hôm nay' : picked[0] + ' hôm nay', | |
| keyword: picked.slice(0, 2).join(' '), | |
| }); | |
| } | |
| } | |
| } | |
| // Absolute last-resort safety net (should never trigger, but keeps UI non-empty) | |
| if (tags.length === 0) { | |
| tags.push({ label: '#TinTức', query: 'tin tức mới nhất hôm nay', keyword: 'tin tức' }); | |
| tags.push({ label: '#ThờiSự', query: 'thời sự nóng nhất hôm nay', keyword: 'thời sự' }); | |
| } | |
| currentHotTags = tags.slice(0, 10); | |
| hotTagsLastFetch = now; | |
| return currentHotTags; | |
| } | |
| /** Match response text keywords to find the most relevant dynamic topic */ | |
| function findBestTopicMatch(responseText) { | |
| const lower = (responseText || "").toLowerCase(); | |
| let bestScore = 0; | |
| let bestMatch = null; | |
| for (const kw of HOT_TOPIC_KEYWORDS) { | |
| if (lower.includes(kw.keyword.toLowerCase())) { | |
| const score = kw.keyword.length; | |
| if (score > bestScore) { | |
| bestScore = score; | |
| bestMatch = kw; | |
| } | |
| } | |
| } | |
| return bestMatch; | |
| } | |
| /** Generate source cards matching the SPECIFIC topic of the response. | |
| * FIX: use VnExpress RSS items (title+image+url+desc are ONE unit) as the | |
| * PRIMARY source, so every card's image ALWAYS matches its news content. | |
| */ | |
| async function generateSourceCards(responseText) { | |
| const matchedTopic = findBestTopicMatch(responseText); | |
| const query = matchedTopic ? matchedTopic.query : responseText; | |
| // Prefer a clean topical keyword that Google News can search on | |
| const searchQuery = query.replace(/^#/, '').replace(/ tin tức.*$| thông tin.*$| mới nhất.*$| hôm nay.*$| nóng nhất.*$| Việt Nam hôm nay.*$/, '').trim() || query; | |
| // ── PRIMARY: VnExpress RSS items carry title+image+url+desc together → no mismatch ── | |
| let cards = []; | |
| const catLabel = matchedTopic ? matchedTopic.label : 'mới nhất'; | |
| try { | |
| const resp = await fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| const imgs = (data.images || []); | |
| if (imgs.length > 0) { | |
| // NEW: extend each card so "Hỏi AI" knows the exact article title too | |
| cards = imgs.slice(0, 6).map(a => ({ | |
| source: a.source || 'VnExpress', | |
| title: a.title, | |
| url: a.url, | |
| icon: '📰', | |
| category: catLabel, | |
| desc: cleanText(a.desc || ''), | |
| image: a.image || '', | |
| articleTitle: a.title || '', | |
| })); | |
| } | |
| } else { | |
| console.warn('[SourceCards] /api/news/images returned', resp.status); | |
| } | |
| } catch (e) { | |
| console.warn('[SourceCards] images fetch failed:', e.message); | |
| } | |
| // ── SECONDARY (no VnExpress items matched): Google News article search, then | |
| // match a VnExpress image to a card by content (title keyword overlap), | |
| // NEVER by array index — this eliminates the image/content mismatch. ── | |
| if (cards.length === 0) { | |
| let imageMap = []; | |
| const imagesPromise = fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`) | |
| .then(r => r.ok ? r.json() : { images: [] }) | |
| .then(d => { imageMap = d.images || []; }) | |
| .catch(() => {}); | |
| try { | |
| const resp = await fetch(`/api/news/articles?q=${encodeURIComponent(searchQuery)}`); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| const arts = (data.articles || []).slice(0, 6); | |
| if (arts.length > 0) { | |
| cards = arts.map((a) => ({ | |
| source: a.source || 'Nguồn tin', | |
| title: a.title, | |
| url: a.url, | |
| icon: '📰', | |
| category: catLabel, | |
| desc: cleanText(a.desc || ''), | |
| image: '', | |
| articleTitle: a.title || '', | |
| })); | |
| } | |
| } | |
| } catch (e) {} | |
| await imagesPromise; | |
| // Content-based image matching (not index-based) | |
| if (imageMap.length > 0) { | |
| const norm = (t) => String(t||'').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[đĐ]/g,'d'); | |
| cards = cards.map(c => { | |
| if (c.image) return c; | |
| const cn = norm(c.title); | |
| // find the VnExpress item whose title overlaps most with this card title | |
| let best = '', bestScore = 0; | |
| for (const m of imageMap) { | |
| const mn = norm(m.title); | |
| let score = 0; | |
| const words = cn.split(/[^a-z0-9]+/).filter(w => w.length > 3); | |
| for (const w of words) if (mn.includes(w)) score++; | |
| if (score > bestScore) { bestScore = score; best = m.image; } | |
| } | |
| if (best) return { ...c, image: best }; | |
| // last resort: reuse this VnExpress item's full card (image+title+url match by construction) | |
| const mi = imageMap.find(m => norm(m.title).includes(cn.slice(0, 20)) || cn.includes(norm(m.title).slice(0, 20))); | |
| if (mi) return { ...c, title: mi.title, url: mi.url, image: mi.image, desc: c.desc || cleanText(mi.desc||'') }; | |
| return c; | |
| }); | |
| } | |
| } | |
| if (cards.length > 0) return cards; | |
| // Fallback #2: topic search (category landing pages) — no index-based images | |
| try { | |
| const resp = await fetch(`/api/news/topic?q=${encodeURIComponent(searchQuery)}`); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| const results = (data.results || []).slice(0, 6); | |
| if (results.length > 0) { | |
| return results.map(r => ({ | |
| source: r.source || 'Nguồn tin', | |
| title: r.title, | |
| url: r.url, | |
| icon: '📰', | |
| category: catLabel, | |
| desc: r.snippet || '', | |
| image: '', | |
| })); | |
| } | |
| } | |
| } catch (e) { | |
| console.warn('[SourceCards] topic fetch failed:', e.message); | |
| } | |
| // Fallback: general news portals | |
| return [ | |
| { source: 'VnExpress', title: 'Tin tổng hợp trong ngày cập nhật mới nhất', url: 'https://vnexpress.net/', icon: '📰', category: catLabel, image: '' }, | |
| { source: 'Tuổi Trẻ', title: 'Thông tin đời sống xã hội và kinh tế', url: 'https://tuoitre.vn/', icon: '📰', category: catLabel, image: '' }, | |
| { source: 'Thanh Niên', title: 'Nhìn đa chiều các vấn đề thời sự', url: 'https://thanhnien.vn/', icon: '📰', category: catLabel, image: '' }, | |
| ]; | |
| } | |
| /** Create DOM element for suggestion pill - black bg like chatbox */ | |
| function createSuggestionPill(text, icon) { | |
| const btn = document.createElement("button"); | |
| btn.className = "chat-suggestion-pill"; | |
| btn.style.background = "#1a1a2e"; | |
| btn.style.color = "#e0e0e0"; | |
| btn.style.border = "1px solid #333"; | |
| btn.style.borderRadius = "16px"; | |
| btn.style.padding = "6px 14px"; | |
| btn.style.fontSize = "13px"; | |
| btn.style.cursor = "pointer"; | |
| btn.style.transition = "background 0.2s"; | |
| btn.innerHTML = `<span class="pill-icon">${icon || '💡'}</span>${text}`; | |
| btn.addEventListener("click", function(e) { | |
| e.stopPropagation(); | |
| sendUserSuggestion(text); | |
| }); | |
| btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); | |
| btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); | |
| return btn; | |
| } | |
| /** Create DOM element for hot tag — click triggers real-time news search */ | |
| function createHotTag(tagObj) { | |
| const btn = document.createElement("button"); | |
| btn.className = "hot-tag"; | |
| btn.textContent = tagObj.label; | |
| btn.title = tagObj.query; | |
| btn.style.background = "#1a1a2e"; | |
| btn.style.color = "#e0e0e0"; | |
| btn.style.border = "1px solid #333"; | |
| btn.style.borderRadius = "16px"; | |
| btn.style.padding = "6px 14px"; | |
| btn.style.fontSize = "13px"; | |
| btn.style.cursor = "pointer"; | |
| btn.style.transition = "background 0.2s"; | |
| btn.addEventListener("click", async function(e) { | |
| e.stopPropagation(); | |
| // Show loading state immediately | |
| const origLabel = btn.textContent; | |
| btn.textContent = '⏳ ' + tagObj.label.replace('#', ''); | |
| btn.disabled = true; | |
| // Send the query — the assistant will use search_news tool to fetch real articles | |
| sendUserSuggestion(tagObj.query); | |
| // Also render concrete news cards with real links right here in the chat | |
| try { | |
| const sources = await generateSourceCards(tagObj.query); | |
| if (sources.length && chatMessages) { | |
| const section = document.createElement("div"); | |
| section.className = "news-section"; | |
| const h = document.createElement("div"); | |
| h.className = "news-section-title"; | |
| h.innerHTML = '<span class="hot-tag-dot"></span> Tin về ' + escHtml(tagObj.label) + ':'; | |
| const row = document.createElement("div"); | |
| row.className = "source-cards-row"; | |
| sources.forEach(s => row.appendChild(createSourceCard(s))); | |
| section.appendChild(h); | |
| section.appendChild(row); | |
| chatMessages.appendChild(section); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| } catch (_) {} | |
| btn.textContent = origLabel; | |
| btn.disabled = false; | |
| }); | |
| btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); | |
| btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); | |
| return btn; | |
| } | |
| function escHtml(s) { | |
| return String(s == null ? '' : s) | |
| .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') | |
| .replace(/"/g, '"').replace(/'/g, '''); | |
| } | |
| /** Strip HTML tags & entities from a text string (for clean card descriptions) */ | |
| function cleanText(s) { | |
| return String(s == null ? '' : s) | |
| .replace(/<[^>]*>/g, ' ') | |
| .replace(/\bhttps?:\/\/\S+/gi, '') | |
| .replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'") | |
| .replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ') | |
| .replace(/\s+/g, ' ').trim(); | |
| } | |
| /** Build context-aware follow-up questions for a SPECIFIC news article so the | |
| * suggested questions are always relevant to the news at hand (ngữ cảnh). */ | |
| function buildNewsSuggestions(sourceObj) { | |
| const title = (sourceObj && sourceObj.articleTitle) || (sourceObj && sourceObj.title) || ''; | |
| if (!title) return ["Cho tôi xem tin tức mới nhất", "Tin nóng hôm nay là gì?", "Có tin gì thú vị không?"]; | |
| // Extract the core subject for natural, contextual questions | |
| const short = String(title).replace(/\s*-\s*[^\-]{2,}$/, '').replace(/^\d+[.:\s]*/, '').trim(); | |
| const norm = String(short).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); | |
| const subj = short.length > 60 ? short.slice(0, 57) + '…' : short; | |
| return [ | |
| 'Cho tôi biết thêm về "' + subj + '"', | |
| 'Nội dung chi tiết của tin "' + subj + '" là gì?', | |
| 'Có thông tin mới nhất nào về "' + subj + '" không?', | |
| 'Bối cảnh và diễn biến của "' + subj + '" như thế nào?' | |
| ]; | |
| } | |
| /** Create DOM element for source news card. | |
| * FIX: "Hỏi AI về tin này" sends the EXACT article title (+ fetches article content | |
| * if possible) so the AI answers about the CORRECT story, and each card now appends | |
| * its own suggested questions specific to that news item. */ | |
| function createSourceCard(sourceObj) { | |
| const card = document.createElement("div"); | |
| card.className = "source-card"; | |
| if (sourceObj.url) { | |
| card.addEventListener("click", function(e) { | |
| e.stopPropagation(); | |
| window.open(sourceObj.url, '_blank'); | |
| }); | |
| card.style.cursor = "pointer"; | |
| } | |
| let sourceName = sourceObj.source || ''; | |
| if (!sourceName && sourceObj.url) { | |
| try { sourceName = new URL(sourceObj.url).hostname.replace(/^www\./, ''); } catch (_) {} | |
| } | |
| if (!sourceName) sourceName = 'Nguồn tin'; | |
| const title = sourceObj.title || ''; | |
| const articleTitle = sourceObj.articleTitle || title; | |
| const imgHtml = sourceObj.image | |
| ? `<div class="source-card-thumb-wrap"><img class="source-card-thumb" src="${escHtml(sourceObj.image)}" alt="" loading="lazy" onerror="this.closest('.source-card-thumb-wrap')?.remove()" /></div>` | |
| : ''; | |
| const newsQs = buildNewsSuggestions(sourceObj); | |
| const qPills = newsQs.map(q => | |
| `<button type="button" class="news-q-pill" data-q="${escHtml(q)}">${escHtml(q)}</button>` | |
| ).join(''); | |
| card.innerHTML = ` | |
| ${imgHtml} | |
| <span class="source-card-source">${sourceObj.icon || '📰'} ${escHtml(sourceName)}</span> | |
| <span class="source-card-title">${escHtml(title)}</span> | |
| ${sourceObj.desc ? '<span class="source-card-desc">' + escHtml(sourceObj.desc) + '</span>' : ''} | |
| <button type="button" class="source-card-askai" data-title="${escHtml(articleTitle)}" data-url="${escHtml(sourceObj.url || '')}" data-topic="${escHtml(sourceObj.category || 'news')}"><i class="fas fa-robot"></i> Hỏi AI về tin này</button> | |
| <div class="news-q-row">${qPills}</div> | |
| `; | |
| const askBtn = card.querySelector(".source-card-askai"); | |
| if (askBtn) { | |
| askBtn.addEventListener("click", function(e) { | |
| e.stopPropagation(); | |
| const artTitle = (this.getAttribute("data-title") || '').trim(); | |
| const artUrl = (this.getAttribute("data-url") || '').trim(); | |
| if (artTitle) { | |
| // Ask AI about the EXACT article — prefer fetching its real content | |
| sendUserSuggestion('Cho tôi biết thêm về tin: "' + artTitle + '". Hãy tìm hiểu và tóm tắt nội dung, diễn biến mới nhất liên quan tới tin này.'); | |
| } else { | |
| sendUserSuggestion("Cho tôi xem tin tức mới nhất"); | |
| } | |
| }); | |
| } | |
| const qPillsEls = card.querySelectorAll(".news-q-pill"); | |
| qPillsEls.forEach(function(btn) { | |
| btn.addEventListener("click", function(e) { | |
| e.stopPropagation(); | |
| sendUserSuggestion(this.getAttribute("data-q")); | |
| }); | |
| }); | |
| return card; | |
| } | |
| /** Build complete post-message context container using DOM elements */ | |
| function buildPostMessageContextBlock(responseText, isGreeting) { | |
| const container = document.createElement("div"); | |
| container.className = "post-message-context"; | |
| // Don't show suggestions after product results | |
| if (!isGreeting && responseText && (responseText.includes('Sản phẩm phù hợp') || responseText.includes('Tìm thấy ') || responseText.includes('Đã mở thông tin'))) { | |
| return container; // Return empty container (won't be appended due to check in addChatMessage) | |
| } | |
| // 1) Suggestions row | |
| if (!isGreeting && responseText) { | |
| const suggestionsRow = document.createElement("div"); | |
| suggestionsRow.className = "suggestions-row"; | |
| const questions = generateSuggestionsFromContext(responseText); | |
| questions.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '💡'))); | |
| container.appendChild(suggestionsRow); | |
| } else if (isGreeting) { | |
| const suggestionsRow = document.createElement("div"); | |
| suggestionsRow.className = "suggestions-row"; | |
| const greetQs = ["Tôi muốn tìm bếp từ Malloca", "Khóa thông minh nào tốt?", "Xem sản phẩm gia dụng"]; | |
| greetQs.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '🛒'))); | |
| container.appendChild(suggestionsRow); | |
| } | |
| // 2) HOT trending tags row | |
| const tagsPromise = fetchHotTags(); | |
| const hotTagsRow = document.createElement("div"); | |
| hotTagsRow.className = "hot-tags-row"; | |
| const label = document.createElement("span"); | |
| label.className = "hot-tags-label"; | |
| label.innerHTML = '<span class="hot-tag-dot"></span> HOT'; | |
| hotTagsRow.appendChild(label); | |
| tagsPromise.then(tags => { | |
| hotTagsRow.innerHTML = ''; | |
| hotTagsRow.appendChild(label); | |
| tags.forEach(tag => hotTagsRow.appendChild(createHotTag(tag))); | |
| }).catch(err => { | |
| console.warn('[HOT] Failed to update tags:', err); | |
| }); | |
| container.appendChild(hotTagsRow); | |
| // 3) Source news cards (non-greeting, lengthy responses) | |
| if (!isGreeting && responseText && responseText.length > 30) { | |
| generateSourceCards(responseText).then(sources => { | |
| const sourceCardsRow = document.createElement("div"); | |
| sourceCardsRow.className = "source-cards-row"; | |
| sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src))); | |
| container.appendChild(sourceCardsRow); | |
| }); | |
| } | |
| return container; | |
| } | |
| // ── Enhanced addChatMessage with product card + post-response context support ── | |
| let _pendingProductCards = null; | |
| let _pendingSourceCards = null; | |
| let _isGreetingMessage = false; | |
| function addChatMessage(role, text, isGreeting) { | |
| _isGreetingMessage = !!isGreeting; | |
| const m = document.createElement("div"); | |
| m.className = "chat-message " + role; | |
| if (role === "assistant" && text) { | |
| const mt = document.createElement("div"); | |
| mt.textContent = text; | |
| m.appendChild(mt); | |
| // Product cards | |
| if (_pendingProductCards) { | |
| const pc = document.createElement("div"); | |
| pc.innerHTML = _pendingProductCards; | |
| m.appendChild(pc); | |
| if (window.vaix) window.vaix.attachChatCardHandlers(pc); | |
| _pendingProductCards = null; | |
| } | |
| // Post-message context: suggestions + hot tags + source cards | |
| const ctxEl = buildPostMessageContextBlock(text, isGreeting); | |
| if (ctxEl && ctxEl.style.display !== "none") { | |
| m.appendChild(ctxEl); | |
| } | |
| _pendingSourceCards = null; | |
| } else { | |
| m.textContent = text; | |
| } | |
| chatMessages.appendChild(m); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); | |
| } | |
| function setPendingProductCards(html) { _pendingProductCards = html; } | |
| function setPendingSourceCards(list) { _pendingSourceCards = list; } | |
| // ── COMBO SUGGESTION CARDS ── | |
| let _comboShownThisSession = false; | |
| function showComboOnEntry(opts) { | |
| try { | |
| if (!window.vaix || !window.vaix.isLoaded || !window.vaix.isLoaded()) return; | |
| if (_comboShownThisSession) return; | |
| _comboShownThisSession = true; | |
| const preferredBrand = (opts && opts.brand) || "Malloca"; | |
| const combo = window.vaix.getRandomCombo(preferredBrand); | |
| if (!combo || !combo.items || !combo.items.length) return; | |
| const m = document.createElement("div"); | |
| m.className = "chat-message assistant"; | |
| const intro = document.createElement("div"); | |
| intro.textContent = "🛒 Gợi ý combo gian bếp " + (combo.brand ? "thương hiệu " + combo.brand : "") + " cho bạn:"; | |
| m.appendChild(intro); | |
| const html = window.vaix.renderComboCardsBlock(combo); | |
| const wrap = document.createElement("div"); | |
| wrap.innerHTML = html; | |
| m.appendChild(wrap); | |
| if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(wrap); | |
| const randomBtn = wrap.querySelector(".chat-combo-random"); | |
| if (randomBtn) { | |
| randomBtn.addEventListener("click", function(e) { | |
| e.stopPropagation(); | |
| const fresh = window.vaix.getRandomCombo(preferredBrand); | |
| if (!fresh) return; | |
| const freshEl = document.createElement("div"); | |
| freshEl.innerHTML = window.vaix.renderComboCardsBlock(fresh); | |
| if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(freshEl); | |
| const oldCards = m.querySelector(".chat-combo-section"); | |
| if (oldCards) oldCards.replaceWith(freshEl.querySelector(".chat-combo-section")); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| }); | |
| } | |
| chatMessages.appendChild(m); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); | |
| } catch (e) { | |
| console.warn('[Combo] error:', e.message); | |
| } | |
| } | |
| // Poll until products are loaded, then show the random combo once per session entry. | |
| function startComboPoll() { | |
| if (typeof showComboOnEntry !== "function") return; | |
| let _i = 0; | |
| const _iv = setInterval(function() { | |
| _i++; | |
| if (_i > 30) { clearInterval(_iv); return; } | |
| showComboOnEntry(); | |
| if (window.vaix && window.vaix.isLoaded && window.vaix.isLoaded() && _comboShownThisSession) clearInterval(_iv); | |
| }, 350); | |
| } | |
| function showTextChat(s){ | |
| textChat.hidden=!s; | |
| if(chatMicBtn) chatMicBtn.hidden=!s; | |
| if(s)chatInput.focus(); | |
| if(!s && voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } | |
| } | |
| 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} | |
| // Store callback for suggested question sends | |
| window._triggerSuggestionSend = function(text) { | |
| if (sendTextViaSession(text)) { | |
| setCaption("GEMMA ĐANG VIẾT…", "live"); | |
| return; | |
| } | |
| // S2S not connected — restart session | |
| if (client) { try { client.close(); } catch(_) {} client = null; } | |
| sessionInProgress = false; | |
| startTextSession(text); | |
| }; | |
| // ✨ Pure text chat client — uses S2S in text-only mode, so it has ALL same tools as voice | |
| function sendTextMessage(t){ | |
| const msg = t || chatInput.value.trim(); | |
| if(!msg) return; | |
| chatInput.value = ""; | |
| addChatMessage("user", msg); | |
| setCaption("GEMMA ĐANG VIẾT…", "live"); | |
| if (sendTextViaSession(msg)) { return; } | |
| if (client) { try { client.close(); } catch(_) {} client = null; } | |
| sessionInProgress = false; | |
| startTextSession(msg); | |
| } | |
| // Safe event binding helpers (null-safety) | |
| function on(el,evt,fn){if(el)el.addEventListener(evt,fn)} | |
| function toggleClass(el,cls,val){if(el)el.classList.toggle(cls,val)} | |
| on(chatCloseBtn,"click",e=>{ | |
| e.stopPropagation(); | |
| textMode=false; | |
| if(voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } | |
| showTextChat(false); | |
| toggleClass(textModeBtn,"active",false); | |
| // Clear suggested questions when closing chat | |
| if(window.vaix && window.vaix.clearSuggestedQuestions) window.vaix.clearSuggestedQuestions(); | |
| currentContextForSuggestions = null; | |
| }); | |
| let mainAction="start"; | |
| 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} | |
| 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"}; | |
| // Track last message content to show suggestions after responses | |
| let lastAssistantMessage = ""; | |
| function onStatus(s){ | |
| stage.setConversationState(s); | |
| if(isGreetingSession){if(s==="ai-speaking")setCaption("");else if(s==="closed"||s==="idle")setCaption(CAPTIONS.idle);return} | |
| setCaption(CAPTIONS[s]??s,s==="error"?"error":s==="idle"||s==="closed"?"":"live"); | |
| switch(s){case"idle":case"closed":setMainButton("start","Start talking");break;case"error":setMainButton("start","Retry");break;case"creating-session":case"connecting":setMainButton("busy","Connecting…");break;case"queued":setMainButton("stop","Leave queue");break;case"your-turn":setMainButton("join","Join now");default:setMainButton("stop","End conversation");break} | |
| if(s==="ai-speaking" && !textMode && client) client.setMuted(true); | |
| if(s==="connected" && !textMode && client) client.setMuted(false); | |
| if(s==="user-speaking" && !textMode && client) client.setMuted(false); | |
| if(s==="user-speaking"){ | |
| if(subtitles)subtitles.classList.remove("visible"); | |
| showTextChat(textMode); | |
| if(!textMode){ | |
| if(voiceTyper && voiceTyper.active && voiceTyperMode==="chat"){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } | |
| startVoiceSubtitle(); | |
| } | |
| } | |
| if(s==="ai-speaking"){ | |
| if(voiceTyper && voiceTyper.active && !textMode) try{voiceTyper.stop();}catch(_){} | |
| } | |
| updateChatAudioToggleBtn(); | |
| } | |
| function runTool(name,argsJson,callId){ | |
| if(!client)return;if(isGreetingSession){client.sendToolOutput(callId,"OK");client.requestResponse();return} | |
| let args={};try{args=JSON.parse(argsJson||"{}")}catch(e){} | |
| const send=r=>{ client.sendToolOutput(callId,r); client.requestResponse(); }; | |
| if(name==="get_current_datetime"){const n=new Date();send(`Date: ${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} Time: ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",second:"2-digit"})}.`);return} | |
| if(name==="search_wikipedia"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(q.replace(/\s+/g,"_"))}`).then(r=>r.json()).then(d=>{if(d.extract){send(`Wikipedia (${d.title}): ${d.extract.slice(0,1000)}\n${d.url}`);return}fetch(`/api/wiki/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(sd=>{if(!sd.results?.length){send("No results.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`).then(r=>r.json()).then(s=>{send(s.extract?`Wikipedia (${s.title}): ${s.extract.slice(0,1000)}`:`${sd.results.slice(0,3).map(r=>r.title+": "+r.snippet).join("\n")}`)})})}).catch(()=>send("Wikipedia failed."));return} | |
| if(name==="search_web"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/web/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(d=>{if(!d.results?.length){send("No results.");return}const lines=d.results.slice(0,5).map((r,i)=>`${i+1}. ${r.title}\n ${r.snippet}\n 🔗 ${r.url}`);send(lines.join("\n\n"))}).catch(()=>send("Web search failed."));return} | |
| if(name==="search_news"){const q=args.query||"";if(!q){send("No query.");return}// Build search query with Vietnamese news sources | |
| const newsQueries=[`${q} site:vnexpress.net`,`${q} site:tuoitre.vn`,`${q} site:thanhnien.vn`,`${q} site:cafef.vn`,`tin tức ${q} hôm nay`];const allResults=[];const fetchPromises=newsQueries.map(nq=>fetch(`/api/web/search?q=${encodeURIComponent(nq)}`).then(r=>r.json()).then(d=>{if(d.results)allResults.push(...d.results)}).catch(()=>{}));Promise.all(fetchPromises).then(()=>{if(!allResults.length){send("Không tìm thấy tin tức nào.");return}// Deduplicate by URL | |
| const seen=new Set();const unique=allResults.filter(r=>{if(seen.has(r.url))return false;seen.add(r.url);return true});// Group by source | |
| const bySource={};for(const r of unique.slice(0,8)){if(!bySource[r.source])bySource[r.source]=[];bySource[r.source].push(r);}let msg="";for(const[src,items]of Object.entries(bySource)){msg+=`\n${src}:\n`;for(const item of items.slice(0,2)){msg+=` • ${item.title}\n 🔗 ${item.url}\n`;}}if(!msg)msg="No news found.";send(msg)}).catch(()=>send("News search failed."));return} | |
| if(name==="open_catalog"){const p=document.getElementById("vaistudio-panel"),t=document.getElementById("vaistudio-toggle");if(p){p.classList.add("open");p.style.display="flex"}if(t)t.classList.add("active");send("Catalog opened.");return} | |
| if(name==="open_product"){const p=window.vaix?.findProduct(args.product_id||"");send(p?`Product: ${p.title_clean}\nPrice: ${(p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ")}\nBrand: ${p.brand||""}\nModel: ${p.model||p.sku||""}`:"Product not found.");return} | |
| if(name==="search_catalog"){send("Searching...");return} | |
| if(name==="query_catalog"){ | |
| const result = window.vaix?.queryCatalog(args.query||"")||"Catalog not loaded yet."; | |
| const results = window.vaix?.getLastSearchResults(); | |
| if(results && results.length){ | |
| let html = '<div class="chat-product-cards">'; | |
| const limit = Math.min(results.length, 5); | |
| for(let i=0;i<limit;i++){ html += window.vaix.createChatProductCard(results[i]); } | |
| html += '</div>'; | |
| setPendingProductCards(html); | |
| } | |
| send(result); | |
| return; | |
| } | |
| if(name==="show_product"){ | |
| const result = window.vaix?.showProduct(args.product_name||"")||"Product not found."; | |
| const p = window.vaix?.getLastShownProduct(); | |
| if(p){ const html = '<div class="chat-product-cards">' + window.vaix.createChatProductCard(p) + '</div>'; setPendingProductCards(html); } | |
| send(result); | |
| return; | |
| } | |
| if(name==="combo_suggest"){ | |
| const combo = window.vaix?.comboByCriteria(args||{})||null; | |
| if(combo && combo.items && combo.items.length){ | |
| const html = '<div class="chat-combo-section">' + window.vaix.renderComboCardsBlock(combo) + '</div>'; | |
| setPendingProductCards(html); | |
| const lines = combo.items.map(function(p){ return '• ' + p.title_clean + ' — ' + (p.brand||'') + ' — ' + (p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"); }); | |
| send("Em gợi ý combo sau đây " + (combo.brand?("thương hiệu "+combo.brand+" "):"") + "cho anh chị:\n" + lines.join("\n") + "\n\nGiá mỗi món đã hiển thị trên thẻ. Anh chị có muốn tôi điều chỉnh theo giá/ chất liệu/ màu sắc/ thương hiệu không?"); | |
| } else { | |
| send("Xin lỗi, em chưa tìm được combo phù hợp với yêu cầu đó. Anh chị có thể thử với tiêu chí khác như thương hiệu Malloca, Eurogold hoặc khoảng giá nhé."); | |
| } | |
| return; | |
| } | |
| const r=stage.runTool(name,args)??`Unknown: ${name}`;send(r); | |
| } | |
| async function connectSession(c){try{await c.connect();return c}catch(e){const code=e?.code;if(isGreetingSession){sessionInProgress=false;return null}if(code==="limit")setCaption("DAILY LIMIT","error");else if(code==="queue-full")setCaption("ALL SEATS","error");else if(code==="join-expired")setCaption("EXPIRED","error");else if(code!=="aborted"){console.error(e);setCaption("NO CONNECTION","error")}sessionInProgress=false;return null}} | |
| async function startVoiceSession(){if(sessionInProgress)return;sessionInProgress=true;isGreetingSession=false;await stage.resume();let ms;if(FAKEMIC_MODE){ms=stage.audioCtx.createMediaStreamDestination().stream}else{try{ms=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}catch{setCaption("MIC BLOCKED","error");sessionInProgress=false;return}}const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;return}const g=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(g),micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:false});client=c;_a(c);setCaption("REQUESTING A SLOT…");const ok=await connectSession(c);if(ok&&!autoGreetingSent)c.requestResponse();startComboPoll(); | |
| // Voice-understanding fix: rely on correct BROWSER vi-VN STT text for what the | |
| // avatar understands, and stop sending raw mic audio to the backend so it never | |
| // produces a misrecognized (English/"mai yomui") audio transcript that the model | |
| // would answer incorrectly. Correct browser text is injected via sendUserText. | |
| if(ok){try{c.setUserAudioEnabled(false);}catch(_){} startVoiceRecognition();}} | |
| async function startGreetingSession(){if(sessionInProgress)return;isGreetingSession=true;sessionInProgress=true;await stage.resume();const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;isGreetingSession=false;return}const fm=ac.createMediaStreamDestination(),ms=fm.stream;const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:GREETING_INSTRUCTIONS,micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:[],_textOnly:false});client=c;_a(c);setCaption("");const ok=await connectSession(c);if(!ok){isGreetingSession=false;return}autoGreetingSent=true;c.requestResponse()} | |
| async function startTextSession(t){if(sessionInProgress){if(client&&t){client.sendUserText(t);client.requestResponse()}return}sessionInProgress=true;isGreetingSession=false;await stage.resume();const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;return}const nh=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(nh),audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:true});client=c;_a(c);textMode=true;showTextChat(true);toggleClass(textModeBtn,"active",true);const ok=await connectSession(c);if(!ok)return;if(!autoGreetingSent)c.requestResponse();startComboPoll();if(t){c.sendUserText(t);c.requestResponse()}} | |
| function _a(c){ | |
| c.addEventListener("status",e=>onStatus(e.detail.status)); | |
| c.addEventListener("queue",e=>{const{position}=e.detail;if(isGreetingSession)return;setCaption(position>0?`#${position} IN LINE…`:"ALMOST THERE…","live")}); | |
| c.addEventListener("transcript",e=>{const{role,text}=e.detail;if(role==="assistant"&&text){lastAssistantMessage=text; const n=smartNormalize(text);showSubtitles(n);addChatMessage("assistant",n,isGreetingSession)}}); | |
| // Server-side Vietnamese STT → user subtitles (the OFFICIAL recognition path). | |
| // The backend runs whisper-1 with language "vi" (see _buildSessionUpdate) and | |
| // streams the user's recognized speech here. Display it under subtitles so the | |
| // user can SEE that their voice was understood, and mirror it into the chat | |
| // input so the avatar is clearly working from the same recognized text. | |
| c.addEventListener("user-transcript", e => { | |
| const text = (e.detail && e.detail.text) || ""; | |
| if (!text) return; | |
| if (settings.subtitles) { | |
| showSubtitles("🗣️ " + text); | |
| if (chatInput) { chatInput.value = text; } | |
| } else if (chatInput && text) { | |
| chatInput.value = text; | |
| } | |
| }); | |
| c.addEventListener("response-finished",()=>{ | |
| autoGreetingSent=true; | |
| fadeSubtitles(); | |
| if(voiceTyper && !voiceTyper.active && voiceTyper._recognition){ try{ voiceTyper.stop(); }catch(_){} voiceTyper=null; } | |
| // Suggestions are now embedded directly in addChatMessage — no separate render needed | |
| if(isGreetingSession){ | |
| // After greeting, show a "Tin nóng hôm nay" section with SPECIFIC article cards (with links) | |
| (async () => { | |
| try { | |
| let data; | |
| try { data = await (await fetch("/api/news/hot")).json(); } catch(e) { data = {}; } | |
| const hotArticles = (data.articles || []).slice(0, 4); | |
| const lastMsg = chatMessages.lastElementChild; | |
| if (lastMsg && lastMsg.classList.contains("chat-message")) { | |
| const section = document.createElement("div"); | |
| section.className = "news-section"; | |
| // Generic hot-news heading | |
| const h = document.createElement("div"); | |
| h.className = "news-section-title"; | |
| h.innerHTML = '<span class="hot-tag-dot"></span> Tin nóng hôm nay:'; | |
| section.appendChild(h); | |
| // FIX: always build cards with matching image+content via generateSourceCards | |
| const titles = data.titles || []; | |
| const topic = (titles[0] || "").replace(/^[\d.]+[\s:]*/,"").trim(); | |
| let sources = []; | |
| if (topic.length > 5) { | |
| sources = await generateSourceCards(topic).catch(()=>[]); | |
| } | |
| // If topic-based raw cards are empty, fall back to the plain hot articles list | |
| if (sources.length === 0 && hotArticles.length > 0) { | |
| sources = hotArticles.map(a => ({ | |
| source: a.source || 'Nguồn tin', | |
| title: a.title, | |
| url: a.url, | |
| icon: '📰', | |
| desc: '', | |
| articleTitle: a.title || '', | |
| })); | |
| } | |
| if (sources.length) { | |
| const sourceCardsRow = document.createElement("div"); | |
| sourceCardsRow.className = "source-cards-row"; | |
| sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src))); | |
| section.appendChild(sourceCardsRow); | |
| } | |
| lastMsg.appendChild(section); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| } | |
| } catch (_) {} | |
| })(); | |
| setTimeout(()=>{ isGreetingSession=false; sessionInProgress=false; setCaption(CAPTIONS.idle); setMainButton("start","Start talking"); updateChatAudioToggleBtn(); },1500); | |
| } | |
| }); | |
| c.addEventListener("toolcall",e=>{const{name,arguments:a,callId}=e.detail;runTool(name,a,callId)}); | |
| c.addEventListener("server-error",e=>console.warn("err:",e.detail.error)); | |
| c.addEventListener("error",()=>void endSession()); | |
| } | |
| /** Show suggested questions after avatar finishes a general response */ | |
| function renderSuggestedQuestionsAfterResponse(responseText) { | |
| // Don't show suggestions right after showing products or tool calls | |
| if (responseText && (responseText.includes('Sản phẩm phù hợp') || responseText.includes('Tìm thấy ') || responseText.includes('Đã mở thông tin'))) { | |
| return; // Already showing product suggestions, skip | |
| } | |
| var container = document.getElementById("suggested-questions"); | |
| if (!container) return; | |
| clearSuggestedQuestions(); | |
| // Generate contextual suggestions based on what avatar just said | |
| var questions = generateSuggestionsFromContext(responseText); | |
| container.innerHTML = ""; | |
| for (var i = 0; i < questions.length; i++) { | |
| var pill = document.createElement("button"); | |
| pill.className = "suggested-pill"; | |
| pill.textContent = "💡 " + questions[i]; | |
| (function(q) { | |
| pill.addEventListener("click", function() { sendUserSuggestion(q); }); | |
| })(questions[i]); | |
| container.appendChild(pill); | |
| } | |
| container.style.display = "flex"; | |
| } | |
| /** Generate contextual suggestions from the avatar's last response */ | |
| function generateSuggestionsFromContext(responseText) { | |
| var lower = (responseText || "").toLowerCase(); | |
| // If mentions time/datetime | |
| if (lower.includes("thứ") || lower.includes("tháng") || lower.includes("năm")) { | |
| return [ | |
| "Thời tiết hôm nay thế nào?", | |
| "Có sự kiện gì thú vị hôm nay không?", | |
| "Cho tôi xem tin tức mới nhất" | |
| ]; | |
| } | |
| // If mentions news or weather | |
| if (lower.includes("tin") || lower.includes("thời tiết") || lower.includes("tin tức") || lower.includes("news") || lower.includes("báo")) { | |
| return [ | |
| "Tin tức nóng nhất hôm nay là gì?", | |
| "Cập nhật tin tức mới nhất", | |
| "Có tin gì thú vị không?" | |
| ]; | |
| } | |
| // If mentions specific topics — use dynamic HOT_TOPIC_KEYWORDS for suggestions | |
| var topicSuggestions = []; | |
| for (const kw of HOT_TOPIC_KEYWORDS) { | |
| if (lower.includes(kw.keyword.toLowerCase())) { | |
| topicSuggestions.push(kw.query); | |
| if (topicSuggestions.length >= 3) break; | |
| } | |
| } | |
| if (topicSuggestions.length > 0) return topicSuggestions; | |
| // ── Topic-contextual suggestions (ngữ cảnh chủ đề) ── | |
| // Build follow-up questions that stay on the SAME topic the user/assistant | |
| // just talked about (Fifa/Infantino, Đội tuyển Việt Nam/Indonesia, ...). | |
| if (lower.includes("infantino") || lower.includes("fifa") || lower.includes("giải vô địch bóng đá thế giới")) { | |
| return [ | |
| "Infantino là ai và vai trò của ông ở FIFA?", | |
| "FIFA có những thay đổi gì mới nhất?", | |
| "Cho tôi xem tin tức về bóng đá thế giới hôm nay" | |
| ]; | |
| } | |
| if (lower.includes("đội tuyển việt nam") || lower.includes("việt nam") && (lower.includes("indonesia") || lower.includes("trận đấu"))) { | |
| return [ | |
| "Kết quả trận Việt Nam vs Indonesia thế nào?", | |
| "Đội tuyển Việt Nam thi đấu khi nào?", | |
| "Lịch thi đấu sắp tới của đội tuyển Việt Nam" | |
| ]; | |
| } | |
| if (lower.includes("indonesia") && (lower.includes("việt nam") || lower.includes("trận") || lower.includes("bóng"))) { | |
| return [ | |
| "Trận đấu Việt Nam vs Indonesia diễn ra khi nào?", | |
| "Đội hình ra sân của hai đội thế nào?", | |
| "Kết quả trận đấu ra sao?" | |
| ]; | |
| } | |
| // Topic-specific fallbacks (Vietnamese keywords) | |
| if (lower.includes("bóng đá") || lower.includes("football") || lower.includes("thể thao") || lower.includes("sport")) { | |
| return [ | |
| "Kết quả bóng đá hôm nay", | |
| "Ngoại hạng Anh tuần này", | |
| "Đội tuyển Việt Nam sắp thi đấu" | |
| ]; | |
| } | |
| if (lower.includes("chứng khoán") || lower.includes("stock") || lower.includes("vn-index") || lower.includes("cổ phiếu")) { | |
| return [ | |
| "Thị trường chứng khoán hôm nay", | |
| "VN-Index biến động ra sao?", | |
| "Cổ phiếu nào tăng mạnh?" | |
| ]; | |
| } | |
| if (lower.includes("phim") || lower.includes("movie") || lower.includes("netflix") || lower.includes("phim hay")) { | |
| return [ | |
| "Phim hay nhất tuần này", | |
| "Phim Netflix đáng xem", | |
| "Bom tấn phòng vé mới" | |
| ]; | |
| } | |
| if (lower.includes("iphone") || lower.includes("apple") || lower.includes("galaxy") || lower.includes("smartphone")) { | |
| return [ | |
| "iPhone 17 Pro Max có gì mới?", | |
| "So sánh iPhone vs Samsung", | |
| "Smartphone nào tốt nhất 2026?" | |
| ]; | |
| } | |
| // General fallback — pick random products | |
| return getRandomSuggestions(); | |
| } | |
| /** Handle Vietnamese voice recognition result: fill chat input */ | |
| let _lastSentVoiceText = ""; | |
| function onVoiceResult(e) { | |
| if (!e || !e.transcript) return; | |
| const text = e.transcript.trim(); | |
| if (voiceTyperMode === "subtitle") { | |
| // Voice mode (subtitle): browser vi-VN STT is the AUTHORITATIVE Vietnamese | |
| // channel. Show the recognized text AND send it into the session so the | |
| // avatar understands the user's Vietnamese even if backend Whisper fails. | |
| if (text && settings.subtitles) showSubtitles("🗣️ " + text); | |
| if (e.isFinal && text && sessionInProgress && !textMode) { | |
| // avoid re-sending the same accumulated utterance in continuous mode | |
| if (text === _lastSentVoiceText) return; | |
| _lastSentVoiceText = text; | |
| // Mirror into chat input for visibility | |
| if (chatInput) chatInput.value = text; | |
| try { sendTextViaSession(text); } catch (_) {} | |
| } | |
| return; | |
| } | |
| if (!chatInput || !sessionInProgress) return; | |
| chatInput.value = text; | |
| if (e.isFinal && sessionInProgress) { | |
| const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i; | |
| if (triggerRe.test(text)) { | |
| setCaption("Đang gửi…", "live"); | |
| sendTextMessage(); | |
| if (voiceTyper && voiceTyper.active) try{voiceTyper.stop();}catch(_){} | |
| } else { | |
| setCaption("Nhấp Send để gửi", "live"); | |
| } | |
| } | |
| } | |
| function startVoiceTyping() { | |
| try { | |
| if (!sessionInProgress || !chatInput) return false; | |
| if (voiceTyper && voiceTyper.active) return true; | |
| if (voiceTyper) { try{ voiceTyper.stop(); }catch(_){} voiceTyper = null; } | |
| voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); | |
| if (!voiceTyper.init()) { | |
| voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false }); | |
| if (!voiceTyper.init()) { console.warn("[voiceTyper] Speech Recognition not supported"); voiceTyper = null; return false; } | |
| } | |
| voiceTyperMode = "chat"; | |
| voiceTyper.onResult = onVoiceResult; | |
| voiceTyper.onError = (e) => { console.warn("[voiceTyper]", e); voiceTyper=null; }; | |
| voiceTyper.onSoundStart = () => { try{setCaption("🎤 Đang nghe…", "live");}catch(_){} }; | |
| voiceTyper.onSoundEnd = () => {}; | |
| voiceTyper.start(); | |
| try{setCaption("🎤 Đang nghe…", "live");}catch(_){} | |
| return true; | |
| } catch(e) { console.warn("[voiceTyper] start failed:", e); voiceTyper = null; return false; } | |
| } | |
| function startVoiceSubtitle() { | |
| try { | |
| if (!sessionInProgress || textMode) return false; | |
| // PREFER server-side Vietnamese STT (whisper-1, language "vi") which is wired | |
| // to subtitles via the "user-transcript" event — this is the reliable VN path. | |
| // If the browser supports vi-VN Web Speech, it only ADDS interim feedback for | |
| // VN; it must NEVER fall back to en-US (that would show English when user | |
| // speaks Vietnamese). | |
| if (voiceTyper && voiceTyper.active && voiceTyperMode === "subtitle") return true; | |
| if (voiceTyper) { try{voiceTyper.stop();}catch(_){} voiceTyper = null; } | |
| var vt = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); | |
| if (!vt.init()) { | |
| // No vi-VN browser support → rely solely on server STT (no English fallback) | |
| voiceTyper = null; | |
| return false; | |
| } | |
| voiceTyper = vt; | |
| voiceTyperMode = "subtitle"; | |
| voiceTyper.onResult = onVoiceResult; | |
| voiceTyper.onError = (e) => console.warn("[voiceTyper]", e); | |
| voiceTyper.onSoundStart = () => {}; | |
| voiceTyper.onSoundEnd = () => {}; | |
| voiceTyper.start(); | |
| return true; | |
| } catch(e) { console.warn("[voiceTyper] subtitle start failed:", e); voiceTyper = null; return false; } | |
| } | |
| // Voice-understanding fix: run the browser vi-VN recognizer as the authoritative | |
| // Vietnamese input channel for voice mode. Because we disable backend mic audio | |
| // (setUserAudioEnabled(false)), server VAD events (speech_started/user-speaking) | |
| // no longer fire, so we start the recognizer here directly instead of waiting for | |
| // them. On each final result onVoiceResult() sends the correct Vietnamese text | |
| // into the session (sendTextViaSession), so the avatar understands Vietnamese. | |
| function startVoiceRecognition() { | |
| try { | |
| if (!sessionInProgress || textMode) return; | |
| if (voiceTyper && voiceTyper.active && voiceTyperMode === "subtitle") return; | |
| if (voiceTyper) { try{voiceTyper.stop();}catch(_){} voiceTyper = null; } | |
| var vt = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); | |
| if (!vt.init()) { console.warn("[voiceTyper] vi-VN SpeechRecognition unavailable"); voiceTyper = null; return; } | |
| voiceTyper = vt; | |
| voiceTyperMode = "subtitle"; | |
| voiceTyper.onResult = onVoiceResult; | |
| voiceTyper.onError = function () { | |
| // auto-restart unless session ended | |
| try { if (sessionInProgress && !textMode && voiceTyperMode === "subtitle") setTimeout(startVoiceRecognition, 600); } catch (_) {} | |
| }; | |
| voiceTyper.onSoundStart = function(){}; | |
| voiceTyper.onSoundEnd = function(){}; | |
| voiceTyper.onEnd = function(){ try { if (sessionInProgress && !textMode && voiceTyperMode === "subtitle") setTimeout(startVoiceRecognition, 400); } catch(_){} }; | |
| voiceTyper.start(); | |
| // Voice mode = always show subtitles ("talking" feedback), regardless of any | |
| // previously stored toggle, so the user sees their Vietnamese transcribed live. | |
| if (subtitles) { | |
| settings.subtitles = true; | |
| subtitles.textContent = "🎤 Đang nghe tiếng Việt…"; | |
| subtitles.classList.add("visible"); | |
| } | |
| return true; | |
| } catch(e) { console.warn("[voiceTyper] startVoiceRecognition failed:", e); voiceTyper = null; return false; } | |
| } | |
| function toggleVoiceTyping() { | |
| try { | |
| if (!voiceTyper) { startVoiceTyping(); if (chatMicBtn) chatMicBtn.classList.add("active"); return; } | |
| if (voiceTyper.active) { try{voiceTyper.stop();}catch(_){} voiceTyper=null; try{setCaption("Voice stopped", "live");}catch(_){} if (chatMicBtn) chatMicBtn.classList.remove("active"); } | |
| else { startVoiceTyping(); if (chatMicBtn) chatMicBtn.classList.add("active"); } | |
| } catch(e) { console.warn("[voiceTyper] toggle failed:", e); voiceTyper = null; } | |
| } | |
| async function endSession(silent=false){ | |
| _lastSentVoiceText = ""; | |
| if(voiceTyper){try{voiceTyper.stop();}catch(_){}voiceTyper=null;voiceTyperMode=null;} | |
| const c=client;client=null;sessionInProgress=false;autoGreetingSent=false;isGreetingSession=false;currentContextForSuggestions=null; | |
| 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"); | |
| clearSuggestedQuestions(); | |
| } | |
| // ── Chat audio toggle (mute/unmute avatar voice in chat) ── | |
| let avatarAudioMuted = false; | |
| let chatAudioToggleBtn = null; | |
| function initChatAudioToggle(){ | |
| chatAudioToggleBtn = $("#chat-audio-toggle-btn"); | |
| if(!chatAudioToggleBtn) return; | |
| chatAudioToggleBtn.addEventListener("click",()=>{ | |
| avatarAudioMuted=!avatarAudioMuted; | |
| client?.setMuted(avatarAudioMuted); | |
| var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted"); | |
| if(snd) snd.style.display=avatarAudioMuted?"none":""; | |
| if(mtc) mtc.style.display=avatarAudioMuted?"":"none"; | |
| chatAudioToggleBtn.setAttribute("aria-label",avatarAudioMuted?"Unmute avatar audio":"Mute avatar audio"); | |
| if(client&&client._playbackNode){ var n=client._playbackNode; if(n.port)n.port.postMessage({kind:avatarAudioMuted?"mute":"unmute"}); } | |
| }); | |
| } | |
| function updateChatAudioToggleBtn(){ | |
| if(!chatAudioToggleBtn) return; | |
| var show=(client&&(client._playbackNode||client._status==="ai-speaking"||client._status==="connected"||client._status==="processing")); | |
| chatAudioToggleBtn.style.display=show?"grid":"none"; | |
| } | |
| // ✨ Welcome button handlers | |
| if (welcomeChatBtn) { | |
| welcomeChatBtn.addEventListener("click", async () => { | |
| welcomeMode = "text"; | |
| // Hide the picker FIRST so the avatar loading % bar can show right after. | |
| hideWelcome(); | |
| // Avatar (with loading %) initializes only after the user picks. | |
| await ensureAvatarReady(); | |
| startTextSession("Xin chào! Tôi muốn trò chuyện."); | |
| }); | |
| } | |
| if (welcomeVoiceBtn) { | |
| welcomeVoiceBtn.addEventListener("click", async () => { | |
| welcomeMode = "voice"; | |
| hideWelcome(); | |
| await ensureAvatarReady(); | |
| startGreetingSession(); | |
| }); | |
| } | |
| function g(s){try{return document.querySelector(s)}catch{return null}} | |
| /** Random general suggestions from catalog */ | |
| function getRandomSuggestions() { | |
| var prods = (window.vaix && window.vaix.allProducts) ? window.vaix.allProducts() : []; | |
| if (prods.length < 5) return [ | |
| "Tôi muốn tìm máy hút mùi âm tủ 700mm", | |
| "Hãy cho tôi biết về bếp từ Malloca", | |
| "Có khóa thông minh nào không?" | |
| ]; | |
| var shuffled = prods.sort(function(){ return Math.random() - 0.5; }); | |
| var selected = []; | |
| var brandsSeen = new Set(); | |
| for (var i = 0; i < shuffled.length && selected.length < 3; i++) { | |
| if (brandsSeen.has(shuffled[i].brand)) continue; | |
| brandsSeen.add(shuffled[i].brand); | |
| selected.push(shuffled[i]); | |
| } | |
| while (selected.length < 3 && selected.length < shuffled.length) { | |
| selected.push(shuffled[selected.length]); | |
| } | |
| return selected.map(function(p) { | |
| if (p.brand) { | |
| return "Tôi muốn tìm " + (p.category ? p.category.toLowerCase() + " " : "") + p.title_clean.toLowerCase() + " của " + p.brand; | |
| } | |
| return "Cho tôi xem " + (p.category ? p.category.toLowerCase() + " " : "") + p.title_clean.toLowerCase(); | |
| }); | |
| } | |
| /** Clear suggestion pills */ | |
| function clearSuggestedQuestions() { | |
| var container = document.getElementById("suggested-questions"); | |
| if (container) { container.innerHTML = ""; container.style.display = "none"; } | |
| } | |
| /** Handle sending a suggested question */ | |
| function sendUserSuggestion(text) { | |
| if (window._triggerSuggestionSend) { | |
| window._triggerSuggestionSend(text); | |
| } | |
| } | |
| 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()}); | |
| on(muteBtn,"click",()=>{ | |
| muted=!muted; | |
| toggleClass(muteBtn,"active",muted); | |
| if(client && client._playbackNode && client._playbackNode.port){ | |
| client._playbackNode.port.postMessage({kind: muted ? "mute" : "unmute"}); | |
| } | |
| client?.setMuted(muted); | |
| var chatToggle=$("#chat-audio-toggle-btn"); | |
| if(chatToggle){ var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted"); if(snd) snd.style.display=muted?"none":""; if(mtc) mtc.style.display=muted?"":"none"; } | |
| }); | |
| on(textModeBtn,"click",()=>{textMode=!textMode;showTextChat(textMode);toggleClass(textModeBtn,"active",textMode)}); | |
| on(chatSendBtn,"click",()=>sendTextMessage()); | |
| on(chatMicBtn,"click",()=>toggleVoiceTyping()); | |
| on(chatInput,"keypress",e=>{if(e.key==="Enter")sendTextMessage()}); | |
| on(chatAvatarSelect,"change",()=>{setAvatarFromSelect(chatAvatarSelect.value);void reloadAvatar()}); | |
| on(settingsAvatarSelect,"change",()=>{setAvatarFromSelect(settingsAvatarSelect.value);if(chatAvatarSelect)chatAvatarSelect.value=settingsAvatarSelect.value||"";void reloadAvatar()}); | |
| 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()}); | |
| settingsDialog.addEventListener("close",()=>{ | |
| var iv=(inputVoice&&(inputVoice.value||""))||DEFAULT_VOICE; | |
| var sa=(settingsAvatarSelect&&settingsAvatarSelect.value)||""; | |
| var ii=(inputInstructions&&inputInstructions.value)||""; | |
| var du=inputDirectUrl?(inputDirectUrl.value||"").trim():settings.directUrl||""; | |
| var cb=(inputSubtitles&&inputSubtitles.checked)||false; | |
| settings={voice:iv,avatar:sa,instructions:ii,directUrl:du,subtitles:cb}; | |
| if(settings.avatar!=="vuong.glb")autoGreetingSent=false; | |
| saveSettings(); | |
| if(chatAvatarSelect)chatAvatarSelect.value=settings.avatar; | |
| if(subtitles&&!settings.subtitles)subtitles.classList.remove("visible"); | |
| client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")}); | |
| }); | |
| window.addEventListener("beforeunload",()=>client?.close()); | |
| initChatAudioToggle(); | |
| async function boot(){ | |
| for(const v of VOICES){const o=document.createElement("option");o.value=v;o.textContent=v.replaceAll("_"," ");inputVoice.append(o)} | |
| try{const r=await fetch("api/config");if(r.ok)config={...config,...(await r.json())}}catch{} | |
| directUrlRow.hidden=!config.allowDirect; | |
| await fetchAvatarList();populateAvatarSelects(settings.avatar); | |
| makeDraggable();makeResizable(); | |
| setCaption("KHỞI ĐỘNG…");setMainButton("busy","Loading…"); | |
| // ── Deferred avatar init ── | |
| // Show the character-picker (welcome) modal FIRST. The avatar (and its | |
| // loading %) is only initialized AFTER the user picks a character, so the | |
| // picker is always visible immediately. ensureAvatarReady() is awaited by | |
| // the session starters. | |
| loading.classList.add("done"); // hide native loading text; picker modal shows now | |
| setCaption(CAPTIONS.idle);setMainButton("start","Start talking"); | |
| const panel=document.getElementById("vaistudio-panel"); | |
| const toggle=document.getElementById("vaistudio-toggle"); | |
| const closeBtn=document.getElementById("vaistudio-close"); | |
| const retryBtn=document.getElementById("vaistudio-retry-btn"); | |
| // ── Fixed: toggle opens panel, loads + renders reliably ── | |
| 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(()=>{if(panel.classList.contains("open"))renderAll();}); } | |
| }); | |
| if(closeBtn)closeBtn.addEventListener("click",()=>{panel.classList.remove("open");panel.style.display="none";toggle?.classList.remove("active")}); | |
| // ── Fixed: retry always renders (not dependent on load() returning Promise) ── | |
| if(retryBtn)retryBtn.addEventListener("click",()=>{window.vaix?.load();renderAll()}); | |
| // ── Fixed renderAll: guard against premature call + avoid double-render from ?product=xxx + auto-render when loaded ── | |
| 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)return; | |
| if(!pe)return; | |
| // If panel is NOT open, we still try to render if products are loaded | |
| // (handles case where products finish loading before user opens panel) | |
| const isOpen = p.classList.contains("open"); | |
| if(!isOpen && !window.vaix?.isLoaded()) return; // Not ready yet | |
| // Don't overwrite if ?product=xxx URL param already rendered panel content | |
| if(isOpen && window.vaix?.hasProductUrlHandled())return; | |
| const prods=window.vaix?.allProducts()||[]; | |
| if(!prods.length && !window.vaix?.isLoaded()){ | |
| // Products still loading — keep loading indicator visible, retry shortly | |
| if(l) l.hidden=false; | |
| clearTimeout(renderAll._timer); | |
| renderAll._timer=setTimeout(renderAll,500); | |
| return; | |
| } | |
| // Products are loaded (or explicitly failed) — clear loading and render | |
| if(l)l.hidden=true; | |
| if(sc)sc.style.display="none"; | |
| pe.innerHTML=""; | |
| pe.style.display="block"; | |
| if(ce)ce.textContent=prods.length+" sản phẩm"; | |
| for(let i=0;i<Math.min(20,prods.length);i++){ | |
| const item=prods[i]; | |
| const c=document.createElement("div"); | |
| c.className="product-card"; | |
| c.innerHTML=(item.image?`<img class="product-card-img" src="${item.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">${item.title_clean||item.name}</p><p class="product-card-brand">${item.brand||""}</p><p class="product-card-price">${item.priceNum>0?item.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"}</p></div>`; | |
| c.addEventListener("click",e=>{e.stopPropagation();window.vaix?.showProduct(item.title_clean)}); | |
| pe.appendChild(c); | |
| } | |
| // If panel is open, make sure display is set | |
| if(isOpen){ p.style.display="flex"; toggle?.classList.add("active"); } | |
| } | |
| renderAll._timer=null; | |
| // ── Poll until loaded or max attempts ── | |
| let tries=0;(function wait(){if(window.vaix?.isLoaded()){clearTimeout(renderAll._timer);renderAll();return}if(++tries>50)return;setTimeout(wait,200)})(); | |
| // ── Show the character picker modal immediately (no waiting) ── | |
| showWelcome(); | |
| // Lightweight pre-fetch of greeting/news in the background (non-blocking). | |
| Promise.race([ | |
| (settings.avatar==="vuong.glb"||!settings.avatar)?getHotNewsGreeting().catch(()=>null):Promise.resolve(null), | |
| new Promise(res=>setTimeout(()=>res(null),8000)) | |
| ]).then(g=>{preFetchedGreeting=g;}).catch(()=>{}); | |
| } | |
| function setLoading(show){ | |
| if(show){ loading.style.display=""; loading.classList.remove("done"); } | |
| else { loading.classList.add("done"); } | |
| } | |
| void boot(); | |
| // HOT-TAGS-V3-DEPLOYED-804 | |
| // DEPLOY-1785728163 | |