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 = `${icon || '💡'}${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 = ' 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, ''');
}
/** 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
? `
';
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 '
' + cards + '
';
} catch (e) { return ""; }
}
let _typingEl = null;
function showTypingBubble() {
removeTypingBubble();
const m = document.createElement("div");
m.className = "chat-message assistant typing";
m.innerHTML = ' đang soạn…';
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