Spaces:
Paused
Paused
Upload src/app.js
Browse files- src/app.js +192 -26
src/app.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
// @ts-check
|
| 2 |
/**
|
| 3 |
-
* App wiring: the avatar stage + the speech-to-speech session.
|
| 4 |
* Modes: voice (mic + VAD) OR text (keyboard only, no mic needed).
|
| 5 |
*
|
| 6 |
* KEY DESIGN: On direct hf.space domains, AudioContext starts "suspended"
|
| 7 |
-
* because there's no user gesture on page load.
|
| 8 |
* silently failing, we wait for the user to click "Start talking" before
|
| 9 |
* any WebSocket connection — that click resumes AudioContext, audio plays,
|
| 10 |
* and HeadAudio lip-sync works.
|
|
@@ -27,11 +27,158 @@ const DEFAULT_VOICE = "Sohee";
|
|
| 27 |
const _urlParams = new URLSearchParams(location.search);
|
| 28 |
const FAKEMIC_MODE = _urlParams.has("fakemic");
|
| 29 |
|
| 30 |
-
// ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
/**
|
| 32 |
-
*
|
| 33 |
-
* Short enough that the AI says it as ONE sentence.
|
| 34 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
async function getHotNewsGreeting() {
|
| 36 |
let hotTitle = "";
|
| 37 |
try {
|
|
@@ -52,6 +199,7 @@ async function getHotNewsGreeting() {
|
|
| 52 |
|
| 53 |
const DEFAULT_INSTRUCTIONS = [
|
| 54 |
"You are a friendly voice assistant with a visible, human-like 3D avatar.",
|
|
|
|
| 55 |
"CRITICAL: Always respond in the SAME LANGUAGE the user writes or speaks.",
|
| 56 |
"If Vietnamese, respond in Vietnamese. If English, respond in English.",
|
| 57 |
"When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
|
|
@@ -61,7 +209,8 @@ const DEFAULT_INSTRUCTIONS = [
|
|
| 61 |
"currency as 'năm mươi nghìn đồng' NOT '50.000đ'.",
|
| 62 |
"Keep replies short, natural, warm. Never list-like.",
|
| 63 |
"You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression.",
|
| 64 |
-
"
|
|
|
|
| 65 |
"NEVER guess. Use search_web or search_wikipedia for ANY factual question.",
|
| 66 |
"First call get_current_datetime to know today's date, then search.",
|
| 67 |
"Never mention tools or that you control an avatar.",
|
|
@@ -79,6 +228,11 @@ const TOOL_DEFS = [
|
|
| 79 |
{ type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } },
|
| 80 |
{ type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
|
| 81 |
{ type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
];
|
| 83 |
|
| 84 |
// ── DOM ──────────────────────────────────────────────────────────────────
|
|
@@ -144,7 +298,6 @@ function saveSettings() {
|
|
| 144 |
|
| 145 |
/**
|
| 146 |
* Build system instructions sent to the backend.
|
| 147 |
-
* The AI gets a short news hook (if any) and is told to greet naturally.
|
| 148 |
*/
|
| 149 |
function effectiveInstructions(newsHook) {
|
| 150 |
const now = new Date();
|
|
@@ -289,6 +442,35 @@ function runTool(name, argsJson, callId) {
|
|
| 289 |
client.requestResponse();
|
| 290 |
}).catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return;
|
| 291 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
|
| 293 |
client.sendToolOutput(callId, result); client.requestResponse();
|
| 294 |
}
|
|
@@ -308,7 +490,6 @@ async function connectSession(c) {
|
|
| 308 |
async function startVoiceSession() {
|
| 309 |
if (sessionInProgress) return;
|
| 310 |
sessionInProgress = true;
|
| 311 |
-
// Await AudioContext resume — this runs in user-gesture handler so it resolves
|
| 312 |
await stage.resume();
|
| 313 |
let micStream;
|
| 314 |
if (FAKEMIC_MODE) { const ctx = /** @type {AudioContext} */ (stage.audioCtx); micStream = ctx.createMediaStreamDestination().stream; }
|
|
@@ -329,15 +510,9 @@ async function startVoiceSession() {
|
|
| 329 |
if (!autoGreetingSent) { c.requestResponse(); }
|
| 330 |
}
|
| 331 |
|
| 332 |
-
/**
|
| 333 |
-
* Start a text-only session.
|
| 334 |
-
* Called from user-gesture handler (mainBtn click) so AudioContext
|
| 335 |
-
* resume resolves — audio plays, HeadAudio processes → lipsync works.
|
| 336 |
-
*/
|
| 337 |
async function startTextSession(initialText) {
|
| 338 |
if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; }
|
| 339 |
sessionInProgress = true;
|
| 340 |
-
// Await AudioContext resume — this is inside user-gesture handler, it WILL resolve
|
| 341 |
await stage.resume();
|
| 342 |
const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
|
| 343 |
if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
|
|
@@ -379,7 +554,6 @@ async function endSession(silent = false) {
|
|
| 379 |
// ── UI events ────────────────────────────────────────────────────────────
|
| 380 |
mainBtn.addEventListener("click", () => {
|
| 381 |
if (mainAction === "start") {
|
| 382 |
-
// User gesture! AudioContext.resume() will resolve → audio plays + lipsync works.
|
| 383 |
if (sessionInProgress) return;
|
| 384 |
if (FAKEMIC_MODE) {
|
| 385 |
void startTextSession();
|
|
@@ -412,21 +586,13 @@ async function boot() {
|
|
| 412 |
await fetchAvatarList(); populateAvatarSelects(settings.avatar);
|
| 413 |
makeDraggable(); makeResizable();
|
| 414 |
setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…");
|
| 415 |
-
// Pre-fetch news hook in parallel with avatar
|
| 416 |
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNewsGreeting().catch(() => null) : Promise.resolve(null);
|
| 417 |
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))}%`; } }); }
|
| 418 |
catch (err) { console.error(err); loading.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; }
|
| 419 |
preFetchedGreeting = await newsPromise;
|
| 420 |
loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
|
| 421 |
|
| 422 |
-
//
|
| 423 |
-
|
| 424 |
-
// context is silent and HeadAudio won't process any frames.
|
| 425 |
-
// User must tap "Start talking" → click provides user gesture →
|
| 426 |
-
// AudioContext.resume() resolves → audio plays → HeadAudio drives lipsync.
|
| 427 |
-
//
|
| 428 |
-
// ?fakemic=1 is an OPT-IN testing flag. Setting it automatically would
|
| 429 |
-
// break the direct domain (bep40-gemma-avatar.hf.space) by routing to
|
| 430 |
-
// text-only mode without a working backend.
|
| 431 |
}
|
| 432 |
-
void boot();
|
|
|
|
| 1 |
// @ts-check
|
| 2 |
/**
|
| 3 |
+
* App wiring: the avatar stage + the speech-to-speech session + V.AISTUDIO iframe control.
|
| 4 |
* Modes: voice (mic + VAD) OR text (keyboard only, no mic needed).
|
| 5 |
*
|
| 6 |
* KEY DESIGN: On direct hf.space domains, AudioContext starts "suspended"
|
| 7 |
+
* because there's no user gesture on page load. Instead of hanging or
|
| 8 |
* silently failing, we wait for the user to click "Start talking" before
|
| 9 |
* any WebSocket connection — that click resumes AudioContext, audio plays,
|
| 10 |
* and HeadAudio lip-sync works.
|
|
|
|
| 27 |
const _urlParams = new URLSearchParams(location.search);
|
| 28 |
const FAKEMIC_MODE = _urlParams.has("fakemic");
|
| 29 |
|
| 30 |
+
// ── V.AISTUDIO iframe bridge ─────────────────────────────────────────────
|
| 31 |
+
const VAISTUDIO_ORIGIN = "https://bep40-v-aistudio.static.hf.space";
|
| 32 |
+
const VAISTUDIO_IFRAME_ID = "vaistudio-iframe";
|
| 33 |
+
const VAISTUDIO_PANEL_ID = "vaistudio-panel";
|
| 34 |
+
const VAISTUDIO_TOGGLE_ID = "vaistudio-toggle";
|
| 35 |
+
const VAISTUDIO_LOADING_ID = "vaistudio-loading";
|
| 36 |
+
|
| 37 |
+
/** @type {HTMLIFrameElement | null} */
|
| 38 |
+
let vaistudioIframe = null;
|
| 39 |
+
/** @type {HTMLDivElement | null} */
|
| 40 |
+
let vaistudioPanel = null;
|
| 41 |
+
/** @type {HTMLButtonElement | null} */
|
| 42 |
+
let vaistudioToggle = null;
|
| 43 |
+
/** @type {HTMLDivElement | null} */
|
| 44 |
+
let vaistudioLoading = null;
|
| 45 |
+
/** @type {boolean} */
|
| 46 |
+
let vaistudioPanelOpen = false;
|
| 47 |
+
/** @type {Map<string, Function>} */
|
| 48 |
+
const vaistudioResponseHandlers = new Map();
|
| 49 |
+
/** @type {number} */
|
| 50 |
+
let vaistudioMessageId = 0;
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Send a command to V.AISTUDIO iframe via postMessage
|
| 54 |
+
*/
|
| 55 |
+
function vaistudioSend(action, data = {}) {
|
| 56 |
+
if (!vaistudioIframe || !vaistudioIframe.contentWindow) {
|
| 57 |
+
console.warn("[VAISTUDIO] iframe not ready");
|
| 58 |
+
return Promise.reject(new Error("iframe not ready"));
|
| 59 |
+
}
|
| 60 |
+
const id = ++vaistudioMessageId;
|
| 61 |
+
const message = { id, action, data, timestamp: Date.now() };
|
| 62 |
+
return new Promise((resolve, reject) => {
|
| 63 |
+
const timeout = setTimeout(() => {
|
| 64 |
+
vaistudioResponseHandlers.delete(id);
|
| 65 |
+
reject(new Error(`VAISTUDIO timeout: ${action}`));
|
| 66 |
+
}, 10000);
|
| 67 |
+
vaistudioResponseHandlers.set(id, (response) => {
|
| 68 |
+
clearTimeout(timeout);
|
| 69 |
+
if (response.error) reject(new Error(response.error));
|
| 70 |
+
else resolve(response.data);
|
| 71 |
+
});
|
| 72 |
+
try {
|
| 73 |
+
vaistudioIframe.contentWindow.postMessage(message, VAISTUDIO_ORIGIN);
|
| 74 |
+
} catch (e) {
|
| 75 |
+
vaistudioResponseHandlers.delete(id);
|
| 76 |
+
clearTimeout(timeout);
|
| 77 |
+
reject(e);
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Handle responses from V.AISTUDIO iframe
|
| 84 |
+
*/
|
| 85 |
+
function vaistudioOnMessage(event) {
|
| 86 |
+
if (event.origin !== VAISTUDIO_ORIGIN) return;
|
| 87 |
+
const msg = event.data;
|
| 88 |
+
if (!msg || typeof msg !== "object") return;
|
| 89 |
+
if (msg.id && vaistudioResponseHandlers.has(msg.id)) {
|
| 90 |
+
const handler = vaistudioResponseHandlers.get(msg.id);
|
| 91 |
+
vaistudioResponseHandlers.delete(msg.id);
|
| 92 |
+
handler(msg);
|
| 93 |
+
}
|
| 94 |
+
// Handle unsolicited events from iframe
|
| 95 |
+
if (msg.event) {
|
| 96 |
+
console.log("[VAISTUDIO] Event:", msg.event, msg.data);
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Initialize V.AISTUDIO iframe controls
|
| 102 |
+
*/
|
| 103 |
+
function initVAISTUDIO() {
|
| 104 |
+
vaistudioIframe = /** @type {HTMLIFrameElement} */ (document.getElementById(VAISTUDIO_IFRAME_ID));
|
| 105 |
+
vaistudioPanel = /** @type {HTMLDivElement} */ (document.getElementById(VAISTUDIO_PANEL_ID));
|
| 106 |
+
vaistudioToggle = /** @type {HTMLButtonElement} */ (document.getElementById(VAISTUDIO_TOGGLE_ID));
|
| 107 |
+
vaistudioLoading = /** @type {HTMLDivElement} */ (document.getElementById(VAISTUDIO_LOADING_ID));
|
| 108 |
+
|
| 109 |
+
if (!vaistudioIframe || !vaistudioPanel || !vaistudioToggle) {
|
| 110 |
+
console.warn("[VAISTUDIO] Required elements not found");
|
| 111 |
+
return;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Toggle panel
|
| 115 |
+
vaistudioToggle.addEventListener("click", () => {
|
| 116 |
+
vaistudioPanelOpen = !vaistudioPanelOpen;
|
| 117 |
+
vaistudioPanel.classList.toggle("open", vaistudioPanelOpen);
|
| 118 |
+
vaistudioToggle.classList.toggle("active", vaistudioPanelOpen);
|
| 119 |
+
vaistudioToggle.setAttribute("aria-label", vaistudioPanelOpen ? "Close V.AI STUDIO" : "Open V.AI STUDIO");
|
| 120 |
+
vaistudioToggle.title = vaistudioPanelOpen ? "Close V.AI STUDIO" : "Open V.AI STUDIO";
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
// Close button
|
| 124 |
+
const closeBtn = document.getElementById("vaistudio-close");
|
| 125 |
+
if (closeBtn) {
|
| 126 |
+
closeBtn.addEventListener("click", () => {
|
| 127 |
+
vaistudioPanelOpen = false;
|
| 128 |
+
vaistudioPanel.classList.remove("open");
|
| 129 |
+
vaistudioToggle.classList.remove("active");
|
| 130 |
+
vaistudioToggle.setAttribute("aria-label", "Open V.AI STUDIO");
|
| 131 |
+
vaistudioToggle.title = "Open V.AI STUDIO";
|
| 132 |
+
});
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Listen for iframe load
|
| 136 |
+
vaistudioIframe.addEventListener("load", () => {
|
| 137 |
+
if (vaistudioLoading) vaistudioLoading.classList.add("hidden");
|
| 138 |
+
console.log("[VAISTUDIO] iframe loaded");
|
| 139 |
+
vaistudioSend("ready", { source: "gemma-avatar" }).catch(() => {});
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
// Listen for postMessage
|
| 143 |
+
window.addEventListener("message", vaistudioOnMessage);
|
| 144 |
+
|
| 145 |
+
// Expose globally for avatar tools
|
| 146 |
+
window.vaistudio = {
|
| 147 |
+
send: vaistudioSend,
|
| 148 |
+
open: () => { if (!vaistudioPanelOpen) vaistudioToggle.click(); },
|
| 149 |
+
close: () => { if (vaistudioPanelOpen) vaistudioToggle.click(); },
|
| 150 |
+
isOpen: () => vaistudioPanelOpen,
|
| 151 |
+
};
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
/**
|
| 155 |
+
* V.AISTUDIO control functions for avatar tools
|
|
|
|
| 156 |
*/
|
| 157 |
+
async function vaistudioOpenCatalog(category = "") {
|
| 158 |
+
await vaistudioSend("navigate", { page: "products", category });
|
| 159 |
+
if (!vaistudioPanelOpen) vaistudioToggle.click();
|
| 160 |
+
return { success: true, message: `Opened catalog${category ? `: ${category}` : ""}` };
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
async function vaistudioOpenProduct(productId) {
|
| 164 |
+
await vaistudioSend("navigate", { page: "product", productId });
|
| 165 |
+
if (!vaistudioPanelOpen) vaistudioToggle.click();
|
| 166 |
+
return { success: true, message: `Opened product ${productId}` };
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function vaistudioSearch(query) {
|
| 170 |
+
await vaistudioSend("search", { query });
|
| 171 |
+
if (!vaistudioPanelOpen) vaistudioToggle.click();
|
| 172 |
+
return { success: true, message: `Searching: ${query}` };
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
async function vaistudioNavigate(page, data = {}) {
|
| 176 |
+
await vaistudioSend("navigate", { page, ...data });
|
| 177 |
+
if (!vaistudioPanelOpen) vaistudioToggle.click();
|
| 178 |
+
return { success: true, message: `Navigated to ${page}` };
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// ── Greeting ─────────────────────────────────────────────────────────────
|
| 182 |
async function getHotNewsGreeting() {
|
| 183 |
let hotTitle = "";
|
| 184 |
try {
|
|
|
|
| 199 |
|
| 200 |
const DEFAULT_INSTRUCTIONS = [
|
| 201 |
"You are a friendly voice assistant with a visible, human-like 3D avatar.",
|
| 202 |
+
"You also have access to V.AI STUDIO - an e-commerce catalog with 8000+ kitchen appliances & smart locks from Malloca, Eurogold, Grob, Canzy, Demax & Dien May Xanh.",
|
| 203 |
"CRITICAL: Always respond in the SAME LANGUAGE the user writes or speaks.",
|
| 204 |
"If Vietnamese, respond in Vietnamese. If English, respond in English.",
|
| 205 |
"When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
|
|
|
|
| 209 |
"currency as 'năm mươi nghìn đồng' NOT '50.000đ'.",
|
| 210 |
"Keep replies short, natural, warm. Never list-like.",
|
| 211 |
"You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression.",
|
| 212 |
+
"You can control V.AI STUDIO catalog with tools: open_catalog, open_product, search_catalog, navigate_catalog.",
|
| 213 |
+
"Use them naturally to express yourself and help users browse products.",
|
| 214 |
"NEVER guess. Use search_web or search_wikipedia for ANY factual question.",
|
| 215 |
"First call get_current_datetime to know today's date, then search.",
|
| 216 |
"Never mention tools or that you control an avatar.",
|
|
|
|
| 228 |
{ type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } },
|
| 229 |
{ type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
|
| 230 |
{ type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
|
| 231 |
+
// V.AISTUDIO Catalog tools
|
| 232 |
+
{ type: "function", name: "open_catalog", description: "Open V.AI STUDIO catalog, optionally filtered by category.", parameters: { type: "object", properties: { category: { type: "string", description: "Category name (e.g., 'Bếp từ', 'Máy hút mùi', 'Khóa thông minh', 'Nồi chiên không dầu'). Empty for all products." } }, required: ["category"] } },
|
| 233 |
+
{ type: "function", name: "open_product", description: "Open a specific product detail page in V.AI STUDIO.", parameters: { type: "object", properties: { product_id: { type: "string", description: "Product ID or SKU from V.AI STUDIO catalog" } }, required: ["product_id"] } },
|
| 234 |
+
{ type: "function", name: "search_catalog", description: "Search V.AI STUDIO catalog for products.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query (product name, brand, feature, price range)" } }, required: ["query"] } },
|
| 235 |
+
{ type: "function", name: "navigate_catalog", description: "Navigate V.AI STUDIO to a specific page.", parameters: { type: "object", properties: { page: { type: "string", enum: ["products", "catalogue", "contact", "cart", "home"], description: "Page to navigate to" }, data: { type: "object", description: "Additional data for navigation" } }, required: ["page"] } },
|
| 236 |
];
|
| 237 |
|
| 238 |
// ── DOM ──────────────────────────────────────────────────────────────────
|
|
|
|
| 298 |
|
| 299 |
/**
|
| 300 |
* Build system instructions sent to the backend.
|
|
|
|
| 301 |
*/
|
| 302 |
function effectiveInstructions(newsHook) {
|
| 303 |
const now = new Date();
|
|
|
|
| 442 |
client.requestResponse();
|
| 443 |
}).catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return;
|
| 444 |
}
|
| 445 |
+
// V.AISTUDIO Catalog tools
|
| 446 |
+
if (name === "open_catalog") {
|
| 447 |
+
vaistudioOpenCatalog(args.category || "").then(result => {
|
| 448 |
+
client.sendToolOutput(callId, result.message); client.requestResponse();
|
| 449 |
+
}).catch(err => {
|
| 450 |
+
client.sendToolOutput(callId, `Failed to open catalog: ${err.message}`); client.requestResponse();
|
| 451 |
+
}); return;
|
| 452 |
+
}
|
| 453 |
+
if (name === "open_product") {
|
| 454 |
+
vaistudioOpenProduct(args.product_id).then(result => {
|
| 455 |
+
client.sendToolOutput(callId, result.message); client.requestResponse();
|
| 456 |
+
}).catch(err => {
|
| 457 |
+
client.sendToolOutput(callId, `Failed to open product: ${err.message}`); client.requestResponse();
|
| 458 |
+
}); return;
|
| 459 |
+
}
|
| 460 |
+
if (name === "search_catalog") {
|
| 461 |
+
vaistudioSearch(args.query).then(result => {
|
| 462 |
+
client.sendToolOutput(callId, result.message); client.requestResponse();
|
| 463 |
+
}).catch(err => {
|
| 464 |
+
client.sendToolOutput(callId, `Failed to search catalog: ${err.message}`); client.requestResponse();
|
| 465 |
+
}); return;
|
| 466 |
+
}
|
| 467 |
+
if (name === "navigate_catalog") {
|
| 468 |
+
vaistudioNavigate(args.page, args.data || {}).then(result => {
|
| 469 |
+
client.sendToolOutput(callId, result.message); client.requestResponse();
|
| 470 |
+
}).catch(err => {
|
| 471 |
+
client.sendToolOutput(callId, `Failed to navigate: ${err.message}`); client.requestResponse();
|
| 472 |
+
}); return;
|
| 473 |
+
}
|
| 474 |
const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
|
| 475 |
client.sendToolOutput(callId, result); client.requestResponse();
|
| 476 |
}
|
|
|
|
| 490 |
async function startVoiceSession() {
|
| 491 |
if (sessionInProgress) return;
|
| 492 |
sessionInProgress = true;
|
|
|
|
| 493 |
await stage.resume();
|
| 494 |
let micStream;
|
| 495 |
if (FAKEMIC_MODE) { const ctx = /** @type {AudioContext} */ (stage.audioCtx); micStream = ctx.createMediaStreamDestination().stream; }
|
|
|
|
| 510 |
if (!autoGreetingSent) { c.requestResponse(); }
|
| 511 |
}
|
| 512 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
async function startTextSession(initialText) {
|
| 514 |
if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; }
|
| 515 |
sessionInProgress = true;
|
|
|
|
| 516 |
await stage.resume();
|
| 517 |
const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
|
| 518 |
if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
|
|
|
|
| 554 |
// ── UI events ────────────────────────────────────────────────────────────
|
| 555 |
mainBtn.addEventListener("click", () => {
|
| 556 |
if (mainAction === "start") {
|
|
|
|
| 557 |
if (sessionInProgress) return;
|
| 558 |
if (FAKEMIC_MODE) {
|
| 559 |
void startTextSession();
|
|
|
|
| 586 |
await fetchAvatarList(); populateAvatarSelects(settings.avatar);
|
| 587 |
makeDraggable(); makeResizable();
|
| 588 |
setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…");
|
|
|
|
| 589 |
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNewsGreeting().catch(() => null) : Promise.resolve(null);
|
| 590 |
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))}%`; } }); }
|
| 591 |
catch (err) { console.error(err); loading.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; }
|
| 592 |
preFetchedGreeting = await newsPromise;
|
| 593 |
loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
|
| 594 |
|
| 595 |
+
// Initialize V.AISTUDIO iframe bridge
|
| 596 |
+
initVAISTUDIO();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
}
|
| 598 |
+
void boot();
|