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"] } },
];
// ── 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 newsToggleFooter = $("#news-toggle-footer");
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 newsListContainer = $("#news-list-container");
const newsCloseBtn = $("#news-close-btn");
const newsToggleBtn = $("#news-toggle-btn");
const newsChevronUp = $("#news-chevron-up");
const newsChevronDown = $("#news-chevron-down");
const newsLoadMoreBtn = $("#news-load-more");
// ── 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;
let newsCollapsed = false;
let newsPage = 1;
const NEWS_PAGE_SIZE = 10;
let newsLoading = false;
let newsHasMore = true;
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;
}
// Format date/time in Vietnamese
function formatDateTimeVN(date) {
const weekdays = ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"];
const months = ["tháng 1", "tháng 2", "tháng 3", "tháng 4", "tháng 5", "tháng 6",
"tháng 7", "tháng 8", "tháng 9", "tháng 10", "tháng 11", "tháng 12"];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const weekday = weekdays[date.getDay()];
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return {
dateStr: `${weekday}, ngày ${day} ${month} năm ${year}`,
timeStr: `${hours}:${minutes}`
};
}
function effectiveInstructions(newsItems) {
const now = new Date();
const { dateStr, timeStr } = formatDateTimeVN(now);
const dateLine = `Hôm nay là ${dateStr}. Bây giờ là ${timeStr}.`;
const extra = settings.instructions.trim();
const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
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.`;
}
const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`;
return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
}
// ── News Panel ─────────────────────────────────────────────────────────
function toggleNewsPanel() {
if (!newsPanel) return;
if (newsPanel.hidden) {
newsPanel.hidden = false;
newsToggleFooter?.classList.add("active");
loadNewsPage(1, true);
} else {
newsPanel.hidden = true;
newsToggleFooter?.classList.remove("active");
}
}
function toggleNewsCollapse() {
if (!newsPanel) return;
newsCollapsed = !newsCollapsed;
newsPanel.classList.toggle("collapsed", newsCollapsed);
if (newsChevronUp && newsChevronDown) {
newsChevronUp.hidden = newsCollapsed;
newsChevronDown.hidden = !newsCollapsed;
}
}
async function loadNewsPage(page = 1, reset = false) {
if (newsLoading) return;
newsLoading = true;
if (newsLoadMoreBtn) {
newsLoadMoreBtn.textContent = "Đang tải...";
newsLoadMoreBtn.disabled = true;
}
try {
const items = await getHotNews(page);
if (reset && newsList) {
newsList.innerHTML = "";
preFetchedNews = [];
}
if (items.length > 0) {
preFetchedNews = [...preFetchedNews, ...items];
appendNewsItems(items);
newsPage = page;
}
if (newsLoadMoreBtn) {
newsLoadMoreBtn.hidden = !newsHasMore;
newsLoadMoreBtn.textContent = "Xem thêm tin tức ↓";
newsLoadMoreBtn.disabled = false;
}
} catch (e) {
console.warn("Failed to load news page:", e);
if (newsLoadMoreBtn) {
newsLoadMoreBtn.textContent = "Lỗi tải tin. Thử lại ↓";
newsLoadMoreBtn.disabled = false;
}
}
newsLoading = false;
}
function appendNewsItems(items) {
if (!newsList) return;
items.forEach((item, idx) => {
const newsItem = document.createElement("div");
newsItem.className = "news-item";
const globalIdx = preFetchedNews.length - items.length + idx + 1;
let imageHtml = "";
if (item.image) {
imageHtml = `
`;
}
newsItem.innerHTML = `
${imageHtml}
${globalIdx}.
${item.title}
(${item.source})
`;
newsItem.addEventListener("click", () => {
summarizeArticle(item.link, item.title);
});
newsList.appendChild(newsItem);
});
}
async function summarizeArticle(url, title) {
const summaryText = `Hãy tóm tắt ngắn gọn và đầy đủ nhất nội dung bài viết này: "${title}" (${url})`;
if (!client || !sessionInProgress) {
void startTextSession(summaryText);
return;
}
addChatMessage("user", `📰 ${title}`);
client.sendUserText(summaryText);
client.requestResponse();
}
// ── 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;
}
}
}
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))}%`;
}
}
});
} 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) {
const msg = document.createElement("div");
msg.className = `chat-message ${role}`;
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) =>
`