Spaces:
Running
Running
| 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 2+ thiết bị nhà bếp hoặc phụ kiện như bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, khóa cửa — lọc theo thương hiệu/giá/chất liệu/màu sắc/kích thướ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 / bộ / gói of 2 or more kitchen appliances or cabinet accessories (e.g. 'combo bếp từ + máy hút mùi', 'bộ nồi bếp', 'kệ xoong nồi, kệ chén dĩa 700mm, kệ dao thớt 400mm', 'combo lò nướng và lò vi sóng', 'khóa cửa thông minh và kệ dao thớt') — call combo_suggest and pass the criteria. IMPORTANT: put each requested item in the categories array (e.g. categories:['bếp từ','máy hút mùi'], or categories:['kệ xoong nồi','kệ chén dĩa','kệ dao thớt']; a combo can be 2, 3 or 4 products). Supported categories: bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa. If a specific size (mm) is mentioned per item, pass sizes object e.g. sizes:{'kệ chén dĩa':[700],'kệ dao thớt':[400]}. Convert price phrases to numbers in VND: 'dưới 20 triệu' → maxPrice:20000000, 'trên 30 triệu' → minPrice:30000000, 'khoảng 15 triệu' → minPrice:14000000,maxPrice:16000000. CRITICAL: the price budget applies to the TOTAL sum of ALL items in the combo, NOT per-item. Pass brand like Malloca/Eurogold/Grob, material like inox/kính/gốm, color like đen/trắng/bạc if mentioned. combo_suggest will show product cards and a text summary with the combined total.", | |
| "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 COMBO of 2, 3 or 4 kitchen appliances or cabinet accessories (bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa — same brand or mixed). Call when user wants a combo/set/bộ/gói of multiple products. Provide categories (which items, e.g. ['bếp từ','máy hút mùi'] or ['kệ xoong nồi','kệ chén dĩa','kệ dao thớt']), brand, price range (minPrice/maxPrice in VND — the budget is the TOTAL sum of all items, NOT per-item; convert 'dưới 20 triệu' to maxPrice:20000000), material (inox/kính/gốm), color (đen/trắng/bạc), and optional per-category sizes in mm (e.g. sizes:{'kệ chén dĩa':[700],'kệ dao thớt':[400]}).", parameters:{type:"object", properties:{brand:{type:"string", description:"Preferred brand e.g. Malloca, Eurogold, Grob, Hafele"}, minPrice:{type:"number", description:"Minimum total-combo price in VND (e.g. 'trên 30 triệu' → 30000000)"}, maxPrice:{type:"number", description:"Maximum total-combo price in VND (e.g. 'dưới 20 triệu' → 20000000)"}, material:{type:"string", description:"Material e.g. inox, kính, gốm"}, color:{type:"string", description:"Color e.g. đen, trắng, bạc"}, sizes:{type:"object", description:"Optional per-category cabinet width in mm, keyed by category label, e.g. {'kệ chén dĩa':[700],'kệ dao thớt':[400]}"}, categories:{type:"array", items:{type:"string"}, description:"Which categories to include, e.g. ['bếp từ','máy hút mùi'] or ['kệ xoong nồi','kệ chén dĩa','kệ dao thớt'] or ['khóa cửa','kệ dao thớt']. Supported: bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa. The price budget applies to the TOTAL of all items."}}, 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"); | |
| const welcomeQuickBtn = $("#welcome-quick-btn"); | |
| // ✨ Welcome mode tracking: null | 'text' | 'voice' | 'quick' | |
| 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 | |
| // ── Single-owner news rendering state ── | |
| // The greeting shows ONE consolidated HOT list (no duplicates) and every follow-up | |
| // answer shows a DIFFERENT news list that follows the conversation context. | |
| let _shownNewsUrls = new Set(); // URLs already shown, so consecutive lists differ | |
| let _hotTopicCursor = 0; // round-robins through HOT topics for variety | |
| // News list visibility: the HOT-tags + source-card news block should ONLY appear | |
| // in the greeting ("câu chào") and when the user actually asks about news. From | |
| // the 2nd non-greeting answer onward, if the user is NOT asking about news, we | |
| // suppress the news list so it doesn't spam every reply. It reappears the moment | |
| // the user asks about news (tin tức / news / hot / thời sự ...). | |
| let _newsContextEnabled = false; // start DISABLED: from the 2nd answer onward | |
| // (non-greeting) no news block until the user | |
| // asks about news; the greeting always shows | |
| // it via isGreeting. | |
| // Detect whether the user's message is asking about news / current events. | |
| function _isNewsRequest(text) { | |
| const t = String(text || "").toLowerCase() | |
| .normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d"); | |
| // Core news intent keywords (whole-word-ish, safe substrings). | |
| const newsKws = ["tin tuc", "tin", " news", "hot", "thoi su", "ban tin", "tin nong", | |
| "cap nhat tin", "tin moi", "tin moi nhat", "tin nong nhat", "thoi cuoc", "su kien"]; | |
| for (const kw of newsKws) { | |
| const k = kw.trim(); | |
| // "tin" / "hot" as bare substrings are too noisy, so require either a longer | |
| // keyword OR a word boundary for the short ones. | |
| if (kw !== "tin " && kw !== " hot") { | |
| if (t.includes(k)) return true; | |
| } else { | |
| if (new RegExp("(^|[^a-z])" + k + "([^a-z]|$)").test(t)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // Call this whenever a user message is processed to update news-list visibility. | |
| function _noteUserMessage(text) { | |
| _newsContextEnabled = _isNewsRequest(text); | |
| } | |
| function _cardKey(c) { | |
| return (c && ((c.url && String(c.url)) || (c.title && String(c.title)))) || ''; | |
| } | |
| // Return only cards whose URL/title was NOT already shown (keeps lists distinct). | |
| function _filterShown(cards) { | |
| const out = []; | |
| for (const c of (cards || [])) { | |
| if (!c) continue; | |
| const key = _cardKey(c); | |
| if (key && _shownNewsUrls.has(key)) continue; | |
| out.push(c); | |
| } | |
| return out; | |
| } | |
| // Mark a set of displayed cards as shown so later lists avoid repeating them. | |
| function _markShown(cards) { | |
| for (const c of (cards || [])) { | |
| const key = _cardKey(c); | |
| if (key) _shownNewsUrls.add(key); | |
| } | |
| return cards; | |
| } | |
| // Cap the seen-set size so it never grows unbounded. | |
| function _trimShown() { | |
| if (_shownNewsUrls.size > 600) { | |
| const arr = Array.from(_shownNewsUrls).slice(-400); | |
| _shownNewsUrls = new Set(arr); | |
| } | |
| } | |
| // Filter out already-shown cards, mark the survivors as shown, and trim the set. | |
| function _finalizeCards(cards) { | |
| const fresh = _filterShown(cards); | |
| _markShown(fresh); | |
| _trimShown(); | |
| return fresh; | |
| } | |
| /** | |
| * Pick a news search query that follows the CONTEXT of the given response text: | |
| * 1) if the response mentions a HOT keyword → use its topical query (e.g. AI → AI news); | |
| * 2) else extract a clean topical phrase straight from the response; | |
| * 3) else rotate to the next HOT topic so consecutive lists still differ. | |
| */ | |
| function _contextQuery(responseText) { | |
| const txt = String(responseText || ''); | |
| // 1) Dynamic HOT keyword match → context-relative news (e.g. talking about AI). | |
| const matched = findBestTopicMatch(txt); | |
| if (matched && matched.query) return matched.query; | |
| // 2) Extract a meaningful topical phrase from the response text itself. | |
| const clean = txt | |
| .replace(/\([^)]*\)/g, ' ') // drop parenthetical content like (AI) | |
| .replace(/[.,:;"'“”‘’!?]/g, ' ') | |
| .replace(/^(em|anh chị|anh chi|anhh chị|anhh chi|dạ|vâng|xin chào|chào|bạn|tôi)\b/gi, ' ') | |
| .replace(/\b(em|anh|chị|chi|bạn|ban|tôi|toi|về|ve|với|voi|cho|theo|các|ca?c|một|mot|những|nhung|đã|da|sẽ|se|đang|dang|hôm|hom|nay|có|co|tin|hot|ngày|ngay|biết|biet|thông|thong|muốn|muon|giúp|giup|không|khong|gì|gi|nào|nao)\b/gi, ' ') | |
| .replace(/\s+/g, ' ').trim(); | |
| const words = []; | |
| for (const w of clean.split(' ')) { | |
| if (!w || w.length < 4 || /^\d+$/.test(w)) continue; | |
| const n = w.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd'); | |
| if (/^(tin|hot|chatgpt|google|news|xin|chao|cam|on|giup|ban|biet|muon|thong|noi|hoc|very|good|hello|world|today)$/.test(n)) continue; | |
| words.push(w); | |
| if (words.length >= 3) break; | |
| } | |
| if (words.length > 0) return words.slice(0, 2).join(' ') + ' hôm nay'; | |
| // 3) Rotate to the next HOT topic. | |
| const kw = HOT_TOPIC_KEYWORDS || []; | |
| if (kw.length > 0) { | |
| const q = kw[_hotTopicCursor % kw.length].query; | |
| _hotTopicCursor++; | |
| return q; | |
| } | |
| return 'tin tức mới nhất hôm nay'; | |
| } | |
| /** | |
| * 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 query = _contextQuery(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 matchedTopic = findBestTopicMatch(responseText); | |
| 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 _finalizeCards(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 _finalizeCards(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 _finalizeCards([ | |
| { 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 [ | |
| 'Kể thêm về "' + subj + '"', | |
| 'Tin này nói gì? Cho em biết chi tiết hơn với', | |
| 'Có gì mới nhất về "' + subj + '" gần đây không?', | |
| 'Em tóm gọn giúp anh/chị tin "' + subj + '"' | |
| ]; | |
| } | |
| /** 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('Kể cho em nghe thêm về tin: "' + artTitle + '". Tóm tắt ngắn gọn, tự nhiên cho em hiểu nhé.'); | |
| } 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"; | |
| // Diverse, catalog-derived greeting suggestions (mix of product categories, | |
| // NOT a fixed khóa-cửa default). Refreshes randomly each greeting. | |
| const greetQs = getGreetingSuggestions(); | |
| greetQs.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '🛒'))); | |
| container.appendChild(suggestionsRow); | |
| } | |
| // 2) HOT trending tags row — only when it's the greeting OR the user asked | |
| // about news. From the 2nd non-greeting answer onward (when the user is NOT | |
| // asking about news) this news block is suppressed. | |
| if (isGreeting || _newsContextEnabled) { | |
| 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 (only greeting or when user asked about news, for | |
| // lengthy responses). | |
| if ((isGreeting || _newsContextEnabled) && 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.className = "chat-msg-text"; | |
| mt.textContent = text; | |
| m.appendChild(mt); | |
| // "Đọc to" (read aloud) button — lets the user hear THIS message even when | |
| // silent mode is on (explicit user intent). | |
| const readBtn = document.createElement("button"); | |
| readBtn.type = "button"; | |
| readBtn.className = "chat-read-btn"; | |
| readBtn.title = "Đọc to tin nhắn này"; | |
| readBtn.setAttribute("aria-label", "Đọc to tin nhắn"); | |
| readBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M22 8a13 13 0 0 1 0 8"></path></svg>'; | |
| readBtn.addEventListener("click", function(e){ | |
| e.stopPropagation(); | |
| speakEdgeTts(text, true); | |
| }); | |
| m.appendChild(readBtn); | |
| // 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 malloca 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 → dùng google/gemma-4-31B-it | |
| window._triggerSuggestionSend = function(text) { | |
| if (typeof text !== "string" || !text) return; | |
| openTextChatUI(); | |
| setCaption("GEMMA ĐANG VIẾT…", "live"); | |
| sendTextMessage(text); | |
| }; | |
| // Send a user question through the text chat. Guarantees the product card for a | |
| // mentioned product code is attached to the reply (buildSuggestionCards matches | |
| // the message against the catalog, incl. mã/model). | |
| window.sendAsUserQuestion = function(text) { | |
| if (typeof text !== "string" || !text) return; | |
| openTextChatUI(); | |
| setCaption("GEMMA ĐANG VIẾT…", "live"); | |
| sendTextMessage(text); | |
| }; | |
| // ── Text chat dùng model google/gemma-4-31B-it qua REST /api/chat ── | |
| // (không phụ thuộc session S2S voice bị lỗi connecting) | |
| let chatHistory = []; | |
| let chatBusy = false; | |
| // Track whether the first assistant message in the current text-chat session has | |
| // been shown — the very first reply is the "câu chào" (greeting) that carries the | |
| // HOT keyword list + HOT news. Only hidden once the user stops asking about news. | |
| let _chatGreetingShown = false; | |
| // Clean AI text for natural display: remove markdown ** _ ` and bullet tokens. | |
| function cleanAiText(text) { | |
| if (!text) return ""; | |
| return String(text) | |
| .replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** → text | |
| .replace(/\*([^*]+)\*/g, "$1") // *italic* → text | |
| .replace(/__([^_]+)__/g, "$1") // __bold__ → text | |
| .replace(/`([^`]+)`/g, "$1") // `code` → text | |
| .replace(/^#{1,6}\s+/gm, "") // headings | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| // Build product-card HTML for suggested products matching a query. | |
| // Detect a GENERIC / greeting catalog query (no concrete product family or | |
| // brand). "Tư vấn nhanh", "Xin chào ... tư vấn nhanh sản phẩm", "giới thiệu | |
| // sản phẩm", "Xem sản phẩm gia dụng" have NO real product intent — feeding them | |
| // through the scored search returns a WALL of one family (e.g. the ~khóa-điện-tử | |
| // "Giovani GSL" rows that dominate the top of the scoring). For these we must | |
| // show a DIVERSE mix instead. If the query names a concrete family/brand, we | |
| // treat it as specific and use the normal combo/search path. | |
| const _SPECIFIC_CAT_HINTS = ["bếp","máy hút mùi","hút khói","chậu","vòi","lò nướng","lò vi sóng","tủ lạnh","tủ rượu","máy giặt","robot","máy lọc nước","khóa","khoá","bản lề","tay nắm","ray","kệ","giá ","xoong","nồi chiên","rửa chén","rửa bát","hút bụi","máy ép","máy xay","sinh tố","phụ kiện","thùng rác","gia vị","combo","malloca","eurogold","grob","hafele","canzy","demax","imundex","cucina","armor","elock","ilock","smart lock","vân tay","lock"]; | |
| function _isGenericCatalogQuery(q){ | |
| const s = String(q || "").toLowerCase(); | |
| if (!s) return true; | |
| for (const k of _SPECIFIC_CAT_HINTS) { if (s.includes(k)) return false; } | |
| return true; | |
| } | |
| function _diverseChatCards(count){ | |
| // Rendering helper: build a chat cards HTML block from a DIVERSE product mix. | |
| const n = count || 4; | |
| const picks = (window.vaix && window.vaix.getDiverseProducts) ? window.vaix.getDiverseProducts(n) : []; | |
| if (!picks || !picks.length) return { combo: null, products: [], html: "" }; | |
| const html = '<div class="chat-product-cards">' + picks.map(p => window.vaix.createChatProductCard(p)).join("") + '</div>'; | |
| return { combo: null, products: picks, html }; | |
| } | |
| function buildSuggestionCards(query) { | |
| try { | |
| if (!window.vaix || !window.vaix.getComboOrSearch) return ""; | |
| // Generic / greeting query (Tư vấn nhanh, Xin chào...): show a DIVERSE mix, | |
| // never the khóa-điện-tử wall that the scored search returns for such queries. | |
| if (_isGenericCatalogQuery(query)) { | |
| return _diverseChatCards(4).html; | |
| } | |
| // Use the same combo/search logic as the product-list panel so the chat | |
| // combo matches the "ds sp" search exactly. | |
| const res = window.vaix.getComboOrSearch(query, 4); | |
| const combo = res.combo; | |
| const products = Array.isArray(res.products) ? res.products.slice(0, 4) : []; | |
| if (!products.length) return ""; | |
| // When a combo matched the user's request, render a combo section (accurate | |
| // to the request) WITH the "Đổi combo" button that swaps to an alternate | |
| // combo still matching the same criteria. | |
| if (combo && combo.items && combo.items.length) { | |
| // Pass the user's original query so the "Đổi combo" button can fetch an | |
| // ALTERNATE combo that still matches the same criteria. | |
| return window.vaix.renderComboCardsBlock(combo, query); | |
| } | |
| // Cards only — no title/total text line (user asked to keep only the combo card). | |
| const cards = products.map(p => window.vaix.createChatProductCard(p)).join(""); | |
| return '<div class="chat-product-cards">' + cards + '</div>'; | |
| } catch (e) { return ""; } | |
| } | |
| let _typingEl = null; | |
| function showTypingBubble() { | |
| removeTypingBubble(); | |
| const m = document.createElement("div"); | |
| m.className = "chat-message assistant typing"; | |
| m.innerHTML = '<span class="typing-dots"><i></i><i></i><i></i></span><span class="typing-label"> đang soạn…</span>'; | |
| chatMessages.appendChild(m); | |
| chatMessages.scrollTop = chatMessages.scrollHeight; | |
| _typingEl = m; | |
| } | |
| function removeTypingBubble() { | |
| if (_typingEl && _typingEl.parentNode) _typingEl.parentNode.removeChild(_typingEl); | |
| _typingEl = null; | |
| } | |
| // ── Edge TTS playback for text chat / quick consult ── | |
| // Voices: Mr V (vuong.glb, male) → Nam Minh; Lisamy (lisamy.glb, female) → Hoài My. | |
| function getEdgeTtsVoice() { | |
| try { | |
| const av = (localStorage.getItem("avatar.model") || "vuong.glb").toLowerCase(); | |
| return av.indexOf("lisamy") !== -1 ? "vi-VN-HoaiMyNeural" : "vi-VN-NamMinhNeural"; | |
| } catch (e) { return "vi-VN-NamMinhNeural"; } | |
| } | |
| let _edgeTtsAudio = null; | |
| let _ttsCtx = null; // shared AudioContext used for TTS playback | |
| let _ttsUnlocked = false; // true once a user gesture has unlocked audio | |
| // Unlock audio on the FIRST user gesture. Browsers block audible playback until | |
| // the user has interacted with the page. Because chat now defaults to MUTED, | |
| // the site may not have attempted any audio yet, so the per-message "Đọc to" | |
| // button would otherwise hit the autoplay wall. Creating + resuming a shared | |
| // AudioContext inside the first gesture permanently unlocks audible output. | |
| function unlockTtsAudio() { | |
| if (_ttsUnlocked) { try { if (_ttsCtx && _ttsCtx.state === "suspended") _ttsCtx.resume().catch(function(){}); } catch(_){} return; } | |
| try { | |
| const AC = window.AudioContext || window.webkitAudioContext; | |
| if (!AC) return; | |
| if (!_ttsCtx) _ttsCtx = new AC({ latencyHint: "interactive" }); | |
| if (_ttsCtx.state === "suspended") _ttsCtx.resume().catch(function(){}); | |
| // Prime an inaudible buffer so the context actually starts producing audio. | |
| const buf = _ttsCtx.createBuffer(1, 1, _ttsCtx.sampleRate); | |
| const src = _ttsCtx.createBufferSource(); | |
| src.buffer = buf; src.connect(_ttsCtx.destination); src.start(0); | |
| _ttsUnlocked = true; | |
| } catch (e) {} | |
| } | |
| // Register the unlock on the first of any common user gestures. | |
| function initAudioUnlock() { | |
| const evts = ["pointerdown", "touchstart", "click", "keydown"]; | |
| const once = function () { | |
| unlockTtsAudio(); | |
| for (const e2 of evts) document.removeEventListener(e2, once); | |
| }; | |
| for (const e2 of evts) { try { document.addEventListener(e2, once, { passive: true, once: true }); } catch (_) {} } | |
| } | |
| let _edgeTtsSrc = null; // current WebAudio BufferSource (to stop cleanly) | |
| // Ensure a shared, running AudioContext exists (created on demand, resumed on | |
| // gesture; safe to call anywhere). | |
| function _ensureTtsCtx() { | |
| if (!_ttsCtx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; _ttsCtx = new AC({ latencyHint: "interactive" }); } | |
| if (_ttsCtx.state === "suspended") { try { _ttsCtx.resume().catch(function(){}); } catch(_){} } | |
| return _ttsCtx; | |
| } | |
| // Play decoded TTS audio through the shared (unlocked) AudioContext. This is | |
| // far more reliable than a one-shot <audio> element for a muted-by-default page, | |
| // because WebAudio only needs the context to be resumed (which we do on gesture). | |
| function _playThroughCtx(buf) { | |
| const ctx = _ensureTtsCtx(); | |
| if (!ctx) return Promise.reject(new Error("no ctx")); | |
| if (ctx.state === "suspended") { | |
| return ctx.resume().catch(function(){}).then(function(){ return _playThroughCtxInner(buf, ctx); }); | |
| } | |
| return _playThroughCtxInner(buf, ctx); | |
| } | |
| function _playThroughCtxInner(buf, ctx) { | |
| return new Promise(function (resolve) { | |
| try { | |
| const src = ctx.createBufferSource(); | |
| src.buffer = buf; | |
| src.connect(ctx.destination); | |
| src.onended = function(){ if (_edgeTtsSrc === src) _edgeTtsSrc = null; try { if (stage && stage.head) stage.head.isSpeaking = false; } catch(_){ } resolve(); }; | |
| if (stage && stage.head) { try { stage.head.isSpeaking = true; } catch(_){} } | |
| _edgeTtsSrc = src; | |
| src.start(0); | |
| } catch (e) { try { if (stage && stage.head) stage.head.isSpeaking = false; } catch(_){ } resolve(); } | |
| }); | |
| } | |
| // Speak the given text through Edge TTS. Pass force=true to speak even when the | |
| // chat is muted (used by the per-message "Đọc to" button). | |
| // Playback is routed through a shared, gesture-unlocked AudioContext (decode + | |
| // BufferSource) which is reliable even for a muted-by-default page — the one-shot | |
| // <audio> fallback stays only as a safety net. | |
| function speakEdgeTts(text, force) { | |
| if (!force && avatarAudioMuted) return false; // sound is OFF — do not speak (unless force) | |
| unlockTtsAudio(); | |
| const clean = String(text || "").trim().replace(/[\*_`#]/g, "").replace(/\s+/g, " ").slice(0, 1400); | |
| if (!clean) return false; | |
| stopEdgeTts(); | |
| const voice = getEdgeTtsVoice(); | |
| const url = "/api/tts?voice=" + encodeURIComponent(voice) + "&text=" + encodeURIComponent(clean) + "&cb=" + Date.now(); | |
| const a = new Audio(); | |
| _edgeTtsAudio = a; | |
| a.preload = "auto"; | |
| a.style.display = "none"; | |
| try { (document.body || document.documentElement).appendChild(a); } catch (e) {} | |
| a.addEventListener("play", function () { | |
| if (stage && stage.head) { try { stage.head.isSpeaking = true; } catch (e) {} } | |
| }); | |
| const cleanup = function () { | |
| try { if (stage && stage.head) stage.head.isSpeaking = false; } catch (e) {} | |
| try { if (a.src && a.src.indexOf("blob:") === 0) URL.revokeObjectURL(a.src); } catch (e) {} | |
| try { a.remove(); } catch (e) {} | |
| if (_edgeTtsAudio === a) _edgeTtsAudio = null; | |
| }; | |
| a.addEventListener("ended", cleanup); | |
| a.addEventListener("error", cleanup); | |
| a.addEventListener("stalled", cleanup); | |
| // Fetch the full MP3; prefer WebAudio playback, fall back to the <audio> node. | |
| fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) { | |
| if (!buf || !buf.byteLength) return; | |
| const ctx = _ensureTtsCtx(); | |
| // Prefer the shared AudioContext path (works when muted-by-default). | |
| if (ctx && ctx.decodeAudioData) { | |
| return ctx.decodeAudioData(buf.slice(0)) | |
| .then(function (decoded) { return _playThroughCtx(decoded); }) | |
| .catch(function () { return _playElement(buf); }); | |
| } | |
| return _playElement(buf); | |
| }).catch(function () { | |
| if (_edgeTtsAudio === a) { try { a.remove(); } catch (e) {} _edgeTtsAudio = null; } | |
| }); | |
| return true; | |
| function _playElement(buf) { | |
| if (_edgeTtsAudio !== a) return; | |
| const blob = new Blob([buf], { type: "audio/mpeg" }); | |
| const objUrl = URL.createObjectURL(blob); | |
| a.src = objUrl; | |
| a.load(); | |
| a.play().catch(function () { /* autoplay blocked — no audio this time */ }); | |
| } | |
| } | |
| function stopEdgeTts() { | |
| if (_edgeTtsSrc) { try { _edgeTtsSrc.stop(); } catch(e){} _edgeTtsSrc = null; } | |
| if (_edgeTtsAudio) { | |
| const a = _edgeTtsAudio; | |
| try { a.pause(); } catch (e) {} | |
| try { if (a.src && a.src.indexOf("blob:") === 0) URL.revokeObjectURL(a.src); } catch (e) {} | |
| try { a.src = ""; } catch (e) {} | |
| try { a.remove(); } catch (e) {} | |
| _edgeTtsAudio = null; | |
| } | |
| try { if (stage && stage.head) stage.head.isSpeaking = false; } catch (e) {} | |
| } | |
| async function sendTextMessage(t){ | |
| const msg = t || chatInput.value.trim(); | |
| if(!msg || chatBusy) return; | |
| const reliable = (window.vaix && window.vaix.isLoaded && window.vaix.isLoaded()); | |
| if(!reliable){ try{ await (window.vaix && window.vaix.load ? window.vaix.load() : Promise.resolve()); }catch(_){} } | |
| chatInput.value = ""; | |
| chatBusy = true; | |
| _noteUserMessage(msg); // update news-list visibility based on this message | |
| if (!window._userBubbleAdded) addChatMessage("user", msg); | |
| window._userBubbleAdded = false; | |
| setCaption("GEMMA ĐANG VIẾT…", "live"); | |
| showTypingBubble(); | |
| if (chatSendBtn) chatSendBtn.disabled = true; | |
| chatHistory.push({ role: "user", content: msg }); | |
| try { | |
| const res = await fetch("/api/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ message: msg, history: chatHistory.slice(0, -1) }), | |
| }); | |
| const data = await res.json().catch(() => ({})); | |
| const reply = cleanAiText(data && data.transcript ? data.transcript : ""); | |
| removeTypingBubble(); | |
| if (!reply) { | |
| const d = (data && data.detail) ? String(data.detail).slice(0, 120) : ""; | |
| addChatMessage("assistant", "Xin lỗi, tôi chưa kết nối được model. " + (data && data.status ? "("+data.status+") " : "") + d); | |
| chatHistory.pop(); | |
| } else { | |
| // Related products / combos: use the SAME logic as the product-list panel | |
| // (parseComboQuery -> combo, else scored search) so the chat combo matches | |
| // the "ds sp" search exactly. We search the USER's message ONLY (like the | |
| // panel does with the raw query) — NOT msg + AI reply. Appending the AI's | |
| // descriptive answer pollutes parsing: the reply often quotes individual | |
| // product prices or the computed combo total ("tổng combo khoảng 19.75 | |
| // triệu"), which _extractBudget then mistakes for the combo budget and can | |
| // make a valid 2-item combo fail (falling back to a single-product card). | |
| const queryForProducts = String(msg || "").trim(); | |
| const comboRes = (window.vaix && window.vaix.getComboOrSearch) | |
| ? window.vaix.getComboOrSearch(queryForProducts, 6) | |
| : { combo: null, products: [] }; | |
| const rel = Array.isArray(comboRes.products) ? comboRes.products : []; | |
| setPendingProductCards(buildSuggestionCards(queryForProducts)); | |
| // Also mirror the panel: show the same combo/search results in the product list. | |
| if (window.vaix && window.vaix.renderPanelResults && rel.length) { | |
| try { window.vaix.renderPanelResults(rel.slice(0, 6)); } catch (_) {} | |
| } | |
| lastAssistantMessage = reply; | |
| if (settings.subtitles) showSubtitles(smartNormalize(reply)); | |
| // The FIRST assistant message in the text-chat session is the "câu chào" | |
| // (greeting): it shows the HOT keyword list + HOT news. It stays visible | |
| // only while the user is asking about news (handled by _newsContextEnabled). | |
| const greet = !_chatGreetingShown; | |
| _chatGreetingShown = true; | |
| addChatMessage("assistant", smartNormalize(reply), greet); | |
| chatHistory.push({ role: "assistant", content: reply }); | |
| // Speak the reply aloud via Edge TTS when sound is enabled. | |
| speakEdgeTts(reply); | |
| } | |
| } catch (e) { | |
| removeTypingBubble(); | |
| addChatMessage("assistant", "Xin lỗi, có lỗi khi gọi model. Vui lòng thử lại."); | |
| chatHistory.pop(); | |
| } | |
| chatBusy = false; | |
| if (chatSendBtn) chatSendBtn.disabled = false; | |
| setCaption(""); | |
| } | |
| // 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; | |
| stopEdgeTts(); | |
| 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){ | |
| // Reconstruct a human-readable query from the tool args so the embedded | |
| // "Đổi combo" button can re-derive the SAME criteria for an alternate. | |
| let qParts = ["combo"]; | |
| const cats = (args && Array.isArray(args.categories)) ? args.categories.slice() : []; | |
| if (cats.length) qParts = qParts.concat(cats); | |
| if (args && args.brand) qParts.push(args.brand); | |
| if (args && args.maxPrice) qParts.push("dưới " + (args.maxPrice / 1000000) + " triệu"); | |
| else if (args && args.minPrice) qParts.push("trên " + (args.minPrice / 1000000) + " triệu"); | |
| const _q = qParts.join(" "); | |
| const html = '<div class="chat-combo-section">' + window.vaix.renderComboCardsBlock(combo, _q) + '</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ệ"); }); | |
| const _tot = (combo.items||[]).reduce(function(s,p){ return s + ((p.priceNum||0)); }, 0); | |
| send("Em gợi ý combo sau đây " + (combo.brand?("thương hiệu "+combo.brand+" "):"") + "cho anh chị:\n" + lines.join("\n") + "\n\nTổng combo (" + combo.items.length + " món): " + (_tot>0 ? (_tot.toLocaleString("vi-VN")+" ₫") : "Liên hệ") + " — đã tính đủ theo tổng các món. 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}} | |
| // ✨ Quick Consult: text chat WITHOUT loading the 3D avatar. Builds its own | |
| // AudioContext + playback so voice of the AI still plays, but no GLB/lip-sync | |
| // is loaded — instant start. "showText" also opens the product panel at once. | |
| let _quickAudioCtx = null; | |
| function getQuickAudioCtx(){ | |
| if(_quickAudioCtx && _quickAudioCtx.state!=="closed") return _quickAudioCtx; | |
| try{ _quickAudioCtx = new (window.AudioContext||window.webkitAudioContext)({latencyHint:"interactive"}); }catch(e){ _quickAudioCtx = null; } | |
| return _quickAudioCtx; | |
| } | |
| function openCatalogPanelNow(){ | |
| 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"); } | |
| } | |
| function openTextChatUI(){ | |
| if(showTextChat){ showTextChat(true); } | |
| if(textModeBtn) toggleClass(textModeBtn,"active",true); | |
| textMode = true; | |
| startComboPoll(); | |
| } | |
| async function startQuickConsult(t){ | |
| const msg = t || "Xin chào! Tôi muốn tư vấn nhanh sản phẩm."; | |
| openTextChatUI(); | |
| // Tư vấn nhanh → text chat dùng model google/gemma-4-31B-it | |
| await sendTextMessage(msg); | |
| // Open the product panel immediately so a product page shows right away. | |
| if(window.vaix && window.vaix.isLoaded && window.vaix.isLoaded()){ | |
| openCatalogPanelNow(); | |
| } else if(window.vaix && window.vaix.load){ | |
| window.vaix.load().then(function(){ openCatalogPanelNow(); }).catch(function(){ openCatalogPanelNow(); }); | |
| } else { | |
| openCatalogPanelNow(); | |
| } | |
| } | |
| async function startTextSession(t, opt){ | |
| if(sessionInProgress){ if(client&&t){client.sendUserText(t);client.requestResponse()} return } | |
| const opts = opt || {}; | |
| sessionInProgress=true; isGreetingSession=false; | |
| let ac, vs; | |
| if(opts.quick){ | |
| ac = getQuickAudioCtx(); vs = ac ? ac.destination : null; | |
| if(!ac){ sessionInProgress=false; return; } | |
| if(ac.state==="suspended"){ try{ await ac.resume(); }catch(_){} } | |
| } else { | |
| await stage.resume(); | |
| 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()}} | |
| 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()} | |
| 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; | |
| _noteUserMessage(text); // update news-list visibility from user speech | |
| 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 ONE consolidated "Tin HOT hôm nay" section with | |
| // distinct, context-aware cards. This is the SINGLE owner of the greeting | |
| // news section — the legacy observers (greeting-news.js / greeting-source-cards.js) | |
| // are disabled so no duplicate list is appended. | |
| (async () => { | |
| try { | |
| const lastMsg = chatMessages.lastElementChild; | |
| if (!lastMsg || !lastMsg.classList.contains("chat-message")) return; | |
| if (lastMsg.querySelector(".news-section")) return; // already has news — never duplicate | |
| const section = document.createElement("div"); | |
| section.className = "news-section"; | |
| section.setAttribute("data-owner", "app-news"); | |
| const h = document.createElement("div"); | |
| h.className = "news-section-title"; | |
| h.innerHTML = '<span class="hot-tag-dot"></span> Tin HOT hôm nay:'; | |
| section.appendChild(h); | |
| // Build up to 8 DIFFERENT cards spanning several HOT topics (each topic | |
| // yields its own cards → a varied, single consolidated list). | |
| let topics = []; | |
| try { topics = await fetchHotTags().catch(()=>[]); } catch(_) {} | |
| const used = new Set(); | |
| const sources = []; | |
| for (const t of topics) { | |
| if (sources.length >= 8) break; | |
| try { | |
| const s = await generateSourceCards((t && t.query) || 'tin tức mới nhất hôm nay').catch(()=>[]); | |
| for (const c of s) { | |
| const key = (c.url && String(c.url)) || (c.title && String(c.title)) || ''; | |
| if (key && (used.has(key) || _shownNewsUrls.has(key))) continue; | |
| if (key) used.add(key); | |
| sources.push({ ...c, category: (t && t.label) || c.category }); | |
| if (sources.length >= 8) break; | |
| } | |
| } catch (_) {} | |
| } | |
| // Fall back to the plain hot-articles list if nothing came back. | |
| if (sources.length === 0) { | |
| try { | |
| const data = await (await fetch("/api/news/hot")).json(); | |
| const hotArticles = (data.articles || []).slice(0, 4); | |
| hotArticles.forEach(a => sources.push({ | |
| source: a.source || 'Nguồn tin', title: a.title, url: a.url, icon: '📰', desc: '', articleTitle: a.title || '' | |
| })); | |
| } catch (_) {} | |
| } | |
| _markShown(sources); | |
| _trimShown(); | |
| 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) ── | |
| // Default: MUTED (im lặng). Khi profile mute, TTS không phải xử lý tin nhắn nên | |
| // tốc độ phản hồi nhanh hơn. User nghe từng tin bằng nút âm thanh trên mỗi tin. | |
| // Nhấn nút mute để BẬT chế độ âm thanh (TTS tự xử lý tin nhắn tiếp theo). | |
| function _loadMutedDefault() { | |
| try { const v = localStorage.getItem("avatar.chatMuted"); if (v !== null) return v === "1"; } catch (_) {} | |
| return true; // mặc định mute | |
| } | |
| let avatarAudioMuted = _loadMutedDefault(); | |
| let chatAudioToggleBtn = null; | |
| let chatboxMuteBtn = null; | |
| // Single source of truth for the muted state. Swaps the sound/muted icons on | |
| // BOTH the chat-audio-toggle button (voice-session header) and the always-visible | |
| // chatbox-mute button, stops any in-flight Edge TTS, and reflects state on the | |
| // playback node. | |
| function setAvatarAudioMuted(v){ | |
| const next = !!v; | |
| if (avatarAudioMuted === next) return; | |
| avatarAudioMuted = next; | |
| try { localStorage.setItem("avatar.chatMuted", next ? "1" : "0"); } catch(_) {} | |
| try { client?.setMuted(avatarAudioMuted); } catch(_) {} | |
| if (avatarAudioMuted) { try { stopEdgeTts(); } catch(_) {} } | |
| const syncIcons = function(root){ | |
| if(!root) return; | |
| const snd = root.querySelector("#audio-icon-sound, #chatbox-mute-sound"); | |
| const mtc = root.querySelector("#audio-icon-muted, #chatbox-mute-muted"); | |
| if (snd) snd.style.display = avatarAudioMuted ? "none" : ""; | |
| if (mtc) mtc.style.display = avatarAudioMuted ? "" : "none"; | |
| }; | |
| if (chatboxMuteBtn) { | |
| chatboxMuteBtn.classList.toggle("mute-on", avatarAudioMuted); | |
| chatboxMuteBtn.setAttribute("aria-label", avatarAudioMuted ? "Bật âm thanh" : "Im lặng (tắt âm thanh)"); | |
| chatboxMuteBtn.title = avatarAudioMuted ? "Bật âm thanh" : "Tắt âm thanh (im lặng)"; | |
| } | |
| // sync per-root icons (this button's SVG + the other button's SVG live in | |
| // different subtrees, so update both). | |
| if(chatboxMuteBtn) syncIcons(chatboxMuteBtn); | |
| if(chatAudioToggleBtn) 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 updateChatboxMuteBtn(){ | |
| if(!chatboxMuteBtn) return; | |
| chatboxMuteBtn.classList.toggle("mute-on", avatarAudioMuted); | |
| const snd = chatboxMuteBtn.querySelector("#chatbox-mute-sound"); | |
| const mtc = chatboxMuteBtn.querySelector("#chatbox-mute-muted"); | |
| if (snd) snd.style.display = avatarAudioMuted ? "none" : ""; | |
| if (mtc) mtc.style.display = avatarAudioMuted ? "" : "none"; | |
| } | |
| function initChatboxMute(){ | |
| chatboxMuteBtn = $("#chatbox-mute-btn"); | |
| if(!chatboxMuteBtn) return; | |
| chatboxMuteBtn.addEventListener("click", ()=>{ setAvatarAudioMuted(!avatarAudioMuted); }); | |
| updateChatboxMuteBtn(); | |
| } | |
| function initChatAudioToggle(){ | |
| chatAudioToggleBtn = $("#chat-audio-toggle-btn"); | |
| if(!chatAudioToggleBtn) return; | |
| chatAudioToggleBtn.addEventListener("click",()=>{ | |
| setAvatarAudioMuted(!avatarAudioMuted); | |
| }); | |
| updateChatboxMuteBtn(); | |
| } | |
| 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(); | |
| await ensureAvatarReady(); | |
| openTextChatUI(); | |
| sendTextMessage("Xin chào! Tôi muốn trò chuyện."); | |
| }); | |
| } | |
| if (welcomeVoiceBtn) { | |
| welcomeVoiceBtn.addEventListener("click", async () => { | |
| welcomeMode = "voice"; | |
| hideWelcome(); | |
| await ensureAvatarReady(); | |
| startGreetingSession(); | |
| }); | |
| } | |
| // ✨ Quick Consult: no avatar load — instant text chat + product panel open. | |
| if (welcomeQuickBtn) { | |
| welcomeQuickBtn.addEventListener("click", async () => { | |
| welcomeMode = "quick"; | |
| hideWelcome(); | |
| startQuickConsult(); | |
| }); | |
| } | |
| function g(s){try{return document.querySelector(s)}catch{return null}} | |
| /** Random general suggestions from catalog */ | |
| function getGreetingSuggestions() { | |
| try { | |
| var prods = (window.vaix && window.vaix.allProducts) ? window.vaix.allProducts() : []; | |
| if (prods.length < 3) return ["Tôi muốn tìm bếp từ Malloca", "Tư vấn máy hút mùi cho tôi", "Xem sản phẩm gia dụng"]; | |
| var diverse = (window.vaix.getDiverseProducts) ? window.vaix.getDiverseProducts(6) : [...prods].sort(() => Math.random() - 0.5).slice(0, 6); | |
| if (!diverse || !diverse.length) return ["Tôi muốn tìm bếp từ Malloca", "Tư vấn máy hút mùi cho tôi", "Xem sản phẩm gia dụng"]; | |
| var out = []; | |
| for (var i = 0; i < diverse.length && out.length < 3; i++) { | |
| var p = diverse[i]; | |
| if (!p) continue; | |
| if (p.brand) out.push("Tôi muốn tìm " + ((p.category || "").toLowerCase() + " ").trim() + p.title_clean.toLowerCase() + " của " + p.brand); | |
| else out.push("Cho tôi xem " + ((p.category || "").toLowerCase() + " ").trim() + p.title_clean.toLowerCase()); | |
| } | |
| return out.length ? out : ["Tôi muốn tìm bếp từ Malloca", "Tư vấn máy hút mùi cho tôi", "Xem sản phẩm gia dụng"]; | |
| } catch (e) { | |
| return ["Tôi muốn tìm bếp từ Malloca", "Tư vấn máy hút mùi cho tôi", "Xem sản phẩm gia dụng"]; | |
| } | |
| } | |
| 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", | |
| "Tôi muốn tìm máy hút mùi âm tủ" | |
| ]; | |
| 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()); | |
| initAudioUnlock(); | |
| initChatboxMute(); | |
| 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"; | |
| // Show a DIVERSE random mix of categories on the initial floating list | |
| // (bếp, máy hút mùi, chậu, vòi, khóa, kệ, lò...) instead of the first N | |
| // catalog rows, which can be dominated by one product type (khóa cửa). | |
| // STRICT diversity guard (v19): the initial floating list MUST be a diverse | |
| // round-robin across product families. NEVER fall back to prods.slice(0,20) | |
| // (the first catalog rows), because a stale/missing getDiverseProducts would | |
| // otherwise render whatever rows the index happens to start with. If we have | |
| // no diverse helper or it returns empty, re-derive a diverse mix from the raw | |
| // catalog via a guaranteed function, and as a last resort pick spread-apart | |
| // products (every ~Nth row) so a single family can never dominate the panel. | |
| let listProds = (window.vaix?.getDiverseProducts) ? window.vaix.getDiverseProducts(20) : []; | |
| if(!listProds || !listProds.length){ | |
| if(window.vaix && typeof window.vaix.getDiverseProducts === "function"){ | |
| try{ listProds = window.vaix.getDiverseProducts(20); }catch(_e){ listProds = []; } | |
| } | |
| if(!listProds || !listProds.length){ | |
| const step = Math.max(1, Math.floor(prods.length / 20)); | |
| for(let i=0;i<prods.length && listProds.length<20;i+=step) listProds.push(prods[i]); | |
| if(!listProds.length) listProds = prods.slice(0, 20); | |
| } | |
| } | |
| for(let i=0;i<Math.min(20,listProds.length);i++){ | |
| const item=listProds[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 | |