Spaces:
Paused
Paused
Upload src/app.js
Browse files- 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 |
-
/
|
| 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 |
-
|
| 154 |
-
//
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
*
|
| 239 |
*/
|
| 240 |
function addChatMessage(role, text) {
|
| 241 |
const msg = document.createElement("div");
|
| 242 |
msg.className = `chat-message ${role}`;
|
| 243 |
|
| 244 |
-
// Inject clickable
|
| 245 |
let displayText = text;
|
| 246 |
if (role === "assistant" && latestNewsUrl && latestNewsSource) {
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 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 |
-
//
|
| 381 |
if (greeting) {
|
| 382 |
latestNewsUrl = greeting.link;
|
| 383 |
-
try { latestNewsSource = new URL(greeting.link).hostname
|
| 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 |
-
//
|
| 412 |
if (newsHook) {
|
| 413 |
latestNewsUrl = newsHook.link;
|
| 414 |
-
try { latestNewsSource = new URL(newsHook.link).hostname
|
| 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 và 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 có 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 và 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 và 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();
|