bep40 commited on
Commit
723af03
·
verified ·
1 Parent(s): 21ff2ad

Khôi phục 100% về commit 077b53285f03cbd22093c875097dd5e96ba063a5: full restore of target commit tree

Browse files
.cache_bust CHANGED
@@ -1 +1 @@
1
- v12-deferred-avatar+lisamy-sohee+reliability
 
1
+ 1785988878945915423
.gitattributes CHANGED
@@ -63,3 +63,6 @@ san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg filter=lfs diff=lfs me
63
  public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
64
  public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
65
  public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text
 
 
 
 
63
  public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
64
  public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
65
  public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text
66
+ image[[:space:]]-[[:space:]]2026-08-05T195019.510.png filter=lfs diff=lfs merge=lfs -text
67
+ public/avatars/vaistudio.png filter=lfs diff=lfs merge=lfs -text
68
+ public/avatars/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
chat ADDED
File without changes
edge-tts.mjs ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Edge TTS — zero-dependency Microsoft Edge neural text-to-speech for Bun/Node.
3
+ * Produces 24kHz 48kbit mono MP3 for Vietnamese voices:
4
+ * vi-VN-HoaiMyNeural (female, "Hoài My")
5
+ * vi-VN-NamMinhNeural (male, "Nam Minh")
6
+ */
7
+ import { createHash, randomUUID, randomBytes } from "node:crypto";
8
+
9
+ const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
10
+ const WIN_EPOCH = 11644473600;
11
+ const WSS_URL =
12
+ "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
13
+ const SEC_MS_GEC_VERSION = "1-143.0.3650.75";
14
+ const CHROME_VER = "143";
15
+
16
+ const UA =
17
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
18
+ `(KHTML, like Gecko) Chrome/${CHROME_VER}.0.0.0 Safari/537.36 Edg/${CHROME_VER}.0.0.0`;
19
+
20
+ function generateSecMsGec() {
21
+ let ticks = Date.now() / 1000 + WIN_EPOCH;
22
+ ticks -= ticks % 300;
23
+ ticks *= 1e7;
24
+ return createHash("sha256")
25
+ .update(`${ticks.toFixed(0)}${TRUSTED_CLIENT_TOKEN}`, "ascii")
26
+ .digest("hex")
27
+ .toUpperCase();
28
+ }
29
+
30
+ function dateToString() {
31
+ const d = new Date();
32
+ const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
33
+ const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
34
+ const p = (n) => String(n).padStart(2, "0");
35
+ return `${days[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${p(d.getUTCDate())} ` +
36
+ `${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} ` +
37
+ `GMT+0000 (Coordinated Universal Time)`;
38
+ }
39
+
40
+ function buildSSML(text, voice, { pitch = "+0Hz", rate = "+0%", volume = "+0%" } = {}) {
41
+ const esc = String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
42
+ return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
43
+ `<voice name='${voice}'><prosody pitch='${pitch}' rate='${rate}' volume='${volume}'>` +
44
+ `${esc}</prosody></voice></speak>`;
45
+ }
46
+
47
+ /** Split long text at sentence boundaries (Edge caps ~3000 chars/request). */
48
+ function splitText(text, max = 2800) {
49
+ if (text.length <= max) return [text];
50
+ const parts = [];
51
+ let rest = text;
52
+ while (rest.length > max) {
53
+ let cut = rest.lastIndexOf(". ", max);
54
+ if (cut < max * 0.5) cut = rest.lastIndexOf(" ", max);
55
+ if (cut <= 0) cut = max;
56
+ parts.push(rest.slice(0, cut + 1));
57
+ rest = rest.slice(cut + 1).trimStart();
58
+ }
59
+ if (rest) parts.push(rest);
60
+ return parts;
61
+ }
62
+
63
+ /**
64
+ * Synthesize text to mp3 bytes.
65
+ * @param {string} text
66
+ * @param {string} voice e.g. "vi-VN-HoaiMyNeural" | "vi-VN-NamMinhNeural"
67
+ * @returns {Promise<Uint8Array>}
68
+ */
69
+ export async function synthesize(text, voice = "vi-VN-HoaiMyNeural") {
70
+ const clean = String(text || "").trim().replace(/\s+/g, " ").replace(/\*\*?|`|_{1,2}/g, "");
71
+ if (!clean) return new Uint8Array(0);
72
+ const parts = splitText(clean);
73
+ const buffers = [];
74
+ for (const part of parts) {
75
+ buffers.push(await synthesizeOne(part, voice));
76
+ }
77
+ const total = buffers.reduce((n, b) => n + b.length, 0);
78
+ const out = new Uint8Array(total);
79
+ let off = 0;
80
+ for (const b of buffers) { out.set(b, off); off += b.length; }
81
+ return out;
82
+ }
83
+
84
+ function synthesizeOne(text, voice) {
85
+ const reqId = randomUUID().replace(/-/g, "");
86
+ const sec = generateSecMsGec();
87
+ const url =
88
+ `${WSS_URL}?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}` +
89
+ `&Sec-MS-GEC=${sec}&Sec-MS-GEC-Version=${SEC_MS_GEC_VERSION}` +
90
+ `&ConnectionId=${randomUUID().replace(/-/g, "")}`;
91
+
92
+ const headers = {
93
+ "User-Agent": UA,
94
+ "Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
95
+ "Pragma": "no-cache",
96
+ "Cache-Control": "no-cache",
97
+ "Cookie": `muid=${randomBytes(16).toString("hex").toUpperCase()};`,
98
+ };
99
+
100
+ // Bun native WebSocket accepts options with headers as 2nd arg.
101
+ const ws = new WebSocket(url, { headers });
102
+
103
+ return new Promise((resolve, reject) => {
104
+ const chunks = [];
105
+ let opened = false;
106
+ const timer = setTimeout(() => {
107
+ try { ws.close(); } catch {}
108
+ reject(new Error("Edge TTS timeout"));
109
+ }, 20000);
110
+
111
+ ws.onopen = () => {
112
+ opened = true;
113
+ ws.send(
114
+ `X-Timestamp:${dateToString()}\r\n` +
115
+ "Content-Type:application/json; charset=utf-8\r\n" +
116
+ "Path:speech.config\r\n\r\n" +
117
+ '{"context":{"synthesis":{"audio":{"metadataoptions":' +
118
+ '{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},' +
119
+ '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}\r\n'
120
+ );
121
+ ws.send(
122
+ `X-RequestId:${reqId}\r\n` +
123
+ "Content-Type:application/ssml+xml\r\n" +
124
+ `X-Timestamp:${dateToString()}Z\r\n` +
125
+ "Path:ssml\r\n\r\n" +
126
+ buildSSML(text, voice)
127
+ );
128
+ };
129
+
130
+ ws.onmessage = (ev) => {
131
+ const data = ev.data;
132
+ if (typeof data === "string") return;
133
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
134
+ if (buf.length < 2) return;
135
+ const hl = buf.readUInt16BE(0);
136
+ const header = buf.subarray(0, hl + 2).toString("latin1");
137
+ const body = buf.subarray(hl + 2);
138
+ if (header.includes("Path:audio")) chunks.push(body);
139
+ else if (header.includes("Path:turn.end")) {
140
+ clearTimeout(timer);
141
+ try { ws.close(); } catch {}
142
+ resolve(Buffer.concat(chunks));
143
+ }
144
+ };
145
+
146
+ ws.onerror = (e) => { if (!opened) { clearTimeout(timer); reject(e); } };
147
+ ws.onclose = () => {
148
+ if (chunks.length) { clearTimeout(timer); resolve(Buffer.concat(chunks)); }
149
+ else clearTimeout(timer);
150
+ };
151
+ });
152
+ }
image - 2026-08-05T195019.510.png ADDED

Git LFS Details

  • SHA256: 13cf0f6e2d3196e802077e0aef50e79e259c4faca785448e43e3b1c03bc695cd
  • Pointer size: 132 Bytes
  • Size of remote file: 2.11 MB
index.html CHANGED
@@ -8,7 +8,7 @@
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
11
- <link rel="stylesheet" href="./src/style.css?gc=12" />
12
  </head>
13
  <body>
14
  <main id="app">
@@ -109,6 +109,13 @@
109
  <span id="chat-title">Text Chat</span>
110
  <div id="chat-header-actions">
111
  <select id="chat-avatar-select" title="Switch avatar"></select>
 
 
 
 
 
 
 
112
  <button id="chat-audio-toggle-btn" class="icon-btn small" aria-label="Toggle avatar audio" title="Toggle avatar audio" style="display:none">
113
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
114
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
@@ -381,6 +388,10 @@
381
  .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px }
382
  .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 }
383
  .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px }
 
 
 
 
384
  .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s }
385
  .vaix-suggestion-item:last-child { border-bottom:none }
386
  .vaix-suggestion-item:hover { background:#f0f9ff }
@@ -419,8 +430,18 @@
419
  .chat-combo-sub { font-size:0.68rem;color:#94a3b8;margin-bottom:8px }
420
  .chat-combo-cards { display:flex;flex-direction:column;gap:7px;max-height:260px;overflow-y:auto }
421
  .chat-combo-cards .chat-product-card { margin:0 }
 
 
 
 
 
 
 
422
  .chat-combo-random { margin-top:8px;padding:6px 12px;background:rgba(34,211,238,0.15);color:#22d3ee;border:1px solid rgba(34,211,238,0.35);border-radius:10px;cursor:pointer;font-size:0.72rem;font-family:inherit;font-weight:600;transition:all 0.15s }
423
  .chat-combo-random:hover { background:rgba(34,211,238,0.28) }
 
 
 
424
  #detail-share-btn { display:inline-flex!important }
425
 
426
  /* ✨ FIX: Suggested Question Pills CSS */
@@ -503,11 +524,11 @@
503
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
504
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
505
  <script src="./src/cart-quote.js?gc=12"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
506
- <script src="./src/vaix-rag.js?gc=14"></script> <!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
507
- <script src="./src/greeting-news.js?gc=12"></script> <!-- Greeting HOT news cards + source links -->
508
  <script src="./src/order-sync.js?gc=12"></script> <!-- Order sync with V.AISTUDIO backend -->
509
- <script type="module" src="./src/app.js?gc=13"></script>
510
- <script src="./src/greeting-source-cards.js?gc=12"></script> <!-- FIX: Nguồn tin cards under greeting -->
511
  <script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
512
 
513
  <script>
 
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
11
+ <link rel="stylesheet" href="./src/style.css?gc=29" />
12
  </head>
13
  <body>
14
  <main id="app">
 
109
  <span id="chat-title">Text Chat</span>
110
  <div id="chat-header-actions">
111
  <select id="chat-avatar-select" title="Switch avatar"></select>
112
+ <button id="chatbox-mute-btn" class="icon-btn small" aria-label="Bật/tắt âm thanh (im lặng)" title="Bật/tắt âm thanh (im lặng)">
113
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
114
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
115
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" id="chatbox-mute-sound"></path>
116
+ <path d="M15.53 8.47a4 4 0 0 1 0 7.07" id="chatbox-mute-muted" style="display:none"></path>
117
+ </svg>
118
+ </button>
119
  <button id="chat-audio-toggle-btn" class="icon-btn small" aria-label="Toggle avatar audio" title="Toggle avatar audio" style="display:none">
120
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
121
  <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
 
388
  .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px }
389
  .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 }
390
  .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px }
391
+ .product-card .product-card-btn { align-self:center;flex-shrink:0;display:inline-flex;align-items:center;gap:5px;padding:8px 12px;border:none;border-radius:8px;background:#25d366;color:#fff;cursor:pointer;font-size:0.72rem;font-weight:600;font-family:inherit;transition:all 0.15s }
392
+ .product-card .product-card-btn:hover { background:#1fb959 }
393
+ .product-card .product-card-btn.added { background:#166534 }
394
+
395
  .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s }
396
  .vaix-suggestion-item:last-child { border-bottom:none }
397
  .vaix-suggestion-item:hover { background:#f0f9ff }
 
430
  .chat-combo-sub { font-size:0.68rem;color:#94a3b8;margin-bottom:8px }
431
  .chat-combo-cards { display:flex;flex-direction:column;gap:7px;max-height:260px;overflow-y:auto }
432
  .chat-combo-cards .chat-product-card { margin:0 }
433
+ /* Make the "Thêm giỏ" button on combo cards clearly visible */
434
+ .chat-combo-cards .chat-product-card-btn.cart-btn {
435
+ background: rgba(37,211,102,0.16);
436
+ color:#4ade80; font-weight:700; flex:1.3;
437
+ }
438
+ .chat-combo-cards .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 }
439
+ .chat-combo-cards .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff }
440
  .chat-combo-random { margin-top:8px;padding:6px 12px;background:rgba(34,211,238,0.15);color:#22d3ee;border:1px solid rgba(34,211,238,0.35);border-radius:10px;cursor:pointer;font-size:0.72rem;font-family:inherit;font-weight:600;transition:all 0.15s }
441
  .chat-combo-random:hover { background:rgba(34,211,238,0.28) }
442
+ /* Chatbox mute button — highlighted red when silent mode is ON */
443
+ #chatbox-mute-btn { color:#94a3b8 }
444
+ #chatbox-mute-btn.mute-on { color:#f87171; background:rgba(248,113,113,0.15); border:1px solid rgba(248,113,113,0.4); border-radius:6px }
445
  #detail-share-btn { display:inline-flex!important }
446
 
447
  /* ✨ FIX: Suggested Question Pills CSS */
 
524
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
525
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
526
  <script src="./src/cart-quote.js?gc=12"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
527
+ <script src="./src/vaix-rag.js?gc=30"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
528
+ <script src="./src/greeting-news.js?gc=14"></script> <!-- Greeting HOT news cards + source links -->
529
  <script src="./src/order-sync.js?gc=12"></script> <!-- Order sync with V.AISTUDIO backend -->
530
+ <script type="module" src="./src/app.js?gc=28"></script>
531
+ <script src="./src/greeting-source-cards.js?gc=14"></script> <!-- FIX: Nguồn tin cards under greeting -->
532
  <script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
533
 
534
  <script>
index.ts CHANGED
@@ -13,6 +13,14 @@ const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8");
13
  import { readdir } from "fs/promises";
14
  import { join } from "path";
15
  import { existsSync } from "fs";
 
 
 
 
 
 
 
 
16
 
17
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
18
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
@@ -282,265 +290,295 @@ async function sessionHandler(req: Request): Promise<Response> {
282
  }
283
  }
284
 
285
- // ── Text-only chat endpoint ──
286
- // Uses S2S backend in text-only mode: POST /session text chat via WebSocket
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  async function textChatHandler(req: Request): Promise<Response> {
288
- if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 });
289
  try {
290
  const reqBody = await req.json();
291
- const userMessage = reqBody.message || "";
292
  if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
293
 
294
- // Create S2S session in text-only mode
295
- const sessionResp = await fetch(UPSTREAM + "/session", {
296
- method: "POST",
297
- headers: { "Content-Type": "application/json" },
298
- body: "{}",
299
- });
300
- if (!sessionResp.ok) {
301
- const errBody = await sessionResp.text().catch(() => "");
302
- return Response.json({
303
- error: "Failed to create S2S session",
304
- status: sessionResp.status,
305
- detail: errBody.slice(0, 500),
306
- }, { status: 502 });
307
- }
308
- const sessionData = await sessionResp.json();
309
- if (sessionData.state === "queued") {
310
- // Poll the queue
311
- let queueResp;
312
- let attempts = 0;
313
- do {
314
- await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000));
315
- queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), {
316
- headers: { "Content-Type": "application/json" },
317
- });
318
- if (!queueResp.ok) continue;
319
- const queueData = await queueResp.json();
320
- if (queueData.state === "queued") {
321
- attempts++;
322
- if (attempts > 30) {
323
- return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 });
324
- }
325
- continue;
326
- }
327
- const grant = queueData;
328
- // Connect WebSocket
329
- const ws = new WebSocket(grant.connect_url);
330
-
331
- const wsPromise = new Promise<any>((resolve, reject) => {
332
- ws.binaryType = "arraybuffer";
333
- let msgReceived = false;
334
-
335
- ws.onopen = () => {
336
- // Session created wait for session.updated then send text
337
- const waitForSession = async () => {
338
- while (!msgReceived) {
339
- // Wait for session.created
340
- // We'll use a timeout approach
341
- }
342
- };
343
-
344
- // Send session.update then user message
345
- setTimeout(() => {
346
- ws.send(JSON.stringify({
347
- type: "session.update",
348
- session: {
349
- type: "realtime",
350
- instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.",
351
- audio: { output: { voice: "Dylan" } }
352
- }
353
- }));
354
- }, 500);
355
-
356
- // Send user message
357
- setTimeout(() => {
358
- ws.send(JSON.stringify({
359
- type: "conversation.item.create",
360
- item: {
361
- type: "message",
362
- role: "user",
363
- content: [{ type: "input_text", text: userMessage }]
364
- }
365
- }));
366
- ws.send(JSON.stringify({ type: "response.create" }));
367
- }, 1000);
368
-
369
- // Collect responses
370
- let fullTranscript = "";
371
- let audioBuffer = "";
372
- let responseDone = false;
373
-
374
- const handleMessage = (raw: string) => {
375
- try {
376
- const event = JSON.parse(raw);
377
- const type = event?.type;
378
-
379
- if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") {
380
- const delta = (event.transcript || event.delta || "");
381
- if (delta) fullTranscript += delta;
382
- }
383
- if (type === "response.audio_transcript.done" || type === "response.done") {
384
- responseDone = true;
385
- clearTimeout(timeout);
386
- msgReceived = true;
387
- resolve({
388
- transcript: fullTranscript.trim(),
389
- audio: audioBuffer,
390
- audioFormat: "pcm16",
391
- });
392
- }
393
- } catch {}
394
- };
395
-
396
- ws.onmessage = (e: any) => {
397
- const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
398
- handleMessage(data);
399
- };
400
-
401
- ws.onerror = () => reject(new Error("WebSocket error"));
402
-
403
- // Timeout
404
- const timeout = setTimeout(() => {
405
- if (!responseDone) {
406
- msgReceived = true;
407
- try { ws.close(1000, "timeout"); } catch {}
408
- resolve({
409
- transcript: fullTranscript.trim() || "No response received.",
410
- audio: "",
411
- });
412
  }
413
- }, 15000);
414
- };
415
-
416
- ws.onclose = () => {
417
- if (!msgReceived) {
418
- msgReceived = true;
419
- resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" });
420
- }
421
- };
422
- });
423
-
424
- return Response.json(await wsPromise);
425
- } while (true);
426
- }
427
-
428
- // Session granted directly — connect WebSocket
429
- const grant = sessionData;
430
- const ws = new WebSocket(grant.connect_url);
431
-
432
- return new Promise<Response>((resolve, reject) => {
433
- ws.binaryType = "arraybuffer";
434
- let msgReceived = false;
435
-
436
- ws.onopen = () => {
437
- // Send session.update
438
- setTimeout(() => {
439
- ws.send(JSON.stringify({
440
- type: "session.update",
441
- session: {
442
- type: "realtime",
443
- instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short, natural, and warm. Only respond directly to the user's message in text form. Do not ask questions or request audio input.",
444
- audio: { output: { voice: "Dylan" } }
445
- }
446
- }));
447
- }, 500);
448
-
449
- // Send user message via text
450
- setTimeout(() => {
451
- ws.send(JSON.stringify({
452
- type: "conversation.item.create",
453
- item: {
454
- type: "message",
455
- role: "user",
456
- content: [{ type: "input_text", text: userMessage }]
457
  }
458
- }));
459
- ws.send(JSON.stringify({ type: "response.create" }));
460
- }, 1000);
461
-
462
- // Collect response
463
- let fullTranscript = "";
464
- let responseDone = false;
465
-
466
- const handleMessage = (raw: string) => {
467
- try {
468
- const event = JSON.parse(raw);
469
- const type = event?.type;
470
-
471
- if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") {
472
- const delta = event.delta || "";
473
- if (delta) fullTranscript += delta;
474
- }
475
- if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") {
476
- const segment = event.transcript || "";
477
- if (segment) fullTranscript += segment;
478
- }
479
- if (type === "response.done") {
480
- responseDone = true;
481
- clearTimeout(timeout);
482
- msgReceived = true;
483
- try { ws.close(1000, "done"); } catch {}
484
- resolve(Response.json({
485
- transcript: fullTranscript.trim(),
486
- status: event.response?.status ?? "completed",
487
- }));
488
- }
489
- } catch (e: any) {
490
- console.warn("[chat] parse error:", e.message);
491
  }
492
- };
493
-
494
- ws.onmessage = (e: any) => {
495
- const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
496
- handleMessage(data);
497
- };
498
-
499
- ws.onerror = () => {
500
- if (!msgReceived) {
501
- msgReceived = true;
502
- try { ws.close(1000, "error"); } catch {}
503
- resolve(Response.json({
504
- transcript: "Error connecting to chat service.",
505
- status: "error",
506
- }));
 
507
  }
508
- };
509
-
510
- ws.onclose = () => {
511
- if (!msgReceived) {
512
- msgReceived = true;
513
- resolve(Response.json({
514
- transcript: fullTranscript.trim() || "Session closed.",
515
- status: "closed",
516
- }));
517
  }
518
- };
519
-
520
- // Timeout
521
- const timeout = setTimeout(() => {
522
- if (!msgReceived) {
523
- msgReceived = true;
524
- try { ws.close(1000, "timeout"); } catch {}
525
- resolve(Response.json({
526
- transcript: fullTranscript.trim() || "No response received. The service may be busy.",
527
- status: "timeout",
528
- }));
529
  }
530
- }, 20000);
531
- };
532
-
533
- ws.onclose = () => {
534
- if (!msgReceived) {
535
- msgReceived = true;
536
- clearTimeout(timeout);
537
- resolve(Response.json({
538
- transcript: "Connection closed.",
539
- status: "closed",
540
- }));
541
  }
542
- };
543
- });
 
 
 
544
  } catch (err: any) {
545
  console.error("[/api/chat] Error:", err.message);
546
  return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
@@ -672,6 +710,54 @@ const server = Bun.serve({
672
  "/api/session": { POST: sessionHandler },
673
  "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
674
  "/api/chat": { POST: textChatHandler },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
676
  "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
677
  "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!),
 
13
  import { readdir } from "fs/promises";
14
  import { join } from "path";
15
  import { existsSync } from "fs";
16
+ import { synthesize as edgeSynthesize } from "./edge-tts.mjs";
17
+
18
+ // ── Edge TTS memory cache: { key: audioBytes } — bound to prevent growth ──
19
+ const TTS_CACHE = new Map<string, Uint8Array>();
20
+ const TTS_CACHE_MAX = 400;
21
+ function ttsCacheKey(text: string, voice: string) {
22
+ return voice + "|" + text.trim().slice(0, 600);
23
+ }
24
 
25
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
26
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
 
290
  }
291
  }
292
 
293
+ // ── Text-only chat endpoint ─────────────────────────────────────────────
294
+ // Chat + vấn nhanh dùng Google Gemma qua HF Inference Providers (router).
295
+ // Streaming (SSE) + cascade gemma-4-31B -> gemma-3-12B -> gemma-3-4B for resilience.
296
+ const CHAIN: Array<{ model: string }> = [
297
+ { model: "google/gemma-4-31B-it" },
298
+ { model: "google/gemma-3-12b-it" },
299
+ { model: "google/gemma-3-4b-it" },
300
+ ];
301
+ const ROUTER = "https://router.huggingface.co/v1/chat/completions";
302
+ const CHAT_TIMEOUT_MS = 45000;
303
+
304
+ // Helper to present one model. Returns { reply, model } or throws.
305
+ async function chatOnce(model: string, messages: any[], token: string) {
306
+ const body = {
307
+ model,
308
+ messages,
309
+ max_tokens: 700,
310
+ temperature: 0.7,
311
+ top_p: 0.9,
312
+ stream: false,
313
+ };
314
+ const resp = await fetch(ROUTER, {
315
+ method: "POST",
316
+ headers: {
317
+ "Content-Type": "application/json",
318
+ "Authorization": `Bearer ${token}`,
319
+ "User-Agent": "gemma-avatar",
320
+ },
321
+ body: JSON.stringify(body),
322
+ signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
323
+ });
324
+ const text = await resp.text().catch(() => "");
325
+ if (!resp.ok) {
326
+ const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200));
327
+ err.status = resp.status;
328
+ throw err;
329
+ }
330
+ try {
331
+ const data = JSON.parse(text);
332
+ const raw = data?.choices?.[0]?.message?.content;
333
+ if (raw && String(raw).trim()) return { reply: String(raw).trim(), model };
334
+ } catch (_) {}
335
+ const err: any = new Error("Empty response from " + model);
336
+ err.status = 500;
337
+ throw err;
338
+ }
339
+
340
+ // ── Server-side tool execution (restores news/web/product search in /api/chat) ──
341
+ const CHAT_TOOLS = [
342
+ { type: "function", function: {
343
+ name: "search_news",
344
+ description: "Tìm tin tức tiếng Việt mới nhất về một chủ đề. Dùng khi người dùng hỏi về tin tức, sự kiện, chương trình khuyến mãi, chiến dịch, hoặc thông tin thời sự.",
345
+ parameters: { type: "object", properties: { query: { type: "string", description: "Chủ đề / từ khóa tin tức" } }, required: ["query"] },
346
+ }},
347
+ { type: "function", function: {
348
+ name: "search_web",
349
+ description: "Tìm kiếm thông tin trên web cho một câu hỏi hoặc chủ đề bất kỳ.",
350
+ parameters: { type: "object", properties: { query: { type: "string", description: "Câu hỏi hoặc từ khóa tìm kiếm" } }, required: ["query"] },
351
+ }},
352
+ ];
353
+
354
+ const sleepMs = (ms: number) => new Promise(r => setTimeout(r, ms));
355
+
356
+ async function ddgSearch(query: string) {
357
+ const results: Array<{ title: string; snippet: string; url: string; source: string }> = [];
358
+ const queries = [
359
+ query,
360
+ query + " hôm nay",
361
+ (query.length < 30 ? "tin tức " + query : query),
362
+ ];
363
+ await Promise.all(queries.map(q =>
364
+ fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) })
365
+ .then(r => r.text())
366
+ .then(h => {
367
+ const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
368
+ const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
369
+ for (let i = 0; i < Math.min(lm.length, 5); i++) {
370
+ let href = lm[i][1].replace(/&amp;/g, "&");
371
+ const ru = href.match(/uddg=(https?%3[^&]+)/i);
372
+ if (ru) { try { href = decodeURIComponent(ru[1]); } catch (_) {} }
373
+ const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
374
+ if (!title) continue;
375
+ const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
376
+ let source = "";
377
+ try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {}
378
+ results.push({ title, snippet, url: href, source });
379
+ }
380
+ })
381
+ .catch(() => {})
382
+ ));
383
+ const seen = new Set<string>();
384
+ const unique = results.filter(r => {
385
+ if (!r.url || seen.has(r.url)) return false;
386
+ seen.add(r.url);
387
+ return r.source && !/duckduckgo|google/i.test(r.source);
388
+ });
389
+ return unique.slice(0, 8);
390
+ }
391
+
392
+ // Reliable Vietnamese news search via Google News RSS (proven reachable from
393
+ // HF Spaces — the duckduckgo html endpoint is NOT reliably reachable).
394
+ async function searchNewsGoogle(query: string) {
395
+ try {
396
+ const resp = await fetch(
397
+ "https://news.google.com/rss/search?q=" + encodeURIComponent(query) +
398
+ "&hl=vi-VN&gl=VN&ceid=VN:vi",
399
+ { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" }, signal: AbortSignal.timeout(15000) }
400
+ );
401
+ if (!resp.ok) throw new Error("RSS failed " + resp.status);
402
+ const rssText = await resp.text();
403
+ const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
404
+ const articles: Array<{ title: string; url: string; source: string; desc: string }> = [];
405
+ for (const block of itemBlocks) {
406
+ const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
407
+ const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
408
+ const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i);
409
+ const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
410
+ if (!tm) continue;
411
+ let title = tm[1].replace(/\+|_/g, " ").trim();
412
+ let source = "";
413
+ if (sm) {
414
+ source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim();
415
+ if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim();
416
+ }
417
+ if (!title) continue;
418
+ let url = "";
419
+ if (lm) url = (lm[1] || "").trim();
420
+ let desc = "";
421
+ if (dm) {
422
+ desc = dm[1].replace(/<[^>]*>/g, " ").replace(/\bhttps?:\/\/\S+/gi, "")
423
+ .replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
424
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ")
425
+ .replace(/\s+/g, " ").trim().slice(0, 160);
426
+ }
427
+ if (title && url) articles.push({ title, url, source: source || "", desc });
428
+ if (articles.length >= 8) break;
429
+ }
430
+ return articles;
431
+ } catch (e) {
432
+ return [];
433
+ }
434
+ }
435
+
436
+ async function executeChatTool(name: string, args: any): Promise<string> {
437
+ const clean = (s: any) => String(s || "").trim();
438
+ try {
439
+ if (name === "search_news") {
440
+ const q = clean(args?.query);
441
+ if (!q) return "Thiếu từ khóa tìm kiếm tin tức.";
442
+ const arts = await searchNewsGoogle(q);
443
+ if (!arts.length) return "Không tìm thấy tin tức cho '" + q + "'.";
444
+ return arts.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.desc ? "\n " + r.desc : "") + "\n " + r.url).join("\n");
445
+ }
446
+ if (name === "search_web") {
447
+ const q = clean(args?.query);
448
+ if (!q) return "Thiếu từ khóa tìm kiếm.";
449
+ const res = await ddgSearch(q);
450
+ if (!res.length) return "Không tìm thấy kết quả cho '" + q + "'.";
451
+ return res.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.snippet ? "\n " + r.snippet : "") + "\n " + r.url).join("\n");
452
+ }
453
+ return "Tool không hỗ trợ: " + name;
454
+ } catch (e: any) {
455
+ return "Lỗi khi chạy tool " + name + ": " + String(e?.message || e);
456
+ }
457
+ }
458
+
459
+ // One chat completion round that supports tool calls. Returns the message content
460
+ // and any tool_calls (name + args + id).
461
+ async function chatOnceWithTools(model: string, messages: any[], token: string, withTools: boolean) {
462
+ const body: any = {
463
+ model,
464
+ messages,
465
+ max_tokens: 800,
466
+ temperature: 0.7,
467
+ top_p: 0.9,
468
+ stream: false,
469
+ tools: withTools ? CHAT_TOOLS : undefined,
470
+ tool_choice: withTools ? "auto" : undefined,
471
+ };
472
+ const resp = await fetch(ROUTER, {
473
+ method: "POST",
474
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": "gemma-avatar" },
475
+ body: JSON.stringify(body),
476
+ signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
477
+ });
478
+ const text = await resp.text().catch(() => "");
479
+ if (!resp.ok) {
480
+ const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200));
481
+ err.status = resp.status;
482
+ throw err;
483
+ }
484
+ const data = JSON.parse(text);
485
+ const msg = data?.choices?.[0]?.message;
486
+ const toolCalls: any[] = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [];
487
+ return { content: String(msg?.content || "").trim(), toolCalls, model };
488
+ }
489
+
490
  async function textChatHandler(req: Request): Promise<Response> {
 
491
  try {
492
  const reqBody = await req.json();
493
+ const userMessage = (reqBody.message || "").toString();
494
  if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
495
 
496
+ let conversation = Array.isArray(reqBody.history) ? reqBody.history : [];
497
+ conversation = conversation.slice(-12).map((m: any) => ({
498
+ role: (m.role === "assistant" || m.role === "user") ? m.role : "user",
499
+ content: String(m.content || ""),
500
+ }));
501
+
502
+ const system = [
503
+ "Bạn trợ của V.AI STUDIO. Bạn giỏi tư vấn thiết bị nhà bếp và khóa cửa (Grob, Hafele, Eurogold, Malloca...), NHƯNG bạn cũng là trợ lý tổng quát thân thiện có thể trả lời tin t���c và câu hỏi thông thường.",
504
+ "Trả lời ngắn gọn, tự nhiên, thân thiện bằng tiếng Việt như một người trò chuyện thật, không gượng gạo.",
505
+ "KHÔNG dùng các nhãn đoạn kiểu 'Diễn biến chính:', 'Hành động:', 'Nội dung:', 'Tóm tắt:' hay bất kỳ tiêu đề máy móc nào. Viết thành lời văn liền mạch, xúc tích cho dễ đọc.",
506
+ "Khi được hỏi về sản phẩm, hãy tư vấn theo nhu cầu (bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, nồi chiên, máy rửa chén, khóa...) và kể tên sản phẩm cụ thể khi phù hợp.",
507
+ "QUAN TRỌNG — TIN TỨC & SỰ KIỆN HIỆN TẠI: Khi người dùng hỏi về tin tức, sự kiện mới, chương trình khuyến mãi, chiến dịch, hoặc bất kỳ thông tin thời sự/gần đây nào, bạn PHẢI gọi tool search_news (có sẵn) để tìm tin mới nhất, rồi dựa vào kết quả trả về để trả lời. Đừng từ chối hay nói 'mình không cập nhật tin tức'. Hãy tóm tắt các bài báo tìm được một cách ngắn gọn, kèm nguồn.",
508
+ "Khi cần thông tin chung không phải tin tức, bạn thể gọi search_web. Nếu chưa đủ thông tin, hãy hỏi thêm thương hiệu, kích thước, chất liệu hoặc ngân sách một cách tự nhiên.",
509
+ ].join("\n");
510
+ const messages = [{ role: "system", content: system }, ...conversation, { role: "user", content: userMessage }];
511
+
512
+ const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
513
+ if (!token) console.error("[chat] No inference token configured (HF_INFERENCE_TOKEN missing).");
514
+
515
+ let lastErr: any = null;
516
+ // Decide whether to offer tools: news/current-event/product questions benefit
517
+ // from search; plain chit-chat doesn't need it. Offer tools always except very
518
+ // short greetings — the model decides via tool_choice:"auto".
519
+ const withTools = userMessage.trim().length >= 2 && !/^(chao|hello|hi|xin chao|cam on|oke|ok|bye)[.\s]*$/i.test(userMessage.trim());
520
+ for (const step of CHAIN) {
521
+ let retries = 3;
522
+ for (let attempt = 0; attempt <= retries; attempt++) {
523
+ try {
524
+ // Tool-calling loop: keep asking with tools, execute any tool_calls the
525
+ // model requests, and feed results back until it gives a final answer.
526
+ let msgs = [...messages];
527
+ let finalReply = "";
528
+ let finalModel = step.model;
529
+ for (let round = 0; round < 3; round++) {
530
+ const { content, toolCalls, model } = await chatOnceWithTools(step.model, msgs, token, withTools);
531
+ finalModel = model;
532
+ if (toolCalls && toolCalls.length) {
533
+ msgs.push({ role: "assistant", content: content || null, tool_calls: toolCalls.map((tc: any) => ({ id: tc.id, type: "function", function: { name: tc.function?.name, arguments: tc.function?.arguments } })) });
534
+ for (const tc of toolCalls) {
535
+ let argsObj: any = {};
536
+ try { argsObj = JSON.parse(tc.function?.arguments || "{}"); } catch (_) {}
537
+ const resultText = await executeChatTool(tc.function?.name || "", argsObj);
538
+ msgs.push({ role: "tool", tool_call_id: tc.id, content: resultText });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  }
540
+ continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  }
542
+ finalReply = content;
543
+ break;
544
+ }
545
+ if (!finalReply) {
546
+ const e: any = new Error("Empty response after tool loop");
547
+ e.status = 500;
548
+ throw e;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  }
550
+ const clean = String(finalReply)
551
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
552
+ .replace(/\*([^*]+)\*/g, "$1")
553
+ .replace(/__([^_]+)__/g, "$1")
554
+ .replace(/`([^`]+)`/g, "$1")
555
+ .replace(/^#{1,6}\s+/gm, "")
556
+ .replace(/[ \t]+\n/g, "\n")
557
+ .replace(/\n{3,}/g, "\n\n")
558
+ .trim();
559
+ return Response.json({ transcript: clean, raw: String(finalReply).slice(0, 40), model: finalModel, status: "completed" });
560
+ } catch (e: any) {
561
+ lastErr = e;
562
+ const code = e?.status;
563
+ console.error("[chat] model " + step.model + " attempt " + attempt + " failed:", code, e?.message);
564
+ if (code === 401) {
565
+ return Response.json({ error: "Chat chưa được cấu hình token inference hợp lệ. Vui lòng thêm Secret HF_INFERENCE_TOKEN (User Access Token có quyền inference.serverless.write) trong Settings của Space.", status: 401, detail: e?.message }, { status: 401 });
566
  }
567
+ if (code === 403) {
568
+ return Response.json({ error: "Token inference không có quyền gọi model này. Hãy kiểm tra quyền inference.serverless.write của token.", status: 403, detail: e?.message }, { status: 403 });
 
 
 
 
 
 
 
569
  }
570
+ if (code === 402) {
571
+ return Response.json({ error: "Tài khoản đã hết credits Inference Providers. Vui lòng nạp credits tại https://huggingface.co/settings/billing để dùng Google Gemma cho chat.", status: 402, detail: e?.message }, { status: 402 });
 
 
 
 
 
 
 
 
 
572
  }
573
+ // Retry transient failures with exponential backoff (429/5xx/timeout).
574
+ const wait = 250 * Math.pow(2, attempt);
575
+ await new Promise(r => setTimeout(r, wait));
 
 
 
 
 
 
 
 
576
  }
577
+ }
578
+ // Cascade to next (smaller) model.
579
+ }
580
+ console.error("[chat] All models failed:", lastErr?.status, lastErr?.message);
581
+ return Response.json({ error: "Chat service error", status: (lastErr?.status || 502), detail: String(lastErr?.message || "") }, { status: lastErr?.status || 502 });
582
  } catch (err: any) {
583
  console.error("[/api/chat] Error:", err.message);
584
  return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
 
710
  "/api/session": { POST: sessionHandler },
711
  "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
712
  "/api/chat": { POST: textChatHandler },
713
+ "/api/tts": { GET: async (req: Request) => {
714
+ try {
715
+ const url2 = new URL(req.url);
716
+ const text = (url2.searchParams.get("text") || "").trim().replace(/\s+/g, " ").slice(0, 2000);
717
+ const voice = url2.searchParams.get("voice") || "vi-VN-HoaiMyNeural";
718
+ const allowed = voice === "vi-VN-NamMinhNeural" ? voice : "vi-VN-HoaiMyNeural";
719
+ if (!text) return Response.json({ error: "Missing ?text=" }, { status: 400 });
720
+ const key = ttsCacheKey(text, allowed);
721
+ let audio = TTS_CACHE.get(key);
722
+ if (!audio) {
723
+ try {
724
+ // Synthesize in a separate Bun subprocess to isolate WebSocket crashes.
725
+ const proc = Bun.spawn(
726
+ [process.execPath, join("/app", "tts-worker.mjs"), allowed, text],
727
+ { stdout: "pipe", stderr: "pipe" },
728
+ );
729
+ const exitCode = await proc.exited;
730
+ const out = await new Response(proc.stdout).arrayBuffer();
731
+ const errText = await new Response(proc.stderr).text();
732
+ if (exitCode !== 0) {
733
+ console.error("[/api/tts] worker failed code="+exitCode+" err="+errText);
734
+ return Response.json({ error: "TTS failed", code: exitCode, detail: errText }, { status: 502 });
735
+ }
736
+ audio = new Uint8Array(out);
737
+ } catch (e: any) {
738
+ console.error("[/api/tts] Error:", e?.message || e, e?.stack || "");
739
+ return Response.json({ error: "TTS failed", detail: String(e?.message || e), stack: String(e?.stack || "") }, { status: 502 });
740
+ }
741
+ if (!audio || !audio.length) {
742
+ return Response.json({ error: "Empty audio" }, { status: 502 });
743
+ }
744
+ TTS_CACHE.set(key, audio);
745
+ if (TTS_CACHE.size > TTS_CACHE_MAX) {
746
+ const firstKey = TTS_CACHE.keys().next().value;
747
+ if (firstKey) TTS_CACHE.delete(firstKey);
748
+ }
749
+ }
750
+ return new Response(audio, {
751
+ headers: {
752
+ "Content-Type": "audio/mpeg",
753
+ "Content-Length": String(audio.length),
754
+ "Cache-Control": "public, max-age=3600",
755
+ },
756
+ });
757
+ } catch (e2: any) {
758
+ return Response.json({ error: "route error", detail: String(e2?.message || e2), stack: String(e2?.stack || "") }, { status: 500 });
759
+ }
760
+ }},
761
  "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
762
  "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
763
  "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!),
package.json CHANGED
@@ -15,7 +15,8 @@
15
  },
16
  "dependencies": {
17
  "@met4citizen/talkinghead": "^1.7.0",
18
- "three": "~0.180.0"
 
19
  },
20
  "version": "1.5.1"
21
  }
 
15
  },
16
  "dependencies": {
17
  "@met4citizen/talkinghead": "^1.7.0",
18
+ "three": "~0.180.0",
19
+ "edge-tts-universal": "^1.4.0"
20
  },
21
  "version": "1.5.1"
22
  }
public/avatars/vaistudio.png ADDED

Git LFS Details

  • SHA256: 13cf0f6e2d3196e802077e0aef50e79e259c4faca785448e43e3b1c03bc695cd
  • Pointer size: 132 Bytes
  • Size of remote file: 2.11 MB
public/avatars/vaistudio_center.png ADDED

Git LFS Details

  • SHA256: f719575626c5bc3f2ed6e028206d4fc02086ceb1618b43a7cc3a92e4a440ae2f
  • Pointer size: 132 Bytes
  • Size of remote file: 6.89 MB
src/app.js CHANGED
@@ -22,9 +22,9 @@ const DEFAULT_INSTRUCTIONS = [
22
  "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).",
23
  "CRITICAL: Same language as user. VN numbers as words (ba mươi lăm not 35), dates as 'ngày 9 tháng 7 năm 2026', currency as 'năm mươi nghìn đồng'.",
24
  "Keep replies short, natural, warm.",
25
- "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, combo_suggest (gợi ý combo gian bếp: bếp từ + máy hút mùi + chậu rửa + vòi rửa, lọc theo thương hiệu/giá/chất liệu/màu sắc), get_current_datetime, search_wikipedia, search_web, search_news (search real-time Vietnamese news), set_mood, make_hand_gesture, make_facial_expression.",
26
  "PRODUCT RULES: When user asks about ANY product — FIRST call query_catalog(query). This searches ALL products AND shows them in the panel AND returns similar product suggestions. After query_catalog, ALWAYS mention similar products and ask if user wants to see them. If user wants details, call show_product(name/SKU) to open the detail modal which also shows similar products below.",
27
- "COMBO RULES: When user asks for a combo / set / bộ / gói of 2 or more kitchen appliances (e.g. 'combo bếp từ + máy hút mùi', 'bộ nồi bếp', ' combo bếp từ máy hút mùi dưới 20 triệu không', 'combo chậu + vòi') — call combo_suggest and pass the criteria. IMPORTANT: put each requested appliance in the categories array (e.g. categories:['bếp từ','máy hút mùi'] for 2 products; a combo can be 2, 3 or 4 products). Convert price phrases to numbers in VND: 'dưới 20 triệu' → maxPrice:20000000, 'trên 30 triệu' → minPrice:30000000, 'khoảng 15 triệu' → minPrice:14000000,maxPrice:16000000. Pass brand like Malloca/Eurogold/Grob, material like inox/kính/gốm, color like đen/trắng/bạc if mentioned. combo_suggest will show product cards and a text summary.",
28
  "SIMILAR PRODUCTS: query_catalog() and show_product() both automatically recommend related products by same category/brand/price range. Use this to cross-sell: 'Chị có muốn xem thêm sản phẩm tương tự không?'",
29
  "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.",
30
  "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.",
@@ -48,7 +48,7 @@ const TOOL_DEFS = [
48
  { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}},
49
  { type:"function", name:"query_catalog", description:"SEARCH all products + show in panel + return details + suggest similar products. ALWAYS use FIRST for product questions.", parameters:{type:"object", properties:{query:{type:"string", description:"Product name, brand, SKU, category, or feature"}}, required:["query"]}},
50
  { type:"function", name:"show_product", description:"Open product detail modal + show similar products. Use AFTER query_catalog. Pass name/SKU.", parameters:{type:"object", properties:{product_name:{type:"string", description:"Product name or SKU"}}, required:["product_name"]}},
51
- { type:"function", name:"combo_suggest", description:"Suggest a kitchen COMBO of 2, 3 or 4 appliances (bếp từ, máy hút mùi, chậu rửa, vòi rửa — same brand ideally). Call when user wants a combo/set/bộ/gói of multiple products. Provide categories (which appliances, e.g. ['bếp từ','máy hút mùi']), brand, price range (minPrice/maxPrice in VND — convert 'dưới 20 triệu' to maxPrice:20000000), material (inox/kính/gốm), or color (đen/trắng/bạc).", parameters:{type:"object", properties:{brand:{type:"string", description:"Preferred brand e.g. Malloca, Eurogold, Grob"}, minPrice:{type:"number", description:"Minimum price in VND (e.g. 'trên 30 triệu' → 30000000)"}, maxPrice:{type:"number", description:"Maximum price in VND (e.g. 'dưới 20 triệu' → 20000000)"}, material:{type:"string", description:"Material e.g. inox, kính, gốm"}, color:{type:"string", description:"Color e.g. đen, trắng, bạc"}, categories:{type:"array", items:{type:"string"}, description:"Which appliance categories to include, e.g. ['bếp từ','máy hút mùi'] for 2 products, or all of ['bếp từ','máy hút mùi','chậu rửa','vòi rửa']"}}, required:[]}},
52
  ];
53
 
54
  const $ = s => document.querySelector(s);
@@ -172,6 +172,121 @@ function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTime
172
  // No fixed topics — every fetch pulls fresh keywords from actual news headlines
173
  let HOT_TOPIC_KEYWORDS = []; // Populated dynamically by fetchHotTags
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  /**
176
  * Extract trending keywords from Google News RSS titles.
177
  * Returns array of {label, query, keyword} objects — fully dynamic, changes every fetch.
@@ -443,13 +558,13 @@ function findBestTopicMatch(responseText) {
443
  * PRIMARY source, so every card's image ALWAYS matches its news content.
444
  */
445
  async function generateSourceCards(responseText) {
446
- const matchedTopic = findBestTopicMatch(responseText);
447
- const query = matchedTopic ? matchedTopic.query : responseText;
448
  // Prefer a clean topical keyword that Google News can search on
449
  const searchQuery = query.replace(/^#/, '').replace(/ tin tức.*$| thông tin.*$| mới nhất.*$| hôm nay.*$| nóng nhất.*$| Việt Nam hôm nay.*$/, '').trim() || query;
450
 
451
  // ── PRIMARY: VnExpress RSS items carry title+image+url+desc together → no mismatch ──
452
  let cards = [];
 
453
  const catLabel = matchedTopic ? matchedTopic.label : 'mới nhất';
454
  try {
455
  const resp = await fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`);
@@ -529,7 +644,7 @@ async function generateSourceCards(responseText) {
529
  }
530
  }
531
 
532
- if (cards.length > 0) return cards;
533
 
534
  // Fallback #2: topic search (category landing pages) — no index-based images
535
  try {
@@ -538,7 +653,7 @@ async function generateSourceCards(responseText) {
538
  const data = await resp.json();
539
  const results = (data.results || []).slice(0, 6);
540
  if (results.length > 0) {
541
- return results.map(r => ({
542
  source: r.source || 'Nguồn tin',
543
  title: r.title,
544
  url: r.url,
@@ -546,7 +661,7 @@ async function generateSourceCards(responseText) {
546
  category: catLabel,
547
  desc: r.snippet || '',
548
  image: '',
549
- }));
550
  }
551
  }
552
  } catch (e) {
@@ -554,11 +669,11 @@ async function generateSourceCards(responseText) {
554
  }
555
 
556
  // Fallback: general news portals
557
- return [
558
  { source: 'VnExpress', title: 'Tin tổng hợp trong ngày cập nhật mới nhất', url: 'https://vnexpress.net/', icon: '📰', category: catLabel, image: '' },
559
  { source: 'Tuổi Trẻ', title: 'Thông tin đời sống xã hội và kinh tế', url: 'https://tuoitre.vn/', icon: '📰', category: catLabel, image: '' },
560
  { source: 'Thanh Niên', title: 'Nhìn đa chiều các vấn đề thời sự', url: 'https://thanhnien.vn/', icon: '📰', category: catLabel, image: '' },
561
- ];
562
  }
563
 
564
  /** Create DOM element for suggestion pill - black bg like chatbox */
@@ -657,10 +772,10 @@ function buildNewsSuggestions(sourceObj) {
657
  const norm = String(short).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd');
658
  const subj = short.length > 60 ? short.slice(0, 57) + '…' : short;
659
  return [
660
- 'Cho tôi biết thêm về "' + subj + '"',
661
- 'Nội dung chi tiết của tin "' + subj + '" là gì?',
662
- 'Có thông tin mới nhất nào về "' + subj + '" không?',
663
- 'Bối cảnh diễn biến của "' + subj + '" như thế nào?'
664
  ];
665
  }
666
 
@@ -708,7 +823,7 @@ function createSourceCard(sourceObj) {
708
  const artUrl = (this.getAttribute("data-url") || '').trim();
709
  if (artTitle) {
710
  // Ask AI about the EXACT article — prefer fetching its real content
711
- sendUserSuggestion('Cho tôi biết thêm về tin: "' + artTitle + '". Hãy tìm hiểu và tóm tắt nội dung, diễn biến mới nhất liên quan tới tin này.');
712
  } else {
713
  sendUserSuggestion("Cho tôi xem tin tức mới nhất");
714
  }
@@ -749,33 +864,38 @@ function buildPostMessageContextBlock(responseText, isGreeting) {
749
  container.appendChild(suggestionsRow);
750
  }
751
 
752
- // 2) HOT trending tags row
753
- const tagsPromise = fetchHotTags();
754
- const hotTagsRow = document.createElement("div");
755
- hotTagsRow.className = "hot-tags-row";
756
-
757
- const label = document.createElement("span");
758
- label.className = "hot-tags-label";
759
- label.innerHTML = '<span class="hot-tag-dot"></span> HOT';
760
- hotTagsRow.appendChild(label);
761
-
762
- tagsPromise.then(tags => {
763
- hotTagsRow.innerHTML = '';
764
  hotTagsRow.appendChild(label);
765
- tags.forEach(tag => hotTagsRow.appendChild(createHotTag(tag)));
766
- }).catch(err => {
767
- console.warn('[HOT] Failed to update tags:', err);
768
- });
769
- container.appendChild(hotTagsRow);
770
-
771
- // 3) Source news cards (non-greeting, lengthy responses)
772
- if (!isGreeting && responseText && responseText.length > 30) {
773
- generateSourceCards(responseText).then(sources => {
774
- const sourceCardsRow = document.createElement("div");
775
- sourceCardsRow.className = "source-cards-row";
776
- sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src)));
777
- container.appendChild(sourceCardsRow);
778
  });
 
 
 
 
 
 
 
 
 
 
 
 
779
  }
780
 
781
  return container;
@@ -791,9 +911,24 @@ function addChatMessage(role, text, isGreeting) {
791
  m.className = "chat-message " + role;
792
  if (role === "assistant" && text) {
793
  const mt = document.createElement("div");
 
794
  mt.textContent = text;
795
  m.appendChild(mt);
796
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
  // Product cards
798
  if (_pendingProductCards) {
799
  const pc = document.createElement("div");
@@ -886,30 +1021,197 @@ function showTextChat(s){
886
  }
887
  function sendTextViaSession(t){if(!client)return false;const s=client._status;if(s!=="connected"&&s!=="ai-speaking"&&s!=="processing"&&s!=="user-speaking")return false;setCaption("SENDING…");client.sendUserText(t);client.requestResponse();return true}
888
 
889
- // Store callback for suggested question sends
890
  window._triggerSuggestionSend = function(text) {
891
- if (sendTextViaSession(text)) {
892
- setCaption("GEMMA ĐANG VIẾT…", "live");
893
- return;
894
- }
895
- // S2S not connected — restart session
896
- if (client) { try { client.close(); } catch(_) {} client = null; }
897
- sessionInProgress = false;
898
- startTextSession(text);
899
  };
900
 
901
- // Pure text chat client uses S2S in text-only mode, so it has ALL same tools as voice
902
- function sendTextMessage(t){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  const msg = t || chatInput.value.trim();
904
- if(!msg) return;
 
 
905
  chatInput.value = "";
906
- addChatMessage("user", msg);
 
 
 
907
  setCaption("GEMMA ĐANG VIẾT…", "live");
 
 
908
 
909
- if (sendTextViaSession(msg)) { return; }
910
- if (client) { try { client.close(); } catch(_) {} client = null; }
911
- sessionInProgress = false;
912
- startTextSession(msg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
913
  }
914
 
915
  // Safe event binding helpers (null-safety)
@@ -919,6 +1221,7 @@ function toggleClass(el,cls,val){if(el)el.classList.toggle(cls,val)}
919
  on(chatCloseBtn,"click",e=>{
920
  e.stopPropagation();
921
  textMode=false;
 
922
  if(voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; }
923
  showTextChat(false);
924
  toggleClass(textModeBtn,"active",false);
@@ -991,7 +1294,16 @@ function runTool(name,argsJson,callId){
991
  if(name==="combo_suggest"){
992
  const combo = window.vaix?.comboByCriteria(args||{})||null;
993
  if(combo && combo.items && combo.items.length){
994
- const html = '<div class="chat-combo-section">' + window.vaix.renderComboCardsBlock(combo) + '</div>';
 
 
 
 
 
 
 
 
 
995
  setPendingProductCards(html);
996
  const lines = combo.items.map(function(p){ return '• ' + p.title_clean + ' — ' + (p.brand||'') + ' — ' + (p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"); });
997
  const _tot = (combo.items||[]).reduce(function(s,p){ return s + ((p.priceNum||0)); }, 0);
@@ -1019,12 +1331,18 @@ function openCatalogPanelNow(){
1019
  if(p){ p.classList.add("open"); p.style.display="flex"; }
1020
  if(t){ t.classList.add("active"); }
1021
  }
 
 
 
 
 
 
 
1022
  async function startQuickConsult(t){
1023
- if(sessionInProgress){ if(client&&t){client.sendUserText(t);client.requestResponse()} return }
1024
- const ac=getQuickAudioCtx();
1025
- if(ac&&ac.state==="suspended"){ try{ await ac.resume(); }catch(_){} }
1026
- if(!ac){ console.warn("[Quick] no AudioContext"); }
1027
- await startTextSession(t||"Xin chào! Tôi muốn tư vấn nhanh sản phẩm.", { quick:true });
1028
  // Open the product panel immediately so a product page shows right away.
1029
  if(window.vaix && window.vaix.isLoaded && window.vaix.isLoaded()){
1030
  openCatalogPanelNow();
@@ -1070,6 +1388,7 @@ function _a(c){
1070
  c.addEventListener("user-transcript", e => {
1071
  const text = (e.detail && e.detail.text) || "";
1072
  if (!text) return;
 
1073
  if (settings.subtitles) {
1074
  showSubtitles("🗣️ " + text);
1075
  if (chatInput) { chatInput.value = text; }
@@ -1085,51 +1404,66 @@ function _a(c){
1085
  // Suggestions are now embedded directly in addChatMessage — no separate render needed
1086
 
1087
  if(isGreetingSession){
1088
- // After greeting, show a "Tin nóng hôm nay" section with SPECIFIC article cards (with links)
 
 
 
1089
  (async () => {
1090
  try {
1091
- let data;
1092
- try { data = await (await fetch("/api/news/hot")).json(); } catch(e) { data = {}; }
1093
- const hotArticles = (data.articles || []).slice(0, 4);
1094
  const lastMsg = chatMessages.lastElementChild;
1095
- if (lastMsg && lastMsg.classList.contains("chat-message")) {
1096
- const section = document.createElement("div");
1097
- section.className = "news-section";
1098
-
1099
- // Generic hot-news heading
1100
- const h = document.createElement("div");
1101
- h.className = "news-section-title";
1102
- h.innerHTML = '<span class="hot-tag-dot"></span> Tin nóng hôm nay:';
1103
- section.appendChild(h);
1104
-
1105
- // FIX: always build cards with matching image+content via generateSourceCards
1106
- const titles = data.titles || [];
1107
- const topic = (titles[0] || "").replace(/^[\d.]+[\s:]*/,"").trim();
1108
- let sources = [];
1109
- if (topic.length > 5) {
1110
- sources = await generateSourceCards(topic).catch(()=>[]);
1111
- }
1112
- // If topic-based raw cards are empty, fall back to the plain hot articles list
1113
- if (sources.length === 0 && hotArticles.length > 0) {
1114
- sources = hotArticles.map(a => ({
1115
- source: a.source || 'Nguồn tin',
1116
- title: a.title,
1117
- url: a.url,
1118
- icon: '📰',
1119
- desc: '',
1120
- articleTitle: a.title || '',
 
 
 
 
 
 
 
 
 
 
 
 
1121
  }));
1122
- }
1123
- if (sources.length) {
1124
- const sourceCardsRow = document.createElement("div");
1125
- sourceCardsRow.className = "source-cards-row";
1126
- sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src)));
1127
- section.appendChild(sourceCardsRow);
1128
- }
1129
-
1130
- lastMsg.appendChild(section);
1131
- chatMessages.scrollTop = chatMessages.scrollHeight;
1132
  }
 
 
 
 
 
 
 
 
 
 
 
 
1133
  } catch (_) {}
1134
  })();
1135
  setTimeout(()=>{ isGreetingSession=false; sessionInProgress=false; setCaption(CAPTIONS.idle); setMainButton("start","Start talking"); updateChatAudioToggleBtn(); },1500);
@@ -1398,18 +1732,57 @@ async function endSession(silent=false){
1398
  // ── Chat audio toggle (mute/unmute avatar voice in chat) ──
1399
  let avatarAudioMuted = false;
1400
  let chatAudioToggleBtn = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1401
  function initChatAudioToggle(){
1402
  chatAudioToggleBtn = $("#chat-audio-toggle-btn");
1403
  if(!chatAudioToggleBtn) return;
1404
  chatAudioToggleBtn.addEventListener("click",()=>{
1405
- avatarAudioMuted=!avatarAudioMuted;
1406
- client?.setMuted(avatarAudioMuted);
1407
- var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted");
1408
- if(snd) snd.style.display=avatarAudioMuted?"none":"";
1409
- if(mtc) mtc.style.display=avatarAudioMuted?"":"none";
1410
- chatAudioToggleBtn.setAttribute("aria-label",avatarAudioMuted?"Unmute avatar audio":"Mute avatar audio");
1411
- if(client&&client._playbackNode){ var n=client._playbackNode; if(n.port)n.port.postMessage({kind:avatarAudioMuted?"mute":"unmute"}); }
1412
  });
 
1413
  }
1414
  function updateChatAudioToggleBtn(){
1415
  if(!chatAudioToggleBtn) return;
@@ -1423,9 +1796,9 @@ if (welcomeChatBtn) {
1423
  welcomeMode = "text";
1424
  // Hide the picker FIRST so the avatar loading % bar can show right after.
1425
  hideWelcome();
1426
- // Avatar (with loading %) initializes only after the user picks.
1427
  await ensureAvatarReady();
1428
- startTextSession("Xin chào! Tôi muốn trò chuyện.");
 
1429
  });
1430
  }
1431
 
@@ -1525,6 +1898,7 @@ settingsDialog.addEventListener("close",()=>{
1525
  client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")});
1526
  });
1527
  window.addEventListener("beforeunload",()=>client?.close());
 
1528
  initChatAudioToggle();
1529
 
1530
  async function boot(){
 
22
  "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).",
23
  "CRITICAL: Same language as user. VN numbers as words (ba mươi lăm not 35), dates as 'ngày 9 tháng 7 năm 2026', currency as 'năm mươi nghìn đồng'.",
24
  "Keep replies short, natural, warm.",
25
+ "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, combo_suggest (gợi ý combo 2+ thiết bị nhà bếp hoặc phụ kiện như bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, khóa cửa — lọc theo thương hiệu/giá/chất liệu/màu sắc/kích thước), get_current_datetime, search_wikipedia, search_web, search_news (search real-time Vietnamese news), set_mood, make_hand_gesture, make_facial_expression.",
26
  "PRODUCT RULES: When user asks about ANY product — FIRST call query_catalog(query). This searches ALL products AND shows them in the panel AND returns similar product suggestions. After query_catalog, ALWAYS mention similar products and ask if user wants to see them. If user wants details, call show_product(name/SKU) to open the detail modal which also shows similar products below.",
27
+ "COMBO RULES: When user asks for a combo / set / bộ / gói of 2 or more kitchen appliances or cabinet accessories (e.g. 'combo bếp từ + máy hút mùi', 'bộ nồi bếp', 'kệ xoong nồi, kệ chén dĩa 700mm, kệ dao thớt 400mm', 'combo nướng và lò vi sóng', 'khóa cửa thông minh và kệ dao thớt') — call combo_suggest and pass the criteria. IMPORTANT: put each requested item in the categories array (e.g. categories:['bếp từ','máy hút mùi'], or categories:['kệ xoong nồi','kệ chén dĩa','kệ dao thớt']; a combo can be 2, 3 or 4 products). Supported categories: bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa. If a specific size (mm) is mentioned per item, pass sizes object e.g. sizes:{'kệ chén dĩa':[700],'kệ dao thớt':[400]}. Convert price phrases to numbers in VND: 'dưới 20 triệu' → maxPrice:20000000, 'trên 30 triệu' → minPrice:30000000, 'khoảng 15 triệu' → minPrice:14000000,maxPrice:16000000. CRITICAL: the price budget applies to the TOTAL sum of ALL items in the combo, NOT per-item. Pass brand like Malloca/Eurogold/Grob, material like inox/kính/gốm, color like đen/trắng/bạc if mentioned. combo_suggest will show product cards and a text summary with the combined total.",
28
  "SIMILAR PRODUCTS: query_catalog() and show_product() both automatically recommend related products by same category/brand/price range. Use this to cross-sell: 'Chị có muốn xem thêm sản phẩm tương tự không?'",
29
  "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.",
30
  "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.",
 
48
  { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}},
49
  { type:"function", name:"query_catalog", description:"SEARCH all products + show in panel + return details + suggest similar products. ALWAYS use FIRST for product questions.", parameters:{type:"object", properties:{query:{type:"string", description:"Product name, brand, SKU, category, or feature"}}, required:["query"]}},
50
  { type:"function", name:"show_product", description:"Open product detail modal + show similar products. Use AFTER query_catalog. Pass name/SKU.", parameters:{type:"object", properties:{product_name:{type:"string", description:"Product name or SKU"}}, required:["product_name"]}},
51
+ { type:"function", name:"combo_suggest", description:"Suggest a COMBO of 2, 3 or 4 kitchen appliances or cabinet accessories (bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa — same brand or mixed). Call when user wants a combo/set/bộ/gói of multiple products. Provide categories (which items, e.g. ['bếp từ','máy hút mùi'] or ['kệ xoong nồi','kệ chén dĩa','kệ dao thớt']), brand, price range (minPrice/maxPrice in VND — the budget is the TOTAL sum of all items, NOT per-item; convert 'dưới 20 triệu' to maxPrice:20000000), material (inox/kính/gốm), color (đen/trắng/bạc), and optional per-category sizes in mm (e.g. sizes:{'kệ chén dĩa':[700],'kệ dao thớt':[400]}).", parameters:{type:"object", properties:{brand:{type:"string", description:"Preferred brand e.g. Malloca, Eurogold, Grob, Hafele"}, minPrice:{type:"number", description:"Minimum total-combo price in VND (e.g. 'trên 30 triệu' → 30000000)"}, maxPrice:{type:"number", description:"Maximum total-combo price in VND (e.g. 'dưới 20 triệu' → 20000000)"}, material:{type:"string", description:"Material e.g. inox, kính, gốm"}, color:{type:"string", description:"Color e.g. đen, trắng, bạc"}, sizes:{type:"object", description:"Optional per-category cabinet width in mm, keyed by category label, e.g. {'kệ chén dĩa':[700],'kệ dao thớt':[400]}"}, categories:{type:"array", items:{type:"string"}, description:"Which categories to include, e.g. ['bếp từ','máy hút mùi'] or ['kệ xoong nồi','kệ chén dĩa','kệ dao thớt'] or ['khóa cửa','kệ dao thớt']. Supported: bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, lò vi sóng, nồi chiên, máy rửa chén, máy ép/xay, kệ xoong nồi, kệ chén dĩa, kệ dao thớt, giá góc, thùng rác, giá gia vị, khóa cửa. The price budget applies to the TOTAL of all items."}}, required:[]}},
52
  ];
53
 
54
  const $ = s => document.querySelector(s);
 
172
  // No fixed topics — every fetch pulls fresh keywords from actual news headlines
173
  let HOT_TOPIC_KEYWORDS = []; // Populated dynamically by fetchHotTags
174
 
175
+ // ── Single-owner news rendering state ──
176
+ // The greeting shows ONE consolidated HOT list (no duplicates) and every follow-up
177
+ // answer shows a DIFFERENT news list that follows the conversation context.
178
+ let _shownNewsUrls = new Set(); // URLs already shown, so consecutive lists differ
179
+ let _hotTopicCursor = 0; // round-robins through HOT topics for variety
180
+
181
+ // News list visibility: the HOT-tags + source-card news block should ONLY appear
182
+ // in the greeting ("câu chào") and when the user actually asks about news. From
183
+ // the 2nd non-greeting answer onward, if the user is NOT asking about news, we
184
+ // suppress the news list so it doesn't spam every reply. It reappears the moment
185
+ // the user asks about news (tin tức / news / hot / thời sự ...).
186
+ let _newsContextEnabled = false; // start DISABLED: from the 2nd answer onward
187
+ // (non-greeting) no news block until the user
188
+ // asks about news; the greeting always shows
189
+ // it via isGreeting.
190
+
191
+ // Detect whether the user's message is asking about news / current events.
192
+ function _isNewsRequest(text) {
193
+ const t = String(text || "").toLowerCase()
194
+ .normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d");
195
+ // Core news intent keywords (whole-word-ish, safe substrings).
196
+ const newsKws = ["tin tuc", "tin", " news", "hot", "thoi su", "ban tin", "tin nong",
197
+ "cap nhat tin", "tin moi", "tin moi nhat", "tin nong nhat", "thoi cuoc", "su kien"];
198
+ for (const kw of newsKws) {
199
+ const k = kw.trim();
200
+ // "tin" / "hot" as bare substrings are too noisy, so require either a longer
201
+ // keyword OR a word boundary for the short ones.
202
+ if (kw !== "tin " && kw !== " hot") {
203
+ if (t.includes(k)) return true;
204
+ } else {
205
+ if (new RegExp("(^|[^a-z])" + k + "([^a-z]|$)").test(t)) return true;
206
+ }
207
+ }
208
+ return false;
209
+ }
210
+ // Call this whenever a user message is processed to update news-list visibility.
211
+ function _noteUserMessage(text) {
212
+ _newsContextEnabled = _isNewsRequest(text);
213
+ }
214
+
215
+
216
+ function _cardKey(c) {
217
+ return (c && ((c.url && String(c.url)) || (c.title && String(c.title)))) || '';
218
+ }
219
+ // Return only cards whose URL/title was NOT already shown (keeps lists distinct).
220
+ function _filterShown(cards) {
221
+ const out = [];
222
+ for (const c of (cards || [])) {
223
+ if (!c) continue;
224
+ const key = _cardKey(c);
225
+ if (key && _shownNewsUrls.has(key)) continue;
226
+ out.push(c);
227
+ }
228
+ return out;
229
+ }
230
+ // Mark a set of displayed cards as shown so later lists avoid repeating them.
231
+ function _markShown(cards) {
232
+ for (const c of (cards || [])) {
233
+ const key = _cardKey(c);
234
+ if (key) _shownNewsUrls.add(key);
235
+ }
236
+ return cards;
237
+ }
238
+ // Cap the seen-set size so it never grows unbounded.
239
+ function _trimShown() {
240
+ if (_shownNewsUrls.size > 600) {
241
+ const arr = Array.from(_shownNewsUrls).slice(-400);
242
+ _shownNewsUrls = new Set(arr);
243
+ }
244
+ }
245
+ // Filter out already-shown cards, mark the survivors as shown, and trim the set.
246
+ function _finalizeCards(cards) {
247
+ const fresh = _filterShown(cards);
248
+ _markShown(fresh);
249
+ _trimShown();
250
+ return fresh;
251
+ }
252
+
253
+ /**
254
+ * Pick a news search query that follows the CONTEXT of the given response text:
255
+ * 1) if the response mentions a HOT keyword → use its topical query (e.g. AI → AI news);
256
+ * 2) else extract a clean topical phrase straight from the response;
257
+ * 3) else rotate to the next HOT topic so consecutive lists still differ.
258
+ */
259
+ function _contextQuery(responseText) {
260
+ const txt = String(responseText || '');
261
+ // 1) Dynamic HOT keyword match → context-relative news (e.g. talking about AI).
262
+ const matched = findBestTopicMatch(txt);
263
+ if (matched && matched.query) return matched.query;
264
+ // 2) Extract a meaningful topical phrase from the response text itself.
265
+ const clean = txt
266
+ .replace(/\([^)]*\)/g, ' ') // drop parenthetical content like (AI)
267
+ .replace(/[.,:;"'“”‘’!?]/g, ' ')
268
+ .replace(/^(em|anh chị|anh chi|anhh chị|anhh chi|dạ|vâng|xin chào|chào|bạn|tôi)\b/gi, ' ')
269
+ .replace(/\b(em|anh|chị|chi|bạn|ban|tôi|toi|về|ve|với|voi|cho|theo|các|ca?c|một|mot|những|nhung|đã|da|sẽ|se|đang|dang|hôm|hom|nay|có|co|tin|hot|ngày|ngay|biết|biet|thông|thong|muốn|muon|giúp|giup|không|khong|gì|gi|nào|nao)\b/gi, ' ')
270
+ .replace(/\s+/g, ' ').trim();
271
+ const words = [];
272
+ for (const w of clean.split(' ')) {
273
+ if (!w || w.length < 4 || /^\d+$/.test(w)) continue;
274
+ const n = w.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd');
275
+ if (/^(tin|hot|chatgpt|google|news|xin|chao|cam|on|giup|ban|biet|muon|thong|noi|hoc|very|good|hello|world|today)$/.test(n)) continue;
276
+ words.push(w);
277
+ if (words.length >= 3) break;
278
+ }
279
+ if (words.length > 0) return words.slice(0, 2).join(' ') + ' hôm nay';
280
+ // 3) Rotate to the next HOT topic.
281
+ const kw = HOT_TOPIC_KEYWORDS || [];
282
+ if (kw.length > 0) {
283
+ const q = kw[_hotTopicCursor % kw.length].query;
284
+ _hotTopicCursor++;
285
+ return q;
286
+ }
287
+ return 'tin tức mới nhất hôm nay';
288
+ }
289
+
290
  /**
291
  * Extract trending keywords from Google News RSS titles.
292
  * Returns array of {label, query, keyword} objects — fully dynamic, changes every fetch.
 
558
  * PRIMARY source, so every card's image ALWAYS matches its news content.
559
  */
560
  async function generateSourceCards(responseText) {
561
+ const query = _contextQuery(responseText);
 
562
  // Prefer a clean topical keyword that Google News can search on
563
  const searchQuery = query.replace(/^#/, '').replace(/ tin tức.*$| thông tin.*$| mới nhất.*$| hôm nay.*$| nóng nhất.*$| Việt Nam hôm nay.*$/, '').trim() || query;
564
 
565
  // ── PRIMARY: VnExpress RSS items carry title+image+url+desc together → no mismatch ──
566
  let cards = [];
567
+ const matchedTopic = findBestTopicMatch(responseText);
568
  const catLabel = matchedTopic ? matchedTopic.label : 'mới nhất';
569
  try {
570
  const resp = await fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`);
 
644
  }
645
  }
646
 
647
+ if (cards.length > 0) return _finalizeCards(cards);
648
 
649
  // Fallback #2: topic search (category landing pages) — no index-based images
650
  try {
 
653
  const data = await resp.json();
654
  const results = (data.results || []).slice(0, 6);
655
  if (results.length > 0) {
656
+ return _finalizeCards(results.map(r => ({
657
  source: r.source || 'Nguồn tin',
658
  title: r.title,
659
  url: r.url,
 
661
  category: catLabel,
662
  desc: r.snippet || '',
663
  image: '',
664
+ })));
665
  }
666
  }
667
  } catch (e) {
 
669
  }
670
 
671
  // Fallback: general news portals
672
+ return _finalizeCards([
673
  { source: 'VnExpress', title: 'Tin tổng hợp trong ngày cập nhật mới nhất', url: 'https://vnexpress.net/', icon: '📰', category: catLabel, image: '' },
674
  { source: 'Tuổi Trẻ', title: 'Thông tin đời sống xã hội và kinh tế', url: 'https://tuoitre.vn/', icon: '📰', category: catLabel, image: '' },
675
  { source: 'Thanh Niên', title: 'Nhìn đa chiều các vấn đề thời sự', url: 'https://thanhnien.vn/', icon: '📰', category: catLabel, image: '' },
676
+ ]);
677
  }
678
 
679
  /** Create DOM element for suggestion pill - black bg like chatbox */
 
772
  const norm = String(short).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd');
773
  const subj = short.length > 60 ? short.slice(0, 57) + '…' : short;
774
  return [
775
+ 'Kể thêm về "' + subj + '"',
776
+ 'Tin này nói gì? Cho em biết chi tiết hơn với',
777
+ 'Có mới nhất về "' + subj + '" gần đây không?',
778
+ 'Em tóm gọn giúp anh/chị tin "' + subj + '"'
779
  ];
780
  }
781
 
 
823
  const artUrl = (this.getAttribute("data-url") || '').trim();
824
  if (artTitle) {
825
  // Ask AI about the EXACT article — prefer fetching its real content
826
+ sendUserSuggestion('Kể cho em nghe thêm về tin: "' + artTitle + '". Tóm tắt ngắn gọn, tự nhiên cho em hiểu nhé.');
827
  } else {
828
  sendUserSuggestion("Cho tôi xem tin tức mới nhất");
829
  }
 
864
  container.appendChild(suggestionsRow);
865
  }
866
 
867
+ // 2) HOT trending tags row — only when it's the greeting OR the user asked
868
+ // about news. From the 2nd non-greeting answer onward (when the user is NOT
869
+ // asking about news) this news block is suppressed.
870
+ if (isGreeting || _newsContextEnabled) {
871
+ const tagsPromise = fetchHotTags();
872
+ const hotTagsRow = document.createElement("div");
873
+ hotTagsRow.className = "hot-tags-row";
874
+
875
+ const label = document.createElement("span");
876
+ label.className = "hot-tags-label";
877
+ label.innerHTML = '<span class="hot-tag-dot"></span> HOT';
 
878
  hotTagsRow.appendChild(label);
879
+
880
+ tagsPromise.then(tags => {
881
+ hotTagsRow.innerHTML = '';
882
+ hotTagsRow.appendChild(label);
883
+ tags.forEach(tag => hotTagsRow.appendChild(createHotTag(tag)));
884
+ }).catch(err => {
885
+ console.warn('[HOT] Failed to update tags:', err);
 
 
 
 
 
 
886
  });
887
+ container.appendChild(hotTagsRow);
888
+
889
+ // 3) Source news cards (only greeting or when user asked about news, for
890
+ // lengthy responses).
891
+ if ((isGreeting || _newsContextEnabled) && responseText && responseText.length > 30) {
892
+ generateSourceCards(responseText).then(sources => {
893
+ const sourceCardsRow = document.createElement("div");
894
+ sourceCardsRow.className = "source-cards-row";
895
+ sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src)));
896
+ container.appendChild(sourceCardsRow);
897
+ });
898
+ }
899
  }
900
 
901
  return container;
 
911
  m.className = "chat-message " + role;
912
  if (role === "assistant" && text) {
913
  const mt = document.createElement("div");
914
+ mt.className = "chat-msg-text";
915
  mt.textContent = text;
916
  m.appendChild(mt);
917
 
918
+ // "Đọc to" (read aloud) button — lets the user hear THIS message even when
919
+ // silent mode is on (explicit user intent).
920
+ const readBtn = document.createElement("button");
921
+ readBtn.type = "button";
922
+ readBtn.className = "chat-read-btn";
923
+ readBtn.title = "Đọc to tin nhắn này";
924
+ readBtn.setAttribute("aria-label", "Đọc to tin nhắn");
925
+ readBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M22 8a13 13 0 0 1 0 8"></path></svg>';
926
+ readBtn.addEventListener("click", function(e){
927
+ e.stopPropagation();
928
+ speakEdgeTts(text, true);
929
+ });
930
+ m.appendChild(readBtn);
931
+
932
  // Product cards
933
  if (_pendingProductCards) {
934
  const pc = document.createElement("div");
 
1021
  }
1022
  function sendTextViaSession(t){if(!client)return false;const s=client._status;if(s!=="connected"&&s!=="ai-speaking"&&s!=="processing"&&s!=="user-speaking")return false;setCaption("SENDING…");client.sendUserText(t);client.requestResponse();return true}
1023
 
1024
+ // Store callback for suggested question sends → dùng google/gemma-4-31B-it
1025
  window._triggerSuggestionSend = function(text) {
1026
+ if (typeof text !== "string" || !text) return;
1027
+ openTextChatUI();
1028
+ setCaption("GEMMA ĐANG VIẾT…", "live");
1029
+ sendTextMessage(text);
 
 
 
 
1030
  };
1031
 
1032
+ // ── Text chat dùng model google/gemma-4-31B-it qua REST /api/chat ──
1033
+ // (không phụ thuộc session S2S voice bị lỗi connecting)
1034
+ let chatHistory = [];
1035
+ let chatBusy = false;
1036
+ // Clean AI text for natural display: remove markdown ** _ ` and bullet tokens.
1037
+ function cleanAiText(text) {
1038
+ if (!text) return "";
1039
+ return String(text)
1040
+ .replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** → text
1041
+ .replace(/\*([^*]+)\*/g, "$1") // *italic* → text
1042
+ .replace(/__([^_]+)__/g, "$1") // __bold__ → text
1043
+ .replace(/`([^`]+)`/g, "$1") // `code` → text
1044
+ .replace(/^#{1,6}\s+/gm, "") // headings
1045
+ .replace(/\s+/g, " ")
1046
+ .trim();
1047
+ }
1048
+
1049
+ // Build product-card HTML for suggested products matching a query.
1050
+ function buildSuggestionCards(query) {
1051
+ try {
1052
+ if (!window.vaix || !window.vaix.getComboOrSearch) return "";
1053
+ // Use the same combo/search logic as the product-list panel so the chat
1054
+ // combo matches the "ds sp" search exactly.
1055
+ const res = window.vaix.getComboOrSearch(query, 4);
1056
+ const combo = res.combo;
1057
+ const products = Array.isArray(res.products) ? res.products.slice(0, 4) : [];
1058
+ if (!products.length) return "";
1059
+ // When a combo matched the user's request, render a combo section (accurate
1060
+ // to the request) WITH the "Đổi combo" button that swaps to an alternate
1061
+ // combo still matching the same criteria.
1062
+ if (combo && combo.items && combo.items.length) {
1063
+ // Pass the user's original query so the "Đổi combo" button can fetch an
1064
+ // ALTERNATE combo that still matches the same criteria.
1065
+ return window.vaix.renderComboCardsBlock(combo, query);
1066
+ }
1067
+ // Cards only — no title/total text line (user asked to keep only the combo card).
1068
+ const cards = products.map(p => window.vaix.createChatProductCard(p)).join("");
1069
+ return '<div class="chat-product-cards">' + cards + '</div>';
1070
+ } catch (e) { return ""; }
1071
+ }
1072
+
1073
+ let _typingEl = null;
1074
+ function showTypingBubble() {
1075
+ removeTypingBubble();
1076
+ const m = document.createElement("div");
1077
+ m.className = "chat-message assistant typing";
1078
+ m.innerHTML = '<span class="typing-dots"><i></i><i></i><i></i></span><span class="typing-label">&nbsp;đang soạn…</span>';
1079
+ chatMessages.appendChild(m);
1080
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1081
+ _typingEl = m;
1082
+ }
1083
+ function removeTypingBubble() {
1084
+ if (_typingEl && _typingEl.parentNode) _typingEl.parentNode.removeChild(_typingEl);
1085
+ _typingEl = null;
1086
+ }
1087
+
1088
+ // ── Edge TTS playback for text chat / quick consult ──
1089
+ // Voices: Mr V (vuong.glb, male) → Nam Minh; Lisamy (lisamy.glb, female) → Hoài My.
1090
+ function getEdgeTtsVoice() {
1091
+ try {
1092
+ const av = (localStorage.getItem("avatar.model") || "vuong.glb").toLowerCase();
1093
+ return av.indexOf("lisamy") !== -1 ? "vi-VN-HoaiMyNeural" : "vi-VN-NamMinhNeural";
1094
+ } catch (e) { return "vi-VN-NamMinhNeural"; }
1095
+ }
1096
+
1097
+ let _edgeTtsAudio = null;
1098
+
1099
+ // Speak the given text through Edge TTS when sound is enabled.
1100
+ // DEFINITIVE FIX: fetch the ENTIRE MP3 into an ArrayBuffer, wrap it in a Blob,
1101
+ // and play from an object URL. This guarantees the browser plays the full audio
1102
+ // (no "only first 2 characters" truncation from streaming/load races) and a
1103
+ // cache-buster prevents replaying any previously cached corrupt/truncated MP3.
1104
+ function speakEdgeTts(text, force) {
1105
+ if (!force && avatarAudioMuted) return false; // sound is OFF — do not speak (unless force)
1106
+ const clean = String(text || "").trim().replace(/[\*_`#]/g, "").replace(/\s+/g, " ").slice(0, 1400);
1107
+ if (!clean) return false;
1108
+ stopEdgeTts();
1109
+ const voice = getEdgeTtsVoice();
1110
+ const url = "/api/tts?voice=" + encodeURIComponent(voice) + "&text=" + encodeURIComponent(clean) + "&cb=" + Date.now();
1111
+ const a = new Audio();
1112
+ _edgeTtsAudio = a;
1113
+ a.preload = "auto";
1114
+ a.style.display = "none";
1115
+ try { (document.body || document.documentElement).appendChild(a); } catch (e) {}
1116
+ a.addEventListener("play", function () {
1117
+ if (stage && stage.head) { try { stage.head.isSpeaking = true; } catch (e) {} }
1118
+ });
1119
+ const cleanup = function () {
1120
+ try { if (stage && stage.head) stage.head.isSpeaking = false; } catch (e) {}
1121
+ try { if (a.src && a.src.indexOf("blob:") === 0) URL.revokeObjectURL(a.src); } catch (e) {}
1122
+ try { a.remove(); } catch (e) {}
1123
+ if (_edgeTtsAudio === a) _edgeTtsAudio = null;
1124
+ };
1125
+ a.addEventListener("ended", cleanup);
1126
+ a.addEventListener("error", cleanup);
1127
+ a.addEventListener("stalled", cleanup);
1128
+ // Fetch the full MP3, then play as a Blob URL.
1129
+ fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
1130
+ if (_edgeTtsAudio !== a || !buf || !buf.byteLength) return;
1131
+ const blob = new Blob([buf], { type: "audio/mpeg" });
1132
+ const objUrl = URL.createObjectURL(blob);
1133
+ a.src = objUrl;
1134
+ a.load();
1135
+ a.play().catch(function () { /* autoplay blocked — no audio this time */ });
1136
+ }).catch(function () {
1137
+ if (_edgeTtsAudio === a) { try { a.remove(); } catch (e) {} _edgeTtsAudio = null; }
1138
+ });
1139
+ return true;
1140
+ }
1141
+ function stopEdgeTts() {
1142
+ if (_edgeTtsAudio) {
1143
+ const a = _edgeTtsAudio;
1144
+ try { a.pause(); } catch (e) {}
1145
+ try { if (a.src && a.src.indexOf("blob:") === 0) URL.revokeObjectURL(a.src); } catch (e) {}
1146
+ try { a.src = ""; } catch (e) {}
1147
+ try { a.remove(); } catch (e) {}
1148
+ _edgeTtsAudio = null;
1149
+ }
1150
+ try { if (stage && stage.head) stage.head.isSpeaking = false; } catch (e) {}
1151
+ }
1152
+
1153
+ async function sendTextMessage(t){
1154
  const msg = t || chatInput.value.trim();
1155
+ if(!msg || chatBusy) return;
1156
+ const reliable = (window.vaix && window.vaix.isLoaded && window.vaix.isLoaded());
1157
+ if(!reliable){ try{ await (window.vaix && window.vaix.load ? window.vaix.load() : Promise.resolve()); }catch(_){} }
1158
  chatInput.value = "";
1159
+ chatBusy = true;
1160
+ _noteUserMessage(msg); // update news-list visibility based on this message
1161
+ if (!window._userBubbleAdded) addChatMessage("user", msg);
1162
+ window._userBubbleAdded = false;
1163
  setCaption("GEMMA ĐANG VIẾT…", "live");
1164
+ showTypingBubble();
1165
+ if (chatSendBtn) chatSendBtn.disabled = true;
1166
 
1167
+ chatHistory.push({ role: "user", content: msg });
1168
+ try {
1169
+ const res = await fetch("/api/chat", {
1170
+ method: "POST",
1171
+ headers: { "Content-Type": "application/json" },
1172
+ body: JSON.stringify({ message: msg, history: chatHistory.slice(0, -1) }),
1173
+ });
1174
+ const data = await res.json().catch(() => ({}));
1175
+ const reply = cleanAiText(data && data.transcript ? data.transcript : "");
1176
+ removeTypingBubble();
1177
+ if (!reply) {
1178
+ const d = (data && data.detail) ? String(data.detail).slice(0, 120) : "";
1179
+ addChatMessage("assistant", "Xin lỗi, tôi chưa kết nối được model. " + (data && data.status ? "("+data.status+") " : "") + d);
1180
+ chatHistory.pop();
1181
+ } else {
1182
+ // Related products / combos: use the SAME logic as the product-list panel
1183
+ // (parseComboQuery -> combo, else scored search) so the chat combo matches
1184
+ // the "ds sp" search exactly. We search the USER's message ONLY (like the
1185
+ // panel does with the raw query) — NOT msg + AI reply. Appending the AI's
1186
+ // descriptive answer pollutes parsing: the reply often quotes individual
1187
+ // product prices or the computed combo total ("tổng combo khoảng 19.75
1188
+ // triệu"), which _extractBudget then mistakes for the combo budget and can
1189
+ // make a valid 2-item combo fail (falling back to a single-product card).
1190
+ const queryForProducts = String(msg || "").trim();
1191
+ const comboRes = (window.vaix && window.vaix.getComboOrSearch)
1192
+ ? window.vaix.getComboOrSearch(queryForProducts, 6)
1193
+ : { combo: null, products: [] };
1194
+ const rel = Array.isArray(comboRes.products) ? comboRes.products : [];
1195
+ setPendingProductCards(buildSuggestionCards(queryForProducts));
1196
+ // Also mirror the panel: show the same combo/search results in the product list.
1197
+ if (window.vaix && window.vaix.renderPanelResults && rel.length) {
1198
+ try { window.vaix.renderPanelResults(rel.slice(0, 6)); } catch (_) {}
1199
+ }
1200
+ lastAssistantMessage = reply;
1201
+ if (settings.subtitles) showSubtitles(smartNormalize(reply));
1202
+ addChatMessage("assistant", smartNormalize(reply));
1203
+ chatHistory.push({ role: "assistant", content: reply });
1204
+ // Speak the reply aloud via Edge TTS when sound is enabled.
1205
+ speakEdgeTts(reply);
1206
+ }
1207
+ } catch (e) {
1208
+ removeTypingBubble();
1209
+ addChatMessage("assistant", "Xin lỗi, có lỗi khi gọi model. Vui lòng thử lại.");
1210
+ chatHistory.pop();
1211
+ }
1212
+ chatBusy = false;
1213
+ if (chatSendBtn) chatSendBtn.disabled = false;
1214
+ setCaption("");
1215
  }
1216
 
1217
  // Safe event binding helpers (null-safety)
 
1221
  on(chatCloseBtn,"click",e=>{
1222
  e.stopPropagation();
1223
  textMode=false;
1224
+ stopEdgeTts();
1225
  if(voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; }
1226
  showTextChat(false);
1227
  toggleClass(textModeBtn,"active",false);
 
1294
  if(name==="combo_suggest"){
1295
  const combo = window.vaix?.comboByCriteria(args||{})||null;
1296
  if(combo && combo.items && combo.items.length){
1297
+ // Reconstruct a human-readable query from the tool args so the embedded
1298
+ // "Đổi combo" button can re-derive the SAME criteria for an alternate.
1299
+ let qParts = ["combo"];
1300
+ const cats = (args && Array.isArray(args.categories)) ? args.categories.slice() : [];
1301
+ if (cats.length) qParts = qParts.concat(cats);
1302
+ if (args && args.brand) qParts.push(args.brand);
1303
+ if (args && args.maxPrice) qParts.push("dưới " + (args.maxPrice / 1000000) + " triệu");
1304
+ else if (args && args.minPrice) qParts.push("trên " + (args.minPrice / 1000000) + " triệu");
1305
+ const _q = qParts.join(" ");
1306
+ const html = '<div class="chat-combo-section">' + window.vaix.renderComboCardsBlock(combo, _q) + '</div>';
1307
  setPendingProductCards(html);
1308
  const lines = combo.items.map(function(p){ return '• ' + p.title_clean + ' — ' + (p.brand||'') + ' — ' + (p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"); });
1309
  const _tot = (combo.items||[]).reduce(function(s,p){ return s + ((p.priceNum||0)); }, 0);
 
1331
  if(p){ p.classList.add("open"); p.style.display="flex"; }
1332
  if(t){ t.classList.add("active"); }
1333
  }
1334
+ function openTextChatUI(){
1335
+ if(showTextChat){ showTextChat(true); }
1336
+ if(textModeBtn) toggleClass(textModeBtn,"active",true);
1337
+ textMode = true;
1338
+ startComboPoll();
1339
+ }
1340
+
1341
  async function startQuickConsult(t){
1342
+ const msg = t || "Xin chào! Tôi muốn tư vấn nhanh sản phẩm.";
1343
+ openTextChatUI();
1344
+ // vấn nhanh text chat dùng model google/gemma-4-31B-it
1345
+ await sendTextMessage(msg);
 
1346
  // Open the product panel immediately so a product page shows right away.
1347
  if(window.vaix && window.vaix.isLoaded && window.vaix.isLoaded()){
1348
  openCatalogPanelNow();
 
1388
  c.addEventListener("user-transcript", e => {
1389
  const text = (e.detail && e.detail.text) || "";
1390
  if (!text) return;
1391
+ _noteUserMessage(text); // update news-list visibility from user speech
1392
  if (settings.subtitles) {
1393
  showSubtitles("🗣️ " + text);
1394
  if (chatInput) { chatInput.value = text; }
 
1404
  // Suggestions are now embedded directly in addChatMessage — no separate render needed
1405
 
1406
  if(isGreetingSession){
1407
+ // After greeting, show ONE consolidated "Tin HOT hôm nay" section with
1408
+ // distinct, context-aware cards. This is the SINGLE owner of the greeting
1409
+ // news section — the legacy observers (greeting-news.js / greeting-source-cards.js)
1410
+ // are disabled so no duplicate list is appended.
1411
  (async () => {
1412
  try {
 
 
 
1413
  const lastMsg = chatMessages.lastElementChild;
1414
+ if (!lastMsg || !lastMsg.classList.contains("chat-message")) return;
1415
+ if (lastMsg.querySelector(".news-section")) return; // already has news — never duplicate
1416
+ const section = document.createElement("div");
1417
+ section.className = "news-section";
1418
+ section.setAttribute("data-owner", "app-news");
1419
+
1420
+ const h = document.createElement("div");
1421
+ h.className = "news-section-title";
1422
+ h.innerHTML = '<span class="hot-tag-dot"></span> Tin HOT hôm nay:';
1423
+ section.appendChild(h);
1424
+
1425
+ // Build up to 8 DIFFERENT cards spanning several HOT topics (each topic
1426
+ // yields its own cards → a varied, single consolidated list).
1427
+ let topics = [];
1428
+ try { topics = await fetchHotTags().catch(()=>[]); } catch(_) {}
1429
+ const used = new Set();
1430
+ const sources = [];
1431
+ for (const t of topics) {
1432
+ if (sources.length >= 8) break;
1433
+ try {
1434
+ const s = await generateSourceCards((t && t.query) || 'tin tức mới nhất hôm nay').catch(()=>[]);
1435
+ for (const c of s) {
1436
+ const key = (c.url && String(c.url)) || (c.title && String(c.title)) || '';
1437
+ if (key && (used.has(key) || _shownNewsUrls.has(key))) continue;
1438
+ if (key) used.add(key);
1439
+ sources.push({ ...c, category: (t && t.label) || c.category });
1440
+ if (sources.length >= 8) break;
1441
+ }
1442
+ } catch (_) {}
1443
+ }
1444
+
1445
+ // Fall back to the plain hot-articles list if nothing came back.
1446
+ if (sources.length === 0) {
1447
+ try {
1448
+ const data = await (await fetch("/api/news/hot")).json();
1449
+ const hotArticles = (data.articles || []).slice(0, 4);
1450
+ hotArticles.forEach(a => sources.push({
1451
+ source: a.source || 'Nguồn tin', title: a.title, url: a.url, icon: '📰', desc: '', articleTitle: a.title || ''
1452
  }));
1453
+ } catch (_) {}
 
 
 
 
 
 
 
 
 
1454
  }
1455
+ _markShown(sources);
1456
+ _trimShown();
1457
+
1458
+ if (sources.length) {
1459
+ const sourceCardsRow = document.createElement("div");
1460
+ sourceCardsRow.className = "source-cards-row";
1461
+ sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src)));
1462
+ section.appendChild(sourceCardsRow);
1463
+ }
1464
+
1465
+ lastMsg.appendChild(section);
1466
+ chatMessages.scrollTop = chatMessages.scrollHeight;
1467
  } catch (_) {}
1468
  })();
1469
  setTimeout(()=>{ isGreetingSession=false; sessionInProgress=false; setCaption(CAPTIONS.idle); setMainButton("start","Start talking"); updateChatAudioToggleBtn(); },1500);
 
1732
  // ── Chat audio toggle (mute/unmute avatar voice in chat) ──
1733
  let avatarAudioMuted = false;
1734
  let chatAudioToggleBtn = null;
1735
+ let chatboxMuteBtn = null;
1736
+
1737
+ // Single source of truth for the muted state. Swaps the sound/muted icons on
1738
+ // BOTH the chat-audio-toggle button (voice-session header) and the always-visible
1739
+ // chatbox-mute button, stops any in-flight Edge TTS, and reflects state on the
1740
+ // playback node.
1741
+ function setAvatarAudioMuted(v){
1742
+ const next = !!v;
1743
+ if (avatarAudioMuted === next) return;
1744
+ avatarAudioMuted = next;
1745
+ try { client?.setMuted(avatarAudioMuted); } catch(_) {}
1746
+ if (avatarAudioMuted) { try { stopEdgeTts(); } catch(_) {} }
1747
+ const syncIcons = function(root){
1748
+ if(!root) return;
1749
+ const snd = root.querySelector("#audio-icon-sound, #chatbox-mute-sound");
1750
+ const mtc = root.querySelector("#audio-icon-muted, #chatbox-mute-muted");
1751
+ if (snd) snd.style.display = avatarAudioMuted ? "none" : "";
1752
+ if (mtc) mtc.style.display = avatarAudioMuted ? "" : "none";
1753
+ };
1754
+ if (chatboxMuteBtn) {
1755
+ chatboxMuteBtn.classList.toggle("mute-on", avatarAudioMuted);
1756
+ chatboxMuteBtn.setAttribute("aria-label", avatarAudioMuted ? "Bật âm thanh" : "Im lặng (tắt âm thanh)");
1757
+ chatboxMuteBtn.title = avatarAudioMuted ? "Bật âm thanh" : "Tắt âm thanh (im lặng)";
1758
+ }
1759
+ // sync per-root icons (this button's SVG + the other button's SVG live in
1760
+ // different subtrees, so update both).
1761
+ if(chatboxMuteBtn) syncIcons(chatboxMuteBtn);
1762
+ if(chatAudioToggleBtn) chatAudioToggleBtn.setAttribute("aria-label", avatarAudioMuted ? "Unmute avatar audio" : "Mute avatar audio");
1763
+ if(client&&client._playbackNode){ var n=client._playbackNode; if(n.port)n.port.postMessage({kind:avatarAudioMuted?"mute":"unmute"}); }
1764
+ }
1765
+ function updateChatboxMuteBtn(){
1766
+ if(!chatboxMuteBtn) return;
1767
+ chatboxMuteBtn.classList.toggle("mute-on", avatarAudioMuted);
1768
+ const snd = chatboxMuteBtn.querySelector("#chatbox-mute-sound");
1769
+ const mtc = chatboxMuteBtn.querySelector("#chatbox-mute-muted");
1770
+ if (snd) snd.style.display = avatarAudioMuted ? "none" : "";
1771
+ if (mtc) mtc.style.display = avatarAudioMuted ? "" : "none";
1772
+ }
1773
+ function initChatboxMute(){
1774
+ chatboxMuteBtn = $("#chatbox-mute-btn");
1775
+ if(!chatboxMuteBtn) return;
1776
+ chatboxMuteBtn.addEventListener("click", ()=>{ setAvatarAudioMuted(!avatarAudioMuted); });
1777
+ updateChatboxMuteBtn();
1778
+ }
1779
  function initChatAudioToggle(){
1780
  chatAudioToggleBtn = $("#chat-audio-toggle-btn");
1781
  if(!chatAudioToggleBtn) return;
1782
  chatAudioToggleBtn.addEventListener("click",()=>{
1783
+ setAvatarAudioMuted(!avatarAudioMuted);
 
 
 
 
 
 
1784
  });
1785
+ updateChatboxMuteBtn();
1786
  }
1787
  function updateChatAudioToggleBtn(){
1788
  if(!chatAudioToggleBtn) return;
 
1796
  welcomeMode = "text";
1797
  // Hide the picker FIRST so the avatar loading % bar can show right after.
1798
  hideWelcome();
 
1799
  await ensureAvatarReady();
1800
+ openTextChatUI();
1801
+ sendTextMessage("Xin chào! Tôi muốn trò chuyện.");
1802
  });
1803
  }
1804
 
 
1898
  client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")});
1899
  });
1900
  window.addEventListener("beforeunload",()=>client?.close());
1901
+ initChatboxMute();
1902
  initChatAudioToggle();
1903
 
1904
  async function boot(){
src/greeting-news.js CHANGED
@@ -140,10 +140,10 @@
140
  var short = String(title).replace(/\s*-\s*[^\-]{2,}$/, '').replace(/^\d+[.:\s]*/, '').trim();
141
  var subj = short.length > 55 ? short.slice(0, 52) + '…' : short;
142
  return [
143
- 'Cho tôi biết thêm về "' + subj + '"',
144
- 'Nội dung chi tiết của tin "' + subj + '" là gì?',
145
- 'Có thông tin mới nhất nào về "' + subj + '" không?',
146
- 'Bối cảnh diễn biến của "' + subj + '" như thế nào?'
147
  ];
148
  }
149
 
@@ -182,7 +182,7 @@
182
  ask.addEventListener('click', function (e) {
183
  e.stopPropagation();
184
  var t = (this.getAttribute('data-art') || '').trim();
185
- sendQuestion(t ? ('Cho tôi biết thêm về tin: "' + t + '". Hãy tìm hiểu và tóm tắt nội dung, diễn biến mới nhất liên quan tới tin này.') : 'Cho tôi xem tin tức mới nhất');
186
  });
187
  }
188
  card.querySelectorAll('.news-q-pill').forEach(function (btn) {
@@ -301,6 +301,12 @@
301
  }
302
 
303
  function startNews() {
 
 
 
 
 
 
304
  var msgs = document.getElementById('chat-messages');
305
  if (!msgs) return;
306
  var mo = new MutationObserver(function (mutations) {
 
140
  var short = String(title).replace(/\s*-\s*[^\-]{2,}$/, '').replace(/^\d+[.:\s]*/, '').trim();
141
  var subj = short.length > 55 ? short.slice(0, 52) + '…' : short;
142
  return [
143
+ 'Kể thêm về "' + subj + '"',
144
+ 'Tin này nói gì? Cho em biết chi tiết hơn với',
145
+ 'Có mới nhất về "' + subj + '" gần đây không?',
146
+ 'Em tóm gọn giúp anh/chị tin "' + subj + '"'
147
  ];
148
  }
149
 
 
182
  ask.addEventListener('click', function (e) {
183
  e.stopPropagation();
184
  var t = (this.getAttribute('data-art') || '').trim();
185
+ sendQuestion(t ? ('Kể cho em nghe thêm về tin: "' + t + '". Tóm tắt ngắn gọn, tự nhiên cho em hiểu nhé.') : 'Cho tôi xem tin tức mới nhất');
186
  });
187
  }
188
  card.querySelectorAll('.news-q-pill').forEach(function (btn) {
 
301
  }
302
 
303
  function startNews() {
304
+ // DISABLED (2026): The greeting HOT news section is now rendered by app.js
305
+ // (single owner: buildPostMessageContextBlock + the greeting response-finished
306
+ // handler). Keeping this observer active would append a DUPLICATE "Tin HOT hôm
307
+ // nay" list under the greeting. The rest of greeting-news.js (order-detail
308
+ // modal, AEC audio fix, shared-product deep link) still runs.
309
+ return;
310
  var msgs = document.getElementById('chat-messages');
311
  if (!msgs) return;
312
  var mo = new MutationObserver(function (mutations) {
src/greeting-source-cards.js CHANGED
@@ -125,7 +125,7 @@
125
  var imgHtml = obj.image
126
  ? '<div class="source-card-thumb-wrap"><img class="source-card-thumb" src="' + escHtml(obj.image) + '" alt="" loading="lazy" onerror="this.closest(\'.source-card-thumb-wrap\')?.remove()" /></div>'
127
  : "";
128
- var q = 'Cho tôi biết thêm về tin: "' + escHtml(obj.title) + '". Hãy tìm hiểu và tóm tắt nội dung, diễn biến mới nhất liên quan tới tin này.';
129
  wrap.innerHTML =
130
  imgHtml +
131
  '<span class="source-card-source">' + (obj.icon || "\uD83D\uDCF0") + " " + escHtml(sourceName(obj)) + "</span>" +
@@ -175,6 +175,10 @@
175
 
176
  // Scan a chat container for greeting assistant messages that lack cards.
177
  function scan(chatEl) {
 
 
 
 
178
  if (!chatEl) return;
179
  var msgs = chatEl.querySelectorAll(".chat-message.assistant");
180
  for (var i = 0; i < msgs.length; i++) {
 
125
  var imgHtml = obj.image
126
  ? '<div class="source-card-thumb-wrap"><img class="source-card-thumb" src="' + escHtml(obj.image) + '" alt="" loading="lazy" onerror="this.closest(\'.source-card-thumb-wrap\')?.remove()" /></div>'
127
  : "";
128
+ var q = 'Kể cho em nghe thêm về tin: "' + escHtml(obj.title) + '". Tóm tắt ngắn gọn, tự nhiên cho em hiểu nhé.';
129
  wrap.innerHTML =
130
  imgHtml +
131
  '<span class="source-card-source">' + (obj.icon || "\uD83D\uDCF0") + " " + escHtml(sourceName(obj)) + "</span>" +
 
175
 
176
  // Scan a chat container for greeting assistant messages that lack cards.
177
  function scan(chatEl) {
178
+ // DISABLED (2026): News/source cards are owned by app.js now. This observer
179
+ // used to append a duplicate "source-cards-row" on top of the app-rendered
180
+ // news, causing the duplicated greeting HOT list. Kept as a guard only.
181
+ return;
182
  if (!chatEl) return;
183
  var msgs = chatEl.querySelectorAll(".chat-message.assistant");
184
  for (var i = 0; i < msgs.length; i++) {
src/style.css CHANGED
@@ -16,11 +16,33 @@
16
 
17
  * { box-sizing: border-box; }
18
  html, body { height: 100%; margin: 0; }
19
- body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, sans-serif; overflow: hidden; }
 
 
 
 
 
 
 
 
 
20
  #app { position: relative; height: 100dvh; }
21
  #stage { position: absolute; inset: 0; }
22
  #stage canvas { display: block; }
23
  [hidden] { display: none !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  #loading { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 12px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); background: var(--bg); transition: opacity 0.6s ease; pointer-events: none; }
25
  #loading.done { opacity: 0; }
26
 
@@ -496,7 +518,17 @@ dialog#settings .check-row input {
496
  #chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
497
  .chat-message { max-width: 85%; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; word-wrap: break-word; }
498
  .chat-message.user { align-self: flex-end; background: var(--live); color: var(--bg); }
499
- .chat-message.assistant { align-self: flex-start; background: var(--bg-elev-2); color: var(--text); border: 1px solid var(--border); }
 
 
 
 
 
 
 
 
 
 
500
 
501
  #chat-input-container { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); background: var(--bg-elev); flex-shrink: 0; }
502
  #chat-input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px; }
 
16
 
17
  * { box-sizing: border-box; }
18
  html, body { height: 100%; margin: 0; }
19
+ /* V.AI STUDIO background image logo centered on screen (centered-crop asset) */
20
+ body {
21
+ background: var(--bg);
22
+ background-image: url("/public/avatars/vaistudio_center.png");
23
+ background-size: cover;
24
+ background-position: center center;
25
+ background-repeat: no-repeat;
26
+ background-attachment: fixed;
27
+ color: var(--text); font-family: Inter, system-ui, -apple-system, sans-serif; overflow: hidden;
28
+ }
29
  #app { position: relative; height: 100dvh; }
30
  #stage { position: absolute; inset: 0; }
31
  #stage canvas { display: block; }
32
  [hidden] { display: none !important; }
33
+ /* Smartphone: show the FULL background image width (no side cropping) so the
34
+ V.AI STUDIO logo/art is fully visible on narrow portrait screens. Because the
35
+ image is short & wide (1114x1024), tile it (loop) vertically to fill the full
36
+ viewport height. */
37
+ @media (max-width: 768px) {
38
+ body {
39
+ background-position: center top;
40
+ background-size: 100% auto;
41
+ background-repeat: repeat;
42
+ background-attachment: scroll;
43
+ }
44
+ }
45
+
46
  #loading { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 12px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); background: var(--bg); transition: opacity 0.6s ease; pointer-events: none; }
47
  #loading.done { opacity: 0; }
48
 
 
518
  #chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
519
  .chat-message { max-width: 85%; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; word-wrap: break-word; }
520
  .chat-message.user { align-self: flex-end; background: var(--live); color: var(--bg); }
521
+ .chat-message.assistant { align-self: flex-start; background: var(--bg-elev-2); color: var(--text); border: 1px solid var(--border); position: relative; }
522
+ .chat-message .chat-read-btn { position:absolute; top:6px; right:6px; display:inline-flex; align-items:center; background:transparent; border:none; color:#64748b; cursor:pointer; padding:3px; border-radius:4px; opacity:0.55; line-height:1; transition:opacity 0.15s, color 0.15s; z-index:2 }
523
+ .chat-message .chat-read-btn:hover { opacity:1; color:#22d3ee }
524
+ .chat-message .chat-msg-text { padding-right:20px }
525
+ .chat-message.typing { display: inline-flex; align-items: center; gap: 6px; background: var(--bg-elev-2); color: var(--text-dim); }
526
+ .typing-dots { display: inline-flex; gap: 4px; }
527
+ .typing-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); animation: typing-blink 1.2s infinite; }
528
+ .typing-dots i:nth-child(2) { animation-delay: 0.2s; }
529
+ .typing-dots i:nth-child(3) { animation-delay: 0.4s; }
530
+ @keyframes typing-blink { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
531
+ .chat-product-cards { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
532
 
533
  #chat-input-container { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); background: var(--bg-elev); flex-shrink: 0; }
534
  #chat-input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px; }
src/vaix-rag.js CHANGED
@@ -6,9 +6,14 @@
6
  (function() {
7
  'use strict';
8
 
9
- const JSON_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_with_slugs.json";
 
10
  let allProducts = [];
11
  let loaded = false;
 
 
 
 
12
  // Guard against double-render when ?product=xxx already handled the panel
13
  let productUrlHandled = false;
14
  let searchDebounceTimer = null;
@@ -102,7 +107,30 @@ function extractTerms(query) {
102
  // (e.g. bếp từ + máy hút mùi + chậu rửa + vòi rửa, same brand) so every chat
103
  // entry shows a different, real combo from the catalog.
104
  // ─────────────────────────────────────────────
 
 
105
  const COMBO_CATEGORIES = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  { keys: ["bếp điện từ", "bếp từ", "bếp gas"], label: "Bếp từ" },
107
  { keys: ["máy hút mùi", "máy hút khói", "hút khói"], label: "Máy hút mùi" },
108
  { keys: ["chậu rửa chén", "chậu rửa bát"], label: "Chậu rửa" },
@@ -112,13 +140,70 @@ const COMBO_CATEGORIES = [
112
  function _matchCombo(p, catDef) {
113
  const cat = sd(p.category || "");
114
  const name = sd(p.title_clean || "");
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  for (const k of catDef.keys) {
116
  const kk = sd(k);
117
- if (cat.includes(kk) || name.includes(kk)) return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
  return false;
120
  }
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  function getRandomCombo(preferredBrand) {
123
  if (!allProducts.length) return null;
124
  const brands = preferredBrand ? [preferredBrand] : Array.from(new Set(allProducts.map(p => p.brand).filter(Boolean)));
@@ -129,19 +214,19 @@ function getRandomCombo(preferredBrand) {
129
  const brandProducts = allProducts.filter(p => p.brand === brand);
130
  const chosen = [];
131
  let ok = true;
132
- for (const catDef of COMBO_CATEGORIES) {
133
  const pool = brandProducts.filter(p => _matchCombo(p, catDef));
134
  if (!pool.length) { ok = false; break; }
135
  chosen.push(pool[Math.floor(Math.random() * pool.length)]);
136
  }
137
- if (ok && chosen.length === COMBO_CATEGORIES.length) {
138
  return { brand, label: chosen.map(c => (c.category || "sản phẩm")).slice(0, 4).join(" + "), items: chosen };
139
  }
140
  }
141
 
142
  // Fallback: any 4 distinct products from different combo categories (mixed brands)
143
  const chosen = [];
144
- for (const catDef of COMBO_CATEGORIES) {
145
  const pool = allProducts.filter(p => _matchCombo(p, catDef));
146
  if (pool.length) chosen.push(pool[Math.floor(Math.random() * pool.length)]);
147
  }
@@ -151,15 +236,16 @@ function getRandomCombo(preferredBrand) {
151
  }
152
 
153
  /** Render a COMBO cards block (heading + product cards grid). Returns HTML string. */
154
- function renderComboCardsBlock(combo) {
155
  if (!combo || !combo.items || !combo.items.length) return "";
156
  const cards = combo.items.map(p => createChatProductCard(p)).join("");
157
  const brand = combo.brand ? ' thương hiệu ' + combo.brand : '';
 
158
  return '<div class="chat-combo-section">' +
159
  '<div class="chat-combo-title"><span class="combo-spark">✨</span> Combo gợi ý' + brand + '</div>' +
160
  '<div class="chat-combo-sub">Gồm: ' + combo.label + ' — giá combo hợp lý nhất cho gian bếp của bạn</div>' +
161
  '<div class="chat-combo-cards">' + cards + '</div>' +
162
- '<button class="chat-combo-random" data-combo="1">🔄 Đổi combo khác</button>' +
163
  '</div>';
164
  }
165
 
@@ -230,9 +316,12 @@ function _pickComboWithinBudget(pools, crit) {
230
  return idx.map((j, i) => pools[i][j]);
231
  }
232
 
233
- function comboByCriteria(criteria) {
234
  if (!allProducts.length) return null;
235
  const c = criteria || {};
 
 
 
236
  const wantedCategories = Array.isArray(c.categories) && c.categories.length
237
  ? c.categories
238
  : ["bếp từ", "máy hút mùi", "chậu rửa", "vòi rửa"];
@@ -240,6 +329,7 @@ function comboByCriteria(criteria) {
240
 
241
  // Build per-category pools for a set of source products (same brand or all).
242
  function poolsFor(sourceProducts) {
 
243
  const pools = [];
244
  for (const catReq of wantedCategories) {
245
  const catDefs = COMBO_CATEGORIES.filter(d => sd(d.label).includes(sd(catReq).replace(/\s+/g, "")) ||
@@ -248,10 +338,36 @@ function comboByCriteria(criteria) {
248
  const def = defs[0];
249
  if (!def) return null;
250
  let pool = sourceProducts.filter(p => _matchCombo(p, def));
251
- pool = filterComboPoolByCriteria(pool, c);
 
252
  if (!pool.length) return null;
 
 
 
 
 
 
 
 
 
 
 
 
253
  if (hasBudget) pool = pool.filter(p => (p.priceNum || 0) > 0);
254
  if (!pool.length) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  pool = pool.slice().sort((a, b) => (a.priceNum || 0) - (b.priceNum || 0));
256
  pools.push(pool);
257
  }
@@ -284,8 +400,19 @@ function comboByCriteria(criteria) {
284
  }
285
 
286
  function comboFromPools(pools, brand) {
287
- const chosen = _pickComboWithinBudget(pools, c);
288
- if (!chosen || chosen.length !== wantedCategories.length) return null;
 
 
 
 
 
 
 
 
 
 
 
289
  const total = chosen.reduce((s, p) => s + (p.priceNum || 0), 0);
290
  return {
291
  brand,
@@ -303,7 +430,11 @@ function comboByCriteria(criteria) {
303
  if (combo) return combo;
304
  }
305
 
306
- // Fallback: mixed brands
 
 
 
 
307
  const pools = poolsFor(allProducts);
308
  if (pools) {
309
  const combo = comboFromPools(pools, "");
@@ -322,22 +453,110 @@ function scoreProduct(p, queryStr, terms) {
322
  const sk = sd(p.sku || p.model);
323
  const br = sd(p.brand);
324
  const cat = sd(p.category);
325
- const all = [nm, br, cat, sd(p.description||""), sd((p.features||[]).join(" ")), sd(p.summary||"")].join(" ");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  if (nm === queryStr) sc += 200;
328
  else if (sk === queryStr) sc += 180;
329
  else if (nm.includes(queryStr)) sc += 100;
330
  else if (sk.includes(queryStr)) sc += 80;
331
- else if (all.includes(queryStr)) sc += 30;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
- for (const t of terms) {
334
- if (nm.includes(t)) sc += 12;
335
- else if (sk.includes(t)) sc += 10;
336
- else if (br.includes(t)) sc += 8;
337
- else if (cat.includes(t)) sc += 6;
338
- else if (all.includes(t)) sc += 2;
 
 
 
 
 
 
 
 
 
 
 
339
  }
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  const brandNames = ["malloca","eurogold","grob","canzy","demax","sunhouse","kangaroo","sharp","toshiba","panasonic","samsung","lg","electrolux","bosch","fischer"];
342
  for (const b of brandNames) {
343
  if (queryStr.includes(b) && br.includes(b)) sc += 50;
@@ -369,21 +588,30 @@ async function load() {
369
  clearTimeout(t0);
370
  if (!resp.ok) throw new Error("HTTP "+resp.status);
371
  var raw = await resp.json();
372
- var trimmed = (Array.isArray(raw) ? raw : []).slice(0, 1000);
 
 
 
 
 
 
373
  allProducts = trimmed.map((p, i) => ({
374
  name: p.n || p.name || "", title_clean: p.n || p.name || "",
375
- brand: p.brand || "", price: p.p || p.price || "", priceNum: Number(p.pn || 0),
376
  category: normalizeCategory(p.c || p.cat || ""), category_slug: p.cs || "", category_icon: p.ci || "fa-box",
377
- sku: p.sku || "", model: p.mod || p.model || "", slug: p.slug || "",
378
- description: p.desc || "", summary: p.sum || p.summary || "",
379
- features: Array.isArray(p.feats) ? p.feats : [],
380
- specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {},
381
- video: p.vid || "", image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || "",
382
- images: p.imgs || p.images || [], link: p.l || ""
383
  }));
384
  loaded = true;
385
  console.log("[VAIX] Loaded " + allProducts.length + " products in " + (Date.now() - _ls) + "ms");
386
  handleProductUrlParam();
 
 
 
387
  } finally {
388
  clearTimeout(t0);
389
  }
@@ -621,10 +849,15 @@ function showShareToast(msg) {
621
  toast._timer = setTimeout(function(){ toast.style.opacity = "0"; }, 3000);
622
  }
623
 
624
- function openImageViewer(imageUrl, productName) {
625
  if (!imageUrl) return;
626
  const existing = document.getElementById("vaix-image-viewer");
627
  if (existing) existing.remove();
 
 
 
 
 
628
  const viewer = document.createElement("div");
629
  viewer.id = "vaix-image-viewer";
630
  viewer.style.cssText = "position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;cursor:zoom-out;animation:modalIn 0.2s ease";
@@ -635,29 +868,61 @@ function openImageViewer(imageUrl, productName) {
635
  closeBtn.onmouseout = function(){ this.style.background = "rgba(255,255,255,0.15)"; };
636
  closeBtn.onclick = function(e){ e.stopPropagation(); viewer.remove(); document.body.style.overflow = ""; };
637
  viewer.appendChild(closeBtn);
 
638
  if (productName) {
639
- const caption = document.createElement("div");
640
  caption.textContent = productName;
641
  caption.style.cssText = "position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:0.85rem;text-align:center;max-width:80%;padding:8px 16px;background:rgba(0,0,0,0.5);border-radius:8px;z-index:2";
642
  viewer.appendChild(caption);
643
  }
 
 
 
644
  const img = document.createElement("img");
645
- img.src = imageUrl;
646
  img.alt = productName || "";
647
- img.style.cssText = "max-width:100%;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,0.5);user-select:none;-webkit-user-drag:none";
648
  img.onerror = function(){ this.alt = "Không thể tải ảnh"; this.style.maxWidth = "300px"; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  const hint = document.createElement("div");
650
- hint.textContent = "Nhấn ESC hoặc click bên ngoài để đóng";
651
  hint.style.cssText = "position:absolute;top:16px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.4);font-size:0.7rem;z-index:2;white-space:nowrap";
652
  viewer.appendChild(hint);
653
  viewer.appendChild(img);
654
  viewer.addEventListener("click", function(e) { if (e.target === viewer) { viewer.remove(); document.body.style.overflow = ""; } });
655
  viewer.setAttribute("tabindex", "0");
656
  viewer.focus();
657
- function keyHandler(e) { if (e.key === "Escape") { viewer.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", keyHandler); } }
 
 
 
 
658
  document.addEventListener("keydown", keyHandler);
659
  document.body.appendChild(viewer);
660
  document.body.style.overflow = "hidden";
 
661
  }
662
 
663
  function createChatProductCard(product) {
@@ -723,6 +988,39 @@ function attachChatCardHandlers(container) {
723
  if (p) { renderDetail(p); renderSimilarInPanel(p); renderPanelResults([p]); const panel=document.getElementById("vaistudio-panel"),toggle=document.getElementById("vaistudio-toggle"); if(panel){panel.classList.add("open");panel.style.display="flex"} if(toggle)toggle.classList.add("active"); }
724
  });
725
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
726
  }
727
 
728
  function queryCatalog(query) {
@@ -840,15 +1138,13 @@ function sendUserSuggestion(text) {
840
  var messagesEl = document.getElementById("chat-messages");
841
  var inputEl = document.getElementById("chat-input");
842
  if (!messagesEl) return;
843
- var userMsg = document.createElement("div");
844
- userMsg.className = "chat-message user";
845
- userMsg.textContent = text;
846
- messagesEl.appendChild(userMsg);
847
- if (inputEl) inputEl.value = text;
848
- clearSuggestedQuestions();
849
- setTimeout(function() { messagesEl.scrollTop = messagesEl.scrollHeight; }, 50);
850
  window._lastUserSuggestion = text;
851
  if (window._triggerSuggestionSend) window._triggerSuggestionSend(text);
 
 
 
 
852
  }
853
 
854
  function openAskAIForProduct(product) {
@@ -901,6 +1197,21 @@ function renderPanelResults(products) {
901
  const pr = document.createElement("p"); pr.className = "product-card-price"; pr.textContent = fmt(p.priceNum);
902
  info.append(t, b, pr);
903
  if (p.category) { const ct = document.createElement("span"); ct.className = "product-card-cat"; ct.textContent = p.category; info.appendChild(ct); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  card.appendChild(info);
905
  card.addEventListener("click", function(e){ e.stopPropagation(); renderDetail(p); renderSimilarInPanel(p); });
906
  productsEl.appendChild(card);
@@ -943,6 +1254,58 @@ function renderSimilarInPanel(mainProduct) {
943
  productsEl.appendChild(section);
944
  }
945
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
946
  function renderDetail(p) {
947
  const overlay = document.getElementById("vaistudio-detail-overlay");
948
  if (!overlay || !p) return;
@@ -964,13 +1327,11 @@ function renderDetail(p) {
964
  if (p.priceNum >= 1000000) { trieuEl.textContent = "(" + (p.priceNum/1000000).toFixed(1) + " triệu)"; trieuEl.style.display="inline"; }
965
  else trieuEl.style.display = "none";
966
 
967
- const ic = document.getElementById("detail-images");
968
- ic.innerHTML = "";
969
- const imgs = [];
970
- if (p.image) imgs.push(p.image);
971
- if (p.images && p.images.length) p.images.forEach(function(i){ if(i && !imgs.includes(i)) imgs.push(i); });
972
- if (!imgs.length) { const d = document.createElement("div"); d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; d.textContent = "📦"; ic.appendChild(d); }
973
- else { imgs.forEach(function(url, idx){ const wrapper = document.createElement("div"); wrapper.style.cssText = "position:relative;flex-shrink:0;scroll-snap-align:start;cursor:zoom-in"; wrapper.title = "Nhấn để xem ảnh toàn màn hình"; const img = document.createElement("img"); img.src = url; img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e2e8f0;transition:transform 0.2s, box-shadow 0.2s"; img.onerror = function(){ this.style.display = "none"; }; img.onmouseover = function(){ this.style.transform = "scale(1.05)"; this.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }; img.onmouseout = function(){ this.style.transform = ""; this.style.boxShadow = ""; }; img.addEventListener("click", function(e){ e.stopPropagation(); openImageViewer(url, p.title_clean); }); wrapper.appendChild(img); const zoomIcon = document.createElement("div"); zoomIcon.textContent = "🔍"; zoomIcon.style.cssText = "position:absolute;bottom:4px;right:4px;font-size:0.7rem;background:rgba(0,0,0,0.5);border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0.7"; wrapper.appendChild(zoomIcon); ic.appendChild(wrapper); }); }
974
 
975
  const se = document.getElementById("detail-summary");
976
  if (p.summary) { se.textContent=p.summary; se.style.display="block"; }
@@ -1299,10 +1660,19 @@ function parseComboQuery(q) {
1299
  if (!s) return null;
1300
  // Combo intent keywords
1301
  const comboWords = ["combo","bộ ","set ","bộ:","combo:"];
 
 
 
 
 
 
 
 
 
 
1302
  const isCombo = comboWords.some(w => s.includes(w)) ||
1303
- (s.includes(" và ") && /bếp|hút mùi|hút khói|chậu|vòi|lò |nồi/.test(s) && s.split(" và ").filter(t=>_catFromTerm(t)).length >= 2) ||
1304
- /(bếp từ|bếp điện từ).*(máy hút mùi|máy hút khói|chậu|vòi)/.test(s) ||
1305
- /(máy hút mùi|chậu rửa|vòi rửa).*(bếp|nồi|lò)/.test(s);
1306
  if (!isCombo) return null;
1307
  const crit = { categories: [] };
1308
  // Brand
@@ -1321,18 +1691,74 @@ function parseComboQuery(q) {
1321
  else if (prefix === "khoang") { if (!crit.minPrice) crit.minPrice = Math.floor(money * 0.9); if (!crit.maxPrice) crit.maxPrice = Math.ceil(money * 1.1); }
1322
  else { if (!crit.maxPrice) crit.maxPrice = money; }
1323
  }
1324
- // Categories: split on " và " / " + " / "," / "combo"/"bộ"
 
 
 
 
1325
  const parts = raw.split(/[+,;]|\s+và\s+|\s+&\s+/);
 
 
 
 
 
 
 
1326
  for (const p of parts) {
1327
  const c = _catFromTerm(p);
1328
- if (c && !crit.categories.includes(c)) crit.categories.push(c);
1329
  }
1330
- if (!crit.categories.length) {
1331
- // fallback: scan whole string for known category labels
1332
- for (const d of COMBO_CATEGORIES) {
1333
- for (const k of d.keys) { if (s.includes(_norm(k))) { if(!crit.categories.includes(d.label)) crit.categories.push(d.label); break; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1334
  }
1335
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
  // Require at least 2 categories to be a real combo (2+ sản phẩm)
1337
  if (crit.categories.length < 2) return null;
1338
  return crit;
@@ -1406,6 +1832,85 @@ function renderProductInChat(container, product) {
1406
  attachChatCardHandlers(container);
1407
  }
1408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1409
  // Export to window
1410
  window.vaix = {
1411
  load: load,
@@ -1423,6 +1928,10 @@ window.vaix = {
1423
  getLastShownProduct: getLastShownProduct,
1424
  renderSearchResultsInChat: renderSearchResultsInChat,
1425
  renderProductInChat: renderProductInChat,
 
 
 
 
1426
  attachChatCardHandlers: attachChatCardHandlers,
1427
  openImageViewer: openImageViewer,
1428
  allProducts: function(){ return allProducts; },
 
6
  (function() {
7
  'use strict';
8
 
9
+ const JSON_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_index.json";
10
+ const DETAIL_URL = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_detail.json";
11
  let allProducts = [];
12
  let loaded = false;
13
+ // Lazy-loaded image galleries (slug -> array of URLs), fetched from
14
+ // products_detail.json only when a product detail modal opens.
15
+ let detailGalleryMap = null;
16
+ let detailGalleryPromise = null;
17
  // Guard against double-render when ?product=xxx already handled the panel
18
  let productUrlHandled = false;
19
  let searchDebounceTimer = null;
 
107
  // (e.g. bếp từ + máy hút mùi + chậu rửa + vòi rửa, same brand) so every chat
108
  // entry shows a different, real combo from the catalog.
109
  // ─────────────────────────────────────────────
110
+ // All appliance categories that can form a combo. A combo = 2+ products from
111
+ // DIFFERENT categories, priced by the TOTAL sum of all items (not per-item).
112
  const COMBO_CATEGORIES = [
113
+ { keys: ["bếp điện từ", "bếp từ", "bếp gas", "bếp hồng ngoại"], label: "Bếp từ" },
114
+ { keys: ["máy hút mùi", "máy hút khói", "hút khói"], label: "Máy hút mùi" },
115
+ { keys: ["chậu rửa chén", "chậu rửa bát"], label: "Chậu rửa" },
116
+ { keys: ["vòi rửa chén", "vòi rửa bát", "vòi rửa"], label: "Vòi rửa" },
117
+ { keys: ["lò nướng", "lò hấp", "ngăn giữ nóng"], label: "Lò nướng" },
118
+ { keys: ["lò vi sóng", "lò vi ba", "vi sóng"], label: "Lò vi sóng" },
119
+ { keys: ["nồi chiên không dầu", "nồi chiên"], label: "Nồi chiên" },
120
+ { keys: ["máy rửa chén", "máy rửa bát", "máy sấy chén"], label: "Máy rửa chén" },
121
+ { keys: ["máy xay sinh tố", "máy ép trái cây", "máy sinh tố"], label: "Máy ép/xay" },
122
+ // ── Cabinet racks & accessories (phụ kiện tủ bếp) ──
123
+ { keys: ["kệ xoong nồi", "giá xoong nồi", "giá xoong", "kệ xoong"], label: "Kệ xoong nồi" },
124
+ { keys: ["kệ chén dĩa", "giá chén dĩa", "giá bát đĩa", "kệ bát đĩa", "giá chén", "kệ chén", "bát đĩa"], label: "Kệ chén dĩa" },
125
+ { keys: ["kệ dao thớt", "giá dao thớt", "giá dao", "kệ dao"], label: "Kệ dao thớt" },
126
+ { keys: ["giá góc", "kệ góc", "giá góc liên hoàn", "góc liên hoàn"], label: "Giá góc" },
127
+ { keys: ["thùng rác", "thung rac", "thùng rác âm tủ"], label: "Thùng rác" },
128
+ { keys: ["giá gia vị", "kệ gia vị", "giá đựng chai lọ", "kệ chai lọ", "chai lọ"], label: "Giá gia vị" },
129
+ // ── Locks (khóa) ──
130
+ { keys: ["khóa cửa thông minh", "khoá cửa thông minh", "khóa điện tử", "khoá điện tử", "khóa cửa", "khoá cửa", "ổ khóa", "ổ khoá", "khóa thông minh", "khoá thông minh", "khóa tủ", "khoá tủ"], label: "Khóa cửa" },
131
+ ];
132
+ // The classic "gian bếp" combo template used for the DEFAULT chat entry.
133
+ const DEFAULT_COMBO_CATEGORIES = [
134
  { keys: ["bếp điện từ", "bếp từ", "bếp gas"], label: "Bếp từ" },
135
  { keys: ["máy hút mùi", "máy hút khói", "hút khói"], label: "Máy hút mùi" },
136
  { keys: ["chậu rửa chén", "chậu rửa bát"], label: "Chậu rửa" },
 
140
  function _matchCombo(p, catDef) {
141
  const cat = sd(p.category || "");
142
  const name = sd(p.title_clean || "");
143
+ // Match a key against a text with WORD boundaries. A bare substring test lets
144
+ // short keys leak into irrelevant names/brands: e.g. "lò"(->"lo") is a
145
+ // substring of "Malloca", and "nồi hấp" would wrongly match the "Lò nướng"
146
+ // category. Word-boundary matching keeps short tokens from matching the middle
147
+ // of an unrelated longer word.
148
+ function hasWord(text, keyNorm) {
149
+ // Build a regex that requires the key to be surrounded by non-letters.
150
+ // Escapes regex metacharacters; keys are plain Vietnamese words here.
151
+ let esc = keyNorm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
152
+ const re = new RegExp("(^|[^a-z])" + esc + "([^a-z]|$)", "i");
153
+ return re.test(text);
154
+ }
155
+ // 1) Exact key as a whole phrase in category or name (first priority).
156
  for (const k of catDef.keys) {
157
  const kk = sd(k);
158
+ // If the raw key is a single short token, require a word boundary;
159
+ // otherwise (multi-word phrase) a normal includes is safe enough,
160
+ // but we still require boundaries at each multi-word key's edges.
161
+ if (!/\s/.test(kk)) {
162
+ if (hasWord(cat, kk) || hasWord(name, kk)) return true;
163
+ } else {
164
+ if (cat.includes(kk) || name.includes(kk)) return true;
165
+ }
166
+ }
167
+ // 2) Flexible match for "kệ đựng dao thớt" / "kệ đựng xoong nồi" naming: the
168
+ // core item phrase (e.g. "dao thớt") may appear with an inserted word like
169
+ // "đựng" between it and the leading "kệ/giá"; the rack suffix words are still
170
+ // adjacent to each other. Only applied to exactly-2-word keys (e.g.
171
+ // ["dao","thớt"], ["xoong","nồi"]) to avoid over-matching on longer keys.
172
+ // Each core word is matched as a WHOLE word so "lo"(lò) never fires inside
173
+ // "Malloca", and both words must appear in order with only the rack-kind word
174
+ // (kệ/giá/đựng...) before them.
175
+ for (const k of catDef.keys) {
176
+ const words = sd(k).split(/\s+/).filter(w => w.length > 1);
177
+ if (words.length !== 2) continue;
178
+ const core = words.slice(-2);
179
+ // Skip the flexible rule for single-letter-ish tokens like "lò hấp" where a
180
+ // short first word ("lo") is too ambiguous to match freely.
181
+ if (core[0].length <= 2) continue;
182
+ const i0 = _findWord(name, core[0]);
183
+ if (i0 === -1) continue;
184
+ const i1 = _findWord(name, core[1], i0 + core[0].length);
185
+ if (i1 !== -1) return true;
186
  }
187
  return false;
188
  }
189
 
190
+ // Return the index where `word` first appears as a whole word in `text`, at or
191
+ // after `from`. Returns -1 if not found.
192
+ function _findWord(text, word, from) {
193
+ if (!word) return -1;
194
+ const start = (from !== undefined && from >= 0) ? from : 0;
195
+ let idx = text.indexOf(word, start);
196
+ while (idx !== -1) {
197
+ const before = idx === 0 ? "" : text.charAt(idx - 1);
198
+ const after = idx + word.length >= text.length ? "" : text.charAt(idx + word.length);
199
+ const beforeOk = !/[a-z]/.test(before);
200
+ const afterOk = !/[a-z]/.test(after);
201
+ if (beforeOk && afterOk) return idx;
202
+ idx = text.indexOf(word, idx + 1);
203
+ }
204
+ return -1;
205
+ }
206
+
207
  function getRandomCombo(preferredBrand) {
208
  if (!allProducts.length) return null;
209
  const brands = preferredBrand ? [preferredBrand] : Array.from(new Set(allProducts.map(p => p.brand).filter(Boolean)));
 
214
  const brandProducts = allProducts.filter(p => p.brand === brand);
215
  const chosen = [];
216
  let ok = true;
217
+ for (const catDef of DEFAULT_COMBO_CATEGORIES) {
218
  const pool = brandProducts.filter(p => _matchCombo(p, catDef));
219
  if (!pool.length) { ok = false; break; }
220
  chosen.push(pool[Math.floor(Math.random() * pool.length)]);
221
  }
222
+ if (ok && chosen.length === DEFAULT_COMBO_CATEGORIES.length) {
223
  return { brand, label: chosen.map(c => (c.category || "sản phẩm")).slice(0, 4).join(" + "), items: chosen };
224
  }
225
  }
226
 
227
  // Fallback: any 4 distinct products from different combo categories (mixed brands)
228
  const chosen = [];
229
+ for (const catDef of DEFAULT_COMBO_CATEGORIES) {
230
  const pool = allProducts.filter(p => _matchCombo(p, catDef));
231
  if (pool.length) chosen.push(pool[Math.floor(Math.random() * pool.length)]);
232
  }
 
236
  }
237
 
238
  /** Render a COMBO cards block (heading + product cards grid). Returns HTML string. */
239
+ function renderComboCardsBlock(combo, query) {
240
  if (!combo || !combo.items || !combo.items.length) return "";
241
  const cards = combo.items.map(p => createChatProductCard(p)).join("");
242
  const brand = combo.brand ? ' thương hiệu ' + combo.brand : '';
243
+ const qAttr = String(query || "").replace(/"/g, '&quot;');
244
  return '<div class="chat-combo-section">' +
245
  '<div class="chat-combo-title"><span class="combo-spark">✨</span> Combo gợi ý' + brand + '</div>' +
246
  '<div class="chat-combo-sub">Gồm: ' + combo.label + ' — giá combo hợp lý nhất cho gian bếp của bạn</div>' +
247
  '<div class="chat-combo-cards">' + cards + '</div>' +
248
+ '<button class="chat-combo-random" data-combo="1" data-query="' + qAttr + '">🔄 Đổi combo khác</button>' +
249
  '</div>';
250
  }
251
 
 
316
  return idx.map((j, i) => pools[i][j]);
317
  }
318
 
319
+ function comboByCriteria(criteria, excludeSet) {
320
  if (!allProducts.length) return null;
321
  const c = criteria || {};
322
+ // Optional set of item identities to AVOID when picking a combo (used by the
323
+ // "Đổi combo" button to fetch an ALTERNATE combo for the SAME criteria).
324
+ const ex = (excludeSet && excludeSet.size) ? excludeSet : null;
325
  const wantedCategories = Array.isArray(c.categories) && c.categories.length
326
  ? c.categories
327
  : ["bếp từ", "máy hút mùi", "chậu rửa", "vòi rửa"];
 
329
 
330
  // Build per-category pools for a set of source products (same brand or all).
331
  function poolsFor(sourceProducts) {
332
+ const sizes = c.sizes || {};
333
  const pools = [];
334
  for (const catReq of wantedCategories) {
335
  const catDefs = COMBO_CATEGORIES.filter(d => sd(d.label).includes(sd(catReq).replace(/\s+/g, "")) ||
 
338
  const def = defs[0];
339
  if (!def) return null;
340
  let pool = sourceProducts.filter(p => _matchCombo(p, def));
341
+ // Brand is strict (user asked for a specific brand).
342
+ pool = filterComboPoolByCriteria(pool, { ...c, material: undefined, color: undefined });
343
  if (!pool.length) return null;
344
+ // Material / color are strong preferences, but must NOT hard-block a combo
345
+ // (e.g. Grob dao thớt is "Mix Oval" not literally "nan oval"; another brand
346
+ // may lack the exact variant). Apply them, then degrade if they empty the pool.
347
+ const styleCrit = {};
348
+ if (c.material) styleCrit.material = c.material;
349
+ if (c.color) styleCrit.color = c.color;
350
+ const styleKeys = Object.keys(styleCrit);
351
+ if (styleKeys.length) {
352
+ const styled = filterComboPoolByCriteria(pool, styleCrit);
353
+ if (styled.length) pool = styled; // keep styled if anything matches
354
+ // else fall through with the brand-filtered pool (relax material/color)
355
+ }
356
  if (hasBudget) pool = pool.filter(p => (p.priceNum || 0) > 0);
357
  if (!pool.length) return null;
358
+ // Per-category size filter: match "Khoang tủ"/"Chiều rộng tủ" specs or
359
+ // the dimension tokens in the product name/specs to any requested mm.
360
+ const catSizes = sizes[def.label] || [];
361
+ if (catSizes.length) {
362
+ const sizeFiltered = pool.filter(p => {
363
+ const specText = sd(Object.values(p.specs || {}).join(" ") + " " + p.title_clean + " " + p.category + " " + (p.description || ""));
364
+ return catSizes.some(mm => specText.includes(String(mm) + "mm") || specText.includes(String(mm) + " mm"));
365
+ });
366
+ // STRICT: an explicitly requested size must match — do NOT fall back to
367
+ // other sizes, otherwise "chén dĩa 700mm" would show 800mm/900mm racks.
368
+ pool = sizeFiltered; // may be empty -> this brand/category can't satisfy
369
+ if (!pool.length) return null; // this brand can't supply the requested size
370
+ }
371
  pool = pool.slice().sort((a, b) => (a.priceNum || 0) - (b.priceNum || 0));
372
  pools.push(pool);
373
  }
 
400
  }
401
 
402
  function comboFromPools(pools, brand) {
403
+ // Drop any candidate items the caller asked us to exclude (alternate combo).
404
+ const poolsF = ex ? pools.map(pool => pool.filter(p => !ex.has(p.sku || p.model || p.title_clean))) : pools;
405
+ if (poolsF.some(p => !p.length)) return null;
406
+ const chosenRaw = _pickComboWithinBudget(poolsF, c);
407
+ if (!chosenRaw || chosenRaw.length !== wantedCategories.length) return null;
408
+ const chosen = [];
409
+ const seen = new Set();
410
+ for (const p of chosenRaw) {
411
+ const key = p.sku || p.model || p.title_clean;
412
+ if (seen.has(key)) return null; // duplicates across categories not allowed for a swap
413
+ seen.add(key);
414
+ chosen.push(p);
415
+ }
416
  const total = chosen.reduce((s, p) => s + (p.priceNum || 0), 0);
417
  return {
418
  brand,
 
430
  if (combo) return combo;
431
  }
432
 
433
+ // Fallback: mixed brands — also reached when a specific brand was requested but
434
+ // could not supply every requested category+size. The requested brand is still
435
+ // preferred per-category when possible (filterComboPoolByCriteria applies it),
436
+ // so the user sees their preferred brand where it exists and other brands fill
437
+ // the gaps, instead of returning nothing.
438
  const pools = poolsFor(allProducts);
439
  if (pools) {
440
  const combo = comboFromPools(pools, "");
 
453
  const sk = sd(p.sku || p.model);
454
  const br = sd(p.brand);
455
  const cat = sd(p.category);
456
+ const rawQuery = sd(String(queryStr || "").trim());
457
+
458
+ // ── EXACT MODEL / SKU / TITLE MATCH ──
459
+ // When the user types a concrete model code (e.g. "GP170BD", "JAPK900LX"),
460
+ // an SKU, or copies the exact product title, that product MUST surface first
461
+ // (and ideally alone) so single-product searches are 100% accurate.
462
+ // Normalize model tokens both compact and spaced ("GP 170 BD" -> "gp170bd").
463
+ const compactQ = rawQuery.replace(/[^a-z0-9]/g, "");
464
+ const compactName = nm.replace(/[^a-z0-9]/g, "");
465
+ const compactSk = sk.replace(/[^a-z0-9]/g, "");
466
+ if (compactQ.length >= 4) {
467
+ if (compactSk === compactQ) { sc += 1000; } // exact sku/model match
468
+ else if (compactName === compactQ) { sc += 900; } // exact title match
469
+ else if (compactName.includes(compactQ) || compactSk.includes(compactQ)) { sc += 550; }
470
+ else if (compactQ.includes(compactSk) && compactSk.length >= 4) { sc += 400; }
471
+ }
472
+ // Also boost when every significant query term appears in the title.
473
+ const sigTerms = terms.filter(t => t.length > 1 && !/^\d{2,4}mm/.test(t));
474
+ if (sigTerms.length && sigTerms.every(t => nm.includes(t))) { sc += 300; }
475
+
476
+ const desc = sd(p.description||"");
477
+ const feats = sd((p.features||[]).join(" "));
478
+ const summary = sd(p.summary||"");
479
+ const specsText = sd(Object.values(p.specs||{}).join(" "));
480
+ const all = [nm, br, cat, desc, feats, summary, specsText].join(" ");
481
 
482
  if (nm === queryStr) sc += 200;
483
  else if (sk === queryStr) sc += 180;
484
  else if (nm.includes(queryStr)) sc += 100;
485
  else if (sk.includes(queryStr)) sc += 80;
486
+ else if (all.includes(queryStr)) sc += 50;
487
+
488
+ // ── Size (mm) matching, robust to formatting in thông số kỹ thuật ──
489
+ // Specs store sizes inconsistently: "900 mm", "900mm", "W565 x D280 x H650",
490
+ // "Khoang tủ: 900", "Chiều rộng tủ: 800mm", "cánh/phủ bì 900mm, lọt lòng 865mm".
491
+ // Extract every distinct numeric-mm value from the product (title + specs + desc
492
+ // + features + summary) and from the query, then score on numeric equality.
493
+ function mmValues(str) {
494
+ const out = {};
495
+ const re = /(\d{1,4})\s*mm/gi; let m;
496
+ while ((m = re.exec(str))) out[parseInt(m[1],10)] = true;
497
+ // Also capture WxDxH dimensions like "w565 x d280 x h650" (numbers without mm
498
+ // adjacency but clearly sizes when present with mm elsewhere / near letters).
499
+ const re2 = /[wdh]\s*[:=]?\s*(\d{1,4})/gi; let m2;
500
+ while ((m2 = re2.exec(str))) out[parseInt(m2[1],10)] = true;
501
+ return Object.keys(out).map(Number);
502
+ }
503
+ const prodMM = new Set(mmValues(nm + " " + specsText + " " + desc + " " + feats + " " + summary));
504
+ // Query size tokens: want the numeric size(s) the user typed (e.g. 700, 900).
505
+ const queryMM = mmValues(queryStr);
506
+ let sizeMatch = false;
507
+ for (const qm of queryMM) {
508
+ if (prodMM.has(qm)) { sizeMatch = true; break; }
509
+ }
510
+ if (sizeMatch) {
511
+ // Prefer when the size is in the TITLE (primary "cánh/phủ bì" width) or specs.
512
+ if (nm.includes(queryStr)) sc += 40; // exact title phrase w/ size already scored
513
+ else if (nm.split(" ").some(t => /^\d{1,4}mm$/i.test(t))) sc += 55;
514
+ else if (specsText !== "") sc += 50; // size present in thông số kỹ thuật
515
+ else sc += 40;
516
+ } else {
517
+ // No exact numeric size match — small penalty so spec-matching products win.
518
+ if (queryMM.length) sc -= 10;
519
+ }
520
 
521
+ // Strong bonus for size tokens (e.g. "700mm") contained in title/specs/desc.
522
+ const sizeTokens = terms.filter(t => /^\d{2,4}mm$/.test(t) || /^\d{2,4}\s*mm$/.test(t));
523
+ for (const st of sizeTokens) {
524
+ const stPlain = st.replace(/\s+/g, "");
525
+ if (nm.includes(stPlain) || nm.includes(st)) sc += 60;
526
+ else if (sk.includes(stPlain)) sc += 40;
527
+ else if (all.includes(stPlain) || all.includes(st)) sc += 25;
528
+ }
529
+
530
+ // ── Material (chất liệu) matching from title AND specs ──
531
+ const materialWords = ["inox 304","inox sus304","inox304","inox","sus304","sus 304","nan oval","nan vuông","nan tron","nan dẹt","nan det","khung nhôm","nhôm","hợp kim","kính","gốm","đá granite","da granite","mây","thép","gang","nhựa abs","abs","thủy tinh"];
532
+ for (const mat of materialWords) {
533
+ const mNorm = sd(mat);
534
+ if (queryStr.includes(mNorm) && (nm.includes(mNorm) || specsText.includes(mNorm) || desc.includes(mNorm))) {
535
+ sc += 30;
536
+ break;
537
+ }
538
  }
539
 
540
+ // Per-term scoring; count how many DISTINCT terms hit the title (multi-term match
541
+ // is a strong signal the product really is what the user wants).
542
+ let titleHits = 0;
543
+ let totalHits = 0;
544
+ const significant = terms.filter(t => t.length > 1 && !/^\d{2,4}mm$/.test(t) && !/^\d{2,4}\s*mm$/.test(t));
545
+ for (const t of significant) {
546
+ let hit = false;
547
+ if (nm.includes(t)) { sc += 16; titleHits++; hit = true; }
548
+ else if (sk.includes(t)) { sc += 12; hit = true; }
549
+ else if (br.includes(t)) { sc += 8; hit = true; }
550
+ else if (cat.includes(t)) { sc += 10; hit = true; }
551
+ else if (all.includes(t)) { sc += 3; hit = true; }
552
+ if (hit) totalHits++;
553
+ }
554
+ // Multi-term bonus: matching >=2 meaningful terms in the title is a strong signal.
555
+ if (titleHits >= 2) sc += 35;
556
+ else if (titleHits === 1 && significant.length >= 2) sc += 10;
557
+ // Category bonus: if the product's category matches a query term, boost more.
558
+ for (const t of significant) { if (cat.includes(t)) sc += 6; }
559
+
560
  const brandNames = ["malloca","eurogold","grob","canzy","demax","sunhouse","kangaroo","sharp","toshiba","panasonic","samsung","lg","electrolux","bosch","fischer"];
561
  for (const b of brandNames) {
562
  if (queryStr.includes(b) && br.includes(b)) sc += 50;
 
588
  clearTimeout(t0);
589
  if (!resp.ok) throw new Error("HTTP "+resp.status);
590
  var raw = await resp.json();
591
+ // Load the FULL catalog (11,704 products) from the FAST index file.
592
+ // Previously we loaded a ~33MB JSON synchronously, which blocked the main
593
+ // thread and hung the loading overlay on slow connections. utils_index is
594
+ // ~10MB (68% smaller) and contains every field search/combo/cart/panel
595
+ // need. Image galleries are fetched lazily from products_detail.json when
596
+ // a product's detail modal opens (see ensureGallery).
597
+ var trimmed = (Array.isArray(raw) ? raw : []);
598
  allProducts = trimmed.map((p, i) => ({
599
  name: p.n || p.name || "", title_clean: p.n || p.name || "",
600
+ brand: p.b || p.brand || "", price: p.p || p.price || "", priceNum: Number(p.pn || 0),
601
  category: normalizeCategory(p.c || p.cat || ""), category_slug: p.cs || "", category_icon: p.ci || "fa-box",
602
+ sku: p.s || p.sku || "", model: p.m || p.mod || p.model || "", slug: p.sl || p.slug || "",
603
+ description: p.d || p.desc || "", summary: p.sm || p.sum || p.summary || "",
604
+ features: Array.isArray(p.f) ? p.f : (Array.isArray(p.feats) ? p.feats : []),
605
+ specs: (p.sp && typeof p.sp === "object") ? p.sp : (p.specs && typeof p.specs === "object" ? p.specs : {}),
606
+ video: p.vid || "", image: p.i || p.image || "",
607
+ images: p.g || (Array.isArray(p.imgs) ? p.imgs : []) || [], link: p.l || ""
608
  }));
609
  loaded = true;
610
  console.log("[VAIX] Loaded " + allProducts.length + " products in " + (Date.now() - _ls) + "ms");
611
  handleProductUrlParam();
612
+ // Kick off the (lazy) gallery fetch in the background so Grob avatars get
613
+ // corrected (applyGrobAvatarFix) and detail thumbnails are ready quickly.
614
+ ensureGallery().catch(function(){});
615
  } finally {
616
  clearTimeout(t0);
617
  }
 
849
  toast._timer = setTimeout(function(){ toast.style.opacity = "0"; }, 3000);
850
  }
851
 
852
+ function openImageViewer(imageUrl, productName, gallery) {
853
  if (!imageUrl) return;
854
  const existing = document.getElementById("vaix-image-viewer");
855
  if (existing) existing.remove();
856
+ // Build a de-duplicated gallery (imageUrl first), for prev/next sliding.
857
+ const gal = [];
858
+ if (imageUrl) gal.push(imageUrl);
859
+ if (Array.isArray(gallery)) gallery.forEach(function(u){ if (u && gal.indexOf(u) === -1) gal.push(u); });
860
+ let cur = 0;
861
  const viewer = document.createElement("div");
862
  viewer.id = "vaix-image-viewer";
863
  viewer.style.cssText = "position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.92);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;cursor:zoom-out;animation:modalIn 0.2s ease";
 
868
  closeBtn.onmouseout = function(){ this.style.background = "rgba(255,255,255,0.15)"; };
869
  closeBtn.onclick = function(e){ e.stopPropagation(); viewer.remove(); document.body.style.overflow = ""; };
870
  viewer.appendChild(closeBtn);
871
+ const caption = document.createElement("div");
872
  if (productName) {
 
873
  caption.textContent = productName;
874
  caption.style.cssText = "position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:0.85rem;text-align:center;max-width:80%;padding:8px 16px;background:rgba(0,0,0,0.5);border-radius:8px;z-index:2";
875
  viewer.appendChild(caption);
876
  }
877
+ const counter = document.createElement("div");
878
+ counter.style.cssText = "position:absolute;top:16px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:0.78rem;z-index:2;background:rgba(0,0,0,0.5);padding:4px 10px;border-radius:12px;white-space:nowrap";
879
+ viewer.appendChild(counter);
880
  const img = document.createElement("img");
 
881
  img.alt = productName || "";
882
+ img.style.cssText = "max-width:100%;max-height:88vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,0.5);user-select:none;-webkit-user-drag:none";
883
  img.onerror = function(){ this.alt = "Không thể tải ảnh"; this.style.maxWidth = "300px"; };
884
+ function makeNavArrow(dir) {
885
+ const b = document.createElement("button");
886
+ b.innerHTML = dir === -1 ? "‹" : "›";
887
+ b.style.cssText = "position:absolute;top:50%;" + (dir === -1 ? "left:10px;" : "right:10px;") + "transform:translateY(-50%);width:52px;height:72px;border:none;background:rgba(255,255,255,0.12);color:#fff;font-size:2.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:12px;transition:background 0.2s;z-index:3";
888
+ b.onmouseover = function(){ this.style.background = "rgba(255,255,255,0.28)"; };
889
+ b.onmouseout = function(){ this.style.background = "rgba(255,255,255,0.12)"; };
890
+ b.onclick = function(e){ e.stopPropagation(); showImage(cur + dir); };
891
+ return b;
892
+ }
893
+ const prevBtn = makeNavArrow(-1);
894
+ const nextBtn = makeNavArrow(1);
895
+ function showImage(idx) {
896
+ if (!gal.length) return;
897
+ cur = ((idx % gal.length) + gal.length) % gal.length;
898
+ img.style.opacity = "0";
899
+ img.src = gal[cur];
900
+ img.onload = function(){ img.style.opacity = "1"; };
901
+ img.style.transition = "opacity 0.15s ease";
902
+ if (gal.length > 1) { counter.textContent = (cur + 1) + " / " + gal.length; counter.style.display = ""; }
903
+ else counter.style.display = "none";
904
+ prevBtn.style.visibility = gal.length > 1 ? "visible" : "hidden";
905
+ nextBtn.style.visibility = gal.length > 1 ? "visible" : "hidden";
906
+ if (caption && productName) caption.textContent = productName + (gal.length > 1 ? " (" + (cur+1) + "/" + gal.length + ")" : "");
907
+ }
908
+ if (gal.length > 1) { viewer.appendChild(prevBtn); viewer.appendChild(nextBtn); }
909
  const hint = document.createElement("div");
910
+ hint.textContent = gal.length > 1 ? "Dùng ← → để xem ảnh tiếp · Nhấn ESC để đóng" : "Nhấn ESC hoặc click bên ngoài để đóng";
911
  hint.style.cssText = "position:absolute;top:16px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.4);font-size:0.7rem;z-index:2;white-space:nowrap";
912
  viewer.appendChild(hint);
913
  viewer.appendChild(img);
914
  viewer.addEventListener("click", function(e) { if (e.target === viewer) { viewer.remove(); document.body.style.overflow = ""; } });
915
  viewer.setAttribute("tabindex", "0");
916
  viewer.focus();
917
+ function keyHandler(e) {
918
+ if (e.key === "Escape") { viewer.remove(); document.body.style.overflow = ""; document.removeEventListener("keydown", keyHandler); }
919
+ else if (e.key === "ArrowLeft") { e.preventDefault(); showImage(cur - 1); }
920
+ else if (e.key === "ArrowRight") { e.preventDefault(); showImage(cur + 1); }
921
+ }
922
  document.addEventListener("keydown", keyHandler);
923
  document.body.appendChild(viewer);
924
  document.body.style.overflow = "hidden";
925
+ showImage(0);
926
  }
927
 
928
  function createChatProductCard(product) {
 
988
  if (p) { renderDetail(p); renderSimilarInPanel(p); renderPanelResults([p]); const panel=document.getElementById("vaistudio-panel"),toggle=document.getElementById("vaistudio-toggle"); if(panel){panel.classList.add("open");panel.style.display="flex"} if(toggle)toggle.classList.add("active"); }
989
  });
990
  });
991
+
992
+ // "Đổi combo" button — swaps to an ALTERNATE combo that still matches the
993
+ // user's original criteria (stored in data-query). If no alternate exists,
994
+ // keeps the current combo and shows a short notice.
995
+ container.querySelectorAll(".chat-combo-random").forEach(function(btn) {
996
+ btn.addEventListener("click", function(e) {
997
+ e.stopPropagation();
998
+ const q = (this.getAttribute("data-query") || "").trim();
999
+ const section = this.closest(".chat-combo-section") || this.closest(".chat-product-cards") || this.parentNode;
1000
+ if (!section) return;
1001
+ // Find the current combo items rendered in this section to exclude them.
1002
+ const curProducts = [];
1003
+ section.querySelectorAll(".chat-product-card").forEach(function(card) {
1004
+ const nm = card.getAttribute("data-product");
1005
+ const p = nm ? findProduct(nm) : null;
1006
+ if (p) curProducts.push(p);
1007
+ });
1008
+ const curCombo = { items: curProducts };
1009
+ const alt = window.vaix && window.vaix.getAlternativeCombo ? window.vaix.getAlternativeCombo(q || (window.vaix.getLastShownProduct && ""), curCombo) : null;
1010
+ const freshRaw = alt || (window.vaix && window.vaix.getComboOrSearch ? window.vaix.getComboOrSearch(q, 4).combo : null);
1011
+ if (freshRaw && freshRaw.items && freshRaw.items.length && alt) {
1012
+ const freshEl = document.createElement("div");
1013
+ freshEl.innerHTML = renderComboCardsBlock(freshRaw, q);
1014
+ if (attachChatCardHandlers) attachChatCardHandlers(freshEl);
1015
+ const block = freshEl.querySelector(".chat-combo-section");
1016
+ if (block) section.replaceWith(block);
1017
+ } else {
1018
+ btn.textContent = "✓ Đã hết combo khác phù hợp";
1019
+ btn.disabled = true;
1020
+ setTimeout(function() { if (btn && btn.parentNode) btn.textContent = "🔄 Đổi combo khác"; btn.disabled = false; }, 1800);
1021
+ }
1022
+ });
1023
+ });
1024
  }
1025
 
1026
  function queryCatalog(query) {
 
1138
  var messagesEl = document.getElementById("chat-messages");
1139
  var inputEl = document.getElementById("chat-input");
1140
  if (!messagesEl) return;
1141
+ // Giao user bubble cho sendTextMessage (gemma REST) để tránh trùng lặp.
 
 
 
 
 
 
1142
  window._lastUserSuggestion = text;
1143
  if (window._triggerSuggestionSend) window._triggerSuggestionSend(text);
1144
+ else {
1145
+ if (inputEl) inputEl.value = text;
1146
+ clearSuggestedQuestions();
1147
+ }
1148
  }
1149
 
1150
  function openAskAIForProduct(product) {
 
1197
  const pr = document.createElement("p"); pr.className = "product-card-price"; pr.textContent = fmt(p.priceNum);
1198
  info.append(t, b, pr);
1199
  if (p.category) { const ct = document.createElement("span"); ct.className = "product-card-cat"; ct.textContent = p.category; info.appendChild(ct); }
1200
+ // Cart button on each floating product card
1201
+ const cartBtn = document.createElement("button");
1202
+ cartBtn.type = "button";
1203
+ cartBtn.className = "product-card-btn cart-btn";
1204
+ cartBtn.innerHTML = '<i class="fas fa-shopping-cart"></i> Thêm giỏ';
1205
+ cartBtn.addEventListener("click", function(e) {
1206
+ e.stopPropagation();
1207
+ const prodObj = { slug: p.slug || '', sku: p.sku || p.model || '', name: p.title_clean || p.name || '', price: p.priceNum, priceNum: p.priceNum, image: p.image || '' };
1208
+ window.addToCart(prodObj);
1209
+ const self = this;
1210
+ self.innerHTML = '<i class="fas fa-check"></i> Đã thêm';
1211
+ self.classList.add("added");
1212
+ setTimeout(function() { self.innerHTML = '<i class="fas fa-shopping-cart"></i> Thêm giỏ'; self.classList.remove("added"); }, 1500);
1213
+ });
1214
+ card.appendChild(cartBtn);
1215
  card.appendChild(info);
1216
  card.addEventListener("click", function(e){ e.stopPropagation(); renderDetail(p); renderSimilarInPanel(p); });
1217
  productsEl.appendChild(card);
 
1254
  productsEl.appendChild(section);
1255
  }
1256
 
1257
+ // Fetch the lazy image-gallery map once and cache it. Called on demand so the
1258
+ // heavy gallery data never blocks the fast catalog load.
1259
+ function ensureGallery() {
1260
+ if (detailGalleryMap) return Promise.resolve(detailGalleryMap);
1261
+ if (detailGalleryPromise) return detailGalleryPromise;
1262
+ detailGalleryPromise = fetch(DETAIL_URL, { signal: (new AbortController()).signal })
1263
+ .then(function(r){ if(!r.ok) throw new Error("HTTP "+r.status); return r.json(); })
1264
+ .then(function(m){ detailGalleryMap = (m && typeof m === "object") ? m : {}; applyGrobAvatarFix(); return detailGalleryMap; })
1265
+ .catch(function(e){ console.warn("[VAIX] Gallery load failed:", e.message || e); detailGalleryMap = {}; return detailGalleryMap; });
1266
+ return detailGalleryPromise;
1267
+ }
1268
+
1269
+ // Grob products historically ship with a WRONG default avatar (all 648 products
1270
+ // pointed at the same grob_p12.jpg). Their per-product galleries live in the
1271
+ // detail map; the correct avatar is the 2nd gallery image (index 1). Once the
1272
+ // gallery map is available, patch each Grob product's avatar so every card /
1273
+ // search result shows the right image.
1274
+ function applyGrobAvatarFix() {
1275
+ if (!detailGalleryMap) return;
1276
+ for (let i = 0; i < allProducts.length; i++) {
1277
+ const p = allProducts[i];
1278
+ if (!p || (p.brand || "").toLowerCase() !== "grob") continue;
1279
+ const slug = p.slug;
1280
+ if (!slug) continue;
1281
+ const g = detailGalleryMap[slug];
1282
+ if (!Array.isArray(g) || g.length < 2) continue;
1283
+ const secondImg = g[1];
1284
+ if (secondImg && secondImg !== p.image) {
1285
+ p.image = secondImg;
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // Attach lazy galleries to a product if not already present, then render thumbnails.
1291
+ function renderDetailImages(p) {
1292
+ const ic = document.getElementById("detail-images");
1293
+ if (!ic) return;
1294
+ let imgs = [];
1295
+ if (p.image) imgs.push(p.image);
1296
+ if (!p.images || !p.images.length) {
1297
+ // product has no gallery yet — try lazy fetch by slug
1298
+ if (detailGalleryMap && p.slug && detailGalleryMap[p.slug]) {
1299
+ (detailGalleryMap[p.slug]||[]).forEach(function(u){ if(u && !imgs.includes(u)) imgs.push(u); });
1300
+ }
1301
+ } else {
1302
+ p.images.forEach(function(u){ if(u && !imgs.includes(u)) imgs.push(u); });
1303
+ }
1304
+ ic.innerHTML = "";
1305
+ if (!imgs.length) { const d = document.createElement("div"); d.style.cssText = "width:120px;height:120px;background:#f1f5f9;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:2rem;flex-shrink:0"; d.textContent = "📦"; ic.appendChild(d); }
1306
+ else { imgs.forEach(function(url, idx){ const wrapper = document.createElement("div"); wrapper.style.cssText = "position:relative;flex-shrink:0;scroll-snap-align:start;cursor:zoom-in"; wrapper.title = "Nhấn để xem ảnh toàn màn hình"; const img = document.createElement("img"); img.src = url; img.style.cssText = "width:120px;height:120px;border-radius:12px;object-fit:cover;border:1px solid #e2e8f0;transition:transform 0.2s, box-shadow 0.2s"; img.onerror = function(){ this.style.display = "none"; }; img.onmouseover = function(){ this.style.transform = "scale(1.05)"; this.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)"; }; img.onmouseout = function(){ this.style.transform = ""; this.style.boxShadow = ""; }; img.addEventListener("click", function(e){ e.stopPropagation(); openImageViewer(url, p.title_clean, imgs); }); wrapper.appendChild(img); const zoomIcon = document.createElement("div"); zoomIcon.textContent = "🔍"; zoomIcon.style.cssText = "position:absolute;bottom:4px;right:4px;font-size:0.7rem;background:rgba(0,0,0,0.5);border-radius:50%;width:22px;height:22px;display:flex;align-items:center;justify-content:center;pointer-events:none;opacity:0.7"; wrapper.appendChild(zoomIcon); ic.appendChild(wrapper); }); }
1307
+ }
1308
+
1309
  function renderDetail(p) {
1310
  const overlay = document.getElementById("vaistudio-detail-overlay");
1311
  if (!overlay || !p) return;
 
1327
  if (p.priceNum >= 1000000) { trieuEl.textContent = "(" + (p.priceNum/1000000).toFixed(1) + " triệu)"; trieuEl.style.display="inline"; }
1328
  else trieuEl.style.display = "none";
1329
 
1330
+ // Render thumbnail gallery; fetch lazy galleries in the background if needed.
1331
+ renderDetailImages(p);
1332
+ ensureGallery().then(function(){
1333
+ if (lastShownProduct === p) renderDetailImages(p); // re-render when galleries arrive
1334
+ });
 
 
1335
 
1336
  const se = document.getElementById("detail-summary");
1337
  if (p.summary) { se.textContent=p.summary; se.style.display="block"; }
 
1660
  if (!s) return null;
1661
  // Combo intent keywords
1662
  const comboWords = ["combo","bộ ","set ","bộ:","combo:"];
1663
+ // Count distinct appliance categories mentioned in the query.
1664
+ const catCount = (function(){
1665
+ // split on separators, then also scan whole string via _catFromTerm fallback
1666
+ const seen = new Set();
1667
+ const parts = raw.split(/[+,;]|\s+và\s+|\s+&\s+/);
1668
+ for (const p of parts) { const c = _catFromTerm(p); if (c && !seen.has(c)) seen.add(c); }
1669
+ if (seen.size >= 2) return seen.size;
1670
+ for (const d of COMBO_CATEGORIES) { for (const k of d.keys) { if (s.includes(_norm(k))) { if(!seen.has(d.label)) seen.add(d.label); break; } } }
1671
+ return seen.size;
1672
+ })();
1673
  const isCombo = comboWords.some(w => s.includes(w)) ||
1674
+ (s.includes(" và ") && catCount >= 2) ||
1675
+ catCount >= 2;
 
1676
  if (!isCombo) return null;
1677
  const crit = { categories: [] };
1678
  // Brand
 
1691
  else if (prefix === "khoang") { if (!crit.minPrice) crit.minPrice = Math.floor(money * 0.9); if (!crit.maxPrice) crit.maxPrice = Math.ceil(money * 1.1); }
1692
  else { if (!crit.maxPrice) crit.maxPrice = money; }
1693
  }
1694
+ // Categories: split on " và " / " + " / "," / "combo"/"bộ". Some users type
1695
+ // multiple categories WITHOUT separators (e.g. "combo bếp từ máy hút mùi
1696
+ // chậu rửa giá 20 triệu"), so we ALSO scan the whole string for every known
1697
+ // category label and merge. This guarantees every requested category is
1698
+ // captured regardless of separators.
1699
  const parts = raw.split(/[+,;]|\s+và\s+|\s+&\s+/);
1700
+ const sizes = {}; // cat label -> [array of mm sizes]
1701
+ function addCat(c) {
1702
+ if (c && !crit.categories.includes(c)) crit.categories.push(c);
1703
+ }
1704
+ // Collect every category the per-part split found (handles "và"/","/"+").
1705
+ // Sizes are NOT read here — they are assigned precisely in the dedicated mm
1706
+ // block below to avoid double-counting.
1707
  for (const p of parts) {
1708
  const c = _catFromTerm(p);
1709
+ if (c) addCat(c);
1710
  }
1711
+ // Whole-string scan — captures space-separated categories the per-part split
1712
+ // missed. Matches on BOTH the keyword AND the category label (some labels like
1713
+ // "Chậu rửa" / "Vòi rửa" aren't literal keys), and only registers the category
1714
+ // (no size association here sizes are handled precisely below).
1715
+ for (const d of COMBO_CATEGORIES) {
1716
+ let hit = false;
1717
+ for (const k of d.keys) { if (s.includes(_norm(k))) { hit = true; break; } }
1718
+ if (!hit && s.includes(_norm(d.label))) hit = true;
1719
+ if (hit) addCat(d.label);
1720
+ }
1721
+ // Precise size assignment: for every <NNN>mm token in the query, assign it to
1722
+ // the LAST category keyword that appears BEFORE it (within a short window).
1723
+ // This correctly handles both "kệ chén dĩa 700mm và kệ dao thớt 400mm" and
1724
+ // the no-separator "kệ chén dĩa 700mm kệ dao thớt 400mm".
1725
+ const mmRe = /(\d{2,4})\s*mm/gi;
1726
+ const catAnchors = [];
1727
+ for (const d of COMBO_CATEGORIES) {
1728
+ const tokens = [d.label].concat(d.keys);
1729
+ for (const t of tokens) {
1730
+ const idx = s.indexOf(_norm(t));
1731
+ if (idx !== -1) catAnchors.push({ idx, label: d.label });
1732
  }
1733
  }
1734
+ catAnchors.sort((a, b) => a.idx - b.idx);
1735
+ let mmMatch = mmRe.exec(s);
1736
+ while (mmMatch) {
1737
+ const mm = parseInt(mmMatch[1], 10);
1738
+ const mmPos = mmMatch.index;
1739
+ // nearest category anchor strictly before the mm token
1740
+ let best = null;
1741
+ for (const a of catAnchors) {
1742
+ if (a.idx >= mmPos) break;
1743
+ if (a.idx < mmPos && (best === null || a.idx > best.idx)) best = a;
1744
+ }
1745
+ if (best) sizes[best.label] = (sizes[best.label] || []).concat(mm);
1746
+ mmMatch = mmRe.exec(s);
1747
+ }
1748
+ // Only keep size assignments for categories that were actually requested.
1749
+ for (const lbl of Object.keys(sizes)) {
1750
+ if (!crit.categories.includes(lbl)) delete sizes[lbl];
1751
+ }
1752
+ if (Object.keys(sizes).length) crit.sizes = sizes;
1753
+ // Material (chất liệu) — e.g. "nan oval", "nan vuông", "inox", "hợp kim",
1754
+ // "nhôm", "kính", "gốm", "đá granite". Extract the first known material phrase.
1755
+ const materialWords = [["nan oval","nan-oval"],["nan vuông","nan-vuong"],["nan tròn","nan-tron"],
1756
+ ["inox 304","inox"],["inox","inox"],["hợp kim","hop-kim"],["nhôm","nhom"],["kính","kinh"],
1757
+ ["gốm","gom"],["đá granite","da-granite"],["đá","da"],["mây","may"]];
1758
+ for (const [word,_normWord] of materialWords) { if (s.includes(_norm(word))) { crit.material = _norm(word); break; } }
1759
+ // Color (màu sắc)
1760
+ const colorWords = ["đen","trắng","bạc","xám","ghi","vàng","champagne","nâu","đồng","vàng gold"];
1761
+ for (const col of colorWords) { if (s.includes(_norm(col))) { crit.color = _norm(col); break; } }
1762
  // Require at least 2 categories to be a real combo (2+ sản phẩm)
1763
  if (crit.categories.length < 2) return null;
1764
  return crit;
 
1832
  attachChatCardHandlers(container);
1833
  }
1834
 
1835
+ // Return top products matching a query as an array (for chat suggestion cards)
1836
+ // without rendering the panel. Reuses the same scoring as queryCatalog.
1837
+ function searchProductsForCards(query, limit) {
1838
+ if (!allProducts.length) return [];
1839
+ const q = String(query || "").trim();
1840
+ if (!q) return [];
1841
+ const qs = sd(q), terms = extractTerms(q), cap = limit || 4;
1842
+ const scored = [];
1843
+ for (let i = 0; i < allProducts.length; i++) {
1844
+ const p = allProducts[i];
1845
+ const sc = scoreProduct(p, qs, terms);
1846
+ if (sc > 0) scored.push({ p, sc });
1847
+ }
1848
+ scored.sort((a, b) => b.sc - a.sc);
1849
+ return scored.slice(0, cap).map(r => r.p);
1850
+ }
1851
+
1852
+ // Combos + search, exactly mirroring the product-list panel:
1853
+ // 1) If the query is a combo query -> return the curated combo items.
1854
+ // 2) Otherwise -> return the scored search results (same as queryCatalog).
1855
+ // Returns { combo, products } where combo is the combo object (or null) and
1856
+ // products is the array of product items shown in the chat / panel.
1857
+ function getComboOrSearch(query, limit) {
1858
+ const raw = String(query || "").trim();
1859
+ const result = { combo: null, products: [] };
1860
+ if (!raw || !allProducts.length) return result;
1861
+ try {
1862
+ const crit = parseComboQuery(raw);
1863
+ if (crit) {
1864
+ const combo = comboByCriteria(crit);
1865
+ if (combo && combo.items && combo.items.length) {
1866
+ result.combo = combo;
1867
+ result.products = combo.items;
1868
+ return result;
1869
+ }
1870
+ }
1871
+ } catch (e) { /* fall through to generic search */ }
1872
+ result.products = searchProductsForCards(raw, limit || 6);
1873
+ return result;
1874
+ }
1875
+
1876
+ // Fetch an ALTERNATE combo that still satisfies the SAME user criteria, but
1877
+ // avoids the products shown in the current combo. Returns null when no other
1878
+ // valid combination exists (caller can then show a "no alternative" message).
1879
+ function getAlternativeCombo(query, currentCombo) {
1880
+ const raw = String(query || "").trim();
1881
+ if (!raw || !allProducts.length) return null;
1882
+ const current = currentCombo || {};
1883
+ const curItems = (current.items || []);
1884
+ if (!curItems.length) return null;
1885
+ const exclude = new Set();
1886
+ for (const p of curItems) exclude.add(p.sku || p.model || p.title_clean);
1887
+ try {
1888
+ const crit = parseComboQuery(raw);
1889
+ if (!crit) return null;
1890
+ return comboByCriteria(crit, exclude);
1891
+ } catch (e) {
1892
+ return null;
1893
+ }
1894
+ }
1895
+
1896
+ // Render combo results into the chat-embedded product cards container,
1897
+ // mirroring how the product-list panel shows combos (showComboResultsInPanel).
1898
+ function renderComboInChat(container, combo, rawQuery) {
1899
+ if (!container || !combo || !combo.items || !combo.items.length) return;
1900
+ const total = (combo.items || []).reduce(function (s, p) { return s + (p.priceNum || 0); }, 0);
1901
+ let html = '<div class="chat-combo-title" style="font-weight:700;margin:8px 2px 6px;color:#0f172a;font-size:0.92rem">' +
1902
+ '<span class="combo-spark">✨</span> Combo gợi ý ' + (combo.brand ? ('thương hiệu ' + combo.brand) : '') + ' — ' + combo.label + '</div>';
1903
+ html += '<div style="color:#64748b;font-size:0.76rem;margin:0 2px 8px">Gồm ' + combo.items.length + ' sản phẩm — Tổng ' +
1904
+ (total > 0 ? (total.toLocaleString('vi-VN') + ' ₫') : 'Liên hệ') + ' · phù hợp "' + String(rawQuery || '') + '".</div>';
1905
+ html += '<div class="chat-product-cards">';
1906
+ for (const p of combo.items) html += createChatProductCard(p);
1907
+ html += '</div>';
1908
+ html += '<button class="chat-combo-random" data-combo="1" data-query="' +
1909
+ String(rawQuery || '').replace(/"/g, '&quot;') + '">🔄 Đổi combo khác</button>';
1910
+ container.innerHTML = html;
1911
+ if (attachChatCardHandlers) attachChatCardHandlers(container);
1912
+ }
1913
+
1914
  // Export to window
1915
  window.vaix = {
1916
  load: load,
 
1928
  getLastShownProduct: getLastShownProduct,
1929
  renderSearchResultsInChat: renderSearchResultsInChat,
1930
  renderProductInChat: renderProductInChat,
1931
+ searchProductsForCards: searchProductsForCards,
1932
+ getComboOrSearch: getComboOrSearch,
1933
+ getAlternativeCombo: getAlternativeCombo,
1934
+ renderComboInChat: renderComboInChat,
1935
  attachChatCardHandlers: attachChatCardHandlers,
1936
  openImageViewer: openImageViewer,
1937
  allProducts: function(){ return allProducts; },
tts-worker.mjs ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TTS worker — run as a separate Bun subprocess so a crash in the Edge-TTS
3
+ * path cannot take down the main server.
4
+ * Usage: bun tts-worker.mjs "<voice>" "<text>"
5
+ * Writes MP3 audio bytes to stdout; non-zero exit on error.
6
+ */
7
+ import { EdgeTTS } from "edge-tts-universal";
8
+
9
+ const voice = process.argv[2] || "vi-VN-HoaiMyNeural";
10
+ const text = process.argv[3] || "";
11
+
12
+ try {
13
+ const tts = new EdgeTTS(text, voice, { rate: "+0%", volume: "+0%", pitch: "+0Hz" });
14
+ const result = await tts.synthesize();
15
+ const audio = Buffer.from(await result.audio.arrayBuffer());
16
+ if (!audio || !audio.length) process.exit(2);
17
+ process.stdout.write(audio);
18
+ } catch (e) {
19
+ console.error("TTS_WORKER_ERROR:", e?.message || e);
20
+ process.exit(1);
21
+ }