bep40's picture
v4: news cards, click-to-summarize, news hook, hang fix, summarize_news tool
cb0973c verified
Raw
History Blame
35.2 kB
// @ts-check
/**
* App wiring: the avatar stage + the speech-to-speech session.
* Modes: voice (mic + VAD) OR text (keyboard only, no mic needed).
* v4 β€” 10+ hot news cards, click-to-summarize, news hook in greeting, fix avatar hang.
*/
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";
// ── News ─────────────────────────────────────────────────────────────────
/** @type {Array<{title:string,link:string,source:string,sourceName:string}>} */
let cachedNewsItems = [];
let newsFetching = false;
let newsFetchPromise = null;
async function fetchNewsItems() {
if (newsFetching && newsFetchPromise) return newsFetchPromise;
newsFetching = true;
newsFetchPromise = (async () => {
try {
const resp = await fetch("/api/news/hot");
if (resp.ok) {
const data = await resp.json();
if (data.items && data.items.length > 0) {
cachedNewsItems = data.items;
}
}
} catch (e) { console.warn("News API failed:", e); }
return cachedNewsItems;
})();
const result = await newsFetchPromise;
newsFetching = false;
newsFetchPromise = null;
return result;
}
/**
* Fetch a SHORT hot news headline to weave into the greeting.
* Uses one of the top news items, preferring short titles.
*/
async function getHotNewsGreeting() {
const items = await fetchNewsItems();
if (items.length === 0) return null;
// Pick the shortest title that's still meaningful
let best = null;
for (const item of items) {
const clean = item.title.replace(/^[\d.]+[\s:]*/, "").trim();
if (clean.length > 10 && clean.length < 200) {
if (!best || clean.length < best.title.length) {
best = { title: clean, link: item.link, sourceName: item.sourceName };
}
}
}
return best;
}
const DEFAULT_INSTRUCTIONS = [
"You are a friendly voice assistant with a visible, human-like 3D avatar.",
"CRITICAL: Always respond in the SAME LANGUAGE 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', 'hai nghìn không trăm hai mưƑi sÑu' not '2026'). This ensures the TTS voice reads them correctly. The display will show the number format naturally.",
"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 or search_wikipedia for ANY factual question.",
"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, description: "Mood name." } }, 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, description: "Gesture name." } }, 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", description: "A single face emoji." } }, required: ["emoji"] } },
{ type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } },
{ type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
{ type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
{ type: "function", name: "summarize_news", description: "Fetch and summarize a news article from a URL. Use when user clicks a hot news card and asks for a summary.", parameters: { type: "object", properties: { url: { type: "string", description: "URL of the news article to summarize" } }, required: ["url"] } },
];
// ── DOM ──────────────────────────────────────────────────────────────────
const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel));
const stageNode = $("#stage");
const mainBtn = /** @type {HTMLButtonElement} */ ($("#main-btn"));
const mainBtnLabel = $("#main-btn-label");
const muteBtn = /** @type {HTMLButtonElement} */ ($("#mute-btn"));
const textModeBtn = /** @type {HTMLButtonElement} */ ($("#text-mode-btn"));
const caption = $("#caption");
const subtitles = $("#subtitles");
const loading = $("#loading");
const settingsBtn = /** @type {HTMLButtonElement} */ ($("#settings-btn"));
const settingsDialog = /** @type {HTMLDialogElement} */ ($("#settings"));
const inputVoice = /** @type {HTMLSelectElement} */ ($("#voice"));
const inputInstructions = /** @type {HTMLTextAreaElement} */ ($("#instructions"));
const inputDirectUrl = /** @type {HTMLInputElement} */ ($("#direct-url"));
const inputSubtitles = /** @type {HTMLInputElement} */ ($("#subtitles-toggle"));
const directUrlRow = $("#direct-url-row");
const textChat = /** @type {HTMLElement} */ ($("#text-chat"));
const chatHeader = /** @type {HTMLElement} */ ($("#chat-header"));
const chatMessages = /** @type {HTMLElement} */ ($("#chat-messages"));
const chatInput = /** @type {HTMLInputElement} */ ($("#chat-input"));
const chatSendBtn = /** @type {HTMLButtonElement} */ ($("#chat-send-btn"));
const chatCloseBtn = /** @type {HTMLButtonElement} */ ($("#chat-close-btn"));
const chatResizeHandle = /** @type {HTMLElement} */ ($("#chat-resize-handle"));
const chatAvatarSelect = /** @type {HTMLSelectElement} */ ($("#chat-avatar-select"));
const settingsAvatarSelect = /** @type {HTMLSelectElement} */ ($("#settings-avatar-select"));
const newsCards = /** @type {HTMLElement} */ ($("#news-cards"));
const newsList = /** @type {HTMLElement} */ ($("#news-list"));
const newsCloseBtn = /** @type {HTMLButtonElement} */ ($("#news-close-btn"));
// ── State (module-level) ─────────────────────────────────────────────────
const stage = new AvatarStage(stageNode);
/** @type {S2sWsRealtimeClient | null} */
let client = null;
let muted = false;
let subtitleTimer = 0;
let textMode = false;
/** @type {{ lb: boolean, allowDirect: boolean }} */
let config = { lb: false, allowDirect: true };
let avatarList = [];
let sessionInProgress = false;
let autoGreetingSent = false;
/** Pre-fetched short news hook */
let preFetchedGreeting = null;
/** URL vΓ  tΓͺn nguα»“n tin tα»©c để frontend inject link clickable */
let latestNewsUrl = null;
let latestNewsSource = null;
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");
}
/**
* RΓΊt gọn hostname thΓ nh tΓͺn nguα»“n dα»… đọc (vd: "vnexpress.net" β†’ "VnExpress").
*/
function getSourceName(hostname) {
const name = hostname.replace(/^www\./, "").toLowerCase();
const known = {
"vnexpress.net": "VnExpress",
"dantri.com.vn": "DΓ’n trΓ­",
"dantri.com": "DΓ’n trΓ­",
"tuoitre.vn": "Tuα»•i TrαΊ»",
"thanhnien.vn": "Thanh NiΓͺn",
"nhandan.vn": "NhΓ’n DΓ’n",
"vietnamnet.vn": "VietNamNet",
"zingnews.vn": "ZingNews",
"cafef.vn": "CafeF",
"techz.vn": "TechZ",
"genk.vn": "GenK",
"soha.vn": "Soha",
"kenh14.vn": "KΓͺnh 14",
"afamily.vn": "Afamily",
"eva.vn": "Eva",
"ngoisao.vn": "NgΓ΄i Sao",
"giadinh.net.vn": "Gia Δ‘Γ¬nh",
"tienphong.vn": "Tiền Phong",
"plo.vn": "PLO",
"vtc.vn": "VTC",
"vtcnews.vn": "VTC News",
"vtv.vn": "VTV",
"nld.com.vn": "Người Lao Động",
"sggp.org.vn": "SGGP",
"cand.com.vn": "CAND",
"qdnd.vn": "QĐND",
"laodong.vn": "Lao Động",
"giaoduc.net.vn": "GiΓ‘o Dα»₯c",
"phunuonline.com.vn": "Phα»₯ Nα»―",
"suckhoedoisong.vn": "Sức Khỏe",
"vnexpress": "VnExpress",
};
return known[name] || name;
}
/**
* Build system instructions sent to the backend.
* AI gets a short news hook (if any) and is told to greet naturally.
* AI chỉ nΓ³i tΓͺn nguα»“n (VD: "nguα»“n VnExpress").
* Frontend tα»± inject link clickable sau khi nhαΊ­n response.
*/
function effectiveInstructions(newsHook) {
const now = new Date();
const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" });
const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
const dateLine = `Today is ${dateStr}. The current time is ${timeStr}.`;
const extra = settings.instructions.trim();
const introLine = "TΓ΄i tΓͺn lΓ  VΖ°Ζ‘ng Δ‘αΊΏn V.AI STUDIO.";
const sourceName = newsHook ? (newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname)) : "";
const newsInst = newsHook
? `\n\nWhen you start the conversation, greet the user naturally and briefly mention today's news hook: "${newsHook.title}"\nAfter the greeting, add the source in EXACT format: "(nguα»“n ${sourceName})" β€” WITH parentheses. Example: "CΓ³ tin nΓ³ng tα»« bΓ‘o VnExpress hΓ΄m nay: ... (nguα»“n ${sourceName})"\nKeep your greeting to ONE short sentence.`
: "";
const base = `${dateLine}\n\n${introLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`;
return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
}
// ── 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");
}
// ── News Cards ───────────────────────────────────────────────────────────
/** Render news cards into the news container */
function renderNewsCards() {
newsList.innerHTML = "";
for (const item of cachedNewsItems) {
const card = document.createElement("div");
card.className = "news-card";
card.dataset.url = item.link;
card.dataset.title = item.title;
const source = document.createElement("span");
source.className = "news-card-source";
source.textContent = item.sourceName || getSourceName(item.source) || "Nguα»“n";
const title = document.createElement("span");
title.className = "news-card-title";
title.textContent = item.title;
card.appendChild(source);
card.appendChild(title);
// Click to summarize
card.addEventListener("click", () => {
if (client && sessionInProgress) {
// Send a text message asking the AI to summarize this news
// The AI will use the summarize_news tool to fetch the article content
const msg = `HΓ£y tΓ³m tαΊ―t ngαΊ―n gọn vΓ  Δ‘αΊ§y Δ‘α»§ tin tα»©c sau tα»« ${item.sourceName || item.source || "bΓ‘o"}: "${item.title}". Đường dαΊ«n: ${item.link}`;
addChatMessage("user", `πŸ“° TΓ³m tαΊ―t: ${item.title}`);
client.sendUserText(msg);
client.requestResponse();
} else {
// No session β€” open in a popup window
window.open(item.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
}
});
newsList.appendChild(card);
}
}
/** Show news cards */
function showNewsCards() {
if (cachedNewsItems.length === 0) return;
renderNewsCards();
newsCards.hidden = false;
}
/** Hide news cards */
function hideNewsCards() {
newsCards.hidden = true;
}
// News close button
if (newsCloseBtn) {
newsCloseBtn.addEventListener("click", () => hideNewsCards());
}
// ── 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; }
/**
* Show subtitles with HTML support β€” renders <a> links as clickable popup links.
*/
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); }
/** Escape special regex characters in a string */
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Add a chat message with optional source link as popup.
* Tα»± Δ‘α»™ng biαΊΏn "(nguα»“n VnExpress)" thΓ nh link clickable.
*/
function addChatMessage(role, text) {
const msg = document.createElement("div");
msg.className = `chat-message ${role}`;
// Inject link clickable: match "(nguα»“n TΓͺnNguα»“n)" trong text
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'),
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;
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");
}
});
});
}
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 = "";
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 (chatCloseBtn) chatCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); textMode = false; showTextChat(false); textModeBtn.classList.remove("active"); });
// ── Button ───────────────────────────────────────────────────────────────
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_current_datetime") {
const now = new Date();
client.sendToolOutput(callId, `Current date/time: ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} at ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}.`);
client.requestResponse(); return;
}
if (name === "search_wikipedia") {
const query = /** @type {string} */ (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 = /** @type {string} */ (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;
}
if (name === "summarize_news") {
const url = /** @type {string} */ (args.url || "");
if (!url) { client.sendToolOutput(callId, `No URL provided.`); client.requestResponse(); return; }
fetch(`/api/news/summary?url=${encodeURIComponent(url)}`).then(r => r.json()).then(data => {
if (data.content && data.content.length > 50) {
client.sendToolOutput(callId, `TΓ³m tαΊ―t tin tα»©c tα»« ${url}:\n\n${data.content.slice(0, 2000)}`);
} else {
client.sendToolOutput(callId, `KhΓ΄ng thể đọc nα»™i dung tα»« ${url}. Vui lΓ²ng thα»­ lαΊ‘i sau.`);
}
client.requestResponse();
}).catch(() => { client.sendToolOutput(callId, `Failed to fetch news summary.`); 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 = /** @type {Error & {code?: string}} */ (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 = /** @type {AudioContext} */ (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 greeting = preFetchedGreeting || (await getHotNewsGreeting());
if (greeting) {
latestNewsUrl = greeting.link;
latestNewsSource = greeting.sourceName || getSourceName(new URL(greeting.link).hostname);
}
const c = new S2sWsRealtimeClient({
...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
voice: settings.voice, instructions: effectiveInstructions(greeting),
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(); }
}
/**
* Start a text-only session.
*/
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 newsHook = preFetchedGreeting || (await getHotNewsGreeting());
if (newsHook) {
latestNewsUrl = newsHook.link;
latestNewsSource = newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname);
}
const c = new S2sWsRealtimeClient({
...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
voice: settings.voice, instructions: effectiveInstructions(newsHook),
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(/** @type {CustomEvent} */ (e).detail.status));
c.addEventListener("queue", (e) => { const { position } = /** @type {CustomEvent} */ (e).detail; setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); });
c.addEventListener("transcript", (e) => {
const { role, text } = /** @type {CustomEvent} */ (e).detail;
if (role === "assistant" && text) {
const normalized = smartNormalize(text);
showSubtitles(normalized);
addChatMessage("assistant", normalized);
}
});
c.addEventListener("response-finished", () => {
autoGreetingSent = true;
fadeSubtitles();
// Show news cards after each response
showNewsCards();
});
c.addEventListener("toolcall", (e) => { const { name, arguments: args, callId } = /** @type {CustomEvent} */ (e).detail; runTool(name, args, callId); });
c.addEventListener("server-error", (e) => console.warn("server error:", /** @type {CustomEvent} */ (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");
hideNewsCards();
if (!silent) setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
}
// ── 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(preFetchedGreeting || "") });
});
window.addEventListener("beforeunload", () => { client?.close(); });
// ── Boot ─────────────────────────────────────────────────────────────────
async function boot() {
for (const v of VOICES) { const o = document.createElement("option"); o.value = v; o.textContent = v.replaceAll("_", " "); inputVoice.append(o); }
try { const resp = await fetch("api/config"); if (resp.ok) config = { ...config, ...(await resp.json()) }; } catch {}
directUrlRow.hidden = !config.allowDirect;
await fetchAvatarList(); populateAvatarSelects(settings.avatar);
makeDraggable(); makeResizable();
const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href);
setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…");
// Pre-fetch news items in parallel with avatar loading
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar)
? fetchNewsItems().then(() => getHotNewsGreeting()).catch(() => null)
: Promise.resolve(null);
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.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; }
preFetchedGreeting = await newsPromise;
loading.classList.add("done");
setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
}
void boot();