bep40 commited on
Commit
816c981
·
verified ·
1 Parent(s): 89557e0

Upload src/app.js

Browse files
Files changed (1) hide show
  1. src/app.js +65 -38
src/app.js CHANGED
@@ -112,7 +112,7 @@ let autoGreetingSent = false;
112
  /** Pre-fetched short news hook */
113
  let preFetchedGreeting = null;
114
 
115
- // Store the latest news URL so frontend can inject it into the greeting
116
  let latestNewsUrl = null;
117
  let latestNewsSource = null;
118
 
@@ -135,6 +135,39 @@ function saveSettings() {
135
  localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0");
136
  }
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  /**
139
  * Build system instructions sent to the backend.
140
  * The AI gets a short news hook (if any) and is told to greet naturally.
@@ -148,13 +181,15 @@ function effectiveInstructions(newsHook) {
148
  const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
149
  const dateLine = `Today is ${dateStr}. The current time is ${timeStr}.`;
150
  const extra = settings.instructions.trim();
151
- // TIỀN TỐ: Câu giới thiệu được thêm vào mỗi câu chào
152
  const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
153
- // HẬU TỐ: Chỉ mention tên nguồn (vnexpress.net) — KHÔNG bao gồm URL hay thẻ <a>
154
- // URL sẽ được frontend inject sau khi nhận response để tránh TTS đọc URL.
 
 
155
  const newsInst = newsHook
156
- ? `\n\nWhen you start the conversation, greet the user naturally and briefly mention today's news hook: "${newsHook.title}"\nAfter the greeting, say the source name like: "(nguồn VnExpress)" — just the site name, NO URL.\nKeep your greeting to ONE short sentence.`
157
  : "";
 
158
  const base = `${dateLine}\n\n${introLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`;
159
  return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
160
  }
@@ -217,10 +252,8 @@ function setCaption(text, kind = "") { caption.textContent = text; caption.class
217
  function showSubtitles(text) {
218
  if (!settings.subtitles) return;
219
  clearTimeout(subtitleTimer);
220
- // Render HTML so source links are clickable
221
  subtitles.innerHTML = text;
222
  subtitles.classList.add("visible");
223
- // Attach click handler to open links in a popup window
224
  subtitles.querySelectorAll("a[href]").forEach((a) => {
225
  a.addEventListener("click", (e) => {
226
  e.preventDefault();
@@ -233,30 +266,42 @@ function showSubtitles(text) {
233
  }
234
  function fadeSubtitles(delayMs = 2600) { clearTimeout(subtitleTimer); subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); }
235
 
 
 
 
 
 
236
  /**
237
  * Add a chat message with optional source link as popup.
238
- * If the text mentions "nguồn VnExpress" (or similar), inject the real link.
239
  */
240
  function addChatMessage(role, text) {
241
  const msg = document.createElement("div");
242
  msg.className = `chat-message ${role}`;
243
 
244
- // Inject clickable source link for the first greeting
245
  let displayText = text;
246
  if (role === "assistant" && latestNewsUrl && latestNewsSource) {
247
- const sourcePattern = new RegExp(`\\(nguồn\\s*${escapeRegex(latestNewsSource)}\\)`, 'gi');
248
- if (sourcePattern.test(displayText)) {
249
- displayText = displayText.replace(
250
- sourcePattern,
251
- `<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">(nguồn ${latestNewsSource})</a>`
252
- );
 
 
 
 
 
 
 
 
253
  }
254
  }
255
 
256
  msg.innerHTML = displayText;
257
  chatMessages.appendChild(msg);
258
  chatMessages.scrollTop = chatMessages.scrollHeight;
259
- // Attach popup link click handlers
260
  msg.querySelectorAll("a[href]").forEach((a) => {
261
  a.addEventListener("click", (e) => {
262
  e.preventDefault();
@@ -268,11 +313,6 @@ function addChatMessage(role, text) {
268
  });
269
  }
270
 
271
- /** Escape special regex characters in a string */
272
- function escapeRegex(str) {
273
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274
- }
275
-
276
  function showTextChat(show) { textChat.hidden = !show; if (show) chatInput.focus(); }
277
 
278
  function sendTextViaSession(text) {
@@ -369,7 +409,6 @@ async function connectSession(c) {
369
  async function startVoiceSession() {
370
  if (sessionInProgress) return;
371
  sessionInProgress = true;
372
- // Await AudioContext resume — this runs in user-gesture handler so it resolves
373
  await stage.resume();
374
  let micStream;
375
  if (new URLSearchParams(location.search).has("fakemic")) { const ctx = /** @type {AudioContext} */ (stage.audioCtx); micStream = ctx.createMediaStreamDestination().stream; }
@@ -377,10 +416,10 @@ async function startVoiceSession() {
377
  const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
378
  if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
379
  const greeting = preFetchedGreeting || (await getHotNewsGreeting());
380
- // Store news URL for frontend link injection
381
  if (greeting) {
382
  latestNewsUrl = greeting.link;
383
- try { latestNewsSource = new URL(greeting.link).hostname.replace(/^www\./, ""); } catch { latestNewsSource = "VnExpress"; }
384
  }
385
  const c = new S2sWsRealtimeClient({
386
  ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
@@ -397,21 +436,18 @@ async function startVoiceSession() {
397
 
398
  /**
399
  * Start a text-only session.
400
- * Called from user-gesture handler (mainBtn click) so AudioContext
401
- * resume resolves — audio plays, HeadAudio processes → lipsync works.
402
  */
403
  async function startTextSession(initialText) {
404
  if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; }
405
  sessionInProgress = true;
406
- // Await AudioContext resume — this is inside user-gesture handler, it WILL resolve
407
  await stage.resume();
408
  const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
409
  if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
410
  const newsHook = preFetchedGreeting || (await getHotNewsGreeting());
411
- // Store news URL for frontend link injection
412
  if (newsHook) {
413
  latestNewsUrl = newsHook.link;
414
- try { latestNewsSource = new URL(newsHook.link).hostname.replace(/^www\./, ""); } catch { latestNewsSource = "VnExpress"; }
415
  }
416
  const c = new S2sWsRealtimeClient({
417
  ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
@@ -433,7 +469,6 @@ function _attachClientEvents(c) {
433
  c.addEventListener("transcript", (e) => {
434
  const { role, text } = /** @type {CustomEvent} */ (e).detail;
435
  if (role === "assistant" && text) {
436
- // Display: keep numbers as digits for clean visual
437
  const normalized = smartNormalize(text);
438
  showSubtitles(normalized);
439
  addChatMessage("assistant", normalized);
@@ -455,7 +490,6 @@ async function endSession(silent = false) {
455
  // ── UI events ────────────────────────────────────────────────────────────
456
  mainBtn.addEventListener("click", () => {
457
  if (mainAction === "start") {
458
- // User gesture! AudioContext.resume() will resolve → audio plays + lipsync works.
459
  if (sessionInProgress) return;
460
  if (new URLSearchParams(location.search).has("fakemic")) {
461
  void startTextSession();
@@ -489,18 +523,11 @@ async function boot() {
489
  makeDraggable(); makeResizable();
490
  const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href);
491
  setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…");
492
- // Pre-fetch news hook in parallel with avatar
493
  const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNewsGreeting().catch(() => null) : Promise.resolve(null);
494
  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))}%`; } }); }
495
  catch (err) { console.error(err); loading.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; }
496
  preFetchedGreeting = await newsPromise;
497
  loading.classList.add("done");
498
  setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
499
-
500
- // NO auto-greeting here. On direct hf.space domains AudioContext is
501
- // "suspended" before first click — any audio routed through a suspended
502
- // context is silent and HeadAudio won't process any frames.
503
- // User must tap "Start talking" → click provides user gesture →
504
- // AudioContext.resume() resolves → audio plays → HeadAudio drives lipsync.
505
  }
506
  void boot();
 
112
  /** Pre-fetched short news hook */
113
  let preFetchedGreeting = null;
114
 
115
+ /** URL tên nguồn tin tức để frontend inject link clickable */
116
  let latestNewsUrl = null;
117
  let latestNewsSource = null;
118
 
 
135
  localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0");
136
  }
137
 
138
+ /**
139
+ * Rút gọn hostname thành tên nguồn dễ đọc (vd: "vnexpress.net" → "VnExpress").
140
+ * Dùng cho cả instruction (AI nói) và regex matching (frontend inject link).
141
+ */
142
+ function getSourceName(hostname) {
143
+ const name = hostname.replace(/^www\./, "").toLowerCase();
144
+ const known = {
145
+ "vnexpress.net": "VnExpress",
146
+ "dantri.com.vn": "Dân trí",
147
+ "tuoitre.vn": "Tuổi Trẻ",
148
+ "thanhnien.vn": "Thanh Niên",
149
+ "nhandan.vn": "Nhân Dân",
150
+ "vietnamnet.vn": "VietNamNet",
151
+ "zingnews.vn": "ZingNews",
152
+ "cafef.vn": "CafeF",
153
+ "techz.vn": "TechZ",
154
+ "genk.vn": "GenK",
155
+ "soha.vn": "Soha",
156
+ "kenh14.vn": "Kênh 14",
157
+ "afamily.vn": "Afamily",
158
+ "eva.vn": "Eva",
159
+ "ngoisao.vn": "Ngôi Sao",
160
+ "giadinh.net.vn": "Gia đình",
161
+ "tienphong.vn": "Tiền Phong",
162
+ "plo.vn": "PLO",
163
+ "vtc.vn": "VTC",
164
+ "vtv.vn": "VTV",
165
+ "dantri.com": "Dân trí",
166
+ "vnexpress": "VnExpress",
167
+ };
168
+ return known[name] || name;
169
+ }
170
+
171
  /**
172
  * Build system instructions sent to the backend.
173
  * The AI gets a short news hook (if any) and is told to greet naturally.
 
181
  const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
182
  const dateLine = `Today is ${dateStr}. The current time is ${timeStr}.`;
183
  const extra = settings.instructions.trim();
 
184
  const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
185
+
186
+ // Chỉ mention tên nguồn (VnExpress) KHÔNG URL hay thẻ <a>
187
+ // AI chỉ nói "nguồn VnExpress" — TTS đọc 2 từ, ko đọc URL
188
+ const sourceName = newsHook ? getSourceName(new URL(newsHook.link).hostname) : "";
189
  const newsInst = newsHook
190
+ ? `\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.`
191
  : "";
192
+
193
  const base = `${dateLine}\n\n${introLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`;
194
  return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
195
  }
 
252
  function showSubtitles(text) {
253
  if (!settings.subtitles) return;
254
  clearTimeout(subtitleTimer);
 
255
  subtitles.innerHTML = text;
256
  subtitles.classList.add("visible");
 
257
  subtitles.querySelectorAll("a[href]").forEach((a) => {
258
  a.addEventListener("click", (e) => {
259
  e.preventDefault();
 
266
  }
267
  function fadeSubtitles(delayMs = 2600) { clearTimeout(subtitleTimer); subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); }
268
 
269
+ /** Escape special regex characters in a string */
270
+ function escapeRegex(str) {
271
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
272
+ }
273
+
274
  /**
275
  * Add a chat message with optional source link as popup.
276
+ * Tự động biến "(nguồn VnExpress)" thành link clickable.
277
  */
278
  function addChatMessage(role, text) {
279
  const msg = document.createElement("div");
280
  msg.className = `chat-message ${role}`;
281
 
282
+ // Inject link clickable: match "(nguồn TênNguồn)" trong text
283
  let displayText = text;
284
  if (role === "assistant" && latestNewsUrl && latestNewsSource) {
285
+ // Match nhiều format: (nguồn VnExpress), (Nguồn: VnExpress), v.v.
286
+ const sourceEscaped = escapeRegex(latestNewsSource);
287
+ const patterns = [
288
+ new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'),
289
+ new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'),
290
+ new RegExp(`nguồn\\s*${sourceEscaped}`, 'gi'),
291
+ ];
292
+ for (const pattern of patterns) {
293
+ if (pattern.test(displayText)) {
294
+ displayText = displayText.replace(pattern, (match) =>
295
+ `<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">${match}</a>`
296
+ );
297
+ break;
298
+ }
299
  }
300
  }
301
 
302
  msg.innerHTML = displayText;
303
  chatMessages.appendChild(msg);
304
  chatMessages.scrollTop = chatMessages.scrollHeight;
 
305
  msg.querySelectorAll("a[href]").forEach((a) => {
306
  a.addEventListener("click", (e) => {
307
  e.preventDefault();
 
313
  });
314
  }
315
 
 
 
 
 
 
316
  function showTextChat(show) { textChat.hidden = !show; if (show) chatInput.focus(); }
317
 
318
  function sendTextViaSession(text) {
 
409
  async function startVoiceSession() {
410
  if (sessionInProgress) return;
411
  sessionInProgress = true;
 
412
  await stage.resume();
413
  let micStream;
414
  if (new URLSearchParams(location.search).has("fakemic")) { const ctx = /** @type {AudioContext} */ (stage.audioCtx); micStream = ctx.createMediaStreamDestination().stream; }
 
416
  const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
417
  if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
418
  const greeting = preFetchedGreeting || (await getHotNewsGreeting());
419
+ // Lưu URL tên nguồn để frontend inject link clickable
420
  if (greeting) {
421
  latestNewsUrl = greeting.link;
422
+ try { latestNewsSource = getSourceName(new URL(greeting.link).hostname); } catch { latestNewsSource = "VnExpress"; }
423
  }
424
  const c = new S2sWsRealtimeClient({
425
  ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
 
436
 
437
  /**
438
  * Start a text-only session.
 
 
439
  */
440
  async function startTextSession(initialText) {
441
  if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; }
442
  sessionInProgress = true;
 
443
  await stage.resume();
444
  const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink;
445
  if (!audioCtx || !voiceSink) { sessionInProgress = false; return; }
446
  const newsHook = preFetchedGreeting || (await getHotNewsGreeting());
447
+ // Lưu URL tên nguồn để frontend inject link clickable
448
  if (newsHook) {
449
  latestNewsUrl = newsHook.link;
450
+ try { latestNewsSource = getSourceName(new URL(newsHook.link).hostname); } catch { latestNewsSource = "VnExpress"; }
451
  }
452
  const c = new S2sWsRealtimeClient({
453
  ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
 
469
  c.addEventListener("transcript", (e) => {
470
  const { role, text } = /** @type {CustomEvent} */ (e).detail;
471
  if (role === "assistant" && text) {
 
472
  const normalized = smartNormalize(text);
473
  showSubtitles(normalized);
474
  addChatMessage("assistant", normalized);
 
490
  // ── UI events ────────────────────────────────────────────────────────────
491
  mainBtn.addEventListener("click", () => {
492
  if (mainAction === "start") {
 
493
  if (sessionInProgress) return;
494
  if (new URLSearchParams(location.search).has("fakemic")) {
495
  void startTextSession();
 
523
  makeDraggable(); makeResizable();
524
  const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href);
525
  setCaption("WAKING HER UP…"); setMainButton("busy", "Loading…");
 
526
  const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNewsGreeting().catch(() => null) : Promise.resolve(null);
527
  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))}%`; } }); }
528
  catch (err) { console.error(err); loading.textContent = "Avatar failed to load. Reload."; setCaption("AVATAR FAILED", "error"); return; }
529
  preFetchedGreeting = await newsPromise;
530
  loading.classList.add("done");
531
  setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
 
 
 
 
 
 
532
  }
533
  void boot();