Spaces:
Running
Running
Khôi phục 100% về commit 077b53285f03cbd22093c875097dd5e96ba063a5: full restore of target commit tree
Browse files- .cache_bust +1 -1
- .gitattributes +3 -0
- chat +0 -0
- edge-tts.mjs +152 -0
- image - 2026-08-05T195019.510.png +3 -0
- index.html +26 -5
- index.ts +333 -247
- package.json +2 -1
- public/avatars/vaistudio.png +3 -0
- public/avatars/vaistudio_center.png +3 -0
- src/app.js +486 -112
- src/greeting-news.js +11 -5
- src/greeting-source-cards.js +5 -1
- src/style.css +34 -2
- src/vaix-rag.js +566 -57
- tts-worker.mjs +21 -0
.cache_bust
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 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
|
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 |
</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=
|
| 507 |
-
<script src="./src/greeting-news.js?gc=
|
| 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=
|
| 510 |
-
<script src="./src/greeting-source-cards.js?gc=
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
const
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 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 |
-
|
| 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 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 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 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 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 |
-
|
| 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 + tư 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(/&/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(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'")
|
| 424 |
+
.replace(/</g, "<").replace(/>/g, ">").replace(/ /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 là trợ lý 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 có 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
|
public/avatars/vaistudio_center.png
ADDED
|
|
Git LFS Details
|
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
|
| 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', '
|
| 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
|
| 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
|
| 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 |
-
'
|
| 661 |
-
'
|
| 662 |
-
'Có
|
| 663 |
-
'
|
| 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('
|
| 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 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
hotTagsRow.innerHTML = '';
|
| 764 |
hotTagsRow.appendChild(label);
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 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 (
|
| 892 |
-
|
| 893 |
-
|
| 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 |
-
//
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
const msg = t || chatInput.value.trim();
|
| 904 |
-
if(!msg) return;
|
|
|
|
|
|
|
| 905 |
chatInput.value = "";
|
| 906 |
-
|
|
|
|
|
|
|
|
|
|
| 907 |
setCaption("GEMMA ĐANG VIẾT…", "live");
|
|
|
|
|
|
|
| 908 |
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
if (sources.length =
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
url
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 lò 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ó gì 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"> đ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 |
+
// Tư 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 |
-
'
|
| 144 |
-
'
|
| 145 |
-
'Có
|
| 146 |
-
'
|
| 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 ? ('
|
| 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ó gì 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 = '
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 ===
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
-
for
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
else if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 381 |
-
video: p.vid || "", image: p.i ||
|
| 382 |
-
images: p.
|
| 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:
|
| 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) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 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à ") &&
|
| 1304 |
-
|
| 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
|
| 1329 |
}
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, '"');
|
| 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, '"') + '">🔄 Đổ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 |
+
}
|