bep40's picture
Upload src/app.js with huggingface_hub
701f998 verified
Raw
History Blame
74.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ủ đề ──
async function openTopic(slug, label) {
try {
setCaption("ĐANG TẢI TIN: " + label, "live");
const raw = await getFilteredNews(label);
const 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 (window._topicTagsRendered) return;
let topics = [];
try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); }
if (!topics.length) topics = TREND_KEYWORDS.map(([s,l]) => ({slug:s,label:l}));
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 = document.getElementById("topic-tags-inline");
if (oldEl) oldEl.remove();
const msgs = document.getElementById("chat-messages");
if (msgs) { msgs.appendChild(wrap); msgs.scrollTop = msgs.scrollHeight; }
window._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 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 avatarList = [];
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 keyword pool — which of these is "hot" is decided at runtime
// from today's real HOT news (getHotNews), so the topic list is time-sensitive.
const TREND_KEYWORDS = [
["worldcup", "World Cup"], ["bongda", "Bóng đá"], ["chuyennhuong", "Chuyển nhượng"],
["ai", "AI"], ["congnghe", "Công nghệ"], ["thethao", "Thể thao"], ["kinhte", "Kinh tế"],
["giaitri", "Giải trí"], ["thoisu", "Thời sự"], ["giaoduc", "Giáo dục"], ["suckhoe", "Sức khỏe"],
["dulich", "Du lịch"], ["oto", "Ô tô"], ["thegioi", "Thế giới"], ["amnhac", "Âm nhạc"], ["doisong", "Đời sống"],
];
function _topicMatches(titles, slug, label) {
const t = titles;
if (t.includes(label.toLowerCase())) return true;
const bare = slug.replace(/[_-]/g, "");
if (bare.length > 2 && t.includes(bare)) return true;
if (slug === "ai") return /(trí tuệ nhân tạo|\bai\b)/.test(t);
return false;
}
// Returns today's real trending topics derived from the live HOT news feed.
async function getTrendingTopics() {
let items = [];
try { items = await getHotNews(); } catch (e) { console.warn("[topic] hot news:", e); }
const titles = items.map((i) => ((i.title || "") + " " + (i.source || ""))).join(" ").toLowerCase();
const found = [];
for (const [slug, label] of TREND_KEYWORDS) {
if (_topicMatches(titles, slug, label)) found.push({ slug, label });
}
// Guarantee a useful minimum even on a quiet news day.
if (found.length < 6) {
for (const [slug, label] of TREND_KEYWORDS) {
if (!found.find((f) => f.slug === slug)) {
found.push({ slug, label });
if (found.length >= 8) break;
}
}
}
return found.slice(0, 12);
}
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) {
const url = new URL(location.href);
url.searchParams.set("fakemic", "1");
history.replaceState(null, "", url.href);
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)/.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 ─────────────────────────────────────────────────────────
async function fetchAvatarList() {
try {
const resp = await fetch("/api/avatars");
if (resp.ok) avatarList = (await resp.json()).avatars || [];
} 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;
}
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);
}
}
// 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 (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) {
const url = new URL(location.href);
url.searchParams.set("fakemic", "1");
history.replaceState(null, "", url.href);
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 if (code !== "aborted") {
console.error(err);
setCaption("COULD NOT CONNECT, TAP TO RETRY", "error");
}
await endSession(true);
return null;
}
}
async function startVoiceSession() {
if (sessionInProgress) return;
sessionInProgress = true;
await stage.resume();
let 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;
if (new URLSearchParams(location.search).has("fakemic")) {
void startTextSession();
} else {
void startVoiceSession();
}
} 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());
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) Reveal the picker IMMEDIATELY — first screen, never a loader.
showPicker();
// 2) 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;
// 3) Best-effort setup; isolate each step so a failure can NEVER hide the picker.
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;
// Keep using the fake microphone (no real getUserMedia) so we never hit
// the "MIC BLOCKED" permission prompt. Restored from the original boot().
try {
const url = new URL(location.href);
url.searchParams.set("fakemic", "1");
history.replaceState(null, "", url.href);
} catch (e) { console.warn("[boot] fakemic:", e); }
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); }
// 4) Fill the picker (avatar cards + voice chips, topics).
try { await buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", 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 "";
return `/avatars/${name}.thumb.png`;
}
// Show the avatar's representative image: prefer the freshly-captured local
// copy (instant), fall back to the server-stored thumbnail (uploaded earlier).
function updateAvatarThumbPreview() {
if (!avatarThumbPreview) return;
const name = chatAvatarSelect.value;
const map = loadThumbs();
const local = map[name] || "";
if (local) {
avatarThumbPreview.src = local;
avatarThumbPreview.hidden = false;
return;
}
const srv = serverThumbUrl(name);
if (srv) {
avatarThumbPreview.onerror = () => { avatarThumbPreview.hidden = true; };
avatarThumbPreview.src = srv;
avatarThumbPreview.hidden = false;
return;
}
avatarThumbPreview.hidden = true;
}
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) {
snapBtn.addEventListener("click", () => {
if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
const url = stage.capturePNG();
if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
_latestSnap = url;
snapPreview.src = url;
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; }
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: key, dataUrl: _latestSnap }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
// Cache locally too (works offline + instant preview)
const map = loadThumbs();
map[key] = _latestSnap;
saveThumbs(map);
// Prefer the server URL now that it is persisted.
if (data.url) { avatarThumbPreview.src = data.url; avatarThumbPreview.hidden = false; map[key] = data.url; saveThumbs(map); }
else updateAvatarThumbPreview();
try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
} catch (e) {
console.warn(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 (choose character + voice BEFORE loading avatar) ──
// Render topics in the picker (shows immediately on open)
async function renderTopicsPicker() {
const box = document.getElementById("picker-topics");
if (!box) return;
let topics = [];
try { topics = await getTrendingTopics(); } catch {}
if (!topics.length) topics = TREND_KEYWORDS.map(([s,l]) => ({slug:s, label:l}));
box.innerHTML = "";
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));
box.appendChild(chip);
}
}
async function buildPicker() {
const grid = document.getElementById("picker-avatars");
const voices = document.getElementById("picker-voices");
if (!grid || !voices) return;
grid.innerHTML = "";
voices.innerHTML = "";
const selAvatar = settings.avatar || "";
const list = avatarList.length ? avatarList : ["vuong.glb"];
for (const name of list) {
const card = document.createElement("button");
card.type = "button";
card.className = "avatar-card" + (name === selAvatar ? " selected" : "");
card.dataset.avatar = name;
const label = name.replace(/\.glb$/i, "").replace(/_/g, " ");
const thumb = document.createElement("div");
thumb.className = "avatar-card-thumb";
const _map = loadThumbs();
const _img = document.createElement("img");
_img.alt = label;
_img.loading = "lazy";
_img.addEventListener("error", () => {
_img.remove();
thumb.textContent = label.slice(0, 1).toUpperCase();
});
const _srv = serverThumbUrl(name);
_img.src = (_srv && !_map[name]) ? _srv : (_map[name] || _srv);
thumb.appendChild(_img);
const span = document.createElement("span");
span.className = "avatar-card-name";
span.textContent = label + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
card.appendChild(thumb);
card.appendChild(span);
card.addEventListener("click", () => {
grid.querySelectorAll(".avatar-card").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
settings.avatar = name;
saveSettings();
});
grid.appendChild(card);
}
for (const v of VOICES) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
chip.dataset.voice = v;
chip.textContent = v.replaceAll("_", " ");
chip.addEventListener("click", () => {
voices.querySelectorAll(".voice-chip").forEach((c) => c.classList.remove("selected"));
chip.classList.add("selected");
settings.voice = v;
saveSettings();
});
voices.appendChild(chip);
}
await renderTopicsPicker();
function showPicker() {
await renderTopicsPicker();
const p = document.getElementById("picker");
if (p) p.hidden = false;
function hidePicker() {
const p = document.getElementById("picker");
if (p) p.hidden = true;
async function startWithSelection() {
hidePicker();
setCaption("WAKING HER UP…");
setMainButton("busy", "Loading…");
loading.classList.remove("done");
loading.classList.add("active");
loading.textContent = "Loading avatar...";
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) {
loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
});
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
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 {
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");
const pickerStartBtn = document.getElementById("picker-start");
if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());