bep40 commited on
Commit
413b67e
·
verified ·
1 Parent(s): 5e5e0a1

Upload src/app.js

Browse files
Files changed (1) hide show
  1. 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. 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,11 +27,158 @@ const DEFAULT_VOICE = "Sohee";
27
  const _urlParams = new URLSearchParams(location.search);
28
  const FAKEMIC_MODE = _urlParams.has("fakemic");
29
 
30
- // ── Greeting ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  /**
32
- * Fetch a SHORT hot news headline to weave into the greeting.
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
- "Use them naturally to express yourself.",
 
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
- // NO auto-greeting here. On direct hf.space domains AudioContext is
423
- // "suspended" before first click — any audio routed through a suspended
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();