gemma-rag / src /app.js
bep40's picture
Fix: add removeAttribute(hidden) on loading in startWithSelection
feb10da verified
Raw
History Blame Contribute Delete
81.5 kB
import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
const VOICES = [
"Aiden", "Ryan", "Dylan", "Eric",
"Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian",
];
const DEFAULT_VOICE = "Sohee";
// ── VNEWS integration ───────────────────────────────────────────────────
// The VNEWS space (bep40/vnews) is reachable at this subdomain. It exposes a
// clean JSON API (no key required) that we use to pull AI/tech news, answer
// from VNEWS data, and rewrite selected chat content into VNEWS "wall" posts.
const VNEWS_BASE = "https://bep40-vnews.hf.space";
// ── News fetching ───────────────────────────────────────────────────────
const NEWS_PAGE_SIZE = 10;
async function getHotNews() {
let items = [];
try {
const resp = await fetch("/api/news/hot");
if (resp.ok) {
const data = await resp.json();
if (data.items && data.items.length > 0) {
items = data.items.slice(0, NEWS_PAGE_SIZE);
}
}
} catch (e) { console.warn("News API failed:", e); }
return items;
}
async function getMoreNews(offset, limit = NEWS_PAGE_SIZE) {
try {
const resp = await fetch(`/api/news/more?offset=${offset}&limit=${limit}`);
if (resp.ok) {
const data = await resp.json();
return {
items: Array.isArray(data.items) ? data.items : [],
nextOffset: data.offset ?? offset + (data.items?.length ?? 0),
hasMore: Boolean(data.hasMore),
};
}
} catch (e) { console.warn("News more API failed:", e); }
return { items: [], nextOffset: offset, hasMore: false };
}
async function getFilteredNews(query, limit = NEWS_PAGE_SIZE) {
try {
const resp = await fetch(`/api/news/filter?q=${encodeURIComponent(query)}&limit=${limit}`);
if (resp.ok) {
const data = await resp.json();
return Array.isArray(data.items) ? data.items : [];
}
} catch (e) { console.warn("News filter API failed:", e); }
return [];
}
// ── VNEWS fetch helpers ─────────────────────────────────────────────────
let vnewsConnected = false;
let vnewsArticles = []; // flattened articles from VNEWS homepage
let vnewsStatusTimer = 0;
async function vnewsFetch(path) {
try {
const resp = await fetch(`${VNEWS_BASE}${path}`, { headers: { "Accept": "application/json" } });
if (!resp.ok) return null;
return await resp.json();
} catch (e) {
console.warn("VNEWS fetch failed:", path, e);
return null;
}
}
/** Pull the GenK "AI" feed (source = genk) — used for the "tin tức về AI" topic. */
async function getVnewsAIFeed(limit = NEWS_PAGE_SIZE) {
const data = await vnewsFetch(`/api/category/cong-nghe`);
if (!Array.isArray(data)) return [];
return data
.filter((a) => a && a.title && a.link)
.map((a) => ({
title: a.title,
link: a.link,
image: a.img || "",
source: a.source === "genk" ? "GenK AI" : (a.source || "VNEWS"),
vnews: true,
}))
.slice(0, limit);
}
/** Pull the VNEWS homepage (all categories) and flatten into a searchable list. */
// ── Topic tags (chủ đề HOT thời gian thực) — click mở modal tin theo chủ đề ──
// Fetch realtime trending topics from the backend API (extracted from today's news).
async function getTrendingTopics() {
try {
const resp = await fetch("/api/topics/trending");
if (resp.ok) {
const data = await resp.json();
if (Array.isArray(data.topics) && data.topics.length > 0) {
return data.topics;
}
}
} catch (e) { console.warn("[topic] API failed:", e); }
return [];
}
async function openTopic(slug, label) {
try {
setCaption("ĐANG TẢI TIN: " + label, "live");
let items;
// AI topic -> use VNEWS AI feed for better results
if (slug === "ai") {
items = await getVnewsAIFeed(NEWS_PAGE_SIZE);
} else {
const raw = await getFilteredNews(slug);
items = (Array.isArray(raw) ? raw : []).map((n) => ({
title: n.title, link: n.link, image: n.image || "",
source: n.source || "Tin tức", vnews: Boolean(n.vnews),
}));
}
showNewsPanel(items);
const nt = document.getElementById("news-title");
if (nt) nt.textContent = "📰 Tin: " + label;
document.querySelectorAll(".topic-chip").forEach((c) => c.classList.toggle("active", c.dataset.slug === slug));
if (!items.length) setCaption("Không có tin cho: " + label, "error");
} catch (e) {
console.warn("[topic] openTopic:", e);
setCaption("LỖI TẢI TIN: " + label, "error");
}
}
// Render today's real trending topics INSIDE the chat, right under the avatar's
// greeting, so they are always visible. Clicking a chip opens that topic's news.
let _topicTagsRendered = false;
async function renderTopicTags() {
if (_topicTagsRendered) {
// Re-render existing tags (update content, re-add AI if missing)
const oldEl = chatMessages.querySelector("#topic-tags-inline");
if (oldEl) oldEl.remove();
_topicTagsRendered = false;
}
let topics = [];
try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); }
if (!topics.length) return;
const wrap = document.createElement("div");
wrap.className = "topic-tags";
wrap.id = "topic-tags-inline";
for (const c of topics) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "topic-chip";
chip.dataset.slug = c.slug;
chip.textContent = "#" + c.label.replace(/\s+/g, "_");
chip.addEventListener("click", () => void openTopic(c.slug, c.label));
wrap.appendChild(chip);
}
const oldEl = chatMessages.querySelector("#topic-tags-inline");
if (oldEl) oldEl.remove();
chatMessages.appendChild(wrap);
chatMessages.scrollTop = chatMessages.scrollHeight;
_topicTagsRendered = true;
}
async function getVnewsHomepage() {
const data = await vnewsFetch(`/api/homepage`);
if (!Array.isArray(data)) return [];
return data
.filter((a) => a && a.title && a.link)
.map((a) => ({
title: a.title,
link: a.link,
image: a.img || "",
source: a.group || a.source || "VNEWS",
vnews: true,
}));
}
/** Search VNEWS articles by a free-text query (case-insensitive, in title). */
function searchVnews(query, limit = 8) {
const q = (query || "").toLowerCase().trim();
if (!q) return vnewsArticles.slice(0, limit);
return vnewsArticles
.filter((a) => a.title.toLowerCase().includes(q))
.slice(0, limit);
}
/** Format VNEWS articles as clickable link cards for the avatar to cite. */
function vnewsLinksAsHtml(articles) {
if (!articles.length) return "(không có bài viết VNEWS phù hợp)";
return articles
.map((a, i) => {
const short = `${VNEWS_BASE}/?p=${encodeURIComponent(a.link)}`;
return `${i + 1}. <a href="${a.link}" target="_blank" rel="noopener">${a.title}</a> — nguồn ${a.source} (link rút gọn: ${short})`;
})
.join("<br>");
}
const DEFAULT_INSTRUCTIONS = [
"You are a friendly voice assistant with a visible, human-like 3D avatar.",
"CRITICAL: Always respond in the SAME LANGUAGE that the user writes or speaks.",
"If Vietnamese, respond in Vietnamese. If English, respond in English.",
"When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
"CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35'). This ensures the TTS voice reads them correctly.",
"Keep replies short, natural, warm. Never list-like.",
"You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression. Use them naturally.",
"NEVER guess. Use search_web, search_wikipedia, or get_vnews_news for ANY factual question.",
"When you cite a VNEWS article, ALWAYS include the direct link as a clickable <a href> tag AND mention the short link form.",
"Never mention tools or that you control an avatar.",
"Source links should be formatted as popup links with <a href> tags.",
].join(" ");
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 your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS } }, required: ["mood"] } },
{ type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES } }, required: ["gesture"] } },
{ type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string" } }, required: ["emoji"] } },
{ type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {} } },
{ type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
{ type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
{ type: "function", name: "get_vnews_news", description: "Search the connected VNEWS site for Vietnamese news articles (AI, tech, sports, world...). Returns article titles with direct links and short links. Use this when the user asks about news, especially AI/technology, or after VNEWS is connected.", parameters: { type: "object", properties: { query: { type: "string", description: "Keyword to search, e.g. 'AI', 'bóng đá', 'thời sự'. Empty = latest." } }, required: ["query"] } },
];
// ── DOM elements ─────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const stageNode = $("#stage");
const mainBtn = $("#main-btn");
const mainBtnLabel = $("#main-btn-label");
const muteBtn = $("#mute-btn");
const textModeBtn = $("#text-mode-btn");
const caption = $("#caption");
const subtitles = $("#subtitles");
const loading = $("#loading");
const settingsBtn = $("#settings-btn");
const settingsDialog = $("#settings");
const inputVoice = $("#voice");
const inputInstructions = $("#instructions");
const inputDirectUrl = $("#direct-url");
const inputSubtitles = $("#subtitles-toggle");
const directUrlRow = $("#direct-url-row");
const textChat = $("#text-chat");
const chatHeader = $("#chat-header");
const chatMessages = $("#chat-messages");
const chatInput = $("#chat-input");
const chatSendBtn = $("#chat-send-btn");
const chatMicBtn = $("#chat-mic-btn");
const chatCloseBtn = $("#chat-close-btn");
const chatResizeHandle = $("#chat-resize-handle");
const chatAvatarSelect = $("#chat-avatar-select");
const settingsAvatarSelect = $("#settings-avatar-select");
const newsPanel = $("#news-panel");
const newsList = $("#news-list");
const newsCloseBtn = $("#news-close-btn");
const newsToggleBtn = $("#news-toggle-btn");
const newsLoadMore = $("#news-loadmore");
const newsLoadMoreBtn = $("#news-loadmore-btn");
const newsSentinel = $("#news-sentinel");
const newsResizeHandle = $("#news-resize-handle");
// ── State ───────────────────────────────────────────────────────────────
const stage = new AvatarStage(stageNode);
let client = null;
let muted = false;
let subtitleTimer = 0;
let textMode = false;
let config = { lb: false, allowDirect: true };
let sessionInProgress = false;
let autoGreetingSent = false;
let preFetchedNews = [];
let latestNewsUrl = null;
let latestNewsSource = null;
// ── News box state ──────────────────────────────────────────────────────
let newsOffset = 0; // how many items already rendered
let newsLoading = false; // guard against concurrent loads
let newsHasMore = true;
let newsObserver = null;
// ── News sequence (for walking images through the conversation) ──────────
let newsSeq = []; // ordered list of news items to surface images from
let newsSeqIdx = -1; // index of the NEXT image to show
let newsTopicQuery = ""; // current topic keyword (empty = general hot news)
function loadSettings() {
return {
voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
avatar: localStorage.getItem(STORAGE_KEYS.avatar) || "vuong.glb",
instructions: localStorage.getItem(STORAGE_KEYS.instructions) || "",
directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
subtitles: localStorage.getItem(STORAGE_KEYS.subtitles) === "1",
};
}
let settings = loadSettings();
function saveSettings() {
localStorage.setItem(STORAGE_KEYS.voice, settings.voice);
localStorage.setItem(STORAGE_KEYS.avatar, settings.avatar);
localStorage.setItem(STORAGE_KEYS.instructions, settings.instructions);
localStorage.setItem(STORAGE_KEYS.directUrl, settings.directUrl);
localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0");
}
function getSourceName(hostname) {
const name = hostname.replace(/^www\./, "").toLowerCase();
const known = {
"vnexpress.net": "VnExpress",
"dantri.com.vn": "Dân trí",
"tuoitre.vn": "Tuổi Trẻ",
"thanhnien.vn": "Thanh Niên",
"vietnamnet.vn": "VietNamNet",
"zingnews.vn": "ZingNews",
"cafef.vn": "CafeF",
"genk.vn": "GenK",
"kenh14.vn": "Kênh 14",
"afamily.vn": "Afamily",
"plo.vn": "PLO",
"vtc.vn": "VTC",
};
return known[name] || name;
}
/** Short label/initial used for the avatar bubble when no image is available. */
function sourceInitial(source) {
const map = {
"VnExpress": "Vn", "Dân trí": "DT", "Tuổi Trẻ": "TT", "Thanh Niên": "TN",
"VietNamNet": "VN", "ZingNews": "Z", "Kênh 14": "K14", "CafeF": "CF",
"GenK": "GK", "Afamily": "AF", "PLO": "PLO", "VTC": "VTC", "GenK AI": "AI",
};
return map[source] || (source ? source.slice(0, 2).toUpperCase() : "?");
}
/** Vietnamese date: "thứ Sáu, ngày 17 tháng 7 năm 2026". */
function formatVietnameseDate(now) {
const weekdays = ["Chủ nhật", "thứ Hai", "thứ Ba", "thứ Tư", "thứ Năm", "thứ Sáu", "thứ Bảy"];
const wd = weekdays[now.getDay()];
return `${wd}, ngày ${now.getDate()} tháng ${now.getMonth() + 1} năm ${now.getFullYear()}`;
}
/** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */
function formatVietnameseTime(now) {
let h = now.getHours();
const m = now.getMinutes();
let part;
if (h >= 0 && h < 5) part = "đêm";
else if (h < 10) part = "sáng";
else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa
else if (h < 18) part = "chiều";
else part = "tối"; // 18h trở đi -> tối
const hh = h === 0 ? 12 : h;
if (m === 0) return `${hh}h ${part}`;
return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
}
function effectiveInstructions(newsItems) {
const now = new Date();
const dateStr = formatVietnameseDate(now);
const timeStr = formatVietnameseTime(now);
const dateLine = `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`;
const extra = settings.instructions.trim();
const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
// Trending topics are now fetched from the backend API /api/topics/trending
// which extracts real topics from today's news. The frontend getTrendingTopics()
// calls this API and renders the topic chips.
let newsInst = "";
if (newsItems && newsItems.length > 0) {
const topNews = newsItems[0];
const sourceName = getSourceName(new URL(topNews.link).hostname);
newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}.\nTin HOT nhất hôm nay: ${topNews.title} (nguồn ${sourceName})"\nInclude the full title and source name in parentheses. After greeting, offer to help with questions.`;
} else {
newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}."\nThen offer to help with questions.`;
}
let vnewsInst = "";
if (vnewsConnected && vnewsArticles.length > 0) {
const sample = vnewsArticles.slice(0, 6).map((a) => `• ${a.title} (${a.source})`).join("\n");
vnewsInst = `\n\nVNEWS is connected. You can answer using the latest VNEWS headlines and MUST cite them with direct <a href> links. Latest VNEWS headlines:\n${sample}`;
}
const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}${vnewsInst}`;
return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
}
// ── News image card (shown inside chat) ─────────────────────────────────
function newsImageCardHtml(item) {
const imgUrl = item.image || (item.images && item.images[0]) || "";
const src = imgUrl
? `<img class="news-card-img" src="${imgUrl}" alt="" loading="lazy" onerror="this.style.display='none'" />`
: `<div class="news-card-img news-card-img--fallback">${sourceInitial(item.source)}</div>`;
const link = item.link
? `data-link="${item.link.replace(/"/g, "&quot;")}"`
: "";
return `<div class="news-card" ${link}>${src}<div class="news-card-meta"><span class="news-card-source">${item.source || "Tin tức"}</span><span class="news-card-title">${item.title}</span></div></div>`;
}
// ── News Panel ─────────────────────────────────────────────────────────
function buildNewsItem(item, idx) {
const el = document.createElement("div");
el.className = "news-item";
const thumb = document.createElement("div");
thumb.className = "news-thumb";
if (item.image) {
const img = document.createElement("img");
img.loading = "lazy";
img.alt = "";
img.src = item.image;
img.addEventListener("error", () => {
thumb.classList.add("news-thumb--fallback");
thumb.textContent = sourceInitial(item.source);
img.remove();
});
thumb.appendChild(img);
} else {
thumb.classList.add("news-thumb--fallback");
thumb.textContent = sourceInitial(item.source);
}
const body = document.createElement("div");
body.className = "news-body";
const meta = document.createElement("div");
meta.className = "news-meta";
meta.innerHTML = `<span class="news-source">${item.source || "Tin tức"}</span>` +
(typeof idx === "number" ? `<span class="news-index">#${idx + 1}</span>` : "");
const title = document.createElement("div");
title.className = "news-title";
title.textContent = item.title;
if (item.description) {
const desc = document.createElement("div");
desc.className = "news-desc";
desc.textContent = item.description;
body.appendChild(desc);
}
body.appendChild(meta);
body.appendChild(title);
el.appendChild(thumb);
el.appendChild(body);
// Action row: open source + ask in chat
const actions = document.createElement("div");
actions.className = "news-actions";
const srcBtn = document.createElement("button");
srcBtn.className = "news-act-btn";
srcBtn.type = "button";
srcBtn.textContent = "🔗 Nguồn";
srcBtn.addEventListener("click", (e) => {
e.stopPropagation();
// VNEWS items open the short link so highlights/videos play on VNEWS.
const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
});
const askBtn = document.createElement("button");
askBtn.className = "news-act-btn news-act-btn--ask";
askBtn.type = "button";
askBtn.textContent = "💬 Hỏi";
askBtn.addEventListener("click", (e) => {
e.stopPropagation();
askAboutNews(item);
});
actions.appendChild(srcBtn);
actions.appendChild(askBtn);
el.appendChild(actions);
// Clicking the card body opens the source (VNEWS short link for VNEWS items).
el.addEventListener("click", () => {
const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
});
return el;
}
/** Fill the chat input with a question about a news item and focus/start chat. */
function askAboutNews(item) {
const q = `Kể chi tiết về tin này: ${item.title}`;
chatInput.value = q;
showTextChat(true);
textModeBtn.classList.add("active");
textMode = true;
chatInput.focus();
if (!client || !sessionInProgress) {
void startTextSession(q);
} else {
addChatMessage("user", q);
void maybeSwitchTopic(q);
if (!sendTextViaSession(q)) setCaption("QUEUED…");
}
}
function showNewsPanel(items) {
if (!newsPanel || !newsList) return;
newsList.innerHTML = "";
newsOffset = 0;
newsHasMore = true;
if (!items || items.length === 0) {
const empty = document.createElement("div");
empty.className = "news-empty";
empty.textContent = "Không có tin tức cho chủ đề này.";
newsList.appendChild(empty);
newsLoadMore.hidden = true;
newsPanel.hidden = false;
return;
}
items.forEach((item, i) => newsList.appendChild(buildNewsItem(item, i)));
newsOffset = items.length;
newsHasMore = items.length >= NEWS_PAGE_SIZE;
newsLoadMore.hidden = !newsHasMore;
newsPanel.hidden = false;
setupNewsObserver();
}
function appendNewsItems(items) {
if (!items || items.length === 0) {
newsHasMore = false;
newsLoadMore.hidden = true;
return;
}
items.forEach((item) => newsList.appendChild(buildNewsItem(item, newsOffset++)));
}
async function loadMoreNews() {
if (newsLoading || !newsHasMore) return;
newsLoading = true;
newsLoadMoreBtn.classList.add("loading");
newsLoadMoreBtn.disabled = true;
// If we're on a filtered topic, "load more" just re-fetches the filtered set.
let items;
if (newsTopicQuery) {
items = await getFilteredNews(newsTopicQuery, NEWS_PAGE_SIZE);
} else {
const r = await getMoreNews(newsOffset, NEWS_PAGE_SIZE);
items = r.items;
newsHasMore = r.hasMore;
newsOffset = r.nextOffset;
}
appendNewsItems(items);
if (!newsTopicQuery) newsLoadMore.hidden = !newsHasMore;
else newsLoadMore.hidden = true; // filtered set is already complete
newsLoadMoreBtn.classList.remove("loading");
newsLoadMoreBtn.disabled = false;
newsLoading = false;
}
function setupNewsObserver() {
if (newsObserver) newsObserver.disconnect();
if (!newsSentinel || !("IntersectionObserver" in window)) return;
newsObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && newsHasMore && !newsLoading && !newsTopicQuery) {
void loadMoreNews();
}
}
}, { root: newsList, rootMargin: "120px" });
newsObserver.observe(newsSentinel);
}
// ── Topic detection & news sequence ─────────────────────────────────────
const VI_STOPWORDS = new Set([
"tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "minh",
"có", "không", "được", "là", "và", "với", "của", "cho", "trong", "ngoài", "về", "tại", "từ",
"để", "khi", "nếu", "như", "đó", "này", "kia", "gì", "nào", "đâu", "ai", "sao", "thế",
"hãy", "xin", "vui", "lòng", "giúp", "biết", "tin", "tức", "bài", "viết", "nói", "chuyện",
"chủ", "đề", "muốn", "hỏi", "xem", "nghe", "à", "ừ", "ok", "okay", "ồ", "nhé", "nhỉ", "ạ",
"đây", "nè", "một", "hai", "ba", "mấy", "các", "những", "cái", "con", "chiếc", "bản",
"hôm", "nay", "tới", "mới", "nhất", "kể",
]);
/** Decide if the user is continuing the current news thread (not switching). */
function isFollowUp(text) {
const t = (text || "").toLowerCase();
// Only truly-continuation cues force a follow-up. Topic phrases like
// "kể về X", "tin tức X", "cho tôi biết về X" are treated as new topics.
if (/(tiếp|tiếp tục|nữa|thêm tin|tin này|tin đó|tin khác|còn tin|tin hot|tin nóng|tin hôm nay|tóm tắt|tóm lại|giải thích|ý nghĩa|chi tiết hơn|kể thêm)/.test(t)) {
return true;
}
return t.trim().split(/\s+/).length <= 3;
}
/** Detect an explicit AI / công nghệ request (route to GenK AI feed). */
function isAITopic(text) {
const t = (text || "").toLowerCase();
return /(\bai\b|trí tuệ nhân tạo|a\.i|a i|robot|machine learning|deep learning|chatbot|generative|gen ai|llm|gpt|qwen|kimi|chatgpt|gemini|openai|claude|tạo ảnh|tạo video|ứng dụng ai)/.test(t);
}
/** Extract a short keyword query from a user message (Vietnamese-aware). */
function extractTopicQuery(text) {
if (isFollowUp(text)) return ""; // follow-ups never trigger a topic switch
const words = (text || "")
.toLowerCase()
.replace(/[^a-zà-ỹ0-9\s]/g, " ")
.split(/\s+/)
.filter(Boolean);
// Keep meaningful words (>=2 chars, not a stopword). Compound nouns like
// "bóng đá", "kinh tế", "bóng rổ" stay intact because both parts are kept.
const keywords = words.filter((w) => w.length >= 2 && !VI_STOPWORDS.has(w));
if (!keywords.length) return "";
return keywords.slice(0, 2).join(" ");
}
/**
* Called when the user sends a message. If it's a new topic, re-filter the
* news list to that topic and reset the image sequence. AI topics pull the
* GenK "AI" feed from VNEWS.
*/
async function maybeSwitchTopic(userText) {
if (isFollowUp(userText)) return false; // keep current sequence/list
// AI topic -> pull GenK AI feed from VNEWS.
if (isAITopic(userText)) {
const aiItems = await getVnewsAIFeed(NEWS_PAGE_SIZE);
if (aiItems.length) {
newsTopicQuery = "ai";
preFetchedNews = aiItems;
newsSeq = aiItems.slice();
newsSeqIdx = -1;
showNewsPanel(aiItems);
setCurrentNews(aiItems[0]);
return true;
}
// Fall back to general news filter on "ai".
const filtered = await getFilteredNews("ai", NEWS_PAGE_SIZE);
if (filtered.length) {
newsTopicQuery = "ai";
preFetchedNews = filtered;
newsSeq = filtered.slice();
newsSeqIdx = -1;
showNewsPanel(filtered);
setCurrentNews(filtered[0]);
return true;
}
return false;
}
const query = extractTopicQuery(userText);
if (!query || query === newsTopicQuery) return false;
const filtered = await getFilteredNews(query, NEWS_PAGE_SIZE);
if (!filtered.length) return false; // no news for this topic -> keep general
newsTopicQuery = query;
preFetchedNews = filtered;
newsSeq = filtered.slice();
newsSeqIdx = -1;
showNewsPanel(filtered);
setCurrentNews(filtered[0]);
return true;
}
// Current news item being discussed + which image to show next.
let curNews = null;
let curImgIdx = 0;
/** Return the next image URL for the CURRENT news item, looping through its
* images (image[0], image[1], ...). Falls back to a single image. */
function nextNewsImage() {
if (!curNews) return null;
const imgs = (curNews.images && curNews.images.length)
? curNews.images
: (curNews.image ? [curNews.image] : []);
if (!imgs.length) return null;
const url = imgs[curImgIdx % imgs.length];
curImgIdx += 1;
return url;
}
/** Set the news item currently being discussed (resets the image cursor). */
function setCurrentNews(item) {
curNews = item || null;
curImgIdx = 0;
}
// ── Avatar list ─────────────────────────────────────────────────────────
let avatarList = [];
let avatarThumbs = {}; // name -> thumbnail URL (persisted on Space)
async function fetchAvatarList() {
try {
const resp = await fetch("/api/avatars");
if (resp.ok) {
const data = await resp.json();
avatarList = (data.avatars || []).map((a) => (typeof a === "string" ? a : a.name));
// Store thumbnail URLs from the API response
avatarThumbs = {};
for (const a of (data.avatars || [])) {
if (typeof a === "object" && a.name && a.thumbnail) {
avatarThumbs[a.name] = a.thumbnail;
}
}
}
} catch {}
}
function populateAvatarSelects(selectedName) {
for (const sel of [chatAvatarSelect, settingsAvatarSelect]) {
sel.innerHTML = "";
const def = document.createElement("option");
def.value = "";
def.textContent = "(Default - Brunette)";
sel.appendChild(def);
for (const name of avatarList) {
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name.replace(/\.glb$/i, "").replace(/_/g, " ") + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
sel.appendChild(opt);
}
if (selectedName && avatarList.includes(selectedName)) {
sel.value = selectedName;
}
}
}
updateAvatarThumbPreview();
function setAvatarFromSelect(value) {
settings.avatar = value || "";
saveSettings();
}
async function reloadAvatar() {
if (!stage.head) return;
loading.classList.remove("done");
loading.textContent = "Loading avatar...";
try {
await stage.init({
avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
onprogress: (ev) => {
if (ev.lengthComputable) {
loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
}
}
});
stage.resume(); // keep the render loop running so the avatar stays visible
} catch (err) {
console.error(err);
}
loading.classList.add("done");
}
// ── Chat / captions ────────────────────────────────────────────────────
function clampRect() {
const vw = window.innerWidth, vh = window.innerHeight;
const 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 dragging = false;
let startX, startY, startLeft, startTop;
function getPos(e) {
const p = e.changedTouches ? e.changedTouches[0] : e;
return { x: p.clientX, y: p.clientY };
}
function onStart(e) {
if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) {
return;
}
const p = getPos(e);
dragging = true;
const rect = textChat.getBoundingClientRect();
startX = p.x;
startY = p.y;
startLeft = rect.left;
startTop = rect.top;
textChat.classList.add("dragging");
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
const p = getPos(e);
textChat.style.left = `${startLeft + p.x - startX}px`;
textChat.style.top = `${startTop + p.y - startY}px`;
textChat.style.right = "auto";
textChat.style.bottom = "auto";
textChat.style.width = "";
textChat.style.height = "";
e.preventDefault();
}
function onEnd() {
if (!dragging) return;
dragging = false;
textChat.classList.remove("dragging");
clampRect();
}
chatHeader.addEventListener("mousedown", onStart);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onEnd);
chatHeader.addEventListener("touchstart", onStart, { passive: false });
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
function makeResizable() {
let resizing = false;
let startX, startY, startW, startH;
function getPos(e) {
const p = e.changedTouches ? e.changedTouches[0] : e;
return { x: p.clientX, y: p.clientY };
}
function onStart(e) {
resizing = true;
const rect = textChat.getBoundingClientRect();
const p = getPos(e);
startX = p.x;
startY = p.y;
startW = rect.width;
startH = rect.height;
textChat.classList.add("resizing");
e.preventDefault();
e.stopPropagation();
}
function onMove(e) {
if (!resizing) return;
const p = getPos(e);
textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`;
textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`;
textChat.style.right = "auto";
textChat.style.bottom = "auto";
e.preventDefault();
}
function onEnd() {
if (!resizing) return;
resizing = false;
textChat.classList.remove("resizing");
}
chatResizeHandle.addEventListener("mousedown", onStart);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onEnd);
chatResizeHandle.addEventListener("touchstart", onStart, { passive: false });
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
function setCaption(text, kind = "") {
caption.textContent = text;
caption.className = kind;
}
function showSubtitles(text) {
if (!settings.subtitles) return;
clearTimeout(subtitleTimer);
subtitles.innerHTML = text;
subtitles.classList.add("visible");
subtitles.querySelectorAll("a[href]").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const href = a.getAttribute("href");
if (href) {
window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
}
});
});
}
function fadeSubtitles(delayMs = 2600) {
clearTimeout(subtitleTimer);
subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs);
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function addChatMessage(role, text, newsItem = null) {
const msg = document.createElement("div");
msg.className = `chat-message ${role}`;
msg.dataset.text = text; // store plain text for rewrite selection
if (newsItem) {
if (newsItem.image) msg.dataset.img = newsItem.image;
if (newsItem.link) msg.dataset.src = newsItem.link;
}
// Detect AI keywords in assistant messages for tag display
let detectedAIKeywords = [];
if (role === "assistant" && text) {
const t = text.toLowerCase();
const AI_PATTERNS = [
"AI", "trí tuệ nhân tạo", "LLM", "GPT", "Qwen", "Kimi", "ChatGPT",
"Gemini", "OpenAI", "Claude", "machine learning", "deep learning",
"chatbot", "ứng dụng AI", "tạo ảnh", "tạo video", "AI tạo sinh",
"thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy",
"neural network", "mạng nơ-ron"
];
for (const kw of AI_PATTERNS) {
if (t.includes(kw.toLowerCase())) {
detectedAIKeywords.push(kw);
}
}
}
let displayText = text;
if (role === "assistant" && latestNewsUrl && latestNewsSource) {
const sourceEscaped = escapeRegex(latestNewsSource);
const patterns = [
new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'),
new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'),
];
for (const pattern of patterns) {
if (pattern.test(displayText)) {
displayText = displayText.replace(pattern, (match) =>
`<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">${match}</a>`
);
break;
}
}
}
msg.innerHTML = displayText;
// Attach a news image card (for greeting + each subsequent answer).
// Render even when the image is missing: newsImageCardHtml shows a
// gradient fallback using the source initials, so the user always sees
// a clickable source card.
if ((role === "assistant") && newsItem) {
const card = document.createElement("div");
card.innerHTML = newsImageCardHtml(newsItem).trim();
const cardEl = card.firstElementChild;
if (cardEl) {
cardEl.addEventListener("click", () => {
if (newsItem.link) window.open(newsItem.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
});
msg.appendChild(cardEl);
}
}
// Render AI keyword tags under assistant messages
if (role === "assistant" && detectedAIKeywords.length > 0) {
const tagRow = document.createElement("div");
tagRow.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;";
for (const kw of detectedAIKeywords) {
const tag = document.createElement("span");
tag.textContent = "\ud83e\udd16 " + kw;
tag.style.cssText = "font-size:11px;background:rgba(59,130,246,0.15);color:#60a5fa;padding:2px 8px;border-radius:10px;border:1px solid rgba(59,130,246,0.3);";
tagRow.appendChild(tag);
}
msg.appendChild(tagRow);
}
// Click a message to toggle multi-select (avatar + user messages) for rewrite.
msg.addEventListener("click", () => {
msg.classList.toggle("selected");
updateRewriteButton();
});
chatMessages.appendChild(msg);
chatMessages.scrollTop = chatMessages.scrollHeight;
msg.querySelectorAll("a[href]").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const href = a.getAttribute("href");
if (href) {
window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
}
});
});
if (role === "assistant" && preFetchedNews.length > 0) {
showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
}
}
function showTextChat(show) {
textChat.hidden = !show;
if (chatMicBtn) chatMicBtn.hidden = !show;
// Remove fakemic flag so mic button can request real mic later
if (show) {
const url = new URL(location.href);
url.searchParams.delete("fakemic");
history.replaceState(null, "", url.href);
}
if (show) chatInput.focus();
}
function sendTextViaSession(text) {
if (!client) return false;
const st = client._status;
if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") {
return false;
}
setCaption("SENDING…");
client.sendUserText(text);
client.requestResponse();
return true;
}
function sendTextMessage() {
const text = chatInput.value.trim();
if (!text) return;
addChatMessage("user", text);
chatInput.value = "";
// New topic? re-filter the news list and reset the image sequence.
if (client && sessionInProgress) {
void maybeSwitchTopic(text);
}
if (!client || !sessionInProgress) {
void startTextSession(text);
return;
}
if (!sendTextViaSession(text)) {
setCaption("QUEUED…");
}
}
if (newsCloseBtn) {
newsCloseBtn.addEventListener("click", (e) => {
e.stopPropagation();
newsPanel.hidden = true;
});
}
if (newsToggleBtn) {
newsToggleBtn.addEventListener("click", () => {
newsPanel.hidden = !newsPanel.hidden;
});
}
if (newsLoadMoreBtn) {
newsLoadMoreBtn.addEventListener("click", () => void loadMoreNews());
}
if (chatCloseBtn) {
chatCloseBtn.addEventListener("click", (e) => {
e.stopPropagation();
textMode = false;
showTextChat(false);
textModeBtn.classList.remove("active");
});
}
// ── VNEWS control panel (created dynamically, no index.html change) ─────
let vnewsPanel, vnewsConnectBtn, vnewsStatus, rewriteBtn, vnewsCloseBtn, vnewsToggleBtn;
function buildVnewsPanel() {
vnewsPanel = document.createElement("div");
vnewsPanel.id = "vnews-panel";
vnewsPanel.hidden = true;
const header = document.createElement("div");
header.id = "vnews-header";
const title = document.createElement("span");
title.id = "vnews-title";
title.textContent = "VNEWS";
vnewsCloseBtn = document.createElement("button");
vnewsCloseBtn.id = "vnews-close-btn";
vnewsCloseBtn.className = "icon-btn small";
vnewsCloseBtn.textContent = "✕";
vnewsCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); vnewsPanel.hidden = true; });
header.appendChild(title);
header.appendChild(vnewsCloseBtn);
vnewsConnectBtn = document.createElement("button");
vnewsConnectBtn.id = "vnews-connect-btn";
vnewsConnectBtn.className = "vnews-btn";
vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
vnewsConnectBtn.addEventListener("click", () => void connectVnews());
vnewsStatus = document.createElement("div");
vnewsStatus.id = "vnews-status";
vnewsStatus.textContent = "Chưa kết nối";
rewriteBtn = document.createElement("button");
rewriteBtn.id = "vnews-rewrite-btn";
rewriteBtn.className = "vnews-btn";
rewriteBtn.textContent = "📝 Đăng bài rewrite";
rewriteBtn.disabled = true;
rewriteBtn.hidden = true;
rewriteBtn.addEventListener("click", () => void doRewriteSelected());
vnewsPanel.appendChild(header);
vnewsPanel.appendChild(vnewsConnectBtn);
vnewsPanel.appendChild(vnewsStatus);
vnewsPanel.appendChild(rewriteBtn);
document.body.appendChild(vnewsPanel);
// A small toggle button on the top bar.
vnewsToggleBtn = document.createElement("button");
vnewsToggleBtn.id = "vnews-toggle-btn";
vnewsToggleBtn.className = "icon-btn";
vnewsToggleBtn.title = "VNEWS";
vnewsToggleBtn.textContent = "📰";
vnewsToggleBtn.addEventListener("click", () => { vnewsPanel.hidden = !vnewsPanel.hidden; });
const topbar = document.querySelector("#topbar");
if (topbar) topbar.appendChild(vnewsToggleBtn);
makeVnewsDraggable();
}
async function connectVnews() {
vnewsConnectBtn.disabled = true;
vnewsConnectBtn.textContent = "⏳ Đang kết nối…";
vnewsStatus.textContent = "Đang tải dữ liệu VNEWS…";
const articles = await getVnewsHomepage();
if (articles.length) {
vnewsConnected = true;
vnewsArticles = articles;
vnewsStatus.textContent = `✅ Đã kết nối · ${articles.length} bài`;
vnewsConnectBtn.textContent = "✅ VNEWS đã kết nối";
rewriteBtn.hidden = false;
rewriteBtn.disabled = true; // enabled when a message is selected
updateRewriteButton();
// Refresh the avatar's instructions so it can answer from VNEWS.
if (client) client.updateSession({ instructions: effectiveInstructions(preFetchedNews) });
} else {
vnewsStatus.textContent = "❌ Không thể kết nối VNEWS";
vnewsConnectBtn.disabled = false;
vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
}
}
function updateRewriteButton() {
const selected = chatMessages.querySelectorAll(".chat-message.selected");
let hasText = false;
selected.forEach((m) => { if ((m.dataset.text || "").trim().length > 0) hasText = true; });
// Caption hint when connected and nothing selected yet.
if (vnewsConnected && selected.length === 0) {
setCaption("CHỌN 1 HAY NHIỀU TIN NHẮN ĐỂ ĐĂNG VNEWS", "live");
}
rewriteBtn.disabled = !vnewsConnected || selected.length === 0 || !hasText;
}
/** Rewrite the selected chat content into a VNEWS wall post. */
async function doRewriteSelected() {
const selected = [...chatMessages.querySelectorAll(".chat-message.selected")];
if (selected.length === 0) { setCaption("CHỌN TIN NHẮN ĐỂ REWRITE", "error"); return; }
// Combine ALL selected messages (avatar + user) into one post.
const context = selected
.map((m) => (m.dataset.text || "").trim())
.filter((t) => t.length > 0)
.join("\n\n");
if (context.length < 20) { setCaption("TIN NHẮN QUÁ NGẮN", "error"); return; }
// Prefer the original source URL of a news item so VNEWS can pull its image.
let srcUrl = "";
for (const m of selected) { if (m.dataset.src) { srcUrl = m.dataset.src; break; } }
rewriteBtn.disabled = true;
rewriteBtn.textContent = "⏳ AI đang xào nấu bài viết…";
let title = "";
let article = context;
try {
const sresp = await fetch("/api/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: context }),
});
if (sresp.ok) {
const sdata = await sresp.json();
if (sdata && sdata.body) { article = sdata.body; title = sdata.title || ""; }
}
} catch (e) { /* fall back to raw context */ }
rewriteBtn.textContent = "⏳ Đăng…";
try {
// Resolve a REAL hero image from the source article (VNEWS scrapes og:image).
let heroImage = srcUrl; // fallback: the article URL itself
if (srcUrl) {
try {
const artResp = await fetch(`${VNEWS_BASE}/api/article?url=${encodeURIComponent(srcUrl)}`, { headers: { "Accept": "application/json" } });
if (artResp.ok) {
const art = await artResp.json();
if (art && art.og_image) heroImage = art.og_image;
}
} catch (_) { /* keep fallback */ }
}
// Publish the AI-composed article directly (not just re-split).
const payload = { body: article };
if (title) payload.title = title;
if (heroImage) payload.image = heroImage;
if (srcUrl) payload.url = srcUrl;
const resp = await fetch(`${VNEWS_BASE}/api/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data && data.post) {
const shown = (title ? `**${title}**\n\n` : "") + article;
addChatMessage("assistant", `✅ Đã đăng bài AI tổng hợp (${selected.length} tin nhắn) lên VNEWS:\n${shown}`);
setCaption("ĐÃ ĐĂNG LÊN VNEWS", "live");
} else {
addChatMessage("assistant", `⚠️ VNEWS: ${data.error || "không đăng được bài"}.`);
}
} catch (e) {
addChatMessage("assistant", "⚠️ Lỗi khi kết nối VNEWS để đăng bài.");
}
rewriteBtn.textContent = "📝 Đăng bài rewrite";
// Clear selection after posting.
selected.forEach((m) => m.classList.remove("selected"));
updateRewriteButton();
}
function makeVnewsDraggable() {
let dragging = false, startX, startY, startLeft, startTop;
const header = vnewsPanel.querySelector("#vnews-header");
function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; }
header.addEventListener("mousedown", (e) => {
if (e.target.closest("#vnews-close-btn")) return;
dragging = true;
const rect = vnewsPanel.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startLeft = rect.left; startTop = rect.top;
vnewsPanel.classList.add("dragging");
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
const vw = window.innerWidth, vh = window.innerHeight;
const w = vnewsPanel.offsetWidth, h = vnewsPanel.offsetHeight;
const l = Math.max(10, Math.min(startLeft + e.clientX - startX, vw - w - 10));
const t = Math.max(10, Math.min(startTop + e.clientY - startY, vh - h - 10));
vnewsPanel.style.left = `${l}px`; vnewsPanel.style.top = `${t}px`;
vnewsPanel.style.right = "auto"; vnewsPanel.style.bottom = "auto";
});
document.addEventListener("mouseup", () => { dragging = false; vnewsPanel.classList.remove("dragging"); });
}
// ── Button logic ────────────────────────────────────────────────────────
let mainAction = "start";
function setMainButton(action, label) {
mainAction = action;
mainBtnLabel.textContent = label;
mainBtn.disabled = action === "busy";
mainBtn.classList.toggle("live", action === "stop");
muteBtn.hidden = action !== "stop";
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",
};
function onStatus(status) {
stage.setConversationState(status);
setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live");
switch (status) {
case "idle":
case "closed":
setMainButton("start", "Start talking");
break;
case "error":
setMainButton("start", "Retry");
break;
case "creating-session":
case "connecting":
setMainButton("busy", "Connecting…");
break;
case "queued":
setMainButton("stop", "Leave queue");
break;
case "your-turn":
setMainButton("join", "Join now");
break;
default:
setMainButton("stop", "End conversation");
break;
}
if (status === "user-speaking") {
subtitles.classList.remove("visible");
showTextChat(textMode);
}
}
// ── Tool runner ─────────────────────────────────────────────────────────
function runTool(name, argsJson, callId) {
if (!client) return;
let args = {};
try { args = JSON.parse(argsJson || "{}"); } catch {}
if (name === "get_vnews_news") {
const query = (args.query || "").trim();
let articles;
if (vnewsConnected) articles = searchVnews(query, 8);
else articles = []; // not connected yet
const out = vnewsLinksAsHtml(articles);
client.sendToolOutput(callId, `Kết quả từ VNEWS${query ? ` cho "${query}"` : " (mới nhất)"}:\n${out}`);
client.requestResponse();
return;
}
if (name === "get_current_datetime") {
const now = new Date();
const dateStr = formatVietnameseDate(now);
const timeStr = formatVietnameseTime(now);
client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`);
client.requestResponse();
return;
}
if (name === "search_wikipedia") {
const query = args.query || "";
if (!query) {
client.sendToolOutput(callId, `No query.`);
client.requestResponse();
return;
}
fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`)
.then(r => r.json())
.then(data => {
if (data.extract) {
client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`);
client.requestResponse();
return;
}
fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(sd => {
if (!sd.results?.length) {
client.sendToolOutput(callId, `No Wikipedia results for "${query}".`);
client.requestResponse();
return;
}
fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`)
.then(r => r.json())
.then(sum => {
client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0, 3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`);
client.requestResponse();
});
});
})
.catch(() => {
client.sendToolOutput(callId, `Wikipedia search failed.`);
client.requestResponse();
});
return;
}
if (name === "search_web") {
const query = args.query || "";
if (!query) {
client.sendToolOutput(callId, `No query.`);
client.requestResponse();
return;
}
fetch(`/api/web/search?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
if (!data.results?.length) {
client.sendToolOutput(callId, `No web results for "${query}".`);
client.requestResponse();
return;
}
client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0, 3).map((r, i) => `${i + 1}. ${r.title}\n ${r.snippet}`).join("\n")}`);
client.requestResponse();
})
.catch(() => {
client.sendToolOutput(callId, `Web search failed.`);
client.requestResponse();
});
return;
}
const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
client.sendToolOutput(callId, result);
client.requestResponse();
}
async function connectSession(c) {
try {
await c.connect();
return c;
} catch (err) {
const code = err?.code;
if (code === "limit") {
setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error");
} else if (code === "queue-full") {
setCaption("ALL SEATS TAKEN, TRY AGAIN", "error");
} else if (code === "join-expired") {
setCaption("SPOT EXPIRED, TAP TO RETRY", "error");
} else {
setCaption("CONNECTION FAILED, TAP TO RETRY", "error");
}
return null;
}
}
async function startVoiceSession(micStream) {
if (sessionInProgress) return;
sessionInProgress = true;
await stage.resume();
if (!micStream) {
if (new URLSearchParams(location.search).has("fakemic")) {
const ctx = stage.audioCtx;
micStream = ctx.createMediaStreamDestination().stream;
} else {
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
} catch {
setCaption("MIC BLOCKED", "error");
sessionInProgress = false;
return;
}
}
}
const audioCtx = stage.audioCtx;
const voiceSink = stage.voiceSink;
if (!audioCtx || !voiceSink) {
sessionInProgress = false;
return;
}
const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
if (!preFetchedNews.length) {
preFetchedNews = newsItems;
}
// Initialize the image sequence from the current news set.
newsSeq = preFetchedNews.slice();
newsSeqIdx = -1;
newsTopicQuery = "";
if (newsItems.length > 0) {
latestNewsUrl = newsItems[0].link;
setCurrentNews(newsItems[0]);
try {
latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
} catch {
latestNewsSource = "VnExpress";
}
}
const c = new S2sWsRealtimeClient({
...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
voice: settings.voice,
instructions: effectiveInstructions(newsItems),
micStream,
audioContext: audioCtx,
outputNode: voiceSink,
workletBaseUrl: "/worklets/",
tools: TOOL_DEFS,
_textOnly: false,
});
client = c;
_attachClientEvents(c);
setCaption("REQUESTING A SLOT…");
const ok = await connectSession(c);
if (!ok) return;
if (!autoGreetingSent) {
c.requestResponse();
}
}
async function startTextSession(initialText) {
if (sessionInProgress) {
if (client && initialText) {
client.sendUserText(initialText);
client.requestResponse();
}
return;
}
sessionInProgress = true;
await stage.resume();
const audioCtx = stage.audioCtx;
const voiceSink = stage.voiceSink;
if (!audioCtx || !voiceSink) {
sessionInProgress = false;
return;
}
const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
if (!preFetchedNews.length) {
preFetchedNews = newsItems;
}
newsSeq = preFetchedNews.slice();
newsSeqIdx = -1;
newsTopicQuery = "";
if (newsItems.length > 0) {
latestNewsUrl = newsItems[0].link;
setCurrentNews(newsItems[0]);
try {
latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
} catch {
latestNewsSource = "VnExpress";
}
}
const c = new S2sWsRealtimeClient({
...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
voice: settings.voice,
instructions: effectiveInstructions(newsItems),
audioContext: audioCtx,
outputNode: voiceSink,
workletBaseUrl: "/worklets/",
tools: TOOL_DEFS,
_textOnly: true,
});
client = c;
_attachClientEvents(c);
textMode = true;
showTextChat(true);
textModeBtn.classList.add("active");
const ok = await connectSession(c);
if (!ok) return;
if (!autoGreetingSent) {
c.requestResponse();
}
if (initialText) {
c.sendUserText(initialText);
c.requestResponse();
}
}
function _attachClientEvents(c) {
c.addEventListener("status", (e) => {
onStatus(e.detail.status);
});
c.addEventListener("queue", (e) => {
const { position } = e.detail;
setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live");
});
c.addEventListener("transcript", (e) => {
const { role, text } = e.detail;
if (role === "assistant" && text) {
const normalized = smartNormalize(text);
showSubtitles(normalized);
// Attach the next image of the CURRENT news item (loops through its images).
const img = nextNewsImage();
const newsItem = curNews ? { ...curNews, image: img || curNews.image } : null;
addChatMessage("assistant", normalized, newsItem);
}
});
c.addEventListener("response-finished", () => {
autoGreetingSent = true;
fadeSubtitles();
if (preFetchedNews.length > 0) {
showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
}
_topicTagsRendered = false;
try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
});
c.addEventListener("toolcall", (e) => {
const { name, arguments: args, callId } = e.detail;
runTool(name, args, callId);
});
c.addEventListener("server-error", (e) => {
console.warn("server error:", e.detail.error);
});
c.addEventListener("error", () => {
void endSession();
});
}
async function endSession(silent = false) {
const c = client;
client = null;
sessionInProgress = false;
autoGreetingSent = false;
if (c) {
if (c.options.micStream) {
for (const track of c.options.micStream?.getTracks() ?? []) {
track.stop();
}
}
await c.close().catch(() => {});
}
stage.setConversationState("idle");
subtitles.classList.remove("visible");
if (newsPanel) {
newsPanel.hidden = true;
}
if (!silent) {
setCaption(CAPTIONS.idle);
setMainButton("start", "Start talking");
// Show today's real trending topics immediately (also re-shown after greeting).
try { void renderTopicTags(); } catch (e) { console.warn("[topic] startWithSelection:", e); }
}
}
// ── UI events ───────────────────────────────────────────────────────────
mainBtn.addEventListener("click", () => {
if (mainAction === "start") {
if (sessionInProgress) return;
// Default: always start text chat (no mic required)
void startTextSession();
} else if (mainAction === "join") {
stage.resume();
client?.join();
} else if (mainAction === "stop") {
void endSession();
}
});
muteBtn.addEventListener("click", () => {
muted = !muted;
client?.setMuted(muted);
muteBtn.classList.toggle("active", muted);
muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
});
textModeBtn.addEventListener("click", () => {
textMode = !textMode;
showTextChat(textMode);
textModeBtn.classList.toggle("active", textMode);
});
chatSendBtn.addEventListener("click", () => sendTextMessage());
chatMicBtn.addEventListener("click", async () => {
// Switch from text chat to voice chat
if (!sessionInProgress) return;
// Request mic stream synchronously (user gesture context) before any async gap
let micStream;
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
} catch {
setCaption("MIC BLOCKED", "error");
return;
}
await endSession(true);
void startVoiceSession(micStream);
});
chatInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
sendTextMessage();
}
});
chatAvatarSelect.addEventListener("change", () => {
setAvatarFromSelect(chatAvatarSelect.value);
void reloadAvatar();
});
settingsAvatarSelect.addEventListener("change", () => {
setAvatarFromSelect(settingsAvatarSelect.value);
chatAvatarSelect.value = settingsAvatarSelect.value;
void reloadAvatar();
});
settingsBtn.addEventListener("click", () => {
inputVoice.value = settings.voice;
inputInstructions.value = settings.instructions;
inputDirectUrl.value = settings.directUrl;
inputSubtitles.checked = settings.subtitles;
settingsAvatarSelect.value = chatAvatarSelect.value;
settingsDialog.showModal();
});
settingsDialog.addEventListener("close", () => {
settings = {
voice: inputVoice.value || DEFAULT_VOICE,
avatar: settingsAvatarSelect.value || "",
instructions: inputInstructions.value,
directUrl: inputDirectUrl.value.trim(),
subtitles: inputSubtitles.checked
};
if (settings.avatar !== "vuong.glb") {
autoGreetingSent = false;
}
saveSettings();
chatAvatarSelect.value = settings.avatar;
if (!settings.subtitles) {
subtitles.classList.remove("visible");
}
client?.updateSession({
voice: settings.voice,
instructions: effectiveInstructions(preFetchedNews)
});
});
window.addEventListener("beforeunload", () => {
client?.close();
});
// ── Boot ────────────────────────────────────────────────────────────────
async function boot() {
// 1) Voice <select> options.
for (const v of VOICES) {
const o = document.createElement("option");
o.value = v;
o.textContent = v.replaceAll("_", " ");
inputVoice.append(o);
}
inputVoice.value = settings.voice;
// 2) Best-effort setup
try {
const resp = await fetch("api/config");
if (resp.ok) config = { ...config, ...(await resp.json()) };
} catch (e) { console.warn("[boot] config:", e); }
directUrlRow.hidden = !config.allowDirect;
try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
try {
makeDraggable(); makeResizable(); makeNewsDraggable(); makeNewsResizable(); buildVnewsPanel();
} catch (e) { console.warn("[boot] ui:", e); }
try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", e); }
// 3) Auto-start avatar (no picker — uses vuong.glb + Sohee by default)
try { await startWithSelection(); } catch (e) { console.error("[boot] startWithSelection:", e); }
}
// ── News box draggable + resizable (mirrors the chat box) ────────────────
function makeNewsDraggable() {
let dragging = false;
let startX, startY, startLeft, startTop;
function getPos(e) {
const p = e.changedTouches ? e.changedTouches[0] : e;
return { x: p.clientX, y: p.clientY };
}
function onStart(e) {
if (e.target.closest("#news-header") === null) return; // only drag from header
if (e.target.closest("#news-close-btn")) return;
const p = getPos(e);
dragging = true;
const rect = newsPanel.getBoundingClientRect();
startX = p.x;
startY = p.y;
startLeft = rect.left;
startTop = rect.top;
newsPanel.classList.add("dragging");
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
const p = getPos(e);
const vw = window.innerWidth, vh = window.innerHeight;
const w = newsPanel.offsetWidth, h = newsPanel.offsetHeight;
let l = Math.max(10, Math.min(startLeft + p.x - startX, vw - w - 10));
let t = Math.max(10, Math.min(startTop + p.y - startY, vh - h - 10));
newsPanel.style.left = `${l}px`;
newsPanel.style.top = `${t}px`;
newsPanel.style.right = "auto";
newsPanel.style.bottom = "auto";
e.preventDefault();
}
function onEnd() {
if (!dragging) return;
dragging = false;
newsPanel.classList.remove("dragging");
}
const header = $("#news-header");
header.addEventListener("mousedown", onStart);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onEnd);
header.addEventListener("touchstart", onStart, { passive: false });
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
function makeNewsResizable() {
let resizing = false;
let startX, startY, startW, startH;
function getPos(e) {
const p = e.changedTouches ? e.changedTouches[0] : e;
return { x: p.clientX, y: p.clientY };
}
function onStart(e) {
resizing = true;
const rect = newsPanel.getBoundingClientRect();
const p = getPos(e);
startX = p.x;
startY = p.y;
startW = rect.width;
startH = rect.height;
newsPanel.classList.add("resizing");
e.preventDefault();
e.stopPropagation();
}
function onMove(e) {
if (!resizing) return;
const p = getPos(e);
newsPanel.style.width = `${Math.max(300, startW + p.x - startX)}px`;
newsPanel.style.height = `${Math.max(180, startH + p.y - startY)}px`;
newsPanel.style.right = "auto";
newsPanel.style.bottom = "auto";
e.preventDefault();
}
function onEnd() {
if (!resizing) return;
resizing = false;
newsPanel.classList.remove("resizing");
}
newsResizeHandle.addEventListener("mousedown", onStart);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onEnd);
newsResizeHandle.addEventListener("touchstart", onStart, { passive: false });
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
void boot();
// ── Capture PNG (snapshot of the 3D avatar) ──────────────
const THUMB_KEY = "avatar.thumbs";
const avatarThumbPreview = document.getElementById("avatar-thumb-preview");
function loadThumbs() {
try { return JSON.parse(localStorage.getItem(THUMB_KEY) || "{}"); }
catch { return {}; }
}
function saveThumbs(map) { localStorage.setItem(THUMB_KEY, JSON.stringify(map)); }
function serverThumbUrl(name) {
if (!name) return "";
// Don't add timestamp for server-stored thumbnails (they are immutable)
// Use dataset repo (no Space rebuild on upload)
return `https://huggingface.co/datasets/bep40/gemma-avatar-thumbnails/resolve/main/public/avatars/${name}.thumb.png`;
}
// Show the avatar's representative image: prefer the server-stored thumbnail
// (persisted on Space), fall back to freshly-captured local copy, then
// a gradient fallback with the initial letter.
function updateAvatarThumbPreview() {
if (!avatarThumbPreview) return;
const name = chatAvatarSelect.value || settings.avatar || "";
// Ensure .glb extension for server URL lookup
const avatarName = name.endsWith(".glb") ? name : (name ? name + ".glb" : "");
const label = (avatarName || "?").replace(/\.glb$/i, "").replace(/_/g, " ");
const initial = label.slice(0, 1).toUpperCase();
const map = loadThumbs();
// Helper: show fallback letter with gradient bg
function showFallback() {
avatarThumbPreview.style.display = "none";
// Find or create the sibling fallback element
let fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
if (!fb) {
fb = document.createElement("div");
fb.className = "thumb-fallback";
if (avatarThumbPreview.parentNode) {
avatarThumbPreview.parentNode.insertBefore(fb, avatarThumbPreview);
}
}
fb.textContent = initial;
fb.style.display = "flex";
}
function showImg(url) {
const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
if (fb) fb.style.display = "none";
avatarThumbPreview.src = url;
avatarThumbPreview.style.display = "";
avatarThumbPreview.hidden = false;
}
// 1. Try server thumbnail first (persisted on Space, survives restarts)
if (avatarName) {
const srv = serverThumbUrl(avatarName);
avatarThumbPreview.onerror = () => {
// Server thumbnail failed — try local cache
const local = map[avatarName] || map[name] || "";
if (local) {
showImg(local);
} else {
showFallback();
}
};
avatarThumbPreview.onload = () => {
// Only show if the image actually loaded (not error, not 70-byte placeholder)
if (avatarThumbPreview.naturalWidth > 0 && avatarThumbPreview.naturalHeight > 0) {
const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
if (fb) fb.style.display = "none";
avatarThumbPreview.style.display = "";
avatarThumbPreview.hidden = false;
} else {
showFallback();
}
};
showImg(srv);
return;
}
// 2. No server thumbnail — try local cache
const local = map[name] || "";
if (local) {
showImg(local);
return;
}
showFallback();
}
if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
const snapBtn = document.getElementById("snap-btn");
const snapDialog = document.getElementById("snap-dialog");
const snapPreview = document.getElementById("snap-preview");
const snapDownload = document.getElementById("snap-download");
const snapSetAvatar = document.getElementById("snap-setavatar");
let _latestSnap = null;
if (snapBtn) {
// Helper: compress data URL - higher quality for avatar thumbnails
function compressDataUrl(dataUrl, maxBytes = 500 * 1024, maxDim = 512) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
let w = img.width, h = img.height;
if (w > maxDim || h > maxDim) {
const scale = Math.min(maxDim / w, maxDim / h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
// Better quality: use imageSmoothingQuality
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, w, h);
// Start with quality 0.85 for avatars (higher visual quality)
let quality = 0.85;
let out = canvas.toDataURL("image/jpeg", quality);
// Allow larger files for better quality (500KB base64 ~ 365KB binary)
while (out.length > maxBytes * 1.37 && quality > 0.5) {
quality -= 0.05;
out = canvas.toDataURL("image/jpeg", quality);
}
resolve(out);
};
img.onerror = () => resolve(dataUrl); // fallback to original
img.src = dataUrl;
});
}
snapBtn.addEventListener("click", async () => {
if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
const url = stage.capturePNG();
if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
_latestSnap = await compressDataUrl(url);
snapPreview.src = _latestSnap;
if (snapDialog.showModal) snapDialog.showModal();
else snapDialog.setAttribute("open", "");
});
}
if (snapDownload) {
snapDownload.addEventListener("click", () => {
if (!_latestSnap) return;
const name = (settings.avatar || "avatar").replace(/\.glb$/i, "");
const a = document.createElement("a");
a.href = _latestSnap; a.download = name + ".png";
document.body.appendChild(a); a.click(); a.remove();
});
}
if (snapSetAvatar) {
snapSetAvatar.addEventListener("click", async () => {
if (!_latestSnap) return;
const key = settings.avatar || "";
if (!key) { setCaption("CHON 1 AVATAR TRUOC", "error"); return; }
// Ensure the avatar name has .glb extension
const avatarName = key.endsWith(".glb") ? key : key + ".glb";
console.log("[snap] saving thumbnail for:", avatarName, "dataUrl length:", _latestSnap?.length);
snapSetAvatar.disabled = true;
snapSetAvatar.textContent = "⏳ Đang lưu…";
try {
const resp = await fetch("/api/avatar-thumbnail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatar: avatarName, dataUrl: _latestSnap }),
});
console.log("[snap] response status:", resp.status);
const data = await resp.json().catch(() => ({}));
console.log("[snap] response data:", data);
if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
// Cache locally too (works offline + instant preview)
const map = loadThumbs();
// Prefer the server URL now that it is persisted on Space
if (data.url) {
map[avatarName] = data.url;
saveThumbs(map);
avatarThumbPreview.src = data.url;
avatarThumbPreview.hidden = false;
const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
if (fb) fb.style.display = "none";
} else {
map[avatarName] = _latestSnap;
saveThumbs(map);
avatarThumbPreview.src = _latestSnap;
avatarThumbPreview.hidden = false;
const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
if (fb) fb.style.display = "none";
}
// Force refresh all avatar previews (chat select + settings select)
try { updateAvatarThumbPreview(); } catch (e) { console.warn("[snap] updateThumb:", e); }
setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
} catch (e) {
console.warn("[snap] error:", e);
setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
} finally {
snapSetAvatar.disabled = false;
snapSetAvatar.textContent = "🖼️ Đặt làm ảnh đại diện";
}
});
}
// ── Onboarding picker (REMOVED — auto-init with vuong.glb + Sohee) ──
async function startWithSelection() {
setCaption("Chờ tôi một lát! Tôi sẽ đến với bạn ngay!");
setMainButton("busy", "Loading…");
loading.removeAttribute("hidden");
loading.classList.remove("done");
loading.classList.add("active");
loading.textContent = "Loading avatar...";
// Show the start progress bar with the friendly message.
const startProgress = document.getElementById("start-progress");
const startProgressFill = startProgress ? startProgress.querySelector(".start-progress__fill") : null;
if (startProgress) startProgress.hidden = false;
if (startProgressFill) startProgressFill.style.width = "0%";
let progress = 0;
const progressInterval = setInterval(() => {
progress = Math.min(progress + 5, 95);
if (startProgressFill) startProgressFill.style.width = progress + "%";
}, 150);
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
let ok = false;
try {
const initPromise = stage.init({
avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
onprogress: (ev) => {
if (ev.lengthComputable) {
const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
loading.textContent = `Loading avatar ${pct}%`;
if (startProgressFill) startProgressFill.style.width = pct + "%";
progress = pct;
}
}
});
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 60000));
await Promise.race([initPromise, timeout]);
stage.resume(); // start render loop so the avatar is actually visible (not a single black frame)
ok = true;
} catch (err) {
console.error("[startWithSelection] init failed/timeout:", err);
} finally {
clearInterval(progressInterval);
if (startProgressFill) startProgressFill.style.width = "100%";
// Hide the start progress bar after a brief moment showing 100%
setTimeout(() => {
if (startProgress) startProgress.hidden = true;
if (startProgressFill) startProgressFill.style.width = "0%";
}, 500);
try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; }
newsSeq = preFetchedNews.slice();
newsSeqIdx = -1;
newsTopicQuery = "";
loading.classList.remove("active");
if (ok) setCaption(CAPTIONS.idle);
else setCaption("AVATAR KHÔNG HIỂN THỰ — THỬ CHỌN AVATAR KHÁC", "error");
setMainButton("start", "Start talking");
}
}
// ── Product Catalogue ───────────────────────────────────────────────────
const catBtn = document.getElementById("catalogue-btn");
const catPanel = document.getElementById("catalogue-panel");
const catClose = document.getElementById("cat-close");
const catSearch = document.getElementById("cat-search");
const catGrid = document.getElementById("cat-grid");
const catFilterBrand = document.getElementById("cat-filter-brand");
const catFilterCat = document.getElementById("cat-filter-cat");
const catCount = document.getElementById("cat-count");
let catProducts = [];
let catSearchTimer = 0;
function debounceCatSearch() {
clearTimeout(catSearchTimer);
catSearchTimer = setTimeout(() => doCatSearch(), 300);
}
window.debounceCatSearch = debounceCatSearch;
async function doCatSearch() {
if (!catGrid || !catSearch) return;
const q = catSearch.value.trim();
const brand = catFilterBrand ? catFilterBrand.value : "all";
const cat = catFilterCat ? catFilterCat.value : "all";
const params = new URLSearchParams({ q, limit: "50" });
if (brand && brand !== "all") params.set("brand", brand);
if (cat && cat !== "all") params.set("cat", cat);
try {
const resp = await fetch(`/api/products/search?${params}`);
if (!resp.ok) { catGrid.innerHTML = '<div class="cat-error">Lỗi tải sản phẩm</div>'; return; }
const data = await resp.json();
catProducts = data.items || [];
renderCatGrid(catProducts);
if (catCount) catCount.textContent = `${data.total || catProducts.length} sản phẩm`;
} catch (e) {
catGrid.innerHTML = '<div class="cat-error">Lỗi kết nối</div>';
}
}
window.doCatSearch = doCatSearch;
function renderCatGrid(items) {
if (!catGrid) return;
if (!items || items.length === 0) {
catGrid.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-dim)">Không tìm thấy sản phẩm</div>';
return;
}
catGrid.innerHTML = items.map((p) => {
const img = p.i ? `<img src="${p.i}" alt="${p.n}" loading="lazy" onerror="this.style.display='none'" />` : '<div style="height:140px;display:grid;place-items:center;font-size:32px;background:#fff">📦</div>';
const price = p.p ? `<div class="price">${p.p.toLocaleString("vi-VN")}₫</div>` : "";
const brand = p.brand ? `<span class="brand">${p.brand}</span>` : "";
return `<div class="catalogue-card" data-slug="${p.slug || ""}">
${img}
<div class="info">
<div class="name">${p.n || ""}</div>
${brand}
${price}
${p.sum ? `<div style="font-size:11px;color:var(--text-dim);margin-top:4px">${p.sum}</div>` : ""}
</div>
</div>`;
}).join("");
catGrid.querySelectorAll(".catalogue-card").forEach((el) => {
el.addEventListener("click", () => {
const slug = el.dataset.slug;
if (slug) window.open(`/api/products/detail?slug=${slug}`, "_blank");
});
});
}
async function initCatFilters() {
try {
const resp = await fetch("/api/products/categories");
if (!resp.ok) return;
const data = await resp.json();
if (catFilterBrand && data.brands) {
catFilterBrand.innerHTML = '<option value="all">Tất cả thương hiệu</option>' +
data.brands.map((b) => `<option value="${b[0]}">${b[0]} (${b[1]})</option>`).join("");
}
if (catFilterCat && data.categories) {
catFilterCat.innerHTML = '<option value="all">Tất cả danh mục</option>' +
data.categories.map((c) => `<option value="${c.slug}">${c.n} (${c.c})</option>`).join("");
}
} catch {}
}
if (catBtn && catPanel) {
catBtn.addEventListener("click", () => {
catPanel.classList.toggle("open");
if (catPanel.classList.contains("open")) {
void initCatFilters();
void doCatSearch();
}
});
}
if (catClose && catPanel) {
catClose.addEventListener("click", () => { catPanel.classList.remove("open"); });
}
// Initialize catalogue on load
void initCatFilters();