bep40's picture
Upload src/app.js
48c66bf verified
Raw
History Blame
35.4 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";
// ── News fetching ───────────────────────────────────────────────────────
async function getHotNews(page = 1, pageSize = 10) {
let items = [];
try {
const resp = await fetch(`/api/news/hot?page=${page}&pageSize=${pageSize}`);
if (resp.ok) {
const data = await resp.json();
if (data.items && data.items.length > 0) {
items = data.items;
newsHasMore = data.hasMore || false;
}
}
} catch (e) { console.warn("News API failed:", e); }
return items;
}
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 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 } }, 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 = `<img class="news-item-image" src="${item.image}" alt="" loading="lazy" onerror="this.style.display='none'" />`;
}
newsItem.innerHTML = `
${imageHtml}
<div class="news-item-content">
<span class="news-index">${globalIdx}.</span>
<span class="news-title">${item.title}</span>
<span class="news-source">(${item.source})</span>
</div>
`;
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) =>
`<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");
}
});
});
if (role === "assistant" && preFetchedNews.length > 0) {
showNewsPanel(preFetchedNews.slice(0, 10));
}
}
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…");
}
}
// ── Event listeners for news panel ─────────────────────────────────────
if (newsCloseBtn) {
newsCloseBtn.addEventListener("click", (e) => {
e.stopPropagation();
newsPanel.hidden = true;
newsToggleFooter?.classList.remove("active");
});
}
if (newsToggleBtn) {
newsToggleBtn.addEventListener("click", (e) => {
e.stopPropagation();
toggleNewsCollapse();
});
}
if (newsPanel) {
newsPanel.addEventListener("click", (e) => {
if (e.target.closest("#news-header") && !e.target.closest("#news-header-actions")) {
toggleNewsCollapse();
}
});
}
if (newsLoadMoreBtn) {
newsLoadMoreBtn.addEventListener("click", () => {
loadNewsPage(newsPage + 1);
});
}
if (newsToggleFooter) {
newsToggleFooter.addEventListener("click", () => {
toggleNewsPanel();
});
}
if (chatCloseBtn) {
chatCloseBtn.addEventListener("click", (e) => {
e.stopPropagation();
textMode = false;
showTextChat(false);
textModeBtn.classList.remove("active");
});
}
// ── 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;
newsToggleFooter.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();
const { dateStr, timeStr } = formatDateTimeVN(now);
client.sendToolOutput(callId, `Hôm nay là ${dateStr}. BÒy giờ 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;
}
if (newsItems.length > 0) {
latestNewsUrl = newsItems[0].link;
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;
}
if (newsItems.length > 0) {
latestNewsUrl = newsItems[0].link;
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);
addChatMessage("assistant", normalized);
}
});
c.addEventListener("response-finished", () => {
autoGreetingSent = true;
fadeSubtitles();
if (preFetchedNews.length > 0 && !newsPanel.hidden) {
}
});
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 (!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(preFetchedNews)
});
});
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…");
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews(1).catch(() => []) : Promise.resolve([]);
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;
}
preFetchedNews = await newsPromise;
loading.classList.add("done");
setCaption(CAPTIONS.idle);
setMainButton("start", "Start talking");
if (newsToggleFooter) {
newsToggleFooter.hidden = false;
}
}
void boot();