diff --git a/.build_trigger b/.build_trigger deleted file mode 100644 index 96eb09148d97da3a3f62ba415db10fa85fdc3a0c..0000000000000000000000000000000000000000 --- a/.build_trigger +++ /dev/null @@ -1 +0,0 @@ -1785805873 diff --git a/.cache_bust b/.cache_bust deleted file mode 100644 index 3f8fd4d2b89e9a535c9a901c3508e1fe479462c6..0000000000000000000000000000000000000000 --- a/.cache_bust +++ /dev/null @@ -1 +0,0 @@ -1786077628147 diff --git a/.gitattributes b/.gitattributes index 8be22d05015c270728b5144fc7f6612bc2fa76cc..9875dc5136e9ad4f01d4b11096eed98a66f1c3f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -54,15 +54,3 @@ public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text model[[:space:]](6).glb filter=lfs diff=lfs merge=lfs -text data/products-light.json filter=lfs diff=lfs merge=lfs -text data/products-formatted.json filter=lfs diff=lfs merge=lfs -text -public/avatars/grit-b6c6c4.glb filter=lfs diff=lfs merge=lfs -text -public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text -san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text -san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text -san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text -san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text -public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text -public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text -public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text -image[[:space:]]-[[:space:]]2026-08-05T195019.510.png filter=lfs diff=lfs merge=lfs -text -public/avatars/vaistudio.png filter=lfs diff=lfs merge=lfs -text -public/avatars/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 1b70f1f5fbc35d6d96e4f5a78eb2437bb7ba539e..9e81e5d1ea76a4032079f8f96b0e92a836729fd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ -# Bun Dockerfile for gemma-avatar +# 1795301633 - Fix proxy() async, RAG trực tiếp Parquet FROM oven/bun:1 WORKDIR /app COPY package.json ./ RUN bun install --production -RUN rm -rf /root/.bun /home/bun/.bun /tmp/* 2>/dev/null; echo "clear-rebuild" +RUN rm -rf /root/.bun /home/bun/.bun /tmp/* 2>/dev/null; echo "clear-1795301633" COPY . /app/ ENV NODE_ENV=production ENV PORT=7860 +ENV BUILD=1795301633 EXPOSE 7860 USER bun -CMD ["bun", "index.ts"] - -# Rebuild trigger: 1785725738.0884876 +CMD ["bun", "index.ts"] \ No newline at end of file diff --git a/FORCE_BOOT.txt b/FORCE_BOOT.txt new file mode 100644 index 0000000000000000000000000000000000000000..15adc3b3662b3483aee6aca2ed3515659137de1c --- /dev/null +++ b/FORCE_BOOT.txt @@ -0,0 +1 @@ +force-1785310612 \ No newline at end of file diff --git a/HOT_TAGS_V3_TRIGGER.txt b/HOT_TAGS_V3_TRIGGER.txt deleted file mode 100644 index 524430a75ba08271422ca71683ea81db986f965c..0000000000000000000000000000000000000000 --- a/HOT_TAGS_V3_TRIGGER.txt +++ /dev/null @@ -1,3 +0,0 @@ -# HOT Tags V3 Fix Trigger -# Generated: 9ef64b29ef1be950 -# Files: app.js (59382 bytes), style.css (15138 bytes) diff --git a/REBUILD.md b/REBUILD.md deleted file mode 100644 index b6c1b251d9b3b3595e2935f494b103314e80b462..0000000000000000000000000000000000000000 --- a/REBUILD.md +++ /dev/null @@ -1 +0,0 @@ -TRIGGER \ No newline at end of file diff --git a/REBUILD.txt b/REBUILD.txt new file mode 100644 index 0000000000000000000000000000000000000000..a80e6e9fb262c318d413b7d78892e9c997c9f575 --- /dev/null +++ b/REBUILD.txt @@ -0,0 +1 @@ +rebuild 7639 \ No newline at end of file diff --git a/TRIGGER_REBUILD.txt b/TRIGGER_REBUILD.txt deleted file mode 100644 index 8ac7573c2b564cb1c7a1b93fa011b762321fbd60..0000000000000000000000000000000000000000 --- a/TRIGGER_REBUILD.txt +++ /dev/null @@ -1 +0,0 @@ -1785850254749 \ No newline at end of file diff --git a/build-trigger b/build-trigger deleted file mode 100644 index f1834b180fc74afc76ba78ba0bc2ef4ba4a08a16..0000000000000000000000000000000000000000 --- a/build-trigger +++ /dev/null @@ -1 +0,0 @@ -rebuild=1786077628148 diff --git a/chat b/chat deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/customers.json b/customers.json deleted file mode 100644 index caf354a86adc8d3f96fe9a8c8b5c4cd79164b406..0000000000000000000000000000000000000000 --- a/customers.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "479b1cfad6a83ff666b9": { - "name": "V Ai Studio", - "ma_kh": "VAS6b9", - "cid": "479b1cfad6a83ff666b9", - "created_at": "07/08/2026 19:23", - "ck": "Malloca 35, Grob 20", - "company": [ - "Vai Studio" - ], - "tax_code": [ - "0123456789" - ], - "shipping_address": [ - "123 Lê Lợi, Q1" - ], - "representative": [ - "Trần Quốc Vương" - ] - } -} \ No newline at end of file diff --git a/edge-tts.mjs b/edge-tts.mjs deleted file mode 100644 index 25bcbe1ac8ecec0ca631c742191184c832b3adb7..0000000000000000000000000000000000000000 --- a/edge-tts.mjs +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Edge TTS — zero-dependency Microsoft Edge neural text-to-speech for Bun/Node. - * Produces 24kHz 48kbit mono MP3 for Vietnamese voices: - * vi-VN-HoaiMyNeural (female, "Hoài My") - * vi-VN-NamMinhNeural (male, "Nam Minh") - */ -import { createHash, randomUUID, randomBytes } from "node:crypto"; - -const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4"; -const WIN_EPOCH = 11644473600; -const WSS_URL = - "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"; -const SEC_MS_GEC_VERSION = "1-143.0.3650.75"; -const CHROME_VER = "143"; - -const UA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + - `(KHTML, like Gecko) Chrome/${CHROME_VER}.0.0.0 Safari/537.36 Edg/${CHROME_VER}.0.0.0`; - -function generateSecMsGec() { - let ticks = Date.now() / 1000 + WIN_EPOCH; - ticks -= ticks % 300; - ticks *= 1e7; - return createHash("sha256") - .update(`${ticks.toFixed(0)}${TRUSTED_CLIENT_TOKEN}`, "ascii") - .digest("hex") - .toUpperCase(); -} - -function dateToString() { - const d = new Date(); - const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]; - const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; - const p = (n) => String(n).padStart(2, "0"); - return `${days[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${p(d.getUTCDate())} ` + - `${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} ` + - `GMT+0000 (Coordinated Universal Time)`; -} - -function buildSSML(text, voice, { pitch = "+0Hz", rate = "+0%", volume = "+0%" } = {}) { - const esc = String(text).replace(/&/g, "&").replace(//g, ">"); - return `` + - `` + - `${esc}`; -} - -/** Split long text at sentence boundaries (Edge caps ~3000 chars/request). */ -function splitText(text, max = 2800) { - if (text.length <= max) return [text]; - const parts = []; - let rest = text; - while (rest.length > max) { - let cut = rest.lastIndexOf(". ", max); - if (cut < max * 0.5) cut = rest.lastIndexOf(" ", max); - if (cut <= 0) cut = max; - parts.push(rest.slice(0, cut + 1)); - rest = rest.slice(cut + 1).trimStart(); - } - if (rest) parts.push(rest); - return parts; -} - -/** - * Synthesize text to mp3 bytes. - * @param {string} text - * @param {string} voice e.g. "vi-VN-HoaiMyNeural" | "vi-VN-NamMinhNeural" - * @returns {Promise} - */ -export async function synthesize(text, voice = "vi-VN-HoaiMyNeural") { - const clean = String(text || "").trim().replace(/\s+/g, " ").replace(/\*\*?|`|_{1,2}/g, ""); - if (!clean) return new Uint8Array(0); - const parts = splitText(clean); - const buffers = []; - for (const part of parts) { - buffers.push(await synthesizeOne(part, voice)); - } - const total = buffers.reduce((n, b) => n + b.length, 0); - const out = new Uint8Array(total); - let off = 0; - for (const b of buffers) { out.set(b, off); off += b.length; } - return out; -} - -function synthesizeOne(text, voice) { - const reqId = randomUUID().replace(/-/g, ""); - const sec = generateSecMsGec(); - const url = - `${WSS_URL}?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}` + - `&Sec-MS-GEC=${sec}&Sec-MS-GEC-Version=${SEC_MS_GEC_VERSION}` + - `&ConnectionId=${randomUUID().replace(/-/g, "")}`; - - const headers = { - "User-Agent": UA, - "Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", - "Pragma": "no-cache", - "Cache-Control": "no-cache", - "Cookie": `muid=${randomBytes(16).toString("hex").toUpperCase()};`, - }; - - // Bun native WebSocket accepts options with headers as 2nd arg. - const ws = new WebSocket(url, { headers }); - - return new Promise((resolve, reject) => { - const chunks = []; - let opened = false; - const timer = setTimeout(() => { - try { ws.close(); } catch {} - reject(new Error("Edge TTS timeout")); - }, 20000); - - ws.onopen = () => { - opened = true; - ws.send( - `X-Timestamp:${dateToString()}\r\n` + - "Content-Type:application/json; charset=utf-8\r\n" + - "Path:speech.config\r\n\r\n" + - '{"context":{"synthesis":{"audio":{"metadataoptions":' + - '{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},' + - '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}\r\n' - ); - ws.send( - `X-RequestId:${reqId}\r\n` + - "Content-Type:application/ssml+xml\r\n" + - `X-Timestamp:${dateToString()}Z\r\n` + - "Path:ssml\r\n\r\n" + - buildSSML(text, voice) - ); - }; - - ws.onmessage = (ev) => { - const data = ev.data; - if (typeof data === "string") return; - const buf = Buffer.isBuffer(data) ? data : Buffer.from(data); - if (buf.length < 2) return; - const hl = buf.readUInt16BE(0); - const header = buf.subarray(0, hl + 2).toString("latin1"); - const body = buf.subarray(hl + 2); - if (header.includes("Path:audio")) chunks.push(body); - else if (header.includes("Path:turn.end")) { - clearTimeout(timer); - try { ws.close(); } catch {} - resolve(Buffer.concat(chunks)); - } - }; - - ws.onerror = (e) => { if (!opened) { clearTimeout(timer); reject(e); } }; - ws.onclose = () => { - if (chunks.length) { clearTimeout(timer); resolve(Buffer.concat(chunks)); } - else clearTimeout(timer); - }; - }); -} diff --git a/image - 2026-08-05T195019.510.png b/image - 2026-08-05T195019.510.png deleted file mode 100644 index 556f044e1141f1fae3c287ff8d8481d47b2b5794..0000000000000000000000000000000000000000 --- a/image - 2026-08-05T195019.510.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13cf0f6e2d3196e802077e0aef50e79e259c4faca785448e43e3b1c03bc695cd -size 2109520 diff --git a/index.html b/index.html index 86af573690cad70abc70ffe84dceabd0d6fb557c..46b9677c854ff08694ae4469ba2e0cdfe8fe1ae6 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,12 @@ - V.AI AVATAR + V.AI STUDIO - Chat & Voice + Gemma Avatar + V.AI STUDIO - Chat & Voice - +
@@ -19,25 +19,9 @@
🤖
-

Chào mừng đến với V.AI AVATAR

+

Chào mừng đến với Gemma Avatar

Bạn muốn giao tiếp thế nào?

- - -
-

Chọn đại diện của em:

-
- -
-
-
-
-
-
🗣️Nhận diện giọng nói tiếng Việt — tự động gõ vào ô chat (nhấn nút mic 🎤 để bật)
-
🎙️Chế độ voice call — nói chuyện trực tiếp với AI, phụ đề song ngữ
-
🛒8000+ sản phẩm gia dụng — Malloca, Eurogold, Grob, Canzy & khoá thông minh
-
🧠AI Avatar + 11 công cụ: tra cứu, web, Wikipedia, thời gian thực
-
🎭3D avatar lip-sync — biểu cảm, cử chỉ, giọng nói Qwen3-TTS
-
⚙️Tuỳ chỉnh giọng, avatar, hướng dẫn riêng, phụ đề
-

Bạn luôn có thể chuyển đổi chế độ sau

- - -
-

V.AI AVATAR + V.AI STUDIO

-

Chat or talk to AI Avatar. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.

-
-
- - - - +

Gemma Avatar + V.AI STUDIO

+

Chat or talk to Gemma 4. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.

+
@@ -114,20 +61,6 @@ Text Chat
- -
- -
@@ -159,7 +84,7 @@
LOADING…
-
- @@ -252,78 +174,7 @@

Thông số kỹ thuật

- - - - -
-
-

Giỏ Hàng

-
- -
- - -
-
- -
-

Lập Báo Giá

-

Điền thông tin khách hàng và chiết khấu để xuất báo giá

-
-
-
-

Thông tin khách hàng

-
-
-
-
-
-
-
-
-
-
- - - 🔒 -
- -
-
-
-

Chi tiết sản phẩm

-
- - - - -
STTHình ảnhTên sản phẩmMã SPThông tinSLĐơn giáĐơn giá CKThành tiềnGhi chú
TỔNG CỘNG:0₫
-
-
-
- - - - - - - -
-
+
@@ -332,55 +183,65 @@ @keyframes modalIn { from { opacity:0;transform:translateY(20px) scale(0.96) } to { opacity:1;transform:translateY(0) scale(1) } } /* ── Welcome Modal ── */ - .welcome-modal-overlay { position: fixed; inset: 0; z-index: 500; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.85); opacity: 0; visibility: hidden; transition: all 0.3s ease; } - .welcome-modal-overlay.show { opacity: 1; visibility: visible; } - .welcome-modal { background: #1a1a1a; border-radius: 20px; padding: 40px; text-align: center; max-width: 400px; width: 90%; border: 1px solid #333; box-shadow: 0 20px 60px rgba(0,0,0,0.5); transform: translateY(20px); transition: transform 0.3s ease; } - .welcome-modal-overlay.show .welcome-modal { transform: translateY(0); } + .welcome-modal-overlay { + position: fixed; inset: 0; z-index: 500; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.85); + opacity: 0; visibility: hidden; transition: all 0.3s ease; + } + .welcome-modal-overlay.show { + opacity: 1; visibility: visible; + } + .welcome-modal { + background: #1a1a1a; border-radius: 20px; padding: 40px; + text-align: center; max-width: 400px; width: 90%; + border: 1px solid #333; box-shadow: 0 20px 60px rgba(0,0,0,0.5); + transform: translateY(20px); transition: transform 0.3s ease; + } + .welcome-modal-overlay.show .welcome-modal { + transform: translateY(0); + } .welcome-avatar-icon { font-size: 3rem; margin-bottom: 16px; } - .welcome-title { color: #fff; font-size: 1.5rem; margin-bottom: 8px; font-family: Inter, sans-serif; font-weight: 600; } - .welcome-subtitle { color: #888; font-size: 0.9rem; margin-bottom: 24px; font-family: Inter, sans-serif; } - .welcome-actions { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; } - .welcome-btn { padding: 16px 24px; border: none; border-radius: 12px; cursor: pointer; font-family: Inter, sans-serif; font-size: 1rem; font-weight: 600; text-align: left; display: flex; align-items: center; gap: 12px; transition: transform 0.2s ease, box-shadow 0.2s ease; } + .welcome-title { + color: #fff; font-size: 1.5rem; margin-bottom: 8px; + font-family: Inter, sans-serif; font-weight: 600; + } + .welcome-subtitle { + color: #888; font-size: 0.9rem; margin-bottom: 24px; + font-family: Inter, sans-serif; + } + .welcome-actions { + display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; + } + .welcome-btn { + padding: 16px 24px; border: none; border-radius: 12px; + cursor: pointer; font-family: Inter, sans-serif; + font-size: 1rem; font-weight: 600; text-align: left; + display: flex; align-items: center; gap: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } .welcome-btn:hover { transform: scale(1.02); } .welcome-btn:active { transform: scale(0.98); } - .welcome-btn-text { background: linear-gradient(135deg, #003f62, #0077b6); color: #fff; box-shadow: 0 4px 16px rgba(0,63,98,0.3); } + .welcome-btn-text { + background: linear-gradient(135deg, #003f62, #0077b6); + color: #fff; + box-shadow: 0 4px 16px rgba(0,63,98,0.3); + } .welcome-btn-text:hover { box-shadow: 0 6px 24px rgba(0,63,98,0.4); } - .welcome-btn-voice { background: linear-gradient(135deg, #6B21A8, #9333EA); color: #fff; box-shadow: 0 4px 16px rgba(107,33,168,0.3); } + .welcome-btn-voice { + background: linear-gradient(135deg, #6B21A8, #9333EA); + color: #fff; + box-shadow: 0 4px 16px rgba(107,33,168,0.3); + } .welcome-btn-voice:hover { box-shadow: 0 6px 24px rgba(107,33,168,0.4); } - .welcome-btn-quick { background: linear-gradient(135deg, #047857, #10B981); color: #fff; box-shadow: 0 4px 16px rgba(4,120,87,0.3); } - .welcome-btn-quick:hover { box-shadow: 0 6px 24px rgba(4,120,87,0.4); } .welcome-btn-icon { font-size: 1.3rem; } .welcome-btn-text-content { display: flex; flex-direction: column; } .welcome-btn-main-text { font-size: 1rem; font-weight: 600; } .welcome-btn-desc { font-size: 0.75rem; font-weight: 400; opacity: 0.8; margin-top: 2px; } - .welcome-hint { color: #555; font-size: 0.75rem; font-family: Inter, sans-serif; } - .welcome-modal { max-height: 90vh; overflow-y: auto; } - .welcome-modal::-webkit-scrollbar { width: 4px; } - .welcome-modal::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; } - .welcome-features { margin: 0 0 16px; text-align: left; display: flex; flex-direction: column; gap: 10px; } - .welcome-feature { display: flex; align-items: flex-start; gap: 10px; color: #ccc; font-size: 0.82rem; line-height: 1.45; font-family: Inter, sans-serif; } - .welcome-feature .wf-icon { flex-shrink: 0; font-size: 1.1rem; width: 22px; text-align: center; } - .welcome-feature .wf-text { color: #aaa; } - - /* ✨ Avatar chooser on the welcome modal */ - .welcome-avatars { margin: 0 0 16px; } - .welcome-avatars-title { color: #888; font-size: 0.82rem; font-family: Inter, sans-serif; margin: 0 0 12px; } - .welcome-avatar-row { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; } - .welcome-avatar-card { background: transparent; border: 2px solid #333; border-radius: 16px; padding: 10px 12px 8px; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px; transition: all 0.2s; font-family: Inter, sans-serif; } - .welcome-avatar-card:hover { border-color: #0077b6; transform: translateY(-2px); } - .welcome-avatar-card.selected { border-color: #22d3ee; box-shadow: 0 0 0 2px rgba(34,211,238,0.35); } - .welcome-avatar-img-wrap { width: 64px; height: 64px; border-radius: 50%; overflow: hidden; border: 2px solid #0077b6; display: flex; align-items: center; justify-content: center; background: #222; } - .welcome-avatar-img-wrap img { width: 100%; height: 100%; object-fit: cover; } - .welcome-avatar-name { color: #e0e0e0; font-size: 0.8rem; font-weight: 600; } - - /* ✨ Avatar loading overlay + 1–100% progress bar */ - .avatar-loading-overlay { position: fixed; inset: 0; z-index: 600; display: flex; align-items: center; justify-content: center; background: rgba(10,10,12,0.92); } - .avatar-loading-box { background: #1a1a1a; border: 1px solid #333; border-radius: 20px; padding: 36px 40px; text-align: center; max-width: 420px; width: 88%; box-shadow: 0 20px 60px rgba(0,0,0,0.6); } - .avatar-loading-face { font-size: 2.6rem; margin-bottom: 14px; } - .avatar-loading-msg { color: #ddd; font-size: 1rem; line-height: 1.5; font-family: Inter, sans-serif; margin-bottom: 20px; } - .avatar-loading-track { height: 12px; background: #2a2a2a; border-radius: 999px; overflow: hidden; border: 1px solid #3a3a3a; } - .avatar-loading-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #003f62, #22d3ee); border-radius: 999px; transition: width 0.3s ease; } - .avatar-loading-pct { color: #22d3ee; font-size: 1rem; font-weight: 700; margin-top: 10px; font-family: Inter, sans-serif; } + .welcome-hint { + color: #555; font-size: 0.75rem; + font-family: Inter, sans-serif; + } #vaistudio-panel.open { display: flex !important; } #vaistudio-loading.hidden { display: none !important; } @@ -393,17 +254,19 @@ .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px } .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 } .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px } - .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 } - .product-card .product-card-btn:hover { background:#1fb959 } - .product-card .product-card-btn.added { background:#166534 } + /* ── Search suggestions ── */ .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s } .vaix-suggestion-item:last-child { border-bottom:none } .vaix-suggestion-item:hover { background:#f0f9ff } #vaix-suggestions::-webkit-scrollbar { width:5px } #vaix-suggestions::-webkit-scrollbar-thumb { background:#e2e8f0;border-radius:3px } + + /* ── Category chips ── */ .vaix-category-chip { display:inline-flex;align-items:center;padding:4px 10px;background:#eef2ff;color:#003f62;border:1px solid #d0dfed;border-radius:14px;font-size:0.7rem;cursor:pointer;white-space:nowrap;transition:all 0.2s;font-family:inherit } .vaix-category-chip:hover { background:#dbeafe;border-color:#0077b6;transform:translateY(-1px) } + + /* ── Chat Product Cards ── */ .chat-product-cards { display:flex;flex-direction:column;gap:8px;margin:6px 0;max-width:100% } .chat-product-card { background:#1e293b;border:1px solid #334155;border-radius:12px;overflow:hidden;transition:all 0.2s } .chat-product-card:hover { border-color:#0077b6;box-shadow:0 2px 8px rgba(0,119,182,0.15) } @@ -414,7 +277,6 @@ .chat-product-card-body { flex:1;min-width:0;display:flex;flex-direction:column;gap:2px } .chat-product-card-name { font-size:0.8rem;font-weight:600;color:#e2e8f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3 } .chat-product-card-meta { display:flex;align-items:center;gap:6px;flex-wrap:wrap } - .chat-product-card-code { font-size:0.62rem;color:#7dd3fc;background:#0c4a6e33;border:1px solid #0c4a6e66;padding:1px 6px;border-radius:4px;display:inline-block;width:fit-content;font-family:monospace;letter-spacing:0.3px } .chat-product-card-brand { font-size:0.68rem;color:#94a3b8 } .chat-product-card-stars { font-size:0.6rem;color:#f59e0b;letter-spacing:1px } .chat-product-card-price { font-size:0.82rem;font-weight:700;color:#22d3ee;margin-top:1px } @@ -425,152 +287,14 @@ .chat-product-card-btn:first-child { border-right:1px solid #334155 } .chat-product-card-btn.detail-btn:hover { color:#22d3ee } .chat-product-card-btn.share-btn:hover { color:#f59e0b } - .chat-product-card-btn.askai-btn { color:#a78bfa } - .chat-product-card-btn.askai-btn:hover { background:#4c1d95; color:#e9d5ff } - .chat-product-card-btn.cart-btn { color:#4ade80 } - .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 } - .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff } .chat-message.assistant .chat-product-card { background:#1e293b;border:1px solid #334155;margin:4px 0 } - /* Combo suggestion section */ - .chat-combo-section { margin:8px 0;padding:10px;background:linear-gradient(135deg,rgba(34,211,238,0.08),rgba(147,51,234,0.08));border:1px solid #334155;border-radius:14px;max-width:100% } - .chat-combo-title { font-size:0.82rem;font-weight:700;color:#e2e8f0;display:flex;align-items:center;gap:6px;margin-bottom:2px } - .chat-combo-spark { color:#f59e0b } - .chat-combo-sub { font-size:0.68rem;color:#94a3b8;margin-bottom:8px } - .chat-combo-cards { display:flex;flex-direction:column;gap:7px;max-height:260px;overflow-y:auto } - .chat-combo-cards .chat-product-card { margin:0 } - /* Make the "Thêm giỏ" button on combo cards clearly visible */ - .chat-combo-cards .chat-product-card-btn.cart-btn { - background: rgba(37,211,102,0.16); - color:#4ade80; font-weight:700; flex:1.3; - } - .chat-combo-cards .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 } - .chat-combo-cards .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff } - .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 } - .chat-combo-random:hover { background:rgba(34,211,238,0.28) } - /* Chatbox mute button — highlighted red when silent mode is ON */ - #chatbox-mute-btn { color:#94a3b8 } - #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 } - #detail-share-btn { display:inline-flex!important } - /* ✨ FIX: Suggested Question Pills CSS */ - .suggested-pill { padding:6px 14px; border:1px solid #0077b6; border-radius:16px; background:#f0f9ff; color:#003f62; font-size:0.75rem; cursor:pointer; white-space:normal; max-width:100%; font-family:Inter,sans-serif; font-weight:500; transition:all 0.2s; } - .suggested-pill:hover { background:#dbeafe; border-color:#005580; } - - - - - - - - - - - - - - - - + + diff --git a/index.ts b/index.ts index 92936d2d9a395bd7908056bbfa075a7359bbace4..e44d7b0326b82d0216cb7861976526be8f57beb2 100644 --- a/index.ts +++ b/index.ts @@ -7,20 +7,10 @@ * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access). * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode. */ -import { readFileSync, writeFileSync } from "fs"; -// Read index.html directly as text to avoid Bun HTMLBundle object -const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8"); +import index from "./index.html"; import { readdir } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; -import { synthesize as edgeSynthesize } from "./edge-tts.mjs"; - -// ── Edge TTS memory cache: { key: audioBytes } — bound to prevent growth ── -const TTS_CACHE = new Map(); -const TTS_CACHE_MAX = 400; -function ttsCacheKey(text: string, voice: string) { - return voice + "|" + text.trim().slice(0, 600); -} const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, ""); const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, ""); @@ -57,8 +47,7 @@ async function loadVaixProducts(): Promise { const data = await resp.json(); VAIX_PRODUCTS = Array.isArray(data) ? data : []; VAIX_LAST_SYNC = Date.now(); - VAIX_PRODUCTS = VAIX_PRODUCTS.slice(0, 1000); // Limit to 1000 for memory - console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products (capped from " + data.length + ") in " + (Date.now() - startMs) + "ms"); + console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products in " + (Date.now() - startMs) + "ms"); } catch (err: any) { console.error("[VAIX] Error:", err.message); setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000); @@ -86,40 +75,8 @@ async function proxy(path: string, req: Request, init: any = {}): Promise = { - "js": "application/javascript", - "mjs": "application/javascript", - "cjs": "application/javascript", - "css": "text/css", - "html": "text/html", - "json": "application/json", - "png": "image/png", - "jpg": "image/jpeg", - "gif": "image/gif", - "svg": "image/svg+xml", - "webp": "image/webp", - "ico": "image/x-icon", - "woff2": "font/woff2", - "woff": "font/woff", - "ttf": "font/ttf", - "bin": "application/octet-stream", - "mjs": "application/javascript", - }; - return types[ext] ?? "application/octet-stream"; -} function staticFile(dir: string, name: string) { - const path = import.meta.dir + "/public/" + dir + "/" + name; - const mime = getMimeType(name); - // no-cache: always revalidate so a deploy is picked up immediately (this Space - // is edited/deployed frequently). CDN/browser won't serve stale JS/CSS. - return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } }); -} -function srcFile(dir: string, name: string) { - const path = import.meta.dir + "/" + dir + "/" + name; - const mime = getMimeType(name); - return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } }); + return new Response(Bun.file(import.meta.dir + "/public/" + dir + "/" + name)); } async function listAvatars() { @@ -159,106 +116,133 @@ function formatProduct(p: any, idx: number) { }; } -async function serveProductOG(req: Request): Promise { - const url = new URL(req.url); - const productSlug = url.searchParams.get("product"); +function normProductSlug(slug: string): string { + return String(slug || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); +} - function esc(s: string): string { - return (s || "") - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\n/g, " ") - .replace(/\r/g, " ") - .trim() - .slice(0, 350); +function findProductBySlug(slug: string): any { + if (!VAIX_PRODUCTS?.length) return null; + const target = normProductSlug(slug); + for (const p of VAIX_PRODUCTS) { + const ps = normProductSlug(p.slug || ""); + if (ps === target) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p)); } - - const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"; - - if (productSlug && VAIX_PRODUCTS?.length) { - const foundProduct = VAIX_PRODUCTS.find((p: any) => { - const slug = p.slug || p._source_alias || ""; - const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); - }); - if (foundProduct) { - const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); - const fpName = esc(fp.name || fp.model || "Sản phẩm"); - const fpBrand = esc(fp.brand || "V.AI STUDIO"); - // ── Image URL: use escURL (not esc) because we need proper URL encoding for og:image ── - const fpImageRaw = fp.image || (fp.images && fp.images[0]) || ""; - const fpImg = fpImageRaw ? escURL(fpImageRaw) : fallbackImg; - - // ── Description: 600 chars to fit full summary + specs ── - const fpDesc = esc((fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 600)); - - // ── Specs: show all specs up to 12 keys ── - const fpSpecs = fp.specs || {}; - let specsStr = ""; - const specKeys = Object.keys(fpSpecs).slice(0, 12); - for (const sk of specKeys) { - specsStr += (specsStr ? ", " : " | ") + esc(sk) + ": " + esc(String(fpSpecs[sk])); - } - const ogDescription = fpDesc + (specsStr ? " | " + specsStr.trim() : "") || "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; - - // ── Price: formatted VND string for og:title ── - const priceNum = fp.priceNum || Number(fp.pn) || 0; - const ogPrice = priceNum > 0 ? " - " + Number(priceNum).toLocaleString("vi-VN") + "₫" : ""; - const ogTitle = fpName + " | " + fpBrand + ogPrice + " - V.AI STUDIO"; - - const ogTags = buildOGTags(ogTitle, ogDescription, fpImg, url.href, true, priceNum); - return new Response(index.replace(/<\/head>/, ogTags + "\n "), { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, - }); - } + for (const p of VAIX_PRODUCTS) { + const ps = normProductSlug(p.sku || ""); + if (ps === target) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p)); } - - // Fallback OG tags — always set for any request (no product match or no ?product param) - const fallbackTitle = "Gemma Avatar + V.AI STUDIO - Chat & Voice AI"; - const fallbackDesc = "Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng."; - const ogTags = buildOGTags(fallbackTitle, fallbackDesc, fallbackImg, url.href, false); - return new Response(index.replace(/<\/head>/, ogTags + "\n "), { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, - }); + for (const p of VAIX_PRODUCTS) { + const nm = normProductSlug(p.name || p.n || ""); + if (nm.includes(target) || target.includes(nm)) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p)); + } + return null; } -function buildOGTags(title: string, desc: string, image: string, href: string, isProduct: boolean, price = 0): string { - return [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - isProduct ? '' : '', - isProduct ? '' : '', - isProduct ? '' : '', - isProduct ? '' : '', - '', - '', - '', - '', - '', - '', - '', - ].filter(Boolean).join("\n "); -} +/** + * Generate OG meta tags snippet for a product (used by index.html template). + * Returns the inner HTML for with og:title, og:image, og:description, + * og:url, twitter:card, and JSON-LD schema.org/Product. + */ +function generateProductOgHead(product: any, baseUrl: string): string { + const slug = product.slug || product.sku || ""; + const ogUrl = baseUrl + "?product=" + encodeURIComponent(slug); + const ogImage = product.image || ""; + const title = (product.title_clean || product.name || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const desc = (product.summary || product.description || "").slice(0, 300).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const htmlImages = product.images && product.images.length ? product.images : (product.image ? [product.image] : []); + const allImages = htmlImages.filter((i: string) => i); + + let jsonLd = `{"@context":"https://schema.org/","@type":"Product","name":"${title}","description":"${desc}","image":[${allImages.map(i => `"${i}"`).join(",")}],"brand":{"@type":"Brand","name":"${(product.brand || "V.AI STUDIO").replace(/"/g, '"')}"},"sku":"${product.sku || ""}","model":"${product.model || ""}","offers":{"@type":"Offer","priceCurrency":"VND","price":${product.priceNum || 0},"availability":"https://schema.org/InStock"}}`; + if (product.specs && typeof product.specs === "object" && Object.keys(product.specs).length) { + const props = Object.entries(product.specs) + .filter(([k, v]) => v && String(v).trim() && String(v).trim() !== "None") + .map(([k, v]) => `{"@type":"PropertyValue","name":"${k.replace(/"/g, '"')}","value":"${String(v).replace(/^\[|\]$/g, "").replace(/"/g, '"').replace(/\\/g, '\\\\')}"}`); + if (props.length) jsonLd += ',"additionalProperty":[' + props.join(",") + ']'; + } -function escURL(s: string): string { - // Only escape what's needed for HTML attribute — URL-safe - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + return ` + + + + + + + ${allImages.map((img, i) => ` `).join("\n")} + + + + + + + + + + `; } - +/** + * Generate a standalone product detail HTML page (used when someone opens + * the share URL directly in a browser without the V.AI STUDIO app). + */ +function generateProductDetailHTML(product: any, baseUrl: string): string { + const ogImage = product.image || ""; + const ogUrl = baseUrl + "?product=" + (product.slug || product.sku || ""); + const priceStr = product.priceNum > 0 ? product.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ"; + const ogDescription = product.summary || product.description || ""; + + + +
+ +
+
${imagesHtml}
+
+

${product.title_clean}

+
+ ${product.brand || "V.AI STUDIO"} + ${product.model ? `${product.model}` : ""} + ${product.category ? `${product.category}` : ""} +
+
${priceStr}
+ ${product.description ? `
${product.description}
` : ""} + ${featuresHtml ? `
Tính năng nổi bật
${featuresHtml}
` : ""} + ${specsHtml ? `
Thông số kỹ thuật
${specsHtml}
` : ""} + +
+
+ +
+ +`; +} function serveStatic(req: Request): Response { const url = new URL(req.url); @@ -292,541 +276,299 @@ async function sessionHandler(req: Request): Promise { } } -// ── Text-only chat endpoint ───────────────────────────────────────────── -// Chat + tư vấn nhanh dùng Google Gemma qua HF Inference Providers (router). -// Streaming (SSE) + cascade gemma-4-31B -> gemma-3-12B -> gemma-3-4B for resilience. -const CHAIN: Array<{ model: string }> = [ - { model: "google/gemma-4-31B-it" }, - { model: "google/gemma-3-12b-it" }, - { model: "google/gemma-3-4b-it" }, -]; -const ROUTER = "https://router.huggingface.co/v1/chat/completions"; -const CHAT_TIMEOUT_MS = 45000; - -// Helper to present one model. Returns { reply, model } or throws. -async function chatOnce(model: string, messages: any[], token: string) { - const body = { - model, - messages, - max_tokens: 700, - temperature: 0.7, - top_p: 0.9, - stream: false, - }; - const resp = await fetch(ROUTER, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}`, - "User-Agent": "gemma-avatar", - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(CHAT_TIMEOUT_MS), - }); - const text = await resp.text().catch(() => ""); - if (!resp.ok) { - const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200)); - err.status = resp.status; - throw err; - } - try { - const data = JSON.parse(text); - const raw = data?.choices?.[0]?.message?.content; - if (raw && String(raw).trim()) return { reply: String(raw).trim(), model }; - } catch (_) {} - const err: any = new Error("Empty response from " + model); - err.status = 500; - throw err; -} - -// ── Server-side tool execution (restores news/web/product search in /api/chat) ── -const CHAT_TOOLS = [ - { type: "function", function: { - name: "search_news", - 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ự.", - parameters: { type: "object", properties: { query: { type: "string", description: "Chủ đề / từ khóa tin tức" } }, required: ["query"] }, - }}, - { type: "function", function: { - name: "search_web", - description: "Tìm kiếm thông tin trên web cho một câu hỏi hoặc chủ đề bất kỳ.", - parameters: { type: "object", properties: { query: { type: "string", description: "Câu hỏi hoặc từ khóa tìm kiếm" } }, required: ["query"] }, - }}, - { type: "function", function: { - name: "query_catalog", - description: "Tìm kiếm sản phẩm trong catalog của V.AI STUDIO (thiết bị nhà bếp, phụ kiện tủ bếp, khóa cửa...). Dùng khi người dùng hỏi về sản phẩm, mua hàng, giá, model/mã sản phẩm. Trả về các sản phẩm khớp với tên/giá/thương hiệu. LUÔN dùng tool này trước khi trả lời câu hỏi về sản phẩm để tránh bịa đặt.", - parameters: { type: "object", properties: { query: { type: "string", description: "Từ khóa tìm kiếm sản phẩm (tên, mã, model, thương hiệu...)" } }, required: ["query"] }, - }}, - { type: "function", function: { - name: "show_product", - description: "Mở chi tiết một sản phẩm cụ thể trong catalog V.AI STUDIO theo tên hoặc mã. Dùng khi người dùng muốn xem thông số, hình ảnh, giá của đúng 1 sản phẩm đã nêu tên.", - parameters: { type: "object", properties: { product_name: { type: "string", description: "Tên hoặc mã sản phẩm" } }, required: ["product_name"] }, - }}, - { type: "function", function: { - name: "combo_suggest", - description: "Gợi ý COMBO 2-4 thiết bị nhà bếp / phụ kiện tủ bếp theo yêu cầu (bếp từ, máy hút mùi, chậu rửa, vòi rửa, kệ, khóa...). Dùng khi người dùng muốn combo/bộ/gói. Trả về các sản phẩm khớp; tổng giá là tổng của tất cả món.", - parameters: { type: "object", properties: { categories: { type: "array", items: { type: "string" }, description: "Các hạng mục mong muốn, ví dụ ['bếp từ','máy hút mùi']" }, brand: { type: "string", description: "Thương hiệu ưu tiên" }, minPrice: { type: "number", description: "Tổng giá tối thiểu" }, maxPrice: { type: "number", description: "Tổng giá tối đa (TOÀN BỘ combo)" }, material: { type: "string", description: "Chất liệu yêu cầu (ví dụ: inox, kính, nan oval, nhôm, gốm...) — nằm trong chi tiết sản phẩm" }, color: { type: "string", description: "Màu sắc yêu cầu (ví dụ: đen, trắng, bạc, vàng gold...)" }, feature: { type: "string", description: "Tính năng yêu cầu (ví dụ: cảm ứng, remote, tự làm sạch, khử mùi, hẹn giờ, inverter...)" }, sizes: { type: "object", description: "Kích thước yêu cầu theo hạng mục, ví dụ {\"máy hút mùi\": [900], \"chậu rửa\": [700]}" } }, required: [] }, - }}, -]; - -const sleepMs = (ms: number) => new Promise(r => setTimeout(r, ms)); - -async function ddgSearch(query: string) { - const results: Array<{ title: string; snippet: string; url: string; source: string }> = []; - const queries = [ - query, - query + " hôm nay", - (query.length < 30 ? "tin tức " + query : query), - ]; - await Promise.all(queries.map(q => - fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) }) - .then(r => r.text()) - .then(h => { - const lm = [...h.matchAll(/]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; - const sm = [...h.matchAll(/]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; - for (let i = 0; i < Math.min(lm.length, 5); i++) { - let href = lm[i][1].replace(/&/g, "&"); - const ru = href.match(/uddg=(https?%3[^&]+)/i); - if (ru) { try { href = decodeURIComponent(ru[1]); } catch (_) {} } - const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); - if (!title) continue; - const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; - let source = ""; - try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {} - results.push({ title, snippet, url: href, source }); - } - }) - .catch(() => {}) - )); - const seen = new Set(); - const unique = results.filter(r => { - if (!r.url || seen.has(r.url)) return false; - seen.add(r.url); - return r.source && !/duckduckgo|google/i.test(r.source); - }); - return unique.slice(0, 8); -} - -// Reliable Vietnamese news search via Google News RSS (proven reachable from -// HF Spaces — the duckduckgo html endpoint is NOT reliably reachable). -async function searchNewsGoogle(query: string) { - try { - const resp = await fetch( - "https://news.google.com/rss/search?q=" + encodeURIComponent(query) + - "&hl=vi-VN&gl=VN&ceid=VN:vi", - { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" }, signal: AbortSignal.timeout(15000) } - ); - if (!resp.ok) throw new Error("RSS failed " + resp.status); - const rssText = await resp.text(); - const itemBlocks = rssText.match(/[\s\S]*?<\/item>/gi) || []; - const articles: Array<{ title: string; url: string; source: string; desc: string }> = []; - for (const block of itemBlocks) { - const tm = block.match(/(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); - const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); - const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i); - const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); - if (!tm) continue; - let title = tm[1].replace(/\+|_/g, " ").trim(); - let source = ""; - if (sm) { - source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim(); - if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim(); - } - if (!title) continue; - let url = ""; - if (lm) url = (lm[1] || "").trim(); - let desc = ""; - if (dm) { - desc = dm[1].replace(/<[^>]*>/g, " ").replace(/\bhttps?:\/\/\S+/gi, "") - .replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'") - .replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ") - .replace(/\s+/g, " ").trim().slice(0, 160); - } - if (title && url) articles.push({ title, url, source: source || "", desc }); - if (articles.length >= 8) break; - } - return articles; - } catch (e) { - return []; - } -} - -async function executeChatTool(name: string, args: any): Promise<string> { - const clean = (s: any) => String(s || "").trim(); - try { - if (name === "search_news") { - const q = clean(args?.query); - if (!q) return "Thiếu từ khóa tìm kiếm tin tức."; - const arts = await searchNewsGoogle(q); - if (!arts.length) return "Không tìm thấy tin tức cho '" + q + "'."; - return arts.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.desc ? "\n " + r.desc : "") + "\n " + r.url).join("\n"); - } - if (name === "search_web") { - const q = clean(args?.query); - if (!q) return "Thiếu từ khóa tìm kiếm."; - const res = await ddgSearch(q); - if (!res.length) return "Không tìm thấy kết quả cho '" + q + "'."; - return res.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.snippet ? "\n " + r.snippet : "") + "\n " + r.url).join("\n"); - } - if (name === "query_catalog") { - await loadVaixProducts(); - const q = clean(args?.query); - if (!q) return "Thiếu từ khóa tìm kiếm sản phẩm."; - const results = serverSearchProducts(q, 6); - if (!results.length) return "Không tìm thấy sản phẩm nào trong catalog V.AI STUDIO cho '" + q + "'. Hãy thử từ khóa khác (tên/brand/mã)."; - return formatProductLines(results).join("\n"); - } - if (name === "show_product") { - await loadVaixProducts(); - const q = clean(args?.product_name); - if (!q) return "Thiếu tên sản phẩm."; - const results = serverSearchProducts(q, 3); - if (!results.length) return "Không tìm thấy sản phẩm '" + q + "' trong catalog."; - const p = results[0]; - const specs = (p && p.specs && typeof p.specs === "object") ? Object.entries(p.specs).slice(0, 8).map(([k, v]) => k + ": " + String(v)).join(", ") : (p.summary || ""); - return [ - (p.title_clean || p.name || "") + (p.brand ? " (" + p.brand + ")" : "") + " — mã " + (p.model || p.sku || "?"), - "Giá: " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ"), - specs ? ("Thông tin: " + specs) : "", - p.link ? ("Link: " + p.link) : "", - ].filter(Boolean).join("\n"); - } - if (name === "combo_suggest") { - await loadVaixProducts(); - const categories: string[] = Array.isArray(args?.categories) ? args.categories.map((c: any) => String(c)) : []; - if (!categories.length) return "Thiếu danh sách hạng mục combo (categories). Ví dụ ['bếp từ','máy hút mùi']."; - const brand = clean(args?.brand); - const style: any = {}; - if (clean(args?.material)) style.material = clean(args?.material); - if (clean(args?.color)) style.color = clean(args?.color); - if (clean(args?.feature)) style.feature = clean(args?.feature); - const items = serverBuildCombo(categories, brand, Number(args?.minPrice) || 0, Number(args?.maxPrice) || 0, style, args?.sizes); - if (!items.length) return "Không tìm thấy combo phù hợp trong catalog V.AI STUDIO cho các hạng mục: " + categories.join(", ") + "."; - - const note = [ - brand ? "thương hiệu " + brand : "", - style.material ? "chất liệu " + style.material : "", - style.feature ? "tính năng " + style.feature : "", - style.color ? "màu " + style.color : "", - ].filter(Boolean).join(", "); - const head = "Combo " + categories.join(" + ") + (note ? " (theo yêu cầu: " + note + ")" : "") + ":"; - const total = items.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0); - return head + "\n" + formatProductLines(items).join("\n") + "\nTổng combo (" + items.length + " món): " + (total > 0 ? total.toLocaleString("vi-VN") + " ₫" : "Liên hệ"); - } - return "Tool không hỗ trợ: " + name; - } catch (e: any) { - return "Lỗi khi chạy tool " + name + ": " + String(e?.message || e); - } -} - -// ── Server-side catalog search + combo (same logic the client panel uses) ── -// Grounds the text-chat model on REAL V.AI STUDIO products so it never answers -// off-space / invents product info, and keeps product context across follow-ups. -const CAT_HINTS: string[] = [ - "khóa","khoá","bếp","máy hút mùi","hút khói","hút mùi","chậu","vòi","lò nướng","lò vi sóng", - "nồi chiên","máy rửa chén","máy xay","máy ép","tủ lạnh","kệ","giá ","xoong","bản lề","tay nắm","ray", - "thùng rác","gia vị","phụ kiện","combo","nồi","lẩu","chảo", -]; -function isGenericCatQuery(q: string): boolean { - const s = String(q || "").toLowerCase(); - if (!s) return true; - for (const k of CAT_HINTS) if (s.includes(k)) return false; - return true; -} -function serverSearchProducts(q: string, limit: number): any[] { - if (!VAIX_PRODUCTS?.length) return []; - const qNorm = norm(q); - const terms = qNorm.split(/\s+/).filter((t: string) => t.length > 1); - const scored: Array<{ p: any; s: number }> = []; - for (let i = 0; i < VAIX_PRODUCTS.length; i++) { - const p = VAIX_PRODUCTS[i]; - const nm = norm(p.name || p.n || ""); - const sk = norm(p.sku || p.mod || p.model || ""); - const br = norm(p.brand || ""); - const ca = norm(p.c || p.cat || ""); - const de = norm(p.desc || ""); - const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : ""); - let score = 0; - if (nm.includes(qNorm)) score += 20; - if (sk.includes(qNorm)) score += 15; - if (br.includes(qNorm)) score += 10; - if (ca.includes(qNorm)) score += 8; - for (const t of terms) { - if (nm.includes(t)) score += 3; - if (sk.includes(t)) score += 2; - if (br.includes(t)) score += 2; - if (ca.includes(t)) score += 1; - if (de.includes(t)) score += 1; - if (ft.includes(t)) score += 1; - } - if (score > 0) scored.push({ p, s: score }); - } - scored.sort((a, b) => b.s - a.s); - return scored.slice(0, limit).map(x => formatProduct(x.p, x.p._idx ?? 0)); -} -// Derive a coarse product family from the product NAME for diverse combos. -function serverItemFamily(p: any): string { - const nm = norm(p.name || p.n || ""); - const pairs: Array<[string[], string]> = [ - [[ "khoacuathongminh","khoadientu","khoathongminh","vantay","faceid","smartlock" ], "khóa cửa thông minh"], - [[ "khoacua","khoatu","okhoa","khoa" ], "khóa"], - [[ "beptu","bephongngoai","bepgas","bepdien","bept" ], "bếp từ"], - [[ "mayhutmui","mayhutkhoi","hutmui","hutkhoi" ], "máy hút mùi"], - [[ "chau ruach","chau rua","bonrua","bonchen" ], "chậu rửa"], - [[ "voirua","voinuoc" ], "vòi rửa"], - [[ "lonuong","lohap","giunong" ], "lò nướng"], - [[ "lovisong","loveda","visong" ], "lò vi sóng"], - [[ "noichien","noi chien" ], "nồi chiên"], - [[ "mayruachen","mayruabat","may sấy chen" ], "máy rửa chén"], - [[ "mayxay","mayep","sinhto" ], "máy xay/ép"], - [[ "tulanh","turuou" ], "tủ lạnh"], - [[ "kexoongnoi","giaxoongnoi","kexoong","giaxoong","xoongnoi" ], "kệ xoong nồi"], - [[ "kechendia","giachendia","giabatdia","kebatdia","kechend","kebechendia" ], "kệ chén dĩa"], - [[ "kedaothot","giadaothot","giadao","kedao" ], "kệ dao thớt"], - [[ "giagoc","kegoc","giagoclienhoan","goclienhoan" ], "giá góc"], - [[ "thungrac","thung rac","thungracamtu" ], "thùng rác"], - [[ "giagiavi","kegiavi","giachailo","kechailo","chailo" ], "giá gia vị"], - [[ "khaychia","khay chiat","khaynhao","tha dian","thiadia","thia nia" ], "khay chia"], - ]; - for (const [keys, label] of pairs) for (const k of keys) if (nm.includes(k)) return label; - return "khác"; -} -// Check whether a product matches a style requirement (material/color/feature) -// against its title/brand/category/summary/features/specs/description. -function serverStyleMatch(p: any, key: string, term: string): boolean { - if (!term) return true; - const nd = norm(p.name || p.n || "") + " " + norm(p.brand || "") + " " + norm(p.c || p.cat || "") + " " + - norm(p.sum || p.summary || "") + " " + norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " + - norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " + - norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || ""); - return nd.includes(norm(term)); -} -function serverBuildCombo(categories: string[], brand: string, minPrice: number, maxPrice: number, style?: any, sizes?: any): any[] { - if (!VAIX_PRODUCTS?.length || !categories || !categories.length) return []; - const st = style || {}; - const sz = sizes || {}; - const hasBudget = (Number(minPrice) > 0) || (Number(maxPrice) > 0); - const picked: any[] = []; - const used = new Set<number>(); - // Product must match at least one requested mm per category (dimension token in - // name/specs/description) — STRICT like the client, so "chén dĩa 700mm" never - // returns an 800mm/900mm rack. - function sizeOk(p: any, cat: string): boolean { - const req = sz[cat]; - if (!req || !Array.isArray(req) || !req.length) return true; - const hay = norm(p.name || p.n || "") + " " + norm(p.c || p.cat || "") + " " + - norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " + - norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " + - norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || ""); - return req.some((mm: number) => hay.includes(String(mm) + "mm") || hay.includes(String(mm) + " mm")); - } - for (const cat of categories) { - const catNorm = norm(cat); - // Prefer products whose family matches the requested category AND style. - let best: any = null; let bestScore = -1; - for (let i = 0; i < VAIX_PRODUCTS.length; i++) { - const p = VAIX_PRODUCTS[i]; - if (used.has(i)) continue; - if (brand && norm(p.brand || "") !== norm(brand)) continue; - if (!sizeOk(p, cat)) continue; - const fam = serverItemFamily(p); - let sc = 0; - if (fam === catNorm) sc += 10; - if (norm(p.c || p.cat || "").includes(catNorm)) sc += 6; - if (norm(p.name || p.n || "").includes(catNorm)) sc += 5; - // Style requirements (soft, preference-weighted). - if (st.material && serverStyleMatch(p, "material", st.material)) sc += 20; - if (st.color && serverStyleMatch(p, "color", st.color)) sc += 20; - if (st.feature && serverStyleMatch(p, "feature", st.feature)) sc += 20; - if (sc > bestScore) { bestScore = sc; best = p; best._i = i; } - } - // Only keep the pick if it satisfies every style requirement; otherwise this - // category can't be matched under the requested style. Prefer a match, but - // don't hard-fail — fall back to the best category match if none satisfies. - if (best && st && Object.keys(st).length) { - const okStyle = (!st.material || serverStyleMatch(best, "material", st.material)) && - (!st.color || serverStyleMatch(best, "color", st.color)) && - (!st.feature || serverStyleMatch(best, "feature", st.feature)); - if (!okStyle) { - // Try to find a styled alternative; if none, relax to the best match. - let styledBest: any = null; let styledScore = -1; - for (let i = 0; i < VAIX_PRODUCTS.length; i++) { - const p = VAIX_PRODUCTS[i]; - if (used.has(i)) continue; - if (brand && norm(p.brand || "") !== norm(brand)) continue; - if (serverItemFamily(p) !== catNorm && !norm(p.c || p.cat || "").includes(catNorm)) continue; - if (!sizeOk(p, cat)) continue; - if (!serverStyleMatch(p, "material", st.material)) continue; - if (!serverStyleMatch(p, "color", st.color)) continue; - if (!serverStyleMatch(p, "feature", st.feature)) continue; - const ps = 10 + (norm(p.c || p.cat || "").includes(catNorm) ? 6 : 0) + (norm(p.name || p.n || "").includes(catNorm) ? 5 : 0); - if (ps > styledScore) { styledScore = ps; styledBest = p; styledBest._i = i; } - } - if (styledBest) best = styledBest; - } - } - // Track index on best for used-set (formatProduct doesn't carry original idx). - if (best) { - const idx = best._i ?? VAIX_PRODUCTS.indexOf(best); - used.add(idx); - picked.push(formatProduct(best, best._idx ?? idx)); - } - } - if (hasBudget && picked.length) { - const sum = picked.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0); - const mn = Number(minPrice) || 0, mx = Number(maxPrice) || Infinity; - if (sum < mn || sum > mx) { - // Budget mismatch: relax brand + style constraints and retry. - return serverBuildCombo(categories, "", 0, 0, undefined, sizes); - } - } - return picked.slice(0, 4); -} -function formatProductLines(products: any[]): string[] { - return products.map((p: any) => - "• " + (p.title_clean || p.name || "") + - (p.brand ? " (" + p.brand + ")" : "") + - (p.model ? " — mã " + p.model : "") + - " — " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ") - ); -} - -// One chat completion round that supports tool calls. Returns the message content -// and any tool_calls (name + args + id). -async function chatOnceWithTools(model: string, messages: any[], token: string, withTools: boolean) { - const body: any = { - model, - messages, - max_tokens: 800, - temperature: 0.7, - top_p: 0.9, - stream: false, - tools: withTools ? CHAT_TOOLS : undefined, - tool_choice: withTools ? "auto" : undefined, - }; - const resp = await fetch(ROUTER, { - method: "POST", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": "gemma-avatar" }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(CHAT_TIMEOUT_MS), - }); - const text = await resp.text().catch(() => ""); - if (!resp.ok) { - const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200)); - err.status = resp.status; - throw err; - } - const data = JSON.parse(text); - const msg = data?.choices?.[0]?.message; - const toolCalls: any[] = Array.isArray(msg?.tool_calls) ? msg.tool_calls : []; - return { content: String(msg?.content || "").trim(), toolCalls, model }; -} - +// ── Text-only chat endpoint ── +// Uses S2S backend in text-only mode: POST /session → text chat via WebSocket async function textChatHandler(req: Request): Promise<Response> { + if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 }); try { const reqBody = await req.json(); - const userMessage = (reqBody.message || "").toString(); + const userMessage = reqBody.message || ""; if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 }); - let conversation = Array.isArray(reqBody.history) ? reqBody.history : []; - conversation = conversation.slice(-12).map((m: any) => ({ - role: (m.role === "assistant" || m.role === "user") ? m.role : "user", - content: String(m.content || ""), - })); - - const system = [ - "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.", - "RẤT QUAN TRỌNG — TRẢ LỜI NGẮN GỌN: Trả lời ngắn gọn, xúc tích (tối đa 2-3 câu cho câu hỏi thường; tối đa 3-4 dòng cho câu hỏi phức tạp), tự nhiên, thân thiện bằng tiếng Việt như người trò chuyện thật. KHÔNG lan man, KHÔNG liệt kê dài dòng, không lặp lại ý. Người dùng đang đọc trên màn hình nhỏ nên câu ngắn gọn đủ ý là tốt nhất.", - "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.", - "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ể (kèm mã/model nếu biết) khi phù hợp.", - "RẤT QUAN TRỌNG — SẢN PHẨM PHẢI LẤY TỪ CATALOG: Khi người dùng hỏi về sản phẩm, giá, mua hàng, hoặc muốn xem combo/bộ sản phẩm, bạn PHẢI gọi query_catalog / show_product / combo_suggest (tools có sẵn) để lấy dữ liệu sản phẩm THẬT từ catalog V.AI STUDIO trước khi trả lời. TUYỆT ĐỐI không bịa giá, mã, tên sản phẩm. Chỉ trả lời dựa trên kết quả tool trả về. Khi người dùng hỏi tiếp (follow-up) về combo/sản phẩm đã nói trước đó, hãy dựa vào lịch sử hội thoại + gọi lại tool nếu cần để giữ đúng ngữ cảnh và đúng sản phẩm.", - "KHI TƯ VẤN COMBO: Nếu người dùng hỏi combo/bộ sản phẩm, gọi combo_suggest với categories (các hạng mục), trả về combo gồm đúng các sản phẩm họ yêu cầu kèm tổng giá. Câu trả lời ngắn gọn, liệt kê từng món trên 1 dòng (tên + mã + giá), không mô tả dài dòng từng món.", - "VIẾT SỐ BẰNG CHỮ SỐ (DIGITS): Mọi con số (mã sản phẩm, giá tiền, kích thước mm, tổng combo) PHẢI được viết bằng chữ số Ả Rập, ví dụ mã 7806922, giá 276.000 đ, tổng 792.000 đ. TUYỆT ĐỐI KHÔNG viết số thành chữ tiếng Việt ('không trăm lẻ bảy triệu...', 'hai trăm bảy mươi sáu...'). Sao chép y nguyên mã sản phẩm và giá mà tool trả về, KHÔNG tự đổi, KHÔNG tự tính toán lại.", - "KHI HIỂN THỊ SẢN PHẨM/COMBO: Sao chép CHÍNH XÁC danh sách sản phẩm, mã và giá do tool (query_catalog/show_product/combo_suggest) trả về. KHÔNG thay đổi mã, không thay sản phẩm khác, không bịa. Nếu người dùng yêu cầu hạng mục cụ thể (ví dụ kệ xoong nồi 800mm, kệ dao thớt 400mm), chỉ liệt kê đúng các sản phẩm khớp đúng yêu cầu đó do tool trả về.", - "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.", - "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.", - ].join("\n"); - const messages = [{ role: "system", content: system }, ...conversation, { role: "user", content: userMessage }]; - - const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); - if (!token) console.error("[chat] No inference token configured (HF_INFERENCE_TOKEN missing)."); - - let lastErr: any = null; - // Decide whether to offer tools: news/current-event/product questions benefit - // from search; plain chit-chat doesn't need it. Offer tools always except very - // short greetings — the model decides via tool_choice:"auto". - const withTools = userMessage.trim().length >= 2 && !/^(chao|hello|hi|xin chao|cam on|oke|ok|bye)[.\s]*$/i.test(userMessage.trim()); - for (const step of CHAIN) { - let retries = 3; - for (let attempt = 0; attempt <= retries; attempt++) { - try { - // Tool-calling loop: keep asking with tools, execute any tool_calls the - // model requests, and feed results back until it gives a final answer. - let msgs = [...messages]; - let finalReply = ""; - let finalModel = step.model; - for (let round = 0; round < 3; round++) { - const { content, toolCalls, model } = await chatOnceWithTools(step.model, msgs, token, withTools); - finalModel = model; - if (toolCalls && toolCalls.length) { - 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 } })) }); - for (const tc of toolCalls) { - let argsObj: any = {}; - try { argsObj = JSON.parse(tc.function?.arguments || "{}"); } catch (_) {} - const resultText = await executeChatTool(tc.function?.name || "", argsObj); - msgs.push({ role: "tool", tool_call_id: tc.id, content: resultText }); + // Create S2S session in text-only mode + const sessionResp = await fetch(UPSTREAM + "/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!sessionResp.ok) { + const errBody = await sessionResp.text().catch(() => ""); + return Response.json({ + error: "Failed to create S2S session", + status: sessionResp.status, + detail: errBody.slice(0, 500), + }, { status: 502 }); + } + const sessionData = await sessionResp.json(); + if (sessionData.state === "queued") { + // Poll the queue + let queueResp; + let attempts = 0; + do { + await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000)); + queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), { + headers: { "Content-Type": "application/json" }, + }); + if (!queueResp.ok) continue; + const queueData = await queueResp.json(); + if (queueData.state === "queued") { + attempts++; + if (attempts > 30) { + return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 }); + } + continue; + } + const grant = queueData; + // Connect WebSocket + const ws = new WebSocket(grant.connect_url); + + const wsPromise = new Promise<any>((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Session created — wait for session.updated then send text + const waitForSession = async () => { + while (!msgReceived) { + // Wait for session.created + // We'll use a timeout approach } - continue; + }; + + // Send session.update then user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.", + audio: { output: { voice: "Sohee" } } + } + })); + }, 500); + + // Send user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect responses + let fullTranscript = ""; + let audioBuffer = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") { + const delta = (event.transcript || event.delta || ""); + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + resolve({ + transcript: fullTranscript.trim(), + audio: audioBuffer, + audioFormat: "pcm16", + }); + } + } catch {} + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => reject(new Error("WebSocket error")); + + // Timeout + const timeout = setTimeout(() => { + if (!responseDone) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve({ + transcript: fullTranscript.trim() || "No response received.", + audio: "", + }); + } + }, 15000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" }); } - finalReply = content; - break; - } - if (!finalReply) { - const e: any = new Error("Empty response after tool loop"); - e.status = 500; - throw e; + }; + }); + + return Response.json(await wsPromise); + } while (true); + } + + // Session granted directly — connect WebSocket + const grant = sessionData; + const ws = new WebSocket(grant.connect_url); + + return new Promise<Response>((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Send session.update + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + 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.", + audio: { output: { voice: "Sohee" } } + } + })); + }, 500); + + // Send user message via text + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect response + let fullTranscript = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") { + const delta = event.delta || ""; + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") { + const segment = event.transcript || ""; + if (segment) fullTranscript += segment; + } + if (type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + try { ws.close(1000, "done"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim(), + status: event.response?.status ?? "completed", + })); + } + } catch (e: any) { + console.warn("[chat] parse error:", e.message); } - const clean = String(finalReply) - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/\*([^*]+)\*/g, "$1") - .replace(/__([^_]+)__/g, "$1") - .replace(/`([^`]+)`/g, "$1") - .replace(/^#{1,6}\s+/gm, "") - .replace(/[ \t]+\n/g, "\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - return Response.json({ transcript: clean, raw: String(finalReply).slice(0, 40), model: finalModel, status: "completed" }); - } catch (e: any) { - lastErr = e; - const code = e?.status; - console.error("[chat] model " + step.model + " attempt " + attempt + " failed:", code, e?.message); - if (code === 401) { - 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 }); + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "error"); } catch {} + resolve(Response.json({ + transcript: "Error connecting to chat service.", + status: "error", + })); } - if (code === 403) { - 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 }); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve(Response.json({ + transcript: fullTranscript.trim() || "Session closed.", + status: "closed", + })); } - if (code === 402) { - 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 }); + }; + + // Timeout + const timeout = setTimeout(() => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim() || "No response received. The service may be busy.", + status: "timeout", + })); } - // Retry transient failures with exponential backoff (429/5xx/timeout). - const wait = 250 * Math.pow(2, attempt); - await new Promise(r => setTimeout(r, wait)); + }, 20000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + clearTimeout(timeout); + resolve(Response.json({ + transcript: "Connection closed.", + status: "closed", + })); } - } - // Cascade to next (smaller) model. - } - console.error("[chat] All models failed:", lastErr?.status, lastErr?.message); - return Response.json({ error: "Chat service error", status: (lastErr?.status || 502), detail: String(lastErr?.message || "") }, { status: lastErr?.status || 502 }); + }; + }); } catch (err: any) { console.error("[/api/chat] Error:", err.message); return Response.json({ error: "Chat service error: " + err.message }, { status: 500 }); } } +/** + * Serve the SPA with OG meta tags injected when ?product=xxx is present. + * This makes share links display proper previews on Facebook, Zalo, Telegram, etc. + */ +async function serveIndexWithOG(req: Request): Promise<Response> { + const url = new URL(req.url); + const productSlug = url.searchParams.get("product"); + + if (productSlug) { + await loadVaixProducts(); + if (VAIX_LOADED && VAIX_PRODUCTS?.length) { + const product = findProductBySlug(decodeURIComponent(productSlug)); + if (product) { + const baseUrl = url.origin + url.pathname; + const ogHead = generateProductOgHead(product, baseUrl); + const injected = index.replace("</head>", ogHead + "\n </head>"); + return new Response(injected, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); + } + } + } + + return new Response(index, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); +} + const server = Bun.serve({ port: PORT, routes: { - "/": { GET: serveProductOG }, + "/": serveIndexWithOG, "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) }, "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) }, "/api/vaix/products": { GET: async (req: Request) => { @@ -836,7 +578,7 @@ const server = Bun.serve({ const url = new URL(req.url); const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999); const total = VAIX_PRODUCTS.length; - const formatted = VAIX_PRODUCTS.map((p, i) => formatProduct(p, i)).slice(0, Math.min(limit, total)); + const formatted = VAIX_PRODUCTS.slice(0, Math.min(limit, total)).map((p, i) => formatProduct(p, i)); return Response.json({ products: formatted, total, limit, page: 1, perPage: limit, @@ -922,15 +664,7 @@ const server = Bun.serve({ if (ru) href = decodeURIComponent(ru[1]); const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; - if (title) { - // Extract source from URL - let source = ''; - try { - const urlObj = new URL(href); - source = urlObj.hostname.replace(/^www\./, ''); - } catch (_) { source = ''; } - res.push({ title, snippet, url: href, source }); - } + if (title) res.push({ title, snippet, url: href }); } return Response.json({ results: res }); } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); } @@ -948,369 +682,9 @@ const server = Bun.serve({ "/api/session": { POST: sessionHandler }, "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler }, "/api/chat": { POST: textChatHandler }, - "/api/tts": { GET: async (req: Request) => { - try { - const url2 = new URL(req.url); - const text = (url2.searchParams.get("text") || "").trim().replace(/\s+/g, " ").slice(0, 2000); - const voice = url2.searchParams.get("voice") || "vi-VN-HoaiMyNeural"; - const allowed = voice === "vi-VN-NamMinhNeural" ? voice : "vi-VN-HoaiMyNeural"; - if (!text) return Response.json({ error: "Missing ?text=" }, { status: 400 }); - const key = ttsCacheKey(text, allowed); - let audio = TTS_CACHE.get(key); - if (!audio) { - try { - // Synthesize in a separate Bun subprocess to isolate WebSocket crashes. - const proc = Bun.spawn( - [process.execPath, join("/app", "tts-worker.mjs"), allowed, text], - { stdout: "pipe", stderr: "pipe" }, - ); - const exitCode = await proc.exited; - const out = await new Response(proc.stdout).arrayBuffer(); - const errText = await new Response(proc.stderr).text(); - if (exitCode !== 0) { - console.error("[/api/tts] worker failed code="+exitCode+" err="+errText); - return Response.json({ error: "TTS failed", code: exitCode, detail: errText }, { status: 502 }); - } - audio = new Uint8Array(out); - } catch (e: any) { - console.error("[/api/tts] Error:", e?.message || e, e?.stack || ""); - return Response.json({ error: "TTS failed", detail: String(e?.message || e), stack: String(e?.stack || "") }, { status: 502 }); - } - if (!audio || !audio.length) { - return Response.json({ error: "Empty audio" }, { status: 502 }); - } - TTS_CACHE.set(key, audio); - if (TTS_CACHE.size > TTS_CACHE_MAX) { - const firstKey = TTS_CACHE.keys().next().value; - if (firstKey) TTS_CACHE.delete(firstKey); - } - } - return new Response(audio, { - headers: { - "Content-Type": "audio/mpeg", - "Content-Length": String(audio.length), - "Cache-Control": "public, max-age=3600", - }, - }); - } catch (e2: any) { - return Response.json({ error: "route error", detail: String(e2?.message || e2), stack: String(e2?.stack || "") }, { status: 500 }); - } - }}, "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!), "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!), - "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!), "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), - "/src/:name": (req: Request) => srcFile("src", req.params.name!), - "/src/s2s/:name": (req: Request) => srcFile("src/s2s", req.params.name!), - "/api/news/hot": { GET: async () => { - try { - const resp = await fetch("https://news.google.com/rss?hl=vi-VN&gl=VN&ceid=VN:vi"); - if (!resp.ok) throw new Error("RSS failed"); - const rssText = await resp.text(); - // Parse <item> blocks: title, link, source - const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; - const titles: string[] = []; - const articles: Array<{ source: string; title: string; url: string }> = []; - for (const block of itemBlocks) { - const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); - const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); - const sm = block.match(/<source[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*url="([^"]*)"[^>]*>/i); - if (!tm) continue; - const title = tm[1].replace(/\+|_/g, " ").trim(); - if (title) titles.push(title); - let url = ""; - if (lm) { - url = (lm[1] || "").trim(); - // Google News links are /articles/... redirects → keep them, they work in browser - } - let source = ""; - if (sm) source = (sm[1] || sm[0] || "").replace(/<[^>]+>/g, "").trim(); - if (title && url) { - articles.push({ source: source || "", title, url }); - } - if (titles.length >= 12) break; - } - return Response.json({ titles: titles.slice(0, 12), articles }); - } catch (e: any) { - return Response.json({ error: e.message, titles: [], articles: [] }, { status: 502 }); - } - }}, - "/api/news/articles": { GET: async (req: Request) => { - // Search Google News RSS for a specific topic and return SPECIFIC detailed articles - // with real clickable links (each card = one concrete news article, not a category page). - const url = new URL(req.url); - const q = (url.searchParams.get("q") || "").trim(); - if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); - try { - const resp = await fetch( - "https://news.google.com/rss/search?q=" + encodeURIComponent(q) + - "&hl=vi-VN&gl=VN&ceid=VN:vi", - { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" } } - ); - if (!resp.ok) throw new Error("RSS failed " + resp.status); - const rssText = await resp.text(); - const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; - const articles: Array<{ source: string; title: string; url: string; desc: string }> = []; - for (const block of itemBlocks) { - const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); - const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); - const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i); - const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); - if (!tm) continue; - let title = tm[1].replace(/\+|_/g, " ").trim(); - // Strip trailing "- SourceName" that Google appends to titles - let source = ""; - if (sm) { - source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim(); - if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim(); - } - if (!title) continue; - let url = ""; - if (lm) url = (lm[1] || "").trim(); - let desc = ""; - if (dm) { - // Remove all HTML tags (including malformed Google News CDATA tags) - desc = dm[1] - .replace(/<[^>]*>/g, " ") - .replace(/\bhttps?:\/\/\S+/gi, "") - .replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">") - .replace(/ /g, " ") - .replace(/\s+/g, " ").trim().slice(0, 160); - } - if (title && url) { - articles.push({ source: source || "", title, url, desc }); - } - if (articles.length >= 8) break; - } - return Response.json({ articles, query: q }); - } catch (e: any) { - return Response.json({ error: e.message, articles: [] }, { status: 502 }); - } - }}, - "/api/news/images": { GET: async (req: Request) => { - // Return REAL per-article images for a topic using Vietnamese portal RSS feeds - // (VnExpress feeds include <enclosure>/<img> with real article thumbnails). - const url = new URL(req.url); - const q = (url.searchParams.get("q") || "").trim(); - const topicNorm = q.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); - const feeds: Array<{ cat: string; u: string }> = [ - { cat: "tin-moi-nhat", u: "https://vnexpress.net/rss/tin-moi-nhat.rss" }, - { cat: "thoi-su", u: "https://vnexpress.net/rss/thoi-su.rss" }, - { cat: "the-thao", u: "https://vnexpress.net/rss/the-thao.rss" }, - { cat: "kinh-doanh", u: "https://vnexpress.net/rss/kinh-doanh.rss" }, - { cat: "giai-tri", u: "https://vnexpress.net/rss/giai-tri.rss" }, - { cat: "the-gioi", u: "https://vnexpress.net/rss/the-gioi.rss" }, - { cat: "doi-song", u: "https://vnexpress.net/rss/doi-song.rss" }, - { cat: "suc-khoe", u: "https://vnexpress.net/rss/suc-khoe.rss" }, - { cat: "giao-duc", u: "https://vnexpress.net/rss/giao-duc.rss" }, - ]; - const normStr = (s: string) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); - const all: Array<{ title: string; url: string; image: string; source: string; desc: string }> = []; - try { - await Promise.all(feeds.map(async (f) => { - try { - const r = await fetch(f.u, { headers: { "User-Agent": "Mozilla/5.0" } }); - if (!r.ok) return; - const t = await r.text(); - const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; - for (const it of items) { - const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); - const lm = it.match(/<link>(.*?)<\/link>/i); - const em = it.match(/<enclosure[^>]*url="([^"]+)/i); - const im = it.match(/<img[^>]+src="([^"]+)/i); - if (!tm || !lm) continue; - const title = tm[1].trim(); - let image = (em && em[1]) ? em[1].replace(/&/g, "&") : ""; - if (!image && im) image = im[1].replace(/&/g, "&"); - if (!title || !image) continue; - const link = lm[1].trim(); - const dm = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); - let desc = ""; - if (dm) desc = dm[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); - // Filter by topic if a query was provided - if (q && !normStr(title + " " + link).includes(topicNorm)) continue; - all.push({ title, url: link, image, source: "VnExpress", desc }); - } - } catch (_) {} - })); - } catch (_) {} - - // If no exact topic match, fall back to the freshest topical items (always have images) - if (all.length === 0 && q) { - try { - const r = await fetch("https://vnexpress.net/rss/tin-moi-nhat.rss", { headers: { "User-Agent": "Mozilla/5.0" } }); - const t = await r.text(); - const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; - for (const it of items) { - const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); - const lm = it.match(/<link>(.*?)<\/link>/i); - const em = it.match(/<enclosure[^>]*url="([^"]+)/i); - if (tm && lm) { - const image = em ? em[1].replace(/&/g, "&") : ""; - if (image && tm[1].trim()) { - const dm2 = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); - let desc2 = ""; - if (dm2) desc2 = dm2[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); - all.push({ title: tm[1].trim(), url: lm[1].trim(), image, source: "VnExpress", desc: desc2 }); - } - } - } - } catch (_) {} - } - - return Response.json({ images: all.slice(0, 8), query: q }); - }}, - "/api/news/topic": { GET: async (req: Request) => { - // Search Vietnamese news for a specific topic and return concrete article cards with links. - const url = new URL(req.url); - const q = (url.searchParams.get("q") || "").trim(); - if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); - try { - const portalQueries = [ - q + " site:vnexpress.net", - q + " site:tuoitre.vn", - q + " site:thanhnien.vn", - q + " site:cafef.vn", - q + " site:dantri.com.vn", - "tin tức " + q + " hôm nay", - ]; - const allResults: Array<{ title: string; snippet: string; url: string; source: string }> = []; - await Promise.all(portalQueries.map(nq => - fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(nq), { headers: { "User-Agent": "Mozilla/5.0" } }) - .then(r => r.text()) - .then(h => { - const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; - const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; - for (let i = 0; i < Math.min(lm.length, 4); i++) { - let href = lm[i][1]; - const ru = href.match(/uddg=(https?%3[^&]+)/i); - if (ru) href = decodeURIComponent(ru[1]); - const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); - if (!title) continue; - const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; - let source = ""; - try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {} - allResults.push({ title, snippet, url: href, source }); - } - }) - .catch(() => {}) - )); - // Deduplicate + drop empty - const seen = new Set<string>(); - const unique = allResults.filter(r => { - if (!r.url || seen.has(r.url)) return false; - seen.add(r.url); - return r.source && !/duckduckgo|google/i.test(r.source); - }); - return Response.json({ results: unique.slice(0, 8), query: q }); - } catch (e: any) { - return Response.json({ error: e.message, results: [] }, { status: 502 }); - } - }}, - "/api/logs/client": { POST: async (req: Request) => { - try { - const body = await req.json(); - console.error("[ClientError]", body?.message ?? String(body), body?.details ? JSON.stringify(body) : ""); - return Response.json({ ok: true }); - } catch(e: any) { return Response.json({ error: e.message }, { status: 400 }); } - }}, - "/api/vaix/json": async () => { - try { - const r = await fetch(JSON_URL); - if (!r.ok) throw new Error("HTTP "+r.status); - const data = await r.json(); - // Only return top 1000 to reduce server memory - const prods = Array.isArray(data) ? data.slice(0, 1000) : []; - return new Response(JSON.stringify(prods), { - headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } - }); - } catch(e) { - return Response.json({ error: "JSON fetch failed: " + e.message }, { status: 502 }); - } - }, - "/api/customers": { - GET: async () => { - try { - const f = join("/app", "customers.json"); - let data: any = {}; - // Prefer LIVE repo HEAD so edits from any writer (Zalo bot upload, new - // admin saves) appear immediately without waiting for a rebuild. - try { - const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); - const r = await fetch("https://huggingface.co/spaces/bep40/vai-avatar2/raw/main/customers.json", { - headers: token ? { Authorization: "Bearer " + token } : {}, - signal: AbortSignal.timeout(6000), - }); - if (r.ok) { - const raw = await r.text(); - if (raw && raw.trim()) { try { data = JSON.parse(raw); } catch (_e) {} } - } - } catch (_e) {} - // Fallback: local file (fast path, also fine after a local POST write). - if (!data || typeof data !== "object" || !Object.keys(data).length) { - if (existsSync(f)) { - try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_e) { data = {}; } - } - } - return new Response(JSON.stringify(data), { - headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } - }); - } catch (e: any) { - return Response.json({ error: e.message, customers: {} }, { status: 500 }); - } - }, - POST: async (req: Request) => { - try { - const body = await req.json().catch(() => ({})); - if (!body || typeof body !== "object") return Response.json({ error: "Invalid body" }, { status: 400 }); - if (String(body.accessCode || "") !== "V.AISTUDIO") { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - const candidates = typeof body.customer === "object" && body.customer !== null ? [body.customer] : (Array.isArray(body.customers) ? body.customers : []); - if (!candidates.length) return Response.json({ error: "No customers provided" }, { status: 400 }); - - const f = join("/app", "customers.json"); - let data: any = {}; - if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_e) { data = {}; } } - if (!data || typeof data !== "object") data = {}; - - let changed = 0; - candidates.forEach((c: any) => { - const cid = String(c && c.cid || ""); - if (!cid) return; - data[cid] = Object.assign({}, data[cid] || {}, c); - changed++; - }); - if (!changed) return Response.json({ error: "No valid customers" }, { status: 400 }); - - const content = JSON.stringify(data, null, 2); - const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); - if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); - - const payload = [ - { key: "header", value: { summary: "Update customers via api", repo: { type: "space", id: "bep40/vai-avatar2" } } }, - { key: "file", value: { path: "customers.json", content } }, - ].map((x) => JSON.stringify(x)).join("\n"); - const commit = await fetch("https://huggingface.co/api/spaces/bep40/vai-avatar2/commit/main", { - method: "POST", - headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, - body: payload, - }); - if (!commit.ok) { - const errText = await commit.text().catch(() => ""); - return Response.json({ error: "Commit failed: HTTP " + commit.status + " " + errText.slice(0, 300) }, { status: 502 }); - } - // Persist locally too — without this, the live GET keeps returning the - // stale /app/customers.json until the Space rebuilds (rebuilds are slow - // and a write shouldn't depend on one). Keep local file in sync so - // subsequent GET /api/customers reflect the save immediately. - try { writeFileSync(f, content); } catch (_e) {} - return Response.json({ ok: true, updated: changed, customers: data }); - } catch (e: any) { - return Response.json({ error: e.message }, { status: 500 }); - } - }, - }, "/*": { GET: serveStatic }, }, }); diff --git a/index.ts.patched b/index.ts.patched deleted file mode 100644 index 8263df0d341ea943801c31952f458c6be1594a9b..0000000000000000000000000000000000000000 --- a/index.ts.patched +++ /dev/null @@ -1,684 +0,0 @@ -/** - * Gemma Avatar — realtime voice/text chat with 3D talking-head avatar + V.AI STUDIO. - * Products: frontend loads DIRECTLY from dataset JSON URL (1 HTTP GET, no backend proxy). - * Backend keeps /api/vaix/products for backward compat / debugging. - * Fast, reliable, no WASM, no compiled deps. - * - * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access). - * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode. - */ -import index from "./index.html"; -import { readdir } from "fs/promises"; -import { join } from "path"; -import { existsSync } from "fs"; - -const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, ""); -const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, ""); -const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL; -const PORT = Number(Bun.env.PORT ?? 7860); - -// ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ── -const JSON_URL = - "https://huggingface.co/datasets/bep40/grob-products-updated" + - "/resolve/main/products_with_slugs.json"; - -let VAIX_PRODUCTS: any[] | null = null; -let VAIX_LOADING = false; -let VAIX_LOADED = false; -let VAIX_LAST_SYNC = Date.now(); - -async function loadVaixProducts(): Promise<void> { - if (VAIX_LOADED && VAIX_PRODUCTS?.length) return; - if (VAIX_LOADING) { - for (let i = 0; i < 120; i++) { - await new Promise(r => setTimeout(r, 500)); - if (VAIX_LOADED) return; - } - } - VAIX_LOADING = true; - const startMs = Date.now(); - console.log("[VAIX] Fetching JSON from bep40/grob-products-updated..."); - try { - const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), 120_000); - const resp = await fetch(JSON_URL, { signal: ctrl.signal }); - clearTimeout(timer); - if (!resp.ok) throw new Error("HTTP " + resp.status); - const data = await resp.json(); - VAIX_PRODUCTS = Array.isArray(data) ? data : []; - VAIX_LAST_SYNC = Date.now(); - console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products in " + (Date.now() - startMs) + "ms"); - } catch (err: any) { - console.error("[VAIX] Error:", err.message); - setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000); - } finally { - VAIX_LOADING = false; - } -} -loadVaixProducts().catch(() => {}); -setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000); - -function norm(s: string): string { - return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); -} - -async function proxy(path: string, req: Request, init: any = {}): Promise<Response> { - const headers = new Headers(init.headers); - headers.set("Content-Type", "application/json"); - const cookie = req.headers.get("cookie"); - if (cookie) headers.set("Cookie", cookie); - const resp = await fetch(UPSTREAM + path, { ...init, headers }); - const body = await resp.text(); - const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } }); - const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []); - for (const sc of setCookies) out.headers.append("Set-Cookie", sc.split(";").map((p: string) => p.trim()).filter((p: string) => !/^domain=/i.test(p)).join("; ")); - return out; -} - -function staticFile(dir: string, name: string) { - return new Response(Bun.file(import.meta.dir + "/public/" + dir + "/" + name)); -} - -async function listAvatars() { - const avatarsDir = join(import.meta.dir, "public/avatars"); - const names: string[] = []; - try { - const dir = await readdir(avatarsDir, { withFileTypes: true }); - for (const e of dir) { - if (e.isFile() && e.name.endsWith(".glb")) names.push(e.name); - } - } catch {} - return names.sort(); -} - -function formatProduct(p: any, idx: number) { - return { - name: p.name || p.n || "", - title_clean: p.name || p.n || "", - brand: p.brand || "", - price: p.p || p.price || "", - priceNum: Number(p.pn ?? 0), - category: p.c || p.cat || "", - category_slug: p.cs || "", - category_icon: p.ci || "fa-box", - sku: p.sku || "", - model: p.mod || p.model || "", - slug: p.slug || "", - description: p.desc || "", - summary: p.sum || p.summary || "", - features: Array.isArray(p.feats) ? p.feats : [], - specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {}, - video: p.vid || "", - image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || (Array.isArray(p.images) ? p.images[0] : ""), - images: p.imgs || p.images || [], - link: p.l || "", - _idx: p._idx ?? idx, - }; -} - -async function serveProductOG(req: Request): Promise<Response> { - const url = new URL(req.url); - const productSlug = url.searchParams.get("product"); - if (productSlug && VAIX_PRODUCTS?.length) { - const foundProduct = VAIX_PRODUCTS.find((p: any) => { - const slug = p.slug || p._source_alias || ""; - const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); - }); - if (foundProduct) { - const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); - const fpName = fp.name || fp.model || "Sản phẩm"; - const fpBrand = fp.brand || "V.AI STUDIO"; - const fpPriceNum = fp.priceNum || Number(fp.pn) || 0; - const fpDesc = (fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 200); - const fpImg = fp.image || (fp.images && fp.images[0]) || ""; - const fpCategory = fp.category || ""; - const fpModel = fp.model || ""; - const fpSpecs = fp.specs || {}; - let specsStr = ""; - const specKeys = Object.keys(fpSpecs).slice(0, 8); - for (const sk of specKeys) { - specsStr += `\n* ${sk}: ${fpSpecs[sk]}`; - } - const ogDescription = fpDesc ? fpDesc + specsStr : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; - const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"; - const ogImage = fpImg || fallbackImg; - const ogTitle = `${fpName} | ${fpBrand} - V.AI STUDIO`; - const ogTags = ` - <meta property="og:title" content="${ogTitle}" /> - <meta property="og:description" content="${ogDescription}" /> - <meta property="og:url" content="${url.href}" /> - <meta property="og:type" content="product" /> - <meta property="og:image" content="${ogImage}" /> - <meta property="og:image:secure_url" content="${ogImage}" /> - <meta property="og:image:type" content="image/jpeg" /> - <meta property="og:image:width" content="1200" /> - <meta property="og:image:height" content="630" /> - <meta property="product:price:amount" content="${fpPriceNum}" /> - <meta property="product:price:currency" content="VND" /> - <meta property="product:availability" content="in stock" /> - <meta property="product:condition" content="new" /> - <meta property="fb:app_id" content="1321688464574422" /> - <meta name="twitter:card" content="summary_large_image" /> - <meta name="twitter:site" content="@bep40" /> - <meta name="twitter:title" content="${ogTitle}" /> - <meta name="twitter:description" content="${ogDescription}" /> - <meta name="twitter:image" content="${ogImage}" /> - <meta name="description" content="${ogDescription}" />`; - return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - } - return new Response(index, { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); -} - -async function serveProductOG(req: Request): Promise<Response> { - const url = new URL(req.url); - const productSlug = url.searchParams.get("product"); - - function escapeHTML(str: string): string { - return String(str || "") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/\r\n/g, " ") - .replace(/\n/g, " ") - .replace(/\r/g, " ") - .slice(0, 350); - } - - if (productSlug && VAIX_PRODUCTS?.length) { - const foundProduct = VAIX_PRODUCTS.find((p: any) => { - const slug = p.slug || p._source_alias || ""; - const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); - return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); - }); - if (foundProduct) { - const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); - // HTML-escape ALL values before injecting - const ogTitle = escapeHTML(`${fp.name || fp.model || "Sản phẩm"} | ${fp.brand || "V.AI STUDIO"} - V.AI STUDIO`); - const fpDesc = escapeHTML((fp.summary || fp.description || "").trim().slice(0, 200)); - const fpImg = escapeHTML(fp.image || (fp.images && fp.images[0]) || ""); - const fpPriceNum = fp.priceNum || Number(fp.pn) || 0; - let specsStr = ""; - const specKeys = Object.keys(fp.specs || {}).slice(0, 8); - for (const sk of specKeys) { - const key = escapeHTML(sk || ""); - const val = escapeHTML(String(fp.specs[sk] || "")); - specsStr += `${key}: ${val}, `; - } - specsStr = specsStr.slice(0, -2); - const ogDescription = fpDesc ? `${fpDesc}${specsStr ? " -- " + specsStr : ""}` : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; - const fallbackImg = escapeHTML("https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"); - const ogImage = fpImg || fallbackImg; - const ogTags = `<meta property="og:title" content="${ogTitle}" /> - <meta property="og:description" content="${ogDescription}" /> - <meta property="og:url" content="${escapeHTML(url.href)}" /> - <meta property="og:type" content="product" /> - <meta property="og:image" content="${ogImage}" /> - <meta property="og:image:secure_url" content="${ogImage}" /> - <meta property="og:image:type" content="image/jpeg" /> - <meta property="og:image:width" content="1200" /> - <meta property="og:image:height" content="630" /> - <meta property="product:price:amount" content="${fpPriceNum}" /> - <meta property="product:price:currency" content="VND" /> - <meta property="product:availability" content="in stock" /> - <meta property="product:condition" content="new" /> - <meta property="fb:app_id" content="1321688464574422" /> - <meta name="twitter:card" content="summary_large_image" /> - <meta name="twitter:site" content="@bep40" /> - <meta name="twitter:title" content="${ogTitle}" /> - <meta name="twitter:description" content="${ogDescription}" /> - <meta name="twitter:image" content="${ogImage}" /> - <meta name="description" content="${ogDescription}" />`; - return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - } - // No product param — serve default HTML with static OG fallback - const fallbackTitle = escapeHTML("Gemma Avatar + V.AI STUDIO - Chat & Voice AI"); - const fallbackDesc = escapeHTML("Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng."); - const fallbackOgTags = `<meta property="og:title" content="${fallbackTitle}" /> - <meta property="og:description" content="${fallbackDesc}" /> - <meta property="og:url" content="${escapeHTML(url.href)}" /> - <meta property="og:type" content="website" /> - <meta property="og:image" content="${fallbackImg}" /> - <meta name="twitter:card" content="summary_large_image" />`; - return new Response(index.replace(/<\/head>/, fallbackOgTags + "\n </head>"), { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); -}` - -function serveStatic(req: Request): Response { - const url = new URL(req.url); - const fullPath = join("/app", url.pathname.slice(1)); - if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 }); - return new Response(Bun.file(fullPath)); -} - -async function queueHandler(req: Request): Promise<Response> { - if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); - const url = new URL(req.url); - const parts = url.pathname.split("/"); - const id = parts[parts.length - 1]; - if (!id) return Response.json({ error: "Missing id" }, { status: 400 }); - try { - if (req.method === "DELETE") { - return await proxy("/queue/" + encodeURIComponent(id), req, { method: "DELETE" }); - } - return await proxy("/queue/" + encodeURIComponent(id), req); - } catch { - return Response.json({ error: "Speech service unreachable." }, { status: 502 }); - } -} - -async function sessionHandler(req: Request): Promise<Response> { - if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); - try { - return await proxy("/session", req, { method: "POST", body: "{}" }); - } catch { - return Response.json({ error: "Speech service unreachable." }, { status: 502 }); - } -} - -// ── Text-only chat endpoint ── -// Uses S2S backend in text-only mode: POST /session → text chat via WebSocket -async function textChatHandler(req: Request): Promise<Response> { - if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 }); - try { - const reqBody = await req.json(); - const userMessage = reqBody.message || ""; - if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 }); - - // Create S2S session in text-only mode - const sessionResp = await fetch(UPSTREAM + "/session", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - if (!sessionResp.ok) { - const errBody = await sessionResp.text().catch(() => ""); - return Response.json({ - error: "Failed to create S2S session", - status: sessionResp.status, - detail: errBody.slice(0, 500), - }, { status: 502 }); - } - const sessionData = await sessionResp.json(); - if (sessionData.state === "queued") { - // Poll the queue - let queueResp; - let attempts = 0; - do { - await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000)); - queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), { - headers: { "Content-Type": "application/json" }, - }); - if (!queueResp.ok) continue; - const queueData = await queueResp.json(); - if (queueData.state === "queued") { - attempts++; - if (attempts > 30) { - return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 }); - } - continue; - } - const grant = queueData; - // Connect WebSocket - const ws = new WebSocket(grant.connect_url); - - const wsPromise = new Promise<any>((resolve, reject) => { - ws.binaryType = "arraybuffer"; - let msgReceived = false; - - ws.onopen = () => { - // Session created — wait for session.updated then send text - const waitForSession = async () => { - while (!msgReceived) { - // Wait for session.created - // We'll use a timeout approach - } - }; - - // Send session.update then user message - setTimeout(() => { - ws.send(JSON.stringify({ - type: "session.update", - session: { - type: "realtime", - instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.", - audio: { output: { voice: "Sohee" } } - } - })); - }, 500); - - // Send user message - setTimeout(() => { - ws.send(JSON.stringify({ - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [{ type: "input_text", text: userMessage }] - } - })); - ws.send(JSON.stringify({ type: "response.create" })); - }, 1000); - - // Collect responses - let fullTranscript = ""; - let audioBuffer = ""; - let responseDone = false; - - const handleMessage = (raw: string) => { - try { - const event = JSON.parse(raw); - const type = event?.type; - - if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") { - const delta = (event.transcript || event.delta || ""); - if (delta) fullTranscript += delta; - } - if (type === "response.audio_transcript.done" || type === "response.done") { - responseDone = true; - clearTimeout(timeout); - msgReceived = true; - resolve({ - transcript: fullTranscript.trim(), - audio: audioBuffer, - audioFormat: "pcm16", - }); - } - } catch {} - }; - - ws.onmessage = (e: any) => { - const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); - handleMessage(data); - }; - - ws.onerror = () => reject(new Error("WebSocket error")); - - // Timeout - const timeout = setTimeout(() => { - if (!responseDone) { - msgReceived = true; - try { ws.close(1000, "timeout"); } catch {} - resolve({ - transcript: fullTranscript.trim() || "No response received.", - audio: "", - }); - } - }, 15000); - }; - - ws.onclose = () => { - if (!msgReceived) { - msgReceived = true; - resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" }); - } - }; - }); - - return Response.json(await wsPromise); - } while (true); - } - - // Session granted directly — connect WebSocket - const grant = sessionData; - const ws = new WebSocket(grant.connect_url); - - return new Promise<Response>((resolve, reject) => { - ws.binaryType = "arraybuffer"; - let msgReceived = false; - - ws.onopen = () => { - // Send session.update - setTimeout(() => { - ws.send(JSON.stringify({ - type: "session.update", - session: { - type: "realtime", - 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.", - audio: { output: { voice: "Sohee" } } - } - })); - }, 500); - - // Send user message via text - setTimeout(() => { - ws.send(JSON.stringify({ - type: "conversation.item.create", - item: { - type: "message", - role: "user", - content: [{ type: "input_text", text: userMessage }] - } - })); - ws.send(JSON.stringify({ type: "response.create" })); - }, 1000); - - // Collect response - let fullTranscript = ""; - let responseDone = false; - - const handleMessage = (raw: string) => { - try { - const event = JSON.parse(raw); - const type = event?.type; - - if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") { - const delta = event.delta || ""; - if (delta) fullTranscript += delta; - } - if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") { - const segment = event.transcript || ""; - if (segment) fullTranscript += segment; - } - if (type === "response.done") { - responseDone = true; - clearTimeout(timeout); - msgReceived = true; - try { ws.close(1000, "done"); } catch {} - resolve(Response.json({ - transcript: fullTranscript.trim(), - status: event.response?.status ?? "completed", - })); - } - } catch (e: any) { - console.warn("[chat] parse error:", e.message); - } - }; - - ws.onmessage = (e: any) => { - const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); - handleMessage(data); - }; - - ws.onerror = () => { - if (!msgReceived) { - msgReceived = true; - try { ws.close(1000, "error"); } catch {} - resolve(Response.json({ - transcript: "Error connecting to chat service.", - status: "error", - })); - } - }; - - ws.onclose = () => { - if (!msgReceived) { - msgReceived = true; - resolve(Response.json({ - transcript: fullTranscript.trim() || "Session closed.", - status: "closed", - })); - } - }; - - // Timeout - const timeout = setTimeout(() => { - if (!msgReceived) { - msgReceived = true; - try { ws.close(1000, "timeout"); } catch {} - resolve(Response.json({ - transcript: fullTranscript.trim() || "No response received. The service may be busy.", - status: "timeout", - })); - } - }, 20000); - }; - - ws.onclose = () => { - if (!msgReceived) { - msgReceived = true; - clearTimeout(timeout); - resolve(Response.json({ - transcript: "Connection closed.", - status: "closed", - })); - } - }; - }); - } catch (err: any) { - console.error("[/api/chat] Error:", err.message); - return Response.json({ error: "Chat service error: " + err.message }, { status: 500 }); - } -} - -const server = Bun.serve({ - port: PORT, - routes: { - "/": { GET: serveProductOG }, - "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) }, - "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) }, - "/api/vaix/products": { GET: async (req: Request) => { - try { - await loadVaixProducts(); - if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 }); - const url = new URL(req.url); - const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999); - const total = VAIX_PRODUCTS.length; - const formatted = VAIX_PRODUCTS.slice(0, Math.min(limit, total)).map((p, i) => formatProduct(p, i)); - return Response.json({ - products: formatted, - total, limit, page: 1, perPage: limit, - totalPages: 1, - }); - } catch { return Response.json({ products: [], total: 0 }); } - }}, - "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) }, - "/api/vaix/search": { GET: async (req: Request) => { - const url = new URL(req.url); - const q = url.searchParams.get("q"); - if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 }); - try { - await loadVaixProducts(); - if (!VAIX_PRODUCTS?.length) return Response.json({ results: [], aiAnswer: "No products." }); - const qNorm = norm(q); - const terms = qNorm.split(/\s+/).filter(t => t.length > 1); - const results: Array<{ product: any; score: number }> = []; - for (let i = 0; i < VAIX_PRODUCTS.length; i++) { - const p = VAIX_PRODUCTS[i]; - let score = 0; - const nm = norm(p.name || p.n || ""); - const sk = norm(p.sku || p.mod || p.model || ""); - const br = norm(p.brand || ""); - const ca = norm(p.c || p.cat || ""); - const de = norm(p.desc || ""); - const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : ""); - if (nm.includes(qNorm)) score += 20; - if (sk.includes(qNorm)) score += 15; - if (br.includes(qNorm)) score += 10; - if (ca.includes(qNorm)) score += 8; - for (const t of terms) { - if (nm.includes(t)) score += 3; - if (sk.includes(t)) score += 2; - if (br.includes(t)) score += 2; - if (ca.includes(t)) score += 1; - if (de.includes(t)) score += 1; - if (ft.includes(t)) score += 1; - } - if (score > 0) results.push({ product: formatProduct(p, i), score }); - } - results.sort((a, b) => b.score - a.score); - return Response.json({ results: results.slice(0, 50), aiAnswer: results.length > 0 ? "Found " + results.length + " products." : "No products found." }); - } catch (err: any) { return Response.json({ results: [], error: err.message }); } - }}, - "/api/vaix/sync": { POST: async () => { - VAIX_PRODUCTS = null; - VAIX_LOADED = false; - VAIX_LOADING = false; - await loadVaixProducts(); - return Response.json({ synced: true, count: VAIX_PRODUCTS?.length ?? 0 }); - }}, - "/api/wiki/search": { GET: async (req: Request) => { - const q = new URL(req.url).searchParams.get("q"); - if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); - try { - const r = await fetch("https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + encodeURIComponent(q) + "&format=json&srlimit=5&origin=*"); - const d = await r.json(); - return Response.json({ results: (d.query?.search ?? []).map((r: any) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })) }); - } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } - }}, - "/api/wiki/summary": { GET: async (req: Request) => { - const t = new URL(req.url).searchParams.get("title"); - if (!t) return Response.json({ error: "Missing ?title=" }, { status: 400 }); - try { - const r = await fetch("https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(t) + "?origin=*"); - const d = await r.json(); - return Response.json({ title: d.title, extract: d.extract, url: d.content_urls?.desktop?.page }); - } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } - }}, - "/api/web/search": { GET: async (req: Request) => { - const q = new URL(req.url).searchParams.get("q"); - if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); - try { - const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" } }); - const h = await r.text(); - const res: Array<{ title: string; snippet: string; url: string }> = []; - const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; - const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; - for (let i = 0; i < Math.min(lm.length, 5); i++) { - let href = lm[i][1]; - const ru = href.match(/uddg=(https?%3[^&]+)/i); - if (ru) href = decodeURIComponent(ru[1]); - const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); - const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; - if (title) res.push({ title, snippet, url: href }); - } - return Response.json({ results: res }); - } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); } - }}, - "/api/web/content": { GET: async (req: Request) => { - const u = new URL(req.url).searchParams.get("url"); - if (!u) return Response.json({ error: "Missing ?url=" }, { status: 400 }); - try { - const r = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(8000) }); - const h = await r.text(); - const t = h.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim(); - return Response.json({ content: t.slice(0, 3000), url: u }); - } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); } - }}, - "/api/session": { POST: sessionHandler }, - "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler }, - "/api/chat": { POST: textChatHandler }, - "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!), - "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!), - "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), - "/*": { GET: serveStatic }, - }, -}); -console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none")); diff --git a/package.json b/package.json index 14eddd033de8dd979166983031055aff36630d7b..bc3066b242b47d6828aeb72fb2a354a79155a908 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,7 @@ }, "dependencies": { "@met4citizen/talkinghead": "^1.7.0", - "three": "~0.180.0", - "edge-tts-universal": "^1.4.0" + "three": "~0.180.0" }, "version": "1.5.1" } \ No newline at end of file diff --git a/public/avatars/grit-b6c6c4.glb b/public/avatars/grit-b6c6c4.glb deleted file mode 100644 index ea5bf6b7f380e31dc05486a8c819d4b03fe220a6..0000000000000000000000000000000000000000 --- a/public/avatars/grit-b6c6c4.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9e128f8a7263b679c94701b67ed6b9379e639d251cdf5129a92daef0b7a16876 -size 11247792 diff --git a/public/avatars/lisamy.glb b/public/avatars/lisamy.glb deleted file mode 100644 index 4822de86abfefe27f7a4de40b0618f375fd8626b..0000000000000000000000000000000000000000 --- a/public/avatars/lisamy.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f -size 8320540 diff --git a/public/avatars/lisamy.jpg b/public/avatars/lisamy.jpg deleted file mode 100644 index d53955116cbe347e5012e1c635eff8bd9372cbf4..0000000000000000000000000000000000000000 Binary files a/public/avatars/lisamy.jpg and /dev/null differ diff --git a/public/avatars/vaistudio.png b/public/avatars/vaistudio.png deleted file mode 100644 index 556f044e1141f1fae3c287ff8d8481d47b2b5794..0000000000000000000000000000000000000000 --- a/public/avatars/vaistudio.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13cf0f6e2d3196e802077e0aef50e79e259c4faca785448e43e3b1c03bc695cd -size 2109520 diff --git a/public/avatars/vaistudio_center.png b/public/avatars/vaistudio_center.png deleted file mode 100644 index fbd99ee0ac5962cfc89eae0b6ec808fcd565db13..0000000000000000000000000000000000000000 --- a/public/avatars/vaistudio_center.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f719575626c5bc3f2ed6e028206d4fc02086ceb1618b43a7cc3a92e4a440ae2f -size 6893025 diff --git a/public/avatars/vuong.glb b/public/avatars/vuong.glb index 7a2bb3698a04db05625ea333f402e5863668e64a..1f4e8985fc1690294eedd15ee56fa755909e57f9 100644 --- a/public/avatars/vuong.glb +++ b/public/avatars/vuong.glb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a -size 6964484 +oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06 +size 7137424 diff --git a/public/avatars/vuong.jpg b/public/avatars/vuong.jpg deleted file mode 100644 index 8d715ca77694ce5f8f5095425b2811431bf1f856..0000000000000000000000000000000000000000 Binary files a/public/avatars/vuong.jpg and /dev/null differ diff --git a/public/avatars/vuong2.glb b/public/avatars/vuong2.glb deleted file mode 100644 index 1f4e8985fc1690294eedd15ee56fa755909e57f9..0000000000000000000000000000000000000000 --- a/public/avatars/vuong2.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06 -size 7137424 diff --git a/public/quote_logo.png b/public/quote_logo.png deleted file mode 100644 index dada7b51ef07d44adb7c452e4e4087742342d318..0000000000000000000000000000000000000000 Binary files a/public/quote_logo.png and /dev/null differ diff --git a/public/vendor/headaudio.min.mjs b/public/vendor/headaudio.min.mjs deleted file mode 100644 index 92226eacbbfce618fcc2c85c0a61211ad8deb882..0000000000000000000000000000000000000000 --- a/public/vendor/headaudio.min.mjs +++ /dev/null @@ -1 +0,0 @@ -var e={d:(t,s)=>{for(var n in s)e.o(s,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:s[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=16e3,s=32,n=64,i=!0,o=.97,r=512,a=256,l=12,h=40,f=22,m=!1,c=!1,u=12,d=!0,v=1,p=15,g=14,b=0,y=2,w=8,E=u,A=56,M=78,_=368,O=92;class Training{constructor(e){const t=Object.assign({},e||{});this.vectorDiff=new Float32Array(u),this.matrixL=Array.from({length:u},()=>new Float32Array(u)),this.matrixLInv=Array.from({length:u},()=>new Float32Array(u)),this.matrixSigma=Array.from({length:u},()=>new Float32Array(u)),this.indexSigmaInvLower=this._buildSigmaInvLowerLookup()}_buildSigmaInvLowerLookup(){const e=Array.from({length:u},()=>new Uint16Array(u));let t=0;for(let s=0;s<u;s++)for(let n=0;n<=s;n++)e[s][n]=t,e[n][s]=t,t++;return e}computePrototype(e,t,s,n){const i=u,o=n.length;if(o<i)throw new Error(`Not enough samples (${o}) to compute full covariance.`);const r=new Float32Array(O),a=new DataView(r.buffer,0,8),l=undefined,h=undefined,f=(e?.codePointAt(0)||0)<<16|(e?.codePointAt(1)||0||0);a.setUint32(0,f),a.setUint8(5,t),a.setUint8(7,s);const m=new Float32Array(r.buffer,8,E),c=new Float32Array(r.buffer,A,M);for(let e=0;e<o;e++){const t=n[e];for(let e=0;e<i;e++)m[e]+=t[e]}const d=1/o;for(let e=0;e<i;e++)m[e]*=d;const v=this.matrixSigma;v.forEach(e=>e.fill(0));const p=1/(o-1);for(let e=0;e<o;e++){const t=n[e];for(let e=0;e<i;e++){const s=t[e]-m[e];for(let n=e;n<i;n++)v[e][n]+=s*(t[n]-m[n])}}for(let e=0;e<i;e++)for(let t=e;t<i;t++){const s=v[e][t]*p;v[e][t]=s,e!==t&&(v[t][e]=s)}let g=0;for(let e=0;e<i;e++)g+=v[e][e];const b=g/i*1e-5;for(let e=0;e<i;e++)v[e][e]+=b;const y=this.matrixL;y.forEach(e=>e.fill(0));for(let e=0;e<i;e++)for(let t=0;t<=e;t++){let s=v[e][t];for(let n=0;n<t;n++)s-=y[e][n]*y[t][n];if(e===t){if(s<=0)throw new Error("Matrix is not positive definite.");y[e][t]=Math.sqrt(s)}else y[e][t]=s/y[t][t]}const w=this.matrixLInv;w.forEach(e=>e.fill(0));for(let e=0;e<i;e++){w[e][e]=1/y[e][e];for(let t=0;t<e;t++){let s=0;for(let n=t;n<e;n++)s-=y[e][n]*w[n][t];w[e][t]=s/y[e][e]}}const _=Array.from({length:i},()=>new Float32Array(i));let x=0;for(let e=0;e<i;e++)for(let t=0;t<=e;t++){let s=0;for(let n=Math.max(e,t);n<i;n++)n>=e&&n>=t&&(s+=w[n][e]*w[n][t]);_[e][t]=s,_[t][e]=s,c[x]=s,x++}return{mu:m,sigmaInv:_,sigmaInvLower:c,bin:r.buffer}}decodeBinaryRecord(e){const t=new DataView(e.buffer,e.byteOffset,8),s=t.getUint32(0),n=s>>>16,i=65535&s,o=undefined,r=undefined,a=undefined,l=undefined,h=undefined;return{phoneme:0===i?String.fromCodePoint(n):String.fromCodePoint(n,i),group:t.getUint8(5),viseme:t.getUint8(7),mu:new Float32Array(e.buffer,e.byteOffset+8,E),sigmaInvLower:new Float32Array(e.buffer,e.byteOffset+A,M)}}async loadModel(e,t=null){const s=await fetch(e);if(!s.ok)throw new Error(`Failed to fetch model: ${s.status} ${s.statusText}`);const n=await s.arrayBuffer(),i=[],o=n.byteLength;let r=0;for(;r+_<=o;){const e=new Float32Array(n,r,O),s=this.decodeBinaryRecord(e);null!==t&&(s.group=t),i.push(s),r+=_}if(0===i.length)throw new Error("Fetched model is empty");return{model:i,buffer:n}}update(e){for(const t in e);}}class HeadAudio extends AudioWorkletNode{constructor(e,t=null){const s=Object.assign({},t||{});super(e,"headworklet",{numberOfInputs:1,numberOfOutputs:0,channelCount:1,channelCountMode:"explicit",channelInterpretation:"speakers",outputChannelCount:[],processorOptions:s?.processorOptions||{},parameterData:s?.parameterData||{}}),this.frameEventsEnabled=!!s.processorOptions?.frameEventsEnabled,this.vadEventsEnabled=!!s.processorOptions?.vadEventsEnabled,this.featureEventsEnabled=!!s.processorOptions?.featureEventsEnabled,this.visemeEventsEnabled=!!s.processorOptions?.visemeEventsEnabled,this.visemeNames=["viseme_aa","viseme_E","viseme_I","viseme_O","viseme_U","viseme_PP","viseme_SS","viseme_TH","viseme_DD","viseme_FF","viseme_kk","viseme_nn","viseme_RR","viseme_CH","viseme_sil"],this.nVisemes=this.visemeNames.length,this.visemeMaxs=[.65,.65,.65,.65,.65,.75,.65,.65,.65,.75,.65,.65,.65,.65,.65],this.visemeAlphas=new Array(this.nVisemes).fill(0),this.visemeActive=-1,this.easing=this.sigmoidFactory(5),this.onvalue=null,this.onframe=null,this.onsentence=null,this.onstarted=null,this.onended=null,this.onfeature=null,this.onviseme=null,this.training=new Training,this.timerCounter=0,this.isRunning=!0,this.port.onmessage=this._onmessage.bind(this)}sigmoidFactory(e){function t(t){return 1/(1+Math.exp(-e*t))-.5}var s=.5/t(1);return function(e){return s*t(2*Math.max(Math.min(e,1),0)-1)+.5}}_onmessage(e){const t=e.data;switch(t.event){case"frame":this.frameEventsEnabled&&this.onframe&&"function"==typeof this.onframe&&this.onframe(t);break;case"vad":this.vadEventsEnabled&&this.onvad&&"function"==typeof this.onvad&&this.onvad(t);break;case"feature":this.featureEventsEnabled&&this.onfeature&&"function"==typeof this.onfeature&&this.onfeature(t);break;case"viseme":const e=t.viseme;e&&(this.visemeActive=e===g?-1:e),this.visemeEventsEnabled&&this.onviseme&&"function"==typeof this.onviseme&&this.onviseme(t);break;case"started":this.onstarted&&"function"==typeof this.onstarted&&this.onstarted(t);break;case"ended":this.visemeActive=-1,this.lastEndEvent=performance.now(),this.onended&&"function"==typeof this.onended&&this.onended(t);break;case"calibrated":try{const{mu:e,sigmaInvLower:s,bin:n}=this.training.computePrototype("sc",255,g,t.vs);this.port.postMessage({event:"model",model:[{phoneme:"sc",group:255,viseme:g,mu:e,sigmaInvLower:s}]},[n])}catch(e){t.error=e,console.error(e)}this.oncalibrated&&"function"==typeof this.oncalibrated&&this.oncalibrated(t)}}async loadModel(e,t=!0){const{model:s,buffer:n}=await this.training.loadModel(e);t&&this.port.postMessage({event:"reset"}),this.port.postMessage({event:"model",model:s},[n])}update(e){const t=e/100;for(let e=0;e<this.nVisemes;e++){let s=this.visemeAlphas[e],n=!1;if(e===this.visemeActive?(n=!0,s<1&&(s+=t,s>.99&&(s=1))):s>0&&(n=!0,s-=t,s<.01&&(s=0)),n){let t=this.visemeMaxs[e]*this.easing(s);this.onvalue&&"function"==typeof this.onvalue&&this.onvalue(this.visemeNames[e],t),this.visemeAlphas[e]=s}}}start(){this.isRunning=!0,this.port.postMessage({event:"start"})}stop(){this.isRunning=!1,this.port.postMessage({event:"stop"})}calibrate(){this.port.postMessage({event:"calibrate"})}resetAll(){this.port.postMessage({event:"reset"})}resetTimer(){this.timerCounter++,this.parameters.get("timerReset").value=this.timerCounter}}export{HeadAudio}; \ No newline at end of file diff --git a/public/worklets/audio-playback.js b/public/worklets/audio-playback.js index 182deb531e08590fa195fc1a51c98ad5e80208b1..474020fc522c20f98e9a6dafd527d75364543099 100644 --- a/public/worklets/audio-playback.js +++ b/public/worklets/audio-playback.js @@ -39,7 +39,6 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor { this._fadeIn = 0; this._fadeOut = 0; this._lastSample = 0; - this._muted = false; this.port.onmessage = (e) => { const data = e.data; @@ -67,12 +66,6 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor { this._fracPos = 0; this._fadeOut = FADE_FRAMES; break; - case "mute": - this._muted = true; - break; - case "unmute": - this._muted = false; - break; } }; } @@ -124,8 +117,7 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor { for (let i = 0; i < out.length; i++) { let sample = 0; - if (this._muted) { sample = 0; } - else if (this._playing) { + if (this._playing) { const v = this._readInterpolated(); if (v === null) { // Underrun: try to ramp out cleanly to avoid clicks. diff --git a/san-pham/canzy-cz-03t/index.html b/san-pham/canzy-cz-03t/index.html deleted file mode 100644 index 8b6f6bf93219778ff669c89c73c22f0be554a068..0000000000000000000000000000000000000000 --- a/san-pham/canzy-cz-03t/index.html +++ /dev/null @@ -1,20 +0,0 @@ -<!DOCTYPE html> -<html lang="vi"> -<head> -<meta charset="UTF-8"> -<title>CZ-03T | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/canzy-cz-816sdr/index.html b/san-pham/canzy-cz-816sdr/index.html deleted file mode 100644 index 2ee819b4f02f9639f97974754b1842c76121b178..0000000000000000000000000000000000000000 --- a/san-pham/canzy-cz-816sdr/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - -CZ 816SDR | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-007r-canzy/index.html b/san-pham/cz-007r-canzy/index.html deleted file mode 100644 index d89702a5f31cc5fcd68dbd1a74a11650c8b674df..0000000000000000000000000000000000000000 --- a/san-pham/cz-007r-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ 007R | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-09dl-canzy/index.html b/san-pham/cz-09dl-canzy/index.html deleted file mode 100644 index 179571a7e98da1f6e74ce2fa386faecba4642ea1..0000000000000000000000000000000000000000 --- a/san-pham/cz-09dl-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZO9DL | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-11ebn-canzy/index.html b/san-pham/cz-11ebn-canzy/index.html deleted file mode 100644 index 9459cc24319dd54c09a5e5394df5451932c30b48..0000000000000000000000000000000000000000 --- a/san-pham/cz-11ebn-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-11EBN | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-11qam-serial-8-0-canzy/index.html b/san-pham/cz-11qam-serial-8-0-canzy/index.html deleted file mode 100644 index 9b7090a913fa6bd23b08d65312465afb66377a6f..0000000000000000000000000000000000000000 --- a/san-pham/cz-11qam-serial-8-0-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ 11QAM SERIAL 8.0 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-15ya-canzy/index.html b/san-pham/cz-15ya-canzy/index.html deleted file mode 100644 index c4ee6daf6c89b32e019960dd0aeb0f56f68b6c11..0000000000000000000000000000000000000000 --- a/san-pham/cz-15ya-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ15YA | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-16eugr-unique-canzy/index.html b/san-pham/cz-16eugr-unique-canzy/index.html deleted file mode 100644 index 48292093e837e132253ba59ec859f72cfc5ce7cb..0000000000000000000000000000000000000000 --- a/san-pham/cz-16eugr-unique-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-16EUGR UNIQUE | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-34gss-canzy/index.html b/san-pham/cz-34gss-canzy/index.html deleted file mode 100644 index 367b363cf25cfb8a369f00234615cadac494e978..0000000000000000000000000000000000000000 --- a/san-pham/cz-34gss-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ34GSS | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-68dt-canzy/index.html b/san-pham/cz-68dt-canzy/index.html deleted file mode 100644 index 137dd43d1836c30ac7dcd3e2440e05eb482c4bca..0000000000000000000000000000000000000000 --- a/san-pham/cz-68dt-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ68DT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-700-auto-canzy/index.html b/san-pham/cz-700-auto-canzy/index.html deleted file mode 100644 index a22d1e41d3f3d4fc0a8df6273fa3582311a207d9..0000000000000000000000000000000000000000 --- a/san-pham/cz-700-auto-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-700 AUTO | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-70b50-60b50-canzy/index.html b/san-pham/cz-70b50-60b50-canzy/index.html deleted file mode 100644 index 4b2d4bfd264cd2da312f589ebe9077f2461fe24c..0000000000000000000000000000000000000000 --- a/san-pham/cz-70b50-60b50-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ70B50 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-70c66-canzy/index.html b/san-pham/cz-70c66-canzy/index.html deleted file mode 100644 index b57564d0837c92079730d1d8ae88c200b1ec561d..0000000000000000000000000000000000000000 --- a/san-pham/cz-70c66-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-70C66 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-70i50-60i50-canzy/index.html b/san-pham/cz-70i50-60i50-canzy/index.html deleted file mode 100644 index fcf3b5aaa79fa58d3f30684d6168436e04613e83..0000000000000000000000000000000000000000 --- a/san-pham/cz-70i50-60i50-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ70I50 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-780k-canzy/index.html b/san-pham/cz-780k-canzy/index.html deleted file mode 100644 index 47282f5f6698aada3c78011149ed13057a42a55c..0000000000000000000000000000000000000000 --- a/san-pham/cz-780k-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ780K | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-806-a5g-canzy/index.html b/san-pham/cz-806-a5g-canzy/index.html deleted file mode 100644 index 3c25713295fe752bd8e05c1dfaa85e469b26b400..0000000000000000000000000000000000000000 --- a/san-pham/cz-806-a5g-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-806 A5G | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-815tdb-canzy-c-y-n-c-n-ng-l-nh/index.html b/san-pham/cz-815tdb-canzy-c-y-n-c-n-ng-l-nh/index.html deleted file mode 100644 index 519eb3bb511481d4d6c06772a67aa68610c2a808..0000000000000000000000000000000000000000 --- a/san-pham/cz-815tdb-canzy-c-y-n-c-n-ng-l-nh/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ815TDB | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-899-canzy/index.html b/san-pham/cz-899-canzy/index.html deleted file mode 100644 index bae4780a2a5ffec79d1893de99a1d81232dd73f2..0000000000000000000000000000000000000000 --- a/san-pham/cz-899-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ899 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-9979-grh-canzy/index.html b/san-pham/cz-9979-grh-canzy/index.html deleted file mode 100644 index 4bd166374a81e8b8187b49373e839b82e167b23a..0000000000000000000000000000000000000000 --- a/san-pham/cz-9979-grh-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ9979GRH | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-9979-grs-canzy/index.html b/san-pham/cz-9979-grs-canzy/index.html deleted file mode 100644 index 0e98d9d1c07032bbcb9d5cbf66d0b18b7cca305d..0000000000000000000000000000000000000000 --- a/san-pham/cz-9979-grs-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ9979GRS | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-9979-grt-canzy/index.html b/san-pham/cz-9979-grt-canzy/index.html deleted file mode 100644 index 19085ab0b6ba9d6f69806f17f9994dd607dd390e..0000000000000000000000000000000000000000 --- a/san-pham/cz-9979-grt-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ9979GRT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-b15eu-tornado-pro-canzy/index.html b/san-pham/cz-b15eu-tornado-pro-canzy/index.html deleted file mode 100644 index 628f7e1bb444e6aa2ff4387cf56de34d348d8747..0000000000000000000000000000000000000000 --- a/san-pham/cz-b15eu-tornado-pro-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ B15EU TORNADO PRO | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-b3200tec-canzy/index.html b/san-pham/cz-b3200tec-canzy/index.html deleted file mode 100644 index 31cf2282fb251af6484daf723de8bc6dc7531013..0000000000000000000000000000000000000000 --- a/san-pham/cz-b3200tec-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZB3200TEC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-b6001te-canzy/index.html b/san-pham/cz-b6001te-canzy/index.html deleted file mode 100644 index 753dbd25e18f37e60ced97ddbd86bd5d3b36f2a5..0000000000000000000000000000000000000000 --- a/san-pham/cz-b6001te-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZB6001TE | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-b70sg-canzy/index.html b/san-pham/cz-b70sg-canzy/index.html deleted file mode 100644 index dcba8c6ad59500923582e2372a4bf87e1c3e827c..0000000000000000000000000000000000000000 --- a/san-pham/cz-b70sg-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ B70SG | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-b9698te-canzy/index.html b/san-pham/cz-b9698te-canzy/index.html deleted file mode 100644 index 73480105d8c47b6d6f59e56ae6354cfdc5b6cdad..0000000000000000000000000000000000000000 --- a/san-pham/cz-b9698te-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZB9698TE | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-d12gs-canzy/index.html b/san-pham/cz-d12gs-canzy/index.html deleted file mode 100644 index 1a6cdb2e531e39d37d042cbfc0ccb8a28faad63d..0000000000000000000000000000000000000000 --- a/san-pham/cz-d12gs-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ D12GS | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-d33315c-canzy/index.html b/san-pham/cz-d33315c-canzy/index.html deleted file mode 100644 index 7719d3dc5c9057403f39b7e561b7ea08acb47565..0000000000000000000000000000000000000000 --- a/san-pham/cz-d33315c-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-D33315C | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-d777711c-canzy/index.html b/san-pham/cz-d777711c-canzy/index.html deleted file mode 100644 index 1669f09241f70d4291ba38c57b4aea18a3ec3b76..0000000000000000000000000000000000000000 --- a/san-pham/cz-d777711c-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-D777711C | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-eh70aw-canzy/index.html b/san-pham/cz-eh70aw-canzy/index.html deleted file mode 100644 index 69da08127a631abb1049b8bbe457a2da4077cee8..0000000000000000000000000000000000000000 --- a/san-pham/cz-eh70aw-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-EH70AW | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-g52-seri-ii-canzy/index.html b/san-pham/cz-g52-seri-ii-canzy/index.html deleted file mode 100644 index ee6050d38b7035803a1f0b7a41dd920a55d92a55..0000000000000000000000000000000000000000 --- a/san-pham/cz-g52-seri-ii-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-G52 SERI | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1060at-canzy/index.html b/san-pham/cz-h1060at-canzy/index.html deleted file mode 100644 index 7d781719396e45fff27941daa7bed04ed084a9ed..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1060at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH1060AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1070at-h5060at-canzy/index.html b/san-pham/cz-h1070at-h5060at-canzy/index.html deleted file mode 100644 index 0ad05aa919e70faddbdf780ea7d720668d23d5ec..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1070at-h5060at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH1070AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1160at-canzy/index.html b/san-pham/cz-h1160at-canzy/index.html deleted file mode 100644 index 741887d6b2f4ccd3b232c6b7dd2282c8d5e3c980..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1160at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H1160AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1170at-canzy/index.html b/san-pham/cz-h1170at-canzy/index.html deleted file mode 100644 index d7788758970ab023077f44cf905d630bdff4354c..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1170at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H1170KC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1270at-canzy/index.html b/san-pham/cz-h1270at-canzy/index.html deleted file mode 100644 index 1431b2adbb747e9885a317369a7aa8379c22eaa9..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1270at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH1270AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1370at-canzy/index.html b/san-pham/cz-h1370at-canzy/index.html deleted file mode 100644 index 1ea571e7b5db744cbf6f3d25937bcaab06ac0aeb..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1370at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH1370AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1470at-canzy/index.html b/san-pham/cz-h1470at-canzy/index.html deleted file mode 100644 index 798d2d1c5118140b7de49bd2c5eba56ac1dde193..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1470at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H1470AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1470ct-canzy/index.html b/san-pham/cz-h1470ct-canzy/index.html deleted file mode 100644 index 4c0579f470dac919d6574fe22e6818853a1add81..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1470ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H1470CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h1570at-canzy/index.html b/san-pham/cz-h1570at-canzy/index.html deleted file mode 100644 index 3fd9378fb972a8ea2a0ea589f5d178352d7e7312..0000000000000000000000000000000000000000 --- a/san-pham/cz-h1570at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H1570AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h175at-canzy/index.html b/san-pham/cz-h175at-canzy/index.html deleted file mode 100644 index 6b53f8d1c4a1492e2e890c6a70402112aaa8b85e..0000000000000000000000000000000000000000 --- a/san-pham/cz-h175at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H175AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h190atc-h180atc-h170atc-canzy/index.html b/san-pham/cz-h190atc-h180atc-h170atc-canzy/index.html deleted file mode 100644 index 6340e18780de595071adf379b686a0f544c320c0..0000000000000000000000000000000000000000 --- a/san-pham/cz-h190atc-h180atc-h170atc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH190ATC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h2070cd-h2060cd-canzy/index.html b/san-pham/cz-h2070cd-h2060cd-canzy/index.html deleted file mode 100644 index bfbff41fc42ba8723c10b248d0c19b50f5272617..0000000000000000000000000000000000000000 --- a/san-pham/cz-h2070cd-h2060cd-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH2070CD | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h2170cd-h2160cd-canzy/index.html b/san-pham/cz-h2170cd-h2160cd-canzy/index.html deleted file mode 100644 index 3bb2f9a66de5c90989db1ac398e1b447f0b0e63b..0000000000000000000000000000000000000000 --- a/san-pham/cz-h2170cd-h2160cd-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH2170CD | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h2270cdc-h2260cdc-canzy/index.html b/san-pham/cz-h2270cdc-h2260cdc-canzy/index.html deleted file mode 100644 index d3280277c01d1e6569663ff77224ea4f6fd1237d..0000000000000000000000000000000000000000 --- a/san-pham/cz-h2270cdc-h2260cdc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H2270CDC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h2277cdc-canzy/index.html b/san-pham/cz-h2277cdc-canzy/index.html deleted file mode 100644 index 6aab20ce9a208e5d946d5a3752762e3024330c75..0000000000000000000000000000000000000000 --- a/san-pham/cz-h2277cdc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H2277CDC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h245ct-canzy/index.html b/san-pham/cz-h245ct-canzy/index.html deleted file mode 100644 index c60db45e885dfe90d41fa4b4c085a68491515983..0000000000000000000000000000000000000000 --- a/san-pham/cz-h245ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H245CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h271ct-canzy/index.html b/san-pham/cz-h271ct-canzy/index.html deleted file mode 100644 index 4e5169ce0a321bac35009e9a83b6e375cff50877..0000000000000000000000000000000000000000 --- a/san-pham/cz-h271ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H271CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h276ct-canzy/index.html b/san-pham/cz-h276ct-canzy/index.html deleted file mode 100644 index 789aa6d06994fd67e886acadc6cef1cecf2e5a53..0000000000000000000000000000000000000000 --- a/san-pham/cz-h276ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH276CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h278at-canzy/index.html b/san-pham/cz-h278at-canzy/index.html deleted file mode 100644 index fab290f80a90abac0015548e5850337ef539c69f..0000000000000000000000000000000000000000 --- a/san-pham/cz-h278at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H278AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h287at-h277at-canzy/index.html b/san-pham/cz-h287at-h277at-canzy/index.html deleted file mode 100644 index c2302ef0fb87e62dd298ee236d9ad9ba64380965..0000000000000000000000000000000000000000 --- a/san-pham/cz-h287at-h277at-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H287AT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h290ct-h270ct-canzy/index.html b/san-pham/cz-h290ct-h270ct-canzy/index.html deleted file mode 100644 index 1bcf3f7befefd8bd0b2f0f21b852f305fc29835d..0000000000000000000000000000000000000000 --- a/san-pham/cz-h290ct-h270ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H290CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h345ct-canzy/index.html b/san-pham/cz-h345ct-canzy/index.html deleted file mode 100644 index 0e8f63f20656e54633f91ca3c43033b6077cc20c..0000000000000000000000000000000000000000 --- a/san-pham/cz-h345ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH345CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h470vtc-canzy/index.html b/san-pham/cz-h470vtc-canzy/index.html deleted file mode 100644 index fe0bd99574369d3463414e793931faff1a199b41..0000000000000000000000000000000000000000 --- a/san-pham/cz-h470vtc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H470VTC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h570ctc-canzy/index.html b/san-pham/cz-h570ctc-canzy/index.html deleted file mode 100644 index 25e9ec6e5e1fa5cdeaee9919facbabdc5c5e42c4..0000000000000000000000000000000000000000 --- a/san-pham/cz-h570ctc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H570CTC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h600di-canzy/index.html b/san-pham/cz-h600di-canzy/index.html deleted file mode 100644 index 1eced6523541ccd09a022ee63d0e91750c84a08a..0000000000000000000000000000000000000000 --- a/san-pham/cz-h600di-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H600DI | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h601di-canzy/index.html b/san-pham/cz-h601di-canzy/index.html deleted file mode 100644 index 34b86babf12d71df109c002b02a29e17655c1017..0000000000000000000000000000000000000000 --- a/san-pham/cz-h601di-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H601DI | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h8070kcc-canzy/index.html b/san-pham/cz-h8070kcc-canzy/index.html deleted file mode 100644 index 73fd89124f3a6442c917152b168c18a61235f8f9..0000000000000000000000000000000000000000 --- a/san-pham/cz-h8070kcc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH8070KCC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h829ct-canzy/index.html b/san-pham/cz-h829ct-canzy/index.html deleted file mode 100644 index 217029686958e62ec203172919a3aa07596a6f0b..0000000000000000000000000000000000000000 --- a/san-pham/cz-h829ct-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-H829CT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h839vt-h837vt-canzy/index.html b/san-pham/cz-h839vt-h837vt-canzy/index.html deleted file mode 100644 index e58efcef5fbae7b03cd4e63a2e567c1e89d0049a..0000000000000000000000000000000000000000 --- a/san-pham/cz-h839vt-h837vt-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH839VT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h8668ctc-new-canzy/index.html b/san-pham/cz-h8668ctc-new-canzy/index.html deleted file mode 100644 index 8e5cd96b6ff62548156e6055a763a2c263a08529..0000000000000000000000000000000000000000 --- a/san-pham/cz-h8668ctc-new-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH8668CTCNEW | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h8689ctc-new-canzy/index.html b/san-pham/cz-h8689ctc-new-canzy/index.html deleted file mode 100644 index 0da5e82f484428c5457b327f75f0329548e97721..0000000000000000000000000000000000000000 --- a/san-pham/cz-h8689ctc-new-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH8689CTCNEW | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-h900vt-h700vt-canzy/index.html b/san-pham/cz-h900vt-h700vt-canzy/index.html deleted file mode 100644 index e75c5a7ba9f3f2f178ad4ae43e62f1f91500fd16..0000000000000000000000000000000000000000 --- a/san-pham/cz-h900vt-h700vt-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZH900VT | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-hn001-canzy/index.html b/san-pham/cz-hn001-canzy/index.html deleted file mode 100644 index 29b00c8118f78379bdf7a18be58b959192092f15..0000000000000000000000000000000000000000 --- a/san-pham/cz-hn001-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-HN001 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i12881c-canzy/index.html b/san-pham/cz-i12881c-canzy/index.html deleted file mode 100644 index f364f7ae067a71977829ba9b9729337be462757a..0000000000000000000000000000000000000000 --- a/san-pham/cz-i12881c-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI12881C | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i18191c-canzy/index.html b/san-pham/cz-i18191c-canzy/index.html deleted file mode 100644 index 7f52203fbb16c8b20a6fc700a6ec6d72cde1b3fa..0000000000000000000000000000000000000000 --- a/san-pham/cz-i18191c-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-I18191C | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i22602c-canzy/index.html b/san-pham/cz-i22602c-canzy/index.html deleted file mode 100644 index b41fb8c1f871aa21952526c8b28303cf6ef87c85..0000000000000000000000000000000000000000 --- a/san-pham/cz-i22602c-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-I22602C | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i25y22-canzy/index.html b/san-pham/cz-i25y22-canzy/index.html deleted file mode 100644 index 20e96b358e816fb754309902fc2cbd5e5e98f9c6..0000000000000000000000000000000000000000 --- a/san-pham/cz-i25y22-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI25Y22 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i39992c-serial-8-0-canzy/index.html b/san-pham/cz-i39992c-serial-8-0-canzy/index.html deleted file mode 100644 index d5399bdf000241d15d79dc17778e0d3c43455927..0000000000000000000000000000000000000000 --- a/san-pham/cz-i39992c-serial-8-0-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI39992CSERIAL80 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i5892m-canzy/index.html b/san-pham/cz-i5892m-canzy/index.html deleted file mode 100644 index 87a9d21fc920e329ac281091f8baef1aa5e379f9..0000000000000000000000000000000000000000 --- a/san-pham/cz-i5892m-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-I5892M | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i60002d-canzy/index.html b/san-pham/cz-i60002d-canzy/index.html deleted file mode 100644 index 06bf53bb11cd8de471784bf336eee34f81714667..0000000000000000000000000000000000000000 --- a/san-pham/cz-i60002d-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI60002D | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i60013d-canzy/index.html b/san-pham/cz-i60013d-canzy/index.html deleted file mode 100644 index 53ed855c00f8c23134e9ccc6c930092606cae8cc..0000000000000000000000000000000000000000 --- a/san-pham/cz-i60013d-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-I60013D | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i6007dm-canzy/index.html b/san-pham/cz-i6007dm-canzy/index.html deleted file mode 100644 index 1221a144e19a83ae3bb304b079977abfc843d4bd..0000000000000000000000000000000000000000 --- a/san-pham/cz-i6007dm-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-I6007DM | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i68992m-serial-8-0-canzy/index.html b/san-pham/cz-i68992m-serial-8-0-canzy/index.html deleted file mode 100644 index 39b10b4d2967b028201869b26df184db788a7c3d..0000000000000000000000000000000000000000 --- a/san-pham/cz-i68992m-serial-8-0-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI68992MSERIAL80 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-i69692c-promax-canzy/index.html b/san-pham/cz-i69692c-promax-canzy/index.html deleted file mode 100644 index fab81ec8252b34005d44fdecf95914b973c820da..0000000000000000000000000000000000000000 --- a/san-pham/cz-i69692c-promax-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI69692CPROMAX | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-id-078eu-canzy/index.html b/san-pham/cz-id-078eu-canzy/index.html deleted file mode 100644 index 4a1f002ebc4b7b13c423cce1005dd96bff1ed4f9..0000000000000000000000000000000000000000 --- a/san-pham/cz-id-078eu-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-ID 078EU | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-id077eu-canzy/index.html b/san-pham/cz-id077eu-canzy/index.html deleted file mode 100644 index 5d00b846902e585fbd5d176e038b5086d1d5e390..0000000000000000000000000000000000000000 --- a/san-pham/cz-id077eu-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-ID077EU | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-idc-island-canzy/index.html b/san-pham/cz-idc-island-canzy/index.html deleted file mode 100644 index 09f378c8ef94f07dfb9fa3a1476a5b78074de202..0000000000000000000000000000000000000000 --- a/san-pham/cz-idc-island-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZIDCISLAND | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-kd9-canzy/index.html b/san-pham/cz-kd9-canzy/index.html deleted file mode 100644 index 5f4e70ba794085a5b3092100a34723fef4b3902e..0000000000000000000000000000000000000000 --- a/san-pham/cz-kd9-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-KD9 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-l10060-canzy/index.html b/san-pham/cz-l10060-canzy/index.html deleted file mode 100644 index 41c3e4be3e80dc4e10841a4e4b574ebdb100ac65..0000000000000000000000000000000000000000 --- a/san-pham/cz-l10060-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZL10060 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-l10073-canzy/index.html b/san-pham/cz-l10073-canzy/index.html deleted file mode 100644 index ba9e8dd799f9956c7802daf4af8bb0f7b3bc27da..0000000000000000000000000000000000000000 --- a/san-pham/cz-l10073-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZL10073 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-m36825-canzy/index.html b/san-pham/cz-m36825-canzy/index.html deleted file mode 100644 index 668d47fcf891e117b82228a8e789078d7bb8bbef..0000000000000000000000000000000000000000 --- a/san-pham/cz-m36825-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZM36825 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-m38831-canzy/index.html b/san-pham/cz-m38831-canzy/index.html deleted file mode 100644 index a10df2b1fbcdb9a03e8cb9703b997acedf0b230b..0000000000000000000000000000000000000000 --- a/san-pham/cz-m38831-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-M38831 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-ml222sg-canzy/index.html b/san-pham/cz-ml222sg-canzy/index.html deleted file mode 100644 index ded68f0c69d616bdb983085a77f0d21a152075f3..0000000000000000000000000000000000000000 --- a/san-pham/cz-ml222sg-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZML222SG | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-ml8-70dc-canzy/index.html b/san-pham/cz-ml8-70dc-canzy/index.html deleted file mode 100644 index e3d711eb5f5896fe78c90ededb244c8e10e19740..0000000000000000000000000000000000000000 --- a/san-pham/cz-ml8-70dc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ ML8-70DC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-ml877r-canzy/index.html b/san-pham/cz-ml877r-canzy/index.html deleted file mode 100644 index 14f5c6b02442fb308ab2754ca68dd69c8312c6db..0000000000000000000000000000000000000000 --- a/san-pham/cz-ml877r-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZML87ODC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-ml9-70dc-canzy/index.html b/san-pham/cz-ml9-70dc-canzy/index.html deleted file mode 100644 index 22a170a89bfe1e5052c2c2f0b90bd9e15ea5c9d5..0000000000000000000000000000000000000000 --- a/san-pham/cz-ml9-70dc-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ ML9-70DC | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-n70sg-canzy/index.html b/san-pham/cz-n70sg-canzy/index.html deleted file mode 100644 index 5b874e7074a5c9a776d80f343135cd4b6324e53e..0000000000000000000000000000000000000000 --- a/san-pham/cz-n70sg-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ N70SG | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-pid992-serial-8-0-canzy/index.html b/san-pham/cz-pid992-serial-8-0-canzy/index.html deleted file mode 100644 index 57b3637a98e94a535ef77b769dac19389dc3df8d..0000000000000000000000000000000000000000 --- a/san-pham/cz-pid992-serial-8-0-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZPID992SERIAL80 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-r11plus-canzy/index.html b/san-pham/cz-r11plus-canzy/index.html deleted file mode 100644 index 23d9e99e0eead74b2c9148a804d14aefbe7ceb4b..0000000000000000000000000000000000000000 --- a/san-pham/cz-r11plus-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ R11PLUS | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-sl8-0-canzy/index.html b/san-pham/cz-sl8-0-canzy/index.html deleted file mode 100644 index a68ec7f4a4a74cae739e83cbdb5675ac72e473de..0000000000000000000000000000000000000000 --- a/san-pham/cz-sl8-0-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-SL8.0 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-t52-seri-ii-canzy/index.html b/san-pham/cz-t52-seri-ii-canzy/index.html deleted file mode 100644 index 4dd0eff519144a4ea996cfe28a83f1f0b2d19bc4..0000000000000000000000000000000000000000 --- a/san-pham/cz-t52-seri-ii-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZ-T52 SERI II | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-tk170-canzy/index.html b/san-pham/cz-tk170-canzy/index.html deleted file mode 100644 index 37238744d1d22ff6873d94c5b62dc792cd04f4e4..0000000000000000000000000000000000000000 --- a/san-pham/cz-tk170-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZTK170 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-tl875r-canzy/index.html b/san-pham/cz-tl875r-canzy/index.html deleted file mode 100644 index 339edb46b3649b20538a582d29b85cd26f9a012e..0000000000000000000000000000000000000000 --- a/san-pham/cz-tl875r-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZTL875R | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/cz-wok28-canzy/index.html b/san-pham/cz-wok28-canzy/index.html deleted file mode 100644 index b5c5f6d55810327f88c7505b00b5424b564871ee..0000000000000000000000000000000000000000 --- a/san-pham/cz-wok28-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZWOK28 | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/czi-ml789di-canzy/index.html b/san-pham/czi-ml789di-canzy/index.html deleted file mode 100644 index 664f32e6450480d497e786d516d773f5dbe34a53..0000000000000000000000000000000000000000 --- a/san-pham/czi-ml789di-canzy/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - -CZI-ML789DI | Canzy | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - \ No newline at end of file diff --git a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html deleted file mode 100644 index 749f52e21abaa40dfaabba8e222af96dc91dd4b5..0000000000000000000000000000000000000000 --- a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - -MI593ES | Malloca | Bếp từ 3 vùng nấu 59cm | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - diff --git a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg deleted file mode 100644 index f10745ddd8a70ea30870b19f986dc8c3501f4738..0000000000000000000000000000000000000000 --- a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:06d583e5543e95843dda84249ff7a73c7e849345453a77b429536fca1d307858 -size 568130 diff --git a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html deleted file mode 100644 index dfc2648cfb089d76b74df11109d6d865445cb657..0000000000000000000000000000000000000000 --- a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - -MI73TWES | Malloca | Bếp từ 2 vùng nấu 73cm EcoKitchen | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - diff --git a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg deleted file mode 100644 index 4df599680d6cbeb510561dd14b17be40748b0ff5..0000000000000000000000000000000000000000 Binary files a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg and /dev/null differ diff --git a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html deleted file mode 100644 index 86de500481662fa82f5d92288ec72db54fcf4565..0000000000000000000000000000000000000000 --- a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - -MI7502ES | Malloca | Bếp từ 2 vùng nấu 75cm | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - diff --git a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg deleted file mode 100644 index 9c96815a21c91d137fb2cae19fd3fa9c80f4f0bd..0000000000000000000000000000000000000000 --- a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ee503ee49a04f82190607c93d63dd9b4f70a935be4e82e3dbd9129c525e03ef2 -size 466784 diff --git a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html deleted file mode 100644 index 8ed1472ca246134c7c6869fa7cdfa681a15a9ce5..0000000000000000000000000000000000000000 --- a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - -MI803ES | Malloca | Bếp từ 3 vùng nấu 80cm | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - diff --git a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg deleted file mode 100644 index 00785d2f30f80015b49f7306b6905ae7bcf4f555..0000000000000000000000000000000000000000 --- a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4330cd3f85759168321f8aee17377e0e01517e9e153681ea4d30f1fbcf9de5ad -size 588416 diff --git a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html deleted file mode 100644 index a5528aca32cfd8415dcd0366ee5022d7df1ae5c2..0000000000000000000000000000000000000000 --- a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - -MIR9021ES | Malloca | Bếp từ kết hợp hồng ngoại 3 vùng nấu 80cm | V.AI STUDIO - - - - - - - - - - - -Đang chuyển hướng... - diff --git a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg deleted file mode 100644 index 13416062c660cd45779cf20d0a59271e88397875..0000000000000000000000000000000000000000 --- a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f91765d7a2e60183c695ffb7d045738766d11bb5344c7e73546acfc0955a6c1c -size 450717 diff --git a/src/app.js b/src/app.js index 121865d63d32a30a8973a4c39094517b90233345..296eaaae1e939513a8f1d48654508136bab7b427 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,6 @@ import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; import { smartNormalize } from "./viNumberFix.js"; -import { VoiceTyper } from "./voiceTyper.js"; const VOICES = ["Aiden","Ryan","Dylan","Eric","Ono_Anna","Serena","Sohee","Uncle_Fu","Vivian"]; const DEFAULT_VOICE = "Sohee"; @@ -22,13 +21,11 @@ const DEFAULT_INSTRUCTIONS = [ "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).", "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'.", "Keep replies short, natural, warm.", - "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.", + "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, get_current_datetime, search_wikipedia, search_web, set_mood, make_hand_gesture, make_facial_expression.", "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.", - "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.", "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?'", "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.", "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.", - "RELIABILITY RULES (mUST follow 100%): Answer exactly what the user asks, in their language and requested format. When the user asks about news, a campaign, a promotion, a product, or any current event: ALWAYS call the most specific search tool (search_news for Vietnamese news/promos/campaigns, search_web for general/web, query_catalog for products) FIRST and use the returned results to answer. If the first search returns little, RETRY with a second, broader or different keyword (e.g. add the brand name, rename 'chiến dịch' to the brand, try English) before ever saying you couldn't find anything. NEVER reply with a vague 'Em xin lỗi, chưa tìm thấy' / 'không tìm thấy thông tin' refusal. If you genuinely find no exact match, still give a useful answer: summarize the closest info you did find, tell the user what you searched, and offer 2-3 concrete alternatives (e.g. search another keyword, check a specific product, or ask them to clarify). Always deliver a real, substantive answer — never stop at an apology. Follow the user's exact request (quantity, format, detail level, tone).", ].join(" "); const GREETING_INSTRUCTIONS = "You are Gemma. Say exactly: 'Xin chào! Em là Gemma, trợ lý AI thân thiện của Vương V AI STUDIO. Em ở đây để giúp anh chị — trò chuyện, trả lời câu hỏi, xem tin tức, hoặc tìm sản phẩm trong V.AI STUDIO. Rất vui được gặp anh chị!' Then end. No tools."; @@ -42,13 +39,11 @@ const TOOL_DEFS = [ { type:"function", name:"get_current_datetime", description:"Get date/time.", parameters:{type:"object", properties:{}, required:[]}}, { type:"function", name:"search_wikipedia", description:"Search Wikipedia.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, { type:"function", name:"search_web", description:"Search the web.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, - { type:"function", name:"search_news", description:"Search real-time Vietnamese news on a specific topic. Use this when user asks about current events, hot topics, or news.", parameters:{type:"object", properties:{query:{type:"string", description:"News topic or keyword to search for"}}, required:["query"]}}, { type:"function", name:"open_catalog", description:"Open V.AI STUDIO panel.", parameters:{type:"object", properties:{category:{type:"string"}}, required:["category"]}}, { type:"function", name:"search_catalog", description:"Search catalog UI.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}}, { 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"]}}, { 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"]}}, - { 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:[]}}, ]; const $ = s => document.querySelector(s); @@ -60,32 +55,21 @@ const inputDirectUrl = $("#direct-url"), inputSubtitles = $("#subtitles-toggle") const textChat = $("#text-chat"), chatHeader = $("#chat-header"), chatMessages = $("#chat-messages"); const chatInput = $("#chat-input"), chatSendBtn = $("#chat-send-btn"), chatCloseBtn = $("#chat-close-btn"); const chatResizeHandle = $("#chat-resize-handle"), chatAvatarSelect = $("#chat-avatar-select"), settingsAvatarSelect = $("#settings-avatar-select"); -const chatMicBtn = $("#chat-mic-btn"); // ✨ Welcome Modal elements const welcomeModal = $("#welcome-modal"); const welcomeChatBtn = $("#welcome-chat-btn"); const welcomeVoiceBtn = $("#welcome-voice-btn"); -const welcomeQuickBtn = $("#welcome-quick-btn"); -// ✨ Welcome mode tracking: null | 'text' | 'voice' | 'quick' +// ✨ Welcome mode tracking: null | 'text' | 'voice' let welcomeMode = null; -let voiceTyper = null; -let voiceTyperMode = null; // "chat" | "subtitle" const stage = new AvatarStage(stageNode); let client = null, muted = false, subtitleTimer = 0, textMode = false; let config = { lb:false, allowDirect:true }, avatarList = [], sessionInProgress = false, autoGreetingSent = false, preFetchedGreeting = null, isGreetingSession = false; -// Variable để theo dõi context cho gợi ý -var currentContextForSuggestions = null; - function loadSettings() { - // Subtitles default ON: unless the user has explicitly set the toggle to "0", - // subtitles are enabled so the user always sees what they say + what she says. - var subVal = localStorage.getItem(STORAGE_KEYS.subtitles); - var subOn = subVal === null ? true : subVal === "1"; - return { voice:localStorage.getItem(STORAGE_KEYS.voice)||DEFAULT_VOICE, avatar:localStorage.getItem(STORAGE_KEYS.avatar)||"vuong.glb", instructions:localStorage.getItem(STORAGE_KEYS.instructions)||"", directUrl:localStorage.getItem(STORAGE_KEYS.directUrl)||"", subtitles:subOn }; + return { voice:localStorage.getItem(STORAGE_KEYS.voice)||DEFAULT_VOICE, avatar:localStorage.getItem(STORAGE_KEYS.avatar)||"vuong.glb", instructions:localStorage.getItem(STORAGE_KEYS.instructions)||"", directUrl:localStorage.getItem(STORAGE_KEYS.directUrl)||"", subtitles:localStorage.getItem(STORAGE_KEYS.subtitles)==="1" }; } let settings = loadSettings(); function saveSettings() { for(const[k,v]of Object.entries(settings)) localStorage.setItem(STORAGE_KEYS[k],String(v)); } @@ -98,6 +82,7 @@ function effectiveInstructions(newsHook) { // ✨ Welcome modal show/hide function showWelcome() { welcomeModal.classList.add("show"); + // Hide the main app UI while welcome is showing document.getElementById("topbar").style.display = "none"; document.getElementById("controls").style.display = "none"; document.getElementById("subtitles").style.display = "none"; @@ -121,42 +106,21 @@ function populateAvatarSelects(sn){ } } function setAvatarFromSelect(v){settings.avatar=v||"";saveSettings()} -async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await Promise.race([stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}}),new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),15000))])}catch(e){console.error(e)}loading.classList.add("done")} - -// ✨ Ensure the avatar is initialized before starting a session. Called AFTER -// the user picks a character in the welcome modal, so the loading % bar only -// appears once a character is chosen (matches the new startup flow). -let __avatarReadyPromise = null; -function ensureAvatarReady(){ - if(__avatarReadyPromise) return __avatarReadyPromise; - __avatarReadyPromise=(async()=>{ - if(stage.head){ loading.classList.add("done"); return; } - loading.classList.remove("done"); - try{ - await Promise.race([ - stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading avatar ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}}), - new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),45000)), - ]); - }catch(e){ console.error("[AvatarStage] init failed:", e?.message||e); } - loading.classList.add("done"); - if(stage.resume) try{ await stage.resume(); }catch(_){} - })(); - return __avatarReadyPromise; -} +async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}})}catch(e){console.error(e)}loading.classList.add("done")} function clampRect(){const vw=innerWidth,vh=innerHeight,r=textChat.getBoundingClientRect();let l=r.left,t=r.top;l=Math.max(10,Math.min(l,vw-r.width-10));t=Math.max(10,Math.min(t,vh-r.height-10));textChat.style.left=l+"px";textChat.style.top=t+"px"} function makeDraggable(){ let d=false,sx,sy,sl,st; function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} - function os(e){if(e.target.closest("#chat-header-actions,#chat-avatar-select"))return;const p=gp(e);d=true;const r=textChat.getBoundingClientRect();sx=p.x;sy=p.y;sl=r.left;st=r.top;textChat.classList.add("dragging");e.preventDefault} - function om(e){if(!d)return;const p=gp(e);textChat.style.left=(sl+p.x-sx)+"px";textChat.style.top=(st+p.y-sy)+"px";textChat.style.right="auto";textChat.style.bottom="auto";e.preventDefault} + function os(e){if(e.target.closest("#chat-header-actions,#chat-avatar-select"))return;const p=gp(e);d=true;const r=textChat.getBoundingClientRect();sx=p.x;sy=p.y;sl=r.left;st=r.top;textChat.classList.add("dragging");e.preventDefault()} + function om(e){if(!d)return;const p=gp(e);textChat.style.left=(sl+p.x-sx)+"px";textChat.style.top=(st+p.y-sy)+"px";textChat.style.right="auto";textChat.style.bottom="auto";e.preventDefault()} function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()} chatHeader.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatHeader.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); } function makeResizable(){ let r=false,sx,sy,sw,sh; function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} - function os(e){r=true;const rc=textChat.getBoundingClientRect(),p=gp(e);sx=p.x;sy=p.y;sw=rc.width;sh=rc.height;textChat.classList.add("resizing");e.preventDefault;e.stopPropagation} - function om(e){if(!r)return;const p=gp(e);textChat.style.width=Math.max(260,sw+p.x-sx)+"px";textChat.style.height=Math.max(120,sh+p.y-sy)+"px";e.preventDefault} + function os(e){r=true;const rc=textChat.getBoundingClientRect(),p=gp(e);sx=p.x;sy=p.y;sw=rc.width;sh=rc.height;textChat.classList.add("resizing");e.preventDefault();e.stopPropagation()} + function om(e){if(!r)return;const p=gp(e);textChat.style.width=Math.max(260,sw+p.x-sx)+"px";textChat.style.height=Math.max(120,sh+p.y-sy)+"px";e.preventDefault()} function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")} chatResizeHandle.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatResizeHandle.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); } @@ -164,1338 +128,84 @@ function setCaption(t,k=""){caption.textContent=t;caption.className=k} function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")} function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)} -// ═══════════════════════════════════════════════════════════ -// POST-MESSAGE CONTEXT (suggestions + hot tags + source cards) -// ═══════════════════════════════════════════════════════════ - -// ── Dynamic HOT topics extracted from Google News RSS in real-time ── -// No fixed topics — every fetch pulls fresh keywords from actual news headlines -let HOT_TOPIC_KEYWORDS = []; // Populated dynamically by fetchHotTags - -// ── Single-owner news rendering state ── -// The greeting shows ONE consolidated HOT list (no duplicates) and every follow-up -// answer shows a DIFFERENT news list that follows the conversation context. -let _shownNewsUrls = new Set(); // URLs already shown, so consecutive lists differ -let _hotTopicCursor = 0; // round-robins through HOT topics for variety - -// News list visibility: the HOT-tags + source-card news block should ONLY appear -// in the greeting ("câu chào") and when the user actually asks about news. From -// the 2nd non-greeting answer onward, if the user is NOT asking about news, we -// suppress the news list so it doesn't spam every reply. It reappears the moment -// the user asks about news (tin tức / news / hot / thời sự ...). -let _newsContextEnabled = false; // start DISABLED: from the 2nd answer onward - // (non-greeting) no news block until the user - // asks about news; the greeting always shows - // it via isGreeting. - -// Detect whether the user's message is asking about news / current events. -function _isNewsRequest(text) { - const t = String(text || "").toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d"); - // Core news intent keywords (whole-word-ish, safe substrings). - const newsKws = ["tin tuc", "tin", " news", "hot", "thoi su", "ban tin", "tin nong", - "cap nhat tin", "tin moi", "tin moi nhat", "tin nong nhat", "thoi cuoc", "su kien"]; - for (const kw of newsKws) { - const k = kw.trim(); - // "tin" / "hot" as bare substrings are too noisy, so require either a longer - // keyword OR a word boundary for the short ones. - if (kw !== "tin " && kw !== " hot") { - if (t.includes(k)) return true; - } else { - if (new RegExp("(^|[^a-z])" + k + "([^a-z]|$)").test(t)) return true; - } - } - return false; -} -// Call this whenever a user message is processed to update news-list visibility. -function _noteUserMessage(text) { - _newsContextEnabled = _isNewsRequest(text); -} - - -function _cardKey(c) { - return (c && ((c.url && String(c.url)) || (c.title && String(c.title)))) || ''; -} -// Return only cards whose URL/title was NOT already shown (keeps lists distinct). -function _filterShown(cards) { - const out = []; - for (const c of (cards || [])) { - if (!c) continue; - const key = _cardKey(c); - if (key && _shownNewsUrls.has(key)) continue; - out.push(c); - } - return out; -} -// Mark a set of displayed cards as shown so later lists avoid repeating them. -function _markShown(cards) { - for (const c of (cards || [])) { - const key = _cardKey(c); - if (key) _shownNewsUrls.add(key); - } - return cards; -} -// Cap the seen-set size so it never grows unbounded. -function _trimShown() { - if (_shownNewsUrls.size > 600) { - const arr = Array.from(_shownNewsUrls).slice(-400); - _shownNewsUrls = new Set(arr); - } -} -// Filter out already-shown cards, mark the survivors as shown, and trim the set. -function _finalizeCards(cards) { - const fresh = _filterShown(cards); - _markShown(fresh); - _trimShown(); - return fresh; -} - -/** - * Pick a news search query that follows the CONTEXT of the given response text: - * 1) if the response mentions a HOT keyword → use its topical query (e.g. AI → AI news); - * 2) else extract a clean topical phrase straight from the response; - * 3) else rotate to the next HOT topic so consecutive lists still differ. - */ -function _contextQuery(responseText) { - const txt = String(responseText || ''); - // 1) Dynamic HOT keyword match → context-relative news (e.g. talking about AI). - const matched = findBestTopicMatch(txt); - if (matched && matched.query) return matched.query; - // 2) Extract a meaningful topical phrase from the response text itself. - const clean = txt - .replace(/\([^)]*\)/g, ' ') // drop parenthetical content like (AI) - .replace(/[.,:;"'“”‘’!?]/g, ' ') - .replace(/^(em|anh chị|anh chi|anhh chị|anhh chi|dạ|vâng|xin chào|chào|bạn|tôi)\b/gi, ' ') - .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, ' ') - .replace(/\s+/g, ' ').trim(); - const words = []; - for (const w of clean.split(' ')) { - if (!w || w.length < 4 || /^\d+$/.test(w)) continue; - const n = w.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/g, 'd'); - 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; - words.push(w); - if (words.length >= 3) break; - } - if (words.length > 0) return words.slice(0, 2).join(' ') + ' hôm nay'; - // 3) Rotate to the next HOT topic. - const kw = HOT_TOPIC_KEYWORDS || []; - if (kw.length > 0) { - const q = kw[_hotTopicCursor % kw.length].query; - _hotTopicCursor++; - return q; - } - return 'tin tức mới nhất hôm nay'; -} - -/** - * Extract trending keywords from Google News RSS titles. - * Returns array of {label, query, keyword} objects — fully dynamic, changes every fetch. - */ -/** - * Extract trending keywords from Google News RSS titles. - * Labels are derived from the actual RSS titles - fully dynamic. - * Returns array of {label, query, keyword} objects. - */ -function extractTrendingKeywords(titles) { - // Category patterns: each maps to a label generator that creates - // dynamic labels based on the actual RSS title content - const categoryPatterns = [ - // AI / Tech models - extract specific model name from title - { - regex: /\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i, - labelFromTitle: function(title) { - const m = title.match(/\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i); - if (!m) return null; - const kw = m[1].replace(/\s+/g, ''); - return '#' + kw.charAt(0).toUpperCase() + kw.slice(1); - }, - queryFromLabel: function(label) { - return label + ' tin tức mới nhất hôm nay'; - } - }, - // Finance - extract specific topic - { - regex: /\b(chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán|gold|vàng|giá\s+vàng)\b/i, - labelFromTitle: function(title) { - if (/chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán/i.test(title)) - return '#ChứngKhoán'; - if (/gold|vàng|giá\s+vàng/i.test(title)) - return '#GiáVàng'; - return null; - }, - queryFromLabel: function(label) { - return label + ' Việt Nam hôm nay'; - } - }, - // Sports - extract specific event/team - { - regex: /\b(bóng\s+đá|football|premier\s+league|world\s+cup|chuyển\s+nhượng|transfer|tuyển\s+việt\s+nam|u23|asian\s+cup|đội\s+tuyển|fifa|world\s+cup)\b/i, - labelFromTitle: function(title) { - if (/fifa/i.test(title)) return '#Fifa'; - if (/tuyển\s+việt\s+nam|đội\s+tuyển\s+việt\s+nam/i.test(title)) return '#ĐộiTuyểnViệtNam'; - if (/chuyển\s+nhượng|transfer/i.test(title)) return '#ChuyểnNhượng'; - if (/asian\s+cup|u23/i.test(title)) return '#U23'; - if (/world\s+cup/i.test(title)) return '#WorldCup'; - if (/premier\s+league/i.test(title)) return '#PremierLeague'; - return '#BóngĐá'; - }, - queryFromLabel: function(label) { - return label + ' tin tức mới nhất'; - } - }, - // Mobile / Tech - extract specific device - { - regex: /\b(iphone|apple|galaxy|smartphone|pixel|xiaomi|oppo|vivo|samsong)\b/i, - labelFromTitle: function(title) { - const m = title.match(/\b(iphone|galaxy|pixel|xiaomi|oppo|vivo)\b/i); - if (m) return '#' + m[1].charAt(0).toUpperCase() + m[1].slice(1); - return '#iPhone'; - }, - queryFromLabel: function(label) { - return label + ' thông tin mới nhất 2026'; - } - }, - // Entertainment - extract specific content - { - regex: /\b(phim|movie|netflix|disney|box\s+office|phim\s+hay|phim\s+mới)\b/i, - labelFromTitle: function(title) { - if (/netflix/i.test(title)) return '#Netflix'; - if (/disney/i.test(title)) return '#Disney'; - if (/phim\s+mới|phim\s+hay/i.test(title)) return '#PhimMới'; - return '#Phim'; - }, - queryFromLabel: function(label) { - return label + ' hay nhất 2026'; - } - }, - // Weather - { - regex: /\b(bão|thời\s+tiết|weather|nắng\s+nóng|lũ\s+lụt|mưa\s+lớn|bão\s+số)\b/i, - labelFromTitle: function(title) { - if (/bão\s+số/i.test(title)) return '#BãoSố'; - if (/nắng\s+nóng/i.test(title)) return '#NắngNóng'; - if (/lũ\s+lụt|ngập\s+úng/i.test(title)) return '#LũLụt'; - return '#ThờiTiết'; - }, - queryFromLabel: function(label) { - return label + ' Việt Nam hôm nay'; - } - }, - // Economy - { - regex: /\b(nông\s+sản|giá\s+điện|inflation|gdp|kinh\s+tế)\b/i, - labelFromTitle: function(title) { - if (/nông\s+sản/i.test(title)) return '#NôngSản'; - if (/giá\s+điện/i.test(title)) return '#GiáĐiện'; - return '#KinhTế'; - }, - queryFromLabel: function(label) { - return label + ' Việt Nam tin tức mới nhất'; - } - }, - // Politics - { - regex: /\b(chính\s+trị|đảng|chính\s+phủ|pháp\s+luật|thời\s+sự)\b/i, - labelFromTitle: function(title) { - if (/thời\s+sự/i.test(title)) return '#ThờiSự'; - return '#ChínhTrị'; - }, - queryFromLabel: function(label) { - return label + ' nóng nhất hôm nay'; - } - }, - // Education - { - regex: /\b(giáo\s+dục|đại\s+học|tuyển\s+sinh|học\s+sinh|sinh\s+viên)\b/i, - labelFromTitle: function(title) { - if (/tuyển\s+sinh/i.test(title)) return '#TuyểnSinh'; - if (/đại\s+học/i.test(title)) return '#ĐạiHọc'; - return '#GiáoDục'; - }, - queryFromLabel: function(label) { - return label + ' mới nhất hôm nay'; - } - }, - // Health - { - regex: /\b(sức\s+khoẻ|bệnh|y\s+tế|epidemic|dịch|vaccine)\b/i, - labelFromTitle: function(title) { - if (/vaccine/i.test(title)) return '#Vaccine'; - if (/dịch|epidemic/i.test(title)) return '#DịchBệnh'; - return '#SứcKhỏe'; - }, - queryFromLabel: function(label) { - return label + ' nóng nhất hôm nay'; - } - }, - ]; - - const matched = []; - const seenLabels = new Set(); - - for (const title of titles) { - for (const cat of categoryPatterns) { - if (cat.regex.test(title)) { - const label = cat.labelFromTitle(title); - if (!label || seenLabels.has(label)) continue; - seenLabels.add(label); - matched.push({ - label: label, - query: cat.queryFromLabel(label), - keyword: cat.regex.source, - }); - if (matched.length >= 10) return matched; - break; // Only match one category per title - } - } - } - - return matched; -} - - -let currentHotTags = []; -let hotTagsLastFetch = 0; -const HOT_TAGS_TTL = 60 * 60 * 1000; // 1 hour cache - -async function fetchHotTags() { - const now = Date.now(); - if (currentHotTags.length > 0 && (now - hotTagsLastFetch) < HOT_TAGS_TTL) { - return currentHotTags; - } - - const realTitles = []; - - // ── Step 1: Fetch real-time news titles via the SERVER → /api/news/hot ── - // (Google News RSS does NOT send CORS headers, so fetching it directly from the - // browser always fails and yields an empty HOT list. The server proxies it.) - try { - const resp = await fetch('/api/news/hot'); - if (resp.ok) { - const data = await resp.json(); - if (Array.isArray(data.titles)) { - for (const t of data.titles) { - const c = t.replace(/^[\d.]+[\s:]*/, '').trim(); - if (c) realTitles.push(c); - if (realTitles.length >= 15) break; - } - } - } else { - console.warn('[HOT] /api/news/hot returned', resp.status); - } - } catch (e) { - console.warn('[HOT] /api/news/hot fetch failed:', e.message); - } - - // ── Step 2: Extract trending keywords dynamically from real news titles ── - HOT_TOPIC_KEYWORDS = extractTrendingKeywords(realTitles); - - // ── Step 3: Build hot tags from extracted keywords ── - const tags = HOT_TOPIC_KEYWORDS.map(kw => ({ - label: kw.label, - query: kw.query, - keyword: kw.keyword, - })); - - // If we have fewer than 10 tags, pad with topic keywords extracted from the - // headline titles so the HOT list always has at least 10 entries. These are - // created only from REAL trending titles (never hard-coded), so they stay HOT-relevant. - if (tags.length < 10 && realTitles.length > 0) { - for (const t of realTitles) { - if (tags.length >= 10) break; - let short = t.replace(/^[\d.]+[\s:]*/, '').trim(); - // Drop trailing source "- SourceName" - short = short.replace(/\s*-\s*[^-\s]+(?:\s+[^-\s]+)?\s*$/, '').trim(); - if (short.length < 6) continue; - // Pick 2-3 meaningful words (skip numbers & common connectors) to form a compact tag - const stopwords = ['của', 'và', 'với', 'cho', 'các', 'trong', 'một', 'những', 'đã', 'sẽ', 'đang', 'có', 'tại', 'theo', 'về', 'ngày', 'hôm']; - const words = short.split(/\s+/).filter(w => w.length > 2 && !/^[\d.,]+$/.test(w) && !stopwords.includes(w.toLowerCase().replace(/[.,]/g,'').toLowerCase())); - const picked = words.slice(0, 3); - if (picked.length === 0) continue; - const label = '#' + picked.join('').replace(/[^\wÀ-ỹà-ỹ]/gi, '').slice(0, 20); - if (label.length > 3 && !tags.find(t => t.label === label)) { - tags.push({ - label, - query: picked.length > 2 ? picked.slice(0, 2).join(' ') + ' hôm nay' : picked[0] + ' hôm nay', - keyword: picked.slice(0, 2).join(' '), - }); - } - } - } - - // Absolute last-resort safety net (should never trigger, but keeps UI non-empty) - if (tags.length === 0) { - tags.push({ label: '#TinTức', query: 'tin tức mới nhất hôm nay', keyword: 'tin tức' }); - tags.push({ label: '#ThờiSự', query: 'thời sự nóng nhất hôm nay', keyword: 'thời sự' }); - } - - currentHotTags = tags.slice(0, 10); - hotTagsLastFetch = now; - return currentHotTags; -} - -/** Match response text keywords to find the most relevant dynamic topic */ -function findBestTopicMatch(responseText) { - const lower = (responseText || "").toLowerCase(); - let bestScore = 0; - let bestMatch = null; - - for (const kw of HOT_TOPIC_KEYWORDS) { - if (lower.includes(kw.keyword.toLowerCase())) { - const score = kw.keyword.length; - if (score > bestScore) { - bestScore = score; - bestMatch = kw; - } - } - } - - return bestMatch; -} - -/** Generate source cards matching the SPECIFIC topic of the response. - * FIX: use VnExpress RSS items (title+image+url+desc are ONE unit) as the - * PRIMARY source, so every card's image ALWAYS matches its news content. - */ -async function generateSourceCards(responseText) { - const query = _contextQuery(responseText); - // Prefer a clean topical keyword that Google News can search on - 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; - - // ── PRIMARY: VnExpress RSS items carry title+image+url+desc together → no mismatch ── - let cards = []; - const matchedTopic = findBestTopicMatch(responseText); - const catLabel = matchedTopic ? matchedTopic.label : 'mới nhất'; - try { - const resp = await fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`); - if (resp.ok) { - const data = await resp.json(); - const imgs = (data.images || []); - if (imgs.length > 0) { - // NEW: extend each card so "Hỏi AI" knows the exact article title too - cards = imgs.slice(0, 6).map(a => ({ - source: a.source || 'VnExpress', - title: a.title, - url: a.url, - icon: '📰', - category: catLabel, - desc: cleanText(a.desc || ''), - image: a.image || '', - articleTitle: a.title || '', - })); - } - } else { - console.warn('[SourceCards] /api/news/images returned', resp.status); - } - } catch (e) { - console.warn('[SourceCards] images fetch failed:', e.message); - } - - // ── SECONDARY (no VnExpress items matched): Google News article search, then - // match a VnExpress image to a card by content (title keyword overlap), - // NEVER by array index — this eliminates the image/content mismatch. ── - if (cards.length === 0) { - let imageMap = []; - const imagesPromise = fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`) - .then(r => r.ok ? r.json() : { images: [] }) - .then(d => { imageMap = d.images || []; }) - .catch(() => {}); - try { - const resp = await fetch(`/api/news/articles?q=${encodeURIComponent(searchQuery)}`); - if (resp.ok) { - const data = await resp.json(); - const arts = (data.articles || []).slice(0, 6); - if (arts.length > 0) { - cards = arts.map((a) => ({ - source: a.source || 'Nguồn tin', - title: a.title, - url: a.url, - icon: '📰', - category: catLabel, - desc: cleanText(a.desc || ''), - image: '', - articleTitle: a.title || '', - })); - } - } - } catch (e) {} - await imagesPromise; - // Content-based image matching (not index-based) - if (imageMap.length > 0) { - const norm = (t) => String(t||'').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[đĐ]/g,'d'); - cards = cards.map(c => { - if (c.image) return c; - const cn = norm(c.title); - // find the VnExpress item whose title overlaps most with this card title - let best = '', bestScore = 0; - for (const m of imageMap) { - const mn = norm(m.title); - let score = 0; - const words = cn.split(/[^a-z0-9]+/).filter(w => w.length > 3); - for (const w of words) if (mn.includes(w)) score++; - if (score > bestScore) { bestScore = score; best = m.image; } - } - if (best) return { ...c, image: best }; - // last resort: reuse this VnExpress item's full card (image+title+url match by construction) - const mi = imageMap.find(m => norm(m.title).includes(cn.slice(0, 20)) || cn.includes(norm(m.title).slice(0, 20))); - if (mi) return { ...c, title: mi.title, url: mi.url, image: mi.image, desc: c.desc || cleanText(mi.desc||'') }; - return c; - }); - } - } - - if (cards.length > 0) return _finalizeCards(cards); - - // Fallback #2: topic search (category landing pages) — no index-based images - try { - const resp = await fetch(`/api/news/topic?q=${encodeURIComponent(searchQuery)}`); - if (resp.ok) { - const data = await resp.json(); - const results = (data.results || []).slice(0, 6); - if (results.length > 0) { - return _finalizeCards(results.map(r => ({ - source: r.source || 'Nguồn tin', - title: r.title, - url: r.url, - icon: '📰', - category: catLabel, - desc: r.snippet || '', - image: '', - }))); - } - } - } catch (e) { - console.warn('[SourceCards] topic fetch failed:', e.message); - } - - // Fallback: general news portals - return _finalizeCards([ - { 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: '' }, - { 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: '' }, - { 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: '' }, - ]); -} - -/** Create DOM element for suggestion pill - black bg like chatbox */ -function createSuggestionPill(text, icon) { - const btn = document.createElement("button"); - btn.className = "chat-suggestion-pill"; - btn.style.background = "#1a1a2e"; - btn.style.color = "#e0e0e0"; - btn.style.border = "1px solid #333"; - btn.style.borderRadius = "16px"; - btn.style.padding = "6px 14px"; - btn.style.fontSize = "13px"; - btn.style.cursor = "pointer"; - btn.style.transition = "background 0.2s"; - btn.innerHTML = `${icon || '💡'}${text}`; - btn.addEventListener("click", function(e) { - e.stopPropagation(); - sendUserSuggestion(text); - }); - btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); - btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); - return btn; -} - -/** Create DOM element for hot tag — click triggers real-time news search */ -function createHotTag(tagObj) { - const btn = document.createElement("button"); - btn.className = "hot-tag"; - btn.textContent = tagObj.label; - btn.title = tagObj.query; - btn.style.background = "#1a1a2e"; - btn.style.color = "#e0e0e0"; - btn.style.border = "1px solid #333"; - btn.style.borderRadius = "16px"; - btn.style.padding = "6px 14px"; - btn.style.fontSize = "13px"; - btn.style.cursor = "pointer"; - btn.style.transition = "background 0.2s"; - btn.addEventListener("click", async function(e) { - e.stopPropagation(); - // Show loading state immediately - const origLabel = btn.textContent; - btn.textContent = '⏳ ' + tagObj.label.replace('#', ''); - btn.disabled = true; - // Send the query — the assistant will use search_news tool to fetch real articles - sendUserSuggestion(tagObj.query); - // Also render concrete news cards with real links right here in the chat - try { - const sources = await generateSourceCards(tagObj.query); - if (sources.length && chatMessages) { - const section = document.createElement("div"); - section.className = "news-section"; - const h = document.createElement("div"); - h.className = "news-section-title"; - h.innerHTML = ' Tin về ' + escHtml(tagObj.label) + ':'; - const row = document.createElement("div"); - row.className = "source-cards-row"; - sources.forEach(s => row.appendChild(createSourceCard(s))); - section.appendChild(h); - section.appendChild(row); - chatMessages.appendChild(section); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - } catch (_) {} - btn.textContent = origLabel; - btn.disabled = false; - }); - btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); - btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); - return btn; -} - -function escHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); -} - -/** Strip HTML tags & entities from a text string (for clean card descriptions) */ -function cleanText(s) { - return String(s == null ? '' : s) - .replace(/<[^>]*>/g, ' ') - .replace(/\bhttps?:\/\/\S+/gi, '') - .replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'") - .replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ') - .replace(/\s+/g, ' ').trim(); -} - -/** Build context-aware follow-up questions for a SPECIFIC news article so the - * suggested questions are always relevant to the news at hand (ngữ cảnh). */ -function buildNewsSuggestions(sourceObj) { - const title = (sourceObj && sourceObj.articleTitle) || (sourceObj && sourceObj.title) || ''; - if (!title) return ["Cho tôi xem tin tức mới nhất", "Tin nóng hôm nay là gì?", "Có tin gì thú vị không?"]; - // Extract the core subject for natural, contextual questions - const short = String(title).replace(/\s*-\s*[^\-]{2,}$/, '').replace(/^\d+[.:\s]*/, '').trim(); - const norm = String(short).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); - const subj = short.length > 60 ? short.slice(0, 57) + '…' : short; - return [ - 'Kể thêm về "' + subj + '"', - 'Tin này nói gì? Cho em biết chi tiết hơn với', - 'Có gì mới nhất về "' + subj + '" gần đây không?', - 'Em tóm gọn giúp anh/chị tin "' + subj + '"' - ]; -} - -/** Create DOM element for source news card. - * FIX: "Hỏi AI về tin này" sends the EXACT article title (+ fetches article content - * if possible) so the AI answers about the CORRECT story, and each card now appends - * its own suggested questions specific to that news item. */ -function createSourceCard(sourceObj) { - const card = document.createElement("div"); - card.className = "source-card"; - if (sourceObj.url) { - card.addEventListener("click", function(e) { - e.stopPropagation(); - window.open(sourceObj.url, '_blank'); - }); - card.style.cursor = "pointer"; - } - let sourceName = sourceObj.source || ''; - if (!sourceName && sourceObj.url) { - try { sourceName = new URL(sourceObj.url).hostname.replace(/^www\./, ''); } catch (_) {} - } - if (!sourceName) sourceName = 'Nguồn tin'; - const title = sourceObj.title || ''; - const articleTitle = sourceObj.articleTitle || title; - const imgHtml = sourceObj.image - ? `
` - : ''; - const newsQs = buildNewsSuggestions(sourceObj); - const qPills = newsQs.map(q => - `` - ).join(''); - card.innerHTML = ` - ${imgHtml} - ${sourceObj.icon || '📰'} ${escHtml(sourceName)} - ${escHtml(title)} - ${sourceObj.desc ? '' + escHtml(sourceObj.desc) + '' : ''} - -
${qPills}
- `; - const askBtn = card.querySelector(".source-card-askai"); - if (askBtn) { - askBtn.addEventListener("click", function(e) { - e.stopPropagation(); - const artTitle = (this.getAttribute("data-title") || '').trim(); - const artUrl = (this.getAttribute("data-url") || '').trim(); - if (artTitle) { - // Ask AI about the EXACT article — prefer fetching its real content - 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é.'); - } else { - sendUserSuggestion("Cho tôi xem tin tức mới nhất"); - } - }); - } - const qPillsEls = card.querySelectorAll(".news-q-pill"); - qPillsEls.forEach(function(btn) { - btn.addEventListener("click", function(e) { - e.stopPropagation(); - sendUserSuggestion(this.getAttribute("data-q")); - }); - }); - return card; -} - -/** Build complete post-message context container using DOM elements */ -function buildPostMessageContextBlock(responseText, isGreeting) { - const container = document.createElement("div"); - container.className = "post-message-context"; - - // Don't show suggestions after product results - if (!isGreeting && responseText && (responseText.includes('Sản phẩm phù hợp') || responseText.includes('Tìm thấy ') || responseText.includes('Đã mở thông tin'))) { - return container; // Return empty container (won't be appended due to check in addChatMessage) - } - - // 1) Suggestions row - if (!isGreeting && responseText) { - const suggestionsRow = document.createElement("div"); - suggestionsRow.className = "suggestions-row"; - const questions = generateSuggestionsFromContext(responseText); - questions.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '💡'))); - container.appendChild(suggestionsRow); - } else if (isGreeting) { - const suggestionsRow = document.createElement("div"); - suggestionsRow.className = "suggestions-row"; - // Diverse, catalog-derived greeting suggestions (mix of product categories, - // NOT a fixed khóa-cửa default). Refreshes randomly each greeting. - const greetQs = getGreetingSuggestions(); - greetQs.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '🛒'))); - container.appendChild(suggestionsRow); - } - - // 2) HOT trending tags row — only when it's the greeting OR the user asked - // about news. From the 2nd non-greeting answer onward (when the user is NOT - // asking about news) this news block is suppressed. - if (isGreeting || _newsContextEnabled) { - const tagsPromise = fetchHotTags(); - const hotTagsRow = document.createElement("div"); - hotTagsRow.className = "hot-tags-row"; - - const label = document.createElement("span"); - label.className = "hot-tags-label"; - label.innerHTML = ' HOT'; - hotTagsRow.appendChild(label); - - tagsPromise.then(tags => { - hotTagsRow.innerHTML = ''; - hotTagsRow.appendChild(label); - tags.forEach(tag => hotTagsRow.appendChild(createHotTag(tag))); - }).catch(err => { - console.warn('[HOT] Failed to update tags:', err); - }); - container.appendChild(hotTagsRow); - - // 3) Source news cards (only greeting or when user asked about news, for - // lengthy responses). - if ((isGreeting || _newsContextEnabled) && responseText && responseText.length > 30) { - generateSourceCards(responseText).then(sources => { - const sourceCardsRow = document.createElement("div"); - sourceCardsRow.className = "source-cards-row"; - sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src))); - container.appendChild(sourceCardsRow); - }); - } - } - - return container; -} - -// ── Enhanced addChatMessage with product card + post-response context support ── +// ── Enhanced addChatMessage with product card support ── let _pendingProductCards = null; -let _pendingSourceCards = null; -let _isGreetingMessage = false; -function addChatMessage(role, text, isGreeting) { - _isGreetingMessage = !!isGreeting; - const m = document.createElement("div"); - m.className = "chat-message " + role; - if (role === "assistant" && text) { - const mt = document.createElement("div"); - mt.className = "chat-msg-text"; - mt.textContent = text; - m.appendChild(mt); - - // "Đọc to" (read aloud) button — lets the user hear THIS message even when - // silent mode is on (explicit user intent). - const readBtn = document.createElement("button"); - readBtn.type = "button"; - readBtn.className = "chat-read-btn"; - readBtn.title = "Đọc to tin nhắn này"; - readBtn.setAttribute("aria-label", "Đọc to tin nhắn"); - readBtn.innerHTML = ''; - readBtn.addEventListener("click", function(e){ - e.stopPropagation(); - speakEdgeTts(text, true); - }); - m.appendChild(readBtn); - - // Product cards - if (_pendingProductCards) { - const pc = document.createElement("div"); - pc.innerHTML = _pendingProductCards; - m.appendChild(pc); - if (window.vaix) window.vaix.attachChatCardHandlers(pc); - _pendingProductCards = null; - } - - // Post-message context: suggestions + hot tags + source cards - const ctxEl = buildPostMessageContextBlock(text, isGreeting); - if (ctxEl && ctxEl.style.display !== "none") { - m.appendChild(ctxEl); - } - - _pendingSourceCards = null; +function addChatMessage(r,t){ + const m=document.createElement("div");m.className="chat-message "+r; + if(r==="assistant" && _pendingProductCards && t){ + const mt=document.createElement("div");mt.textContent=t;m.appendChild(mt); + const pc=document.createElement("div");pc.innerHTML=_pendingProductCards;m.appendChild(pc); + if(window.vaix) window.vaix.attachChatCardHandlers(pc); + _pendingProductCards = null; } else { - m.textContent = text; + m.textContent=t; } - chatMessages.appendChild(m); - chatMessages.scrollTop = chatMessages.scrollHeight; - setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); + chatMessages.appendChild(m);chatMessages.scrollTop=chatMessages.scrollHeight; + setTimeout(()=>{chatMessages.scrollTop=chatMessages.scrollHeight},50); } -function setPendingProductCards(html) { _pendingProductCards = html; } -function setPendingSourceCards(list) { _pendingSourceCards = list; } - -// ── COMBO SUGGESTION CARDS ── -let _comboShownThisSession = false; -function showComboOnEntry(opts) { - try { - if (!window.vaix || !window.vaix.isLoaded || !window.vaix.isLoaded()) return; - if (_comboShownThisSession) return; - _comboShownThisSession = true; - const preferredBrand = (opts && opts.brand) || "Malloca"; - const combo = window.vaix.getRandomCombo(preferredBrand); - if (!combo || !combo.items || !combo.items.length) return; +function setPendingProductCards(html){_pendingProductCards=html;} - const m = document.createElement("div"); - m.className = "chat-message assistant"; - const intro = document.createElement("div"); - intro.textContent = "🛒 Gợi ý combo gian bếp " + (combo.brand ? "thương hiệu " + combo.brand : "") + " cho bạn:"; - m.appendChild(intro); - - const html = window.vaix.renderComboCardsBlock(combo); - const wrap = document.createElement("div"); - wrap.innerHTML = html; - m.appendChild(wrap); - if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(wrap); - - const randomBtn = wrap.querySelector(".chat-combo-random"); - if (randomBtn) { - randomBtn.addEventListener("click", function(e) { - e.stopPropagation(); - const fresh = window.vaix.getRandomCombo(preferredBrand); - if (!fresh) return; - const freshEl = document.createElement("div"); - freshEl.innerHTML = window.vaix.renderComboCardsBlock(fresh); - if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(freshEl); - const oldCards = m.querySelector(".chat-combo-section"); - if (oldCards) oldCards.replaceWith(freshEl.querySelector(".chat-combo-section")); - chatMessages.scrollTop = chatMessages.scrollHeight; - }); - } - - chatMessages.appendChild(m); - chatMessages.scrollTop = chatMessages.scrollHeight; - setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); - } catch (e) { - console.warn('[Combo] error:', e.message); - } -} - -// Poll until products are loaded, then show the random malloca combo once per session entry. -function startComboPoll() { - if (typeof showComboOnEntry !== "function") return; - let _i = 0; - const _iv = setInterval(function() { - _i++; - if (_i > 30) { clearInterval(_iv); return; } - showComboOnEntry(); - if (window.vaix && window.vaix.isLoaded && window.vaix.isLoaded() && _comboShownThisSession) clearInterval(_iv); - }, 350); -} - -function showTextChat(s){ - textChat.hidden=!s; - if(chatMicBtn) chatMicBtn.hidden=!s; - if(s)chatInput.focus(); - if(!s && voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } -} +function showTextChat(s){textChat.hidden=!s;if(s)chatInput.focus()} 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} -// Store callback for suggested question sends → dùng google/gemma-4-31B-it -window._triggerSuggestionSend = function(text) { - if (typeof text !== "string" || !text) return; - openTextChatUI(); - setCaption("GEMMA ĐANG VIẾT…", "live"); - sendTextMessage(text); -}; - -// Send a user question through the text chat. Guarantees the product card for a -// mentioned product code is attached to the reply (buildSuggestionCards matches -// the message against the catalog, incl. mã/model). -window.sendAsUserQuestion = function(text) { - if (typeof text !== "string" || !text) return; - openTextChatUI(); - setCaption("GEMMA ĐANG VIẾT…", "live"); - sendTextMessage(text); -}; - -// ── Text chat dùng model google/gemma-4-31B-it qua REST /api/chat ── -// (không phụ thuộc session S2S voice bị lỗi connecting) -let chatHistory = []; -let chatBusy = false; -// Retain the last product/combo search context across text-chat turns so a -// follow-up ("sp kia", "combo đó giảm giá được không") keeps showing the right -// combo/product cards and stays on the catalog context, instead of falling back -// to an empty/wrong card list or pushing the model to answer off-data. -let _chatProductContext = null; // { products: [], combo: null, query: "" } -// Track whether the first assistant message in the current text-chat session has -// been shown — the very first reply is the "câu chào" (greeting) that carries the -// HOT keyword list + HOT news. Only hidden once the user stops asking about news. -let _chatGreetingShown = false; -// Clean AI text for natural display: remove markdown ** _ ` and bullet tokens. -function cleanAiText(text) { - if (!text) return ""; - return String(text) - .replace(/\*\*([^*]+)\*\*/g, "$1") // **bold** → text - .replace(/\*([^*]+)\*/g, "$1") // *italic* → text - .replace(/__([^_]+)__/g, "$1") // __bold__ → text - .replace(/`([^`]+)`/g, "$1") // `code` → text - .replace(/^#{1,6}\s+/gm, "") // headings - .replace(/\s+/g, " ") - .trim(); -} - -// Build product-card HTML for suggested products matching a query. -// Detect a GENERIC / greeting catalog query (no concrete product family or -// brand). "Tư vấn nhanh", "Xin chào ... tư vấn nhanh sản phẩm", "giới thiệu -// sản phẩm", "Xem sản phẩm gia dụng" have NO real product intent — feeding them -// through the scored search returns a WALL of one family (e.g. the ~khóa-điện-tử -// "Giovani GSL" rows that dominate the top of the scoring). For these we must -// show a DIVERSE mix instead. If the query names a concrete family/brand, we -// treat it as specific and use the normal combo/search path. -const _SPECIFIC_CAT_HINTS = ["bếp","máy hút mùi","hút khói","chậu","vòi","lò nướng","lò vi sóng","tủ lạnh","tủ rượu","máy giặt","robot","máy lọc nước","khóa","khoá","bản lề","tay nắm","ray","kệ","giá ","xoong","nồi chiên","rửa chén","rửa bát","hút bụi","máy ép","máy xay","sinh tố","phụ kiện","thùng rác","gia vị","combo","malloca","eurogold","grob","hafele","canzy","demax","imundex","cucina","armor","elock","ilock","smart lock","vân tay","lock"]; -function _isGenericCatalogQuery(q){ - const s = String(q || "").toLowerCase(); - if (!s) return true; - for (const k of _SPECIFIC_CAT_HINTS) { if (s.includes(k)) return false; } - return true; -} -function _diverseChatCards(count){ - // Rendering helper: build a chat cards HTML block from a DIVERSE product mix. - const n = count || 4; - const picks = (window.vaix && window.vaix.getDiverseProducts) ? window.vaix.getDiverseProducts(n) : []; - if (!picks || !picks.length) return { combo: null, products: [], html: "" }; - const html = '
' + picks.map(p => window.vaix.createChatProductCard(p)).join("") + '
'; - return { combo: null, products: picks, html }; -} -function buildSuggestionCards(query) { - try { - if (!window.vaix || !window.vaix.getComboOrSearch) return ""; - // Generic / greeting query (Tư vấn nhanh, Xin chào...): show a DIVERSE mix, - // never the khóa-điện-tử wall that the scored search returns for such queries. - if (_isGenericCatalogQuery(query)) { - return _diverseChatCards(4).html; - } - // Use the same combo/search logic as the product-list panel so the chat - // combo matches the "ds sp" search exactly. - const res = window.vaix.getComboOrSearch(query, 4); - const combo = res.combo; - const products = Array.isArray(res.products) ? res.products.slice(0, 4) : []; - if (!products.length) return ""; - // When a combo matched the user's request, render a combo section (accurate - // to the request) WITH the "Đổi combo" button that swaps to an alternate - // combo still matching the same criteria. - if (combo && combo.items && combo.items.length) { - // Pass the user's original query so the "Đổi combo" button can fetch an - // ALTERNATE combo that still matches the same criteria. - return window.vaix.renderComboCardsBlock(combo, query); - } - // Cards only — no title/total text line (user asked to keep only the combo card). - const cards = products.map(p => window.vaix.createChatProductCard(p)).join(""); - return '
' + cards + '
'; - } catch (e) { return ""; } -} - -// Build a CANONICAL text answer for a combo — derived from the SAME combo items -// rendered as cards, so the written answer always matches the cards exactly and -// every number (mã sản phẩm / giá) is written as DIGITS, never Vietnamese words. -// Returns "" when there is nothing to build (caller keeps the AI's reply). -function buildComboAnswerText(combo, query) { - try { - if (!combo || !combo.items || !combo.items.length) return ""; - const items = combo.items; - const brand = combo.brand ? "thương hiệu " + combo.brand + " " : ""; - const lines = items.map(function(p){ - const model = (p.model || p.sku || p.m || "").toString(); - const price = (p.priceNum > 0) - ? p.priceNum.toLocaleString("vi-VN") + " đ" - : "Liên hệ"; - return "• " + (p.title_clean || p.name || "") + - (model ? " (mã " + model + ")" : "") + - " — " + price; - }); - const total = items.reduce(function(s,p){ return s + ((p.priceNum||0)); }, 0); - const catsLabel = (combo.label && String(combo.label).trim()) - ? combo.label - : "theo yêu cầu của anh chị"; - let head = "Em gợi ý combo " + catsLabel + (brand ? " " + brand : "") + "cho anh chị:"; - const tail = "\n\nTổng combo (" + items.length + " món): " + - (total > 0 ? total.toLocaleString("vi-VN") + " đ" : "Liên hệ"); - const q = String(query || "").trim(); - if (q) head += "\n(Theo yêu cầu: " + q + ")"; - return head + "\n" + lines.join("\n") + tail; - } catch (e) { return ""; } -} - -let _typingEl = null; -function showTypingBubble() { - removeTypingBubble(); - const m = document.createElement("div"); - m.className = "chat-message assistant typing"; - m.innerHTML = ' đang soạn…'; - chatMessages.appendChild(m); - chatMessages.scrollTop = chatMessages.scrollHeight; - _typingEl = m; -} -function removeTypingBubble() { - if (_typingEl && _typingEl.parentNode) _typingEl.parentNode.removeChild(_typingEl); - _typingEl = null; -} - -// ── Edge TTS playback for text chat / quick consult ── -// Voices: Mr V (vuong.glb, male) → Nam Minh; Lisamy (lisamy.glb, female) → Hoài My. -function getEdgeTtsVoice() { - try { - const av = (localStorage.getItem("avatar.model") || "vuong.glb").toLowerCase(); - return av.indexOf("lisamy") !== -1 ? "vi-VN-HoaiMyNeural" : "vi-VN-NamMinhNeural"; - } catch (e) { return "vi-VN-NamMinhNeural"; } -} - -let _edgeTtsAudio = null; -let _ttsCtx = null; // shared AudioContext used for TTS playback -let _ttsUnlocked = false; // true once a user gesture has unlocked audio - -// Unlock audio on the FIRST user gesture. Browsers block audible playback until -// the user has interacted with the page. Because chat now defaults to MUTED, -// the site may not have attempted any audio yet, so the per-message "Đọc to" -// button would otherwise hit the autoplay wall. Creating + resuming a shared -// AudioContext inside the first gesture permanently unlocks audible output. -function unlockTtsAudio() { - if (_ttsUnlocked) { try { if (_ttsCtx && _ttsCtx.state === "suspended") _ttsCtx.resume().catch(function(){}); } catch(_){} return; } - try { - const AC = window.AudioContext || window.webkitAudioContext; - if (!AC) return; - if (!_ttsCtx) _ttsCtx = new AC({ latencyHint: "interactive" }); - if (_ttsCtx.state === "suspended") _ttsCtx.resume().catch(function(){}); - // Prime an inaudible buffer so the context actually starts producing audio. - const buf = _ttsCtx.createBuffer(1, 1, _ttsCtx.sampleRate); - const src = _ttsCtx.createBufferSource(); - src.buffer = buf; src.connect(_ttsCtx.destination); src.start(0); - _ttsUnlocked = true; - } catch (e) {} -} -// Register the unlock on the first of any common user gestures. -function initAudioUnlock() { - const evts = ["pointerdown", "touchstart", "click", "keydown"]; - const once = function () { - unlockTtsAudio(); - for (const e2 of evts) document.removeEventListener(e2, once); - }; - for (const e2 of evts) { try { document.addEventListener(e2, once, { passive: true, once: true }); } catch (_) {} } -} - -let _edgeTtsSrc = null; // current WebAudio BufferSource (to stop cleanly) - -// Ensure a shared, running AudioContext exists (created on demand, resumed on -// gesture; safe to call anywhere). -function _ensureTtsCtx() { - if (!_ttsCtx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; _ttsCtx = new AC({ latencyHint: "interactive" }); } - if (_ttsCtx.state === "suspended") { try { _ttsCtx.resume().catch(function(){}); } catch(_){} } - return _ttsCtx; -} - -// Play decoded TTS audio through the shared (unlocked) AudioContext. This is -// far more reliable than a one-shot