fix: Bun v1.3.14 compile error — remove nested template literal in generateProductOgHead()

#2
by bep40 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .build_trigger +0 -1
  2. .cache_bust +0 -1
  3. .gitattributes +0 -12
  4. Dockerfile +4 -5
  5. FORCE_BOOT.txt +1 -0
  6. HOT_TAGS_V3_TRIGGER.txt +0 -3
  7. REBUILD.md +0 -1
  8. REBUILD.txt +1 -0
  9. TRIGGER_REBUILD.txt +0 -1
  10. build-trigger +0 -1
  11. chat +0 -0
  12. customers.json +0 -8
  13. edge-tts.mjs +0 -152
  14. image - 2026-08-05T195019.510.png +0 -3
  15. index.html +72 -325
  16. index.ts +396 -939
  17. index.ts.patched +0 -684
  18. package.json +1 -2
  19. public/avatars/grit-b6c6c4.glb +0 -3
  20. public/avatars/lisamy.glb +0 -3
  21. public/avatars/lisamy.jpg +0 -0
  22. public/avatars/vaistudio.png +0 -3
  23. public/avatars/vaistudio_center.png +0 -3
  24. public/avatars/vuong.glb +2 -2
  25. public/avatars/vuong.jpg +0 -0
  26. public/avatars/vuong2.glb +0 -3
  27. public/quote_logo.png +0 -0
  28. public/vendor/headaudio.min.mjs +0 -1
  29. public/worklets/audio-playback.js +1 -9
  30. san-pham/canzy-cz-03t/index.html +0 -20
  31. san-pham/canzy-cz-816sdr/index.html +0 -20
  32. san-pham/cz-007r-canzy/index.html +0 -18
  33. san-pham/cz-09dl-canzy/index.html +0 -18
  34. san-pham/cz-11ebn-canzy/index.html +0 -18
  35. san-pham/cz-11qam-serial-8-0-canzy/index.html +0 -18
  36. san-pham/cz-15ya-canzy/index.html +0 -18
  37. san-pham/cz-16eugr-unique-canzy/index.html +0 -18
  38. san-pham/cz-34gss-canzy/index.html +0 -18
  39. san-pham/cz-68dt-canzy/index.html +0 -18
  40. san-pham/cz-700-auto-canzy/index.html +0 -18
  41. san-pham/cz-70b50-60b50-canzy/index.html +0 -18
  42. san-pham/cz-70c66-canzy/index.html +0 -18
  43. san-pham/cz-70i50-60i50-canzy/index.html +0 -18
  44. san-pham/cz-780k-canzy/index.html +0 -18
  45. san-pham/cz-806-a5g-canzy/index.html +0 -18
  46. san-pham/cz-815tdb-canzy-c-y-n-c-n-ng-l-nh/index.html +0 -18
  47. san-pham/cz-899-canzy/index.html +0 -18
  48. san-pham/cz-9979-grh-canzy/index.html +0 -18
  49. san-pham/cz-9979-grs-canzy/index.html +0 -18
  50. san-pham/cz-9979-grt-canzy/index.html +0 -18
.build_trigger DELETED
@@ -1 +0,0 @@
1
- 1785805873
 
 
.cache_bust DELETED
@@ -1 +0,0 @@
1
- 1786077628147
 
 
.gitattributes CHANGED
@@ -54,15 +54,3 @@ public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
54
  model[[:space:]](6).glb filter=lfs diff=lfs merge=lfs -text
55
  data/products-light.json filter=lfs diff=lfs merge=lfs -text
56
  data/products-formatted.json filter=lfs diff=lfs merge=lfs -text
57
- public/avatars/grit-b6c6c4.glb filter=lfs diff=lfs merge=lfs -text
58
- public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text
59
- san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text
60
- san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text
61
- san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text
62
- san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg filter=lfs diff=lfs merge=lfs -text
63
- public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
64
- public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
65
- public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text
66
- image[[:space:]]-[[:space:]]2026-08-05T195019.510.png filter=lfs diff=lfs merge=lfs -text
67
- public/avatars/vaistudio.png filter=lfs diff=lfs merge=lfs -text
68
- public/avatars/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text
 
54
  model[[:space:]](6).glb filter=lfs diff=lfs merge=lfs -text
55
  data/products-light.json filter=lfs diff=lfs merge=lfs -text
56
  data/products-formatted.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,14 +1,13 @@
1
- # Bun Dockerfile for gemma-avatar
2
  FROM oven/bun:1
3
  WORKDIR /app
4
  COPY package.json ./
5
  RUN bun install --production
6
- RUN rm -rf /root/.bun /home/bun/.bun /tmp/* 2>/dev/null; echo "clear-rebuild"
7
  COPY . /app/
8
  ENV NODE_ENV=production
9
  ENV PORT=7860
 
10
  EXPOSE 7860
11
  USER bun
12
- CMD ["bun", "index.ts"]
13
-
14
- # Rebuild trigger: 1785725738.0884876
 
1
+ # 1795301633 - Fix proxy() async, RAG trực tiếp Parquet
2
  FROM oven/bun:1
3
  WORKDIR /app
4
  COPY package.json ./
5
  RUN bun install --production
6
+ RUN rm -rf /root/.bun /home/bun/.bun /tmp/* 2>/dev/null; echo "clear-1795301633"
7
  COPY . /app/
8
  ENV NODE_ENV=production
9
  ENV PORT=7860
10
+ ENV BUILD=1795301633
11
  EXPOSE 7860
12
  USER bun
13
+ CMD ["bun", "index.ts"]
 
 
FORCE_BOOT.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ force-1785310612
HOT_TAGS_V3_TRIGGER.txt DELETED
@@ -1,3 +0,0 @@
1
- # HOT Tags V3 Fix Trigger
2
- # Generated: 9ef64b29ef1be950
3
- # Files: app.js (59382 bytes), style.css (15138 bytes)
 
 
 
 
REBUILD.md DELETED
@@ -1 +0,0 @@
1
- TRIGGER
 
 
REBUILD.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild 7639
TRIGGER_REBUILD.txt DELETED
@@ -1 +0,0 @@
1
- 1785850254749
 
 
build-trigger DELETED
@@ -1 +0,0 @@
1
- rebuild=1786077628148
 
 
chat DELETED
File without changes
customers.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "479b1cfad6a83ff666b9": {
3
- "name": "V Ai Studio",
4
- "ma_kh": "VAS6b9",
5
- "cid": "479b1cfad6a83ff666b9",
6
- "created_at": "07/08/2026 19:23"
7
- }
8
- }
 
 
 
 
 
 
 
 
 
edge-tts.mjs DELETED
@@ -1,152 +0,0 @@
1
- /**
2
- * Edge TTS — zero-dependency Microsoft Edge neural text-to-speech for Bun/Node.
3
- * Produces 24kHz 48kbit mono MP3 for Vietnamese voices:
4
- * vi-VN-HoaiMyNeural (female, "Hoài My")
5
- * vi-VN-NamMinhNeural (male, "Nam Minh")
6
- */
7
- import { createHash, randomUUID, randomBytes } from "node:crypto";
8
-
9
- const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
10
- const WIN_EPOCH = 11644473600;
11
- const WSS_URL =
12
- "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
13
- const SEC_MS_GEC_VERSION = "1-143.0.3650.75";
14
- const CHROME_VER = "143";
15
-
16
- const UA =
17
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
18
- `(KHTML, like Gecko) Chrome/${CHROME_VER}.0.0.0 Safari/537.36 Edg/${CHROME_VER}.0.0.0`;
19
-
20
- function generateSecMsGec() {
21
- let ticks = Date.now() / 1000 + WIN_EPOCH;
22
- ticks -= ticks % 300;
23
- ticks *= 1e7;
24
- return createHash("sha256")
25
- .update(`${ticks.toFixed(0)}${TRUSTED_CLIENT_TOKEN}`, "ascii")
26
- .digest("hex")
27
- .toUpperCase();
28
- }
29
-
30
- function dateToString() {
31
- const d = new Date();
32
- const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
33
- const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
34
- const p = (n) => String(n).padStart(2, "0");
35
- return `${days[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${p(d.getUTCDate())} ` +
36
- `${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} ` +
37
- `GMT+0000 (Coordinated Universal Time)`;
38
- }
39
-
40
- function buildSSML(text, voice, { pitch = "+0Hz", rate = "+0%", volume = "+0%" } = {}) {
41
- const esc = String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
42
- return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
43
- `<voice name='${voice}'><prosody pitch='${pitch}' rate='${rate}' volume='${volume}'>` +
44
- `${esc}</prosody></voice></speak>`;
45
- }
46
-
47
- /** Split long text at sentence boundaries (Edge caps ~3000 chars/request). */
48
- function splitText(text, max = 2800) {
49
- if (text.length <= max) return [text];
50
- const parts = [];
51
- let rest = text;
52
- while (rest.length > max) {
53
- let cut = rest.lastIndexOf(". ", max);
54
- if (cut < max * 0.5) cut = rest.lastIndexOf(" ", max);
55
- if (cut <= 0) cut = max;
56
- parts.push(rest.slice(0, cut + 1));
57
- rest = rest.slice(cut + 1).trimStart();
58
- }
59
- if (rest) parts.push(rest);
60
- return parts;
61
- }
62
-
63
- /**
64
- * Synthesize text to mp3 bytes.
65
- * @param {string} text
66
- * @param {string} voice e.g. "vi-VN-HoaiMyNeural" | "vi-VN-NamMinhNeural"
67
- * @returns {Promise<Uint8Array>}
68
- */
69
- export async function synthesize(text, voice = "vi-VN-HoaiMyNeural") {
70
- const clean = String(text || "").trim().replace(/\s+/g, " ").replace(/\*\*?|`|_{1,2}/g, "");
71
- if (!clean) return new Uint8Array(0);
72
- const parts = splitText(clean);
73
- const buffers = [];
74
- for (const part of parts) {
75
- buffers.push(await synthesizeOne(part, voice));
76
- }
77
- const total = buffers.reduce((n, b) => n + b.length, 0);
78
- const out = new Uint8Array(total);
79
- let off = 0;
80
- for (const b of buffers) { out.set(b, off); off += b.length; }
81
- return out;
82
- }
83
-
84
- function synthesizeOne(text, voice) {
85
- const reqId = randomUUID().replace(/-/g, "");
86
- const sec = generateSecMsGec();
87
- const url =
88
- `${WSS_URL}?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}` +
89
- `&Sec-MS-GEC=${sec}&Sec-MS-GEC-Version=${SEC_MS_GEC_VERSION}` +
90
- `&ConnectionId=${randomUUID().replace(/-/g, "")}`;
91
-
92
- const headers = {
93
- "User-Agent": UA,
94
- "Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
95
- "Pragma": "no-cache",
96
- "Cache-Control": "no-cache",
97
- "Cookie": `muid=${randomBytes(16).toString("hex").toUpperCase()};`,
98
- };
99
-
100
- // Bun native WebSocket accepts options with headers as 2nd arg.
101
- const ws = new WebSocket(url, { headers });
102
-
103
- return new Promise((resolve, reject) => {
104
- const chunks = [];
105
- let opened = false;
106
- const timer = setTimeout(() => {
107
- try { ws.close(); } catch {}
108
- reject(new Error("Edge TTS timeout"));
109
- }, 20000);
110
-
111
- ws.onopen = () => {
112
- opened = true;
113
- ws.send(
114
- `X-Timestamp:${dateToString()}\r\n` +
115
- "Content-Type:application/json; charset=utf-8\r\n" +
116
- "Path:speech.config\r\n\r\n" +
117
- '{"context":{"synthesis":{"audio":{"metadataoptions":' +
118
- '{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},' +
119
- '"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}\r\n'
120
- );
121
- ws.send(
122
- `X-RequestId:${reqId}\r\n` +
123
- "Content-Type:application/ssml+xml\r\n" +
124
- `X-Timestamp:${dateToString()}Z\r\n` +
125
- "Path:ssml\r\n\r\n" +
126
- buildSSML(text, voice)
127
- );
128
- };
129
-
130
- ws.onmessage = (ev) => {
131
- const data = ev.data;
132
- if (typeof data === "string") return;
133
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
134
- if (buf.length < 2) return;
135
- const hl = buf.readUInt16BE(0);
136
- const header = buf.subarray(0, hl + 2).toString("latin1");
137
- const body = buf.subarray(hl + 2);
138
- if (header.includes("Path:audio")) chunks.push(body);
139
- else if (header.includes("Path:turn.end")) {
140
- clearTimeout(timer);
141
- try { ws.close(); } catch {}
142
- resolve(Buffer.concat(chunks));
143
- }
144
- };
145
-
146
- ws.onerror = (e) => { if (!opened) { clearTimeout(timer); reject(e); } };
147
- ws.onclose = () => {
148
- if (chunks.length) { clearTimeout(timer); resolve(Buffer.concat(chunks)); }
149
- else clearTimeout(timer);
150
- };
151
- });
152
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
image - 2026-08-05T195019.510.png DELETED

Git LFS Details

  • SHA256: 13cf0f6e2d3196e802077e0aef50e79e259c4faca785448e43e3b1c03bc695cd
  • Pointer size: 132 Bytes
  • Size of remote file: 2.11 MB
index.html CHANGED
@@ -3,12 +3,12 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
- <title>V.AI AVATAR + V.AI STUDIO - Chat & Voice</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
11
- <link rel="stylesheet" href="./src/style.css?gc=30" />
12
  </head>
13
  <body>
14
  <main id="app">
@@ -19,25 +19,9 @@
19
  <div id="welcome-modal" class="welcome-modal-overlay">
20
  <div class="welcome-modal">
21
  <div class="welcome-avatar-icon">🤖</div>
22
- <h2 class="welcome-title">Chào mừng đến với V.AI AVATAR</h2>
23
  <p class="welcome-subtitle">Bạn muốn giao tiếp thế nào?</p>
24
-
25
- <!-- ✨ Avatar chooser (Mr V / Lisamy) -->
26
- <div class="welcome-avatars">
27
- <p class="welcome-avatars-title">Chọn đại diện của em:</p>
28
- <div id="welcome-avatar-row" class="welcome-avatar-row">
29
- <!-- populated by src/avatar-picker.js -->
30
- </div>
31
- </div>
32
-
33
  <div class="welcome-actions">
34
- <button id="welcome-quick-btn" class="welcome-btn welcome-btn-quick" aria-label="Tư vấn nhanh">
35
- <span class="welcome-btn-icon">⚡</span>
36
- <span class="welcome-btn-text-content">
37
- <span class="welcome-btn-main-text">Tư vấn nhanh</span>
38
- <span class="welcome-btn-desc">Không cần chờ avatar, chat với AI và xem sản phẩm ngay</span>
39
- </span>
40
- </button>
41
  <button id="welcome-chat-btn" class="welcome-btn welcome-btn-text" aria-label="Chat tin nhắn">
42
  <span class="welcome-btn-icon">💬</span>
43
  <span class="welcome-btn-text-content">
@@ -53,53 +37,21 @@
53
  </span>
54
  </button>
55
  </div>
56
- <div class="welcome-features">
57
- <div class="welcome-feature"><span class="wf-icon">🗣️</span><span class="wf-text">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)</span></div>
58
- <div class="welcome-feature"><span class="wf-icon">🎙️</span><span class="wf-text">Chế độ voice call — nói chuyện trực tiếp với AI, phụ đề song ngữ</span></div>
59
- <div class="welcome-feature"><span class="wf-icon">🛒</span><span class="wf-text">8000+ sản phẩm gia dụng — Malloca, Eurogold, Grob, Canzy & khoá thông minh</span></div>
60
- <div class="welcome-feature"><span class="wf-icon">🧠</span><span class="wf-text">AI Avatar + 11 công cụ: tra cứu, web, Wikipedia, thời gian thực</span></div>
61
- <div class="welcome-feature"><span class="wf-icon">🎭</span><span class="wf-text">3D avatar lip-sync — biểu cảm, cử chỉ, giọng nói Qwen3-TTS</span></div>
62
- <div class="welcome-feature"><span class="wf-icon">⚙️</span><span class="wf-text">Tuỳ chỉnh giọng, avatar, hướng dẫn riêng, phụ đề</span></div>
63
- </div>
64
  <p class="welcome-hint">Bạn luôn có thể chuyển đổi chế độ sau</p>
65
  </div>
66
  </div>
67
 
68
- <!-- ✨ Avatar loading overlay with 1–100% progress bar -->
69
- <div id="avatar-loading-overlay" class="avatar-loading-overlay" hidden>
70
- <div class="avatar-loading-box">
71
- <div class="avatar-loading-face">🤖</div>
72
- <div class="avatar-loading-msg">V.AI STUDIO sẽ đến với anh chị ngay bây giờ.<br>Mong anh chị chờ em xíu ạ!</div>
73
- <div class="avatar-loading-track"><div class="avatar-loading-fill" id="avatar-loading-bar"></div></div>
74
- <div class="avatar-loading-pct" id="avatar-loading-pct">0%</div>
75
- </div>
76
- </div>
77
-
78
  <header id="topbar">
79
  <div id="identity">
80
- <h1>V.AI&nbsp;AVATAR + V.AI STUDIO</h1>
81
- <p>Chat or talk to <strong>AI Avatar</strong>. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.</p>
82
- </div>
83
- <div style="display:flex;align-items:center;gap:8px;pointer-events:auto">
84
- <button id="order-btn" class="icon-btn" aria-label="Đơn hàng" title="Đơn hàng">
85
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
86
- <path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
87
- </svg>
88
- </button>
89
- <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
90
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
91
- <circle cx="12" cy="12" r="3"></circle>
92
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l-.06-.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
93
- </svg>
94
- </button>
95
- <button id="cart-badge-btn" class="icon-btn" aria-label="Giỏ hàng" title="Giỏ hàng" style="position:relative">
96
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
97
- <circle cx="9" cy="21" r="1"></circle><circle cx="20" cy="21" r="1"></circle>
98
- <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
99
- </svg>
100
- <span id="cartNum" style="display:none;position:absolute;top:-2px;right:-2px;background:#ef4444;color:#fff;font-size:9px;font-weight:800;width:16px;height:16px;border-radius:50%;align-items:center;justify-content:center">0</span>
101
- </button>
102
  </div>
 
 
 
 
 
 
103
  </header>
104
 
105
  <div id="subtitles" aria-live="polite"></div>
@@ -109,20 +61,6 @@
109
  <span id="chat-title">Text Chat</span>
110
  <div id="chat-header-actions">
111
  <select id="chat-avatar-select" title="Switch avatar"></select>
112
- <button id="chatbox-mute-btn" class="icon-btn small" aria-label="Bật/tắt âm thanh (im lặng)" title="Bật/tắt âm thanh (im lặng)">
113
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
114
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
115
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14" id="chatbox-mute-sound"></path>
116
- <path d="M15.53 8.47a4 4 0 0 1 0 7.07" id="chatbox-mute-muted" style="display:none"></path>
117
- </svg>
118
- </button>
119
- <button id="chat-audio-toggle-btn" class="icon-btn small" aria-label="Toggle avatar audio" title="Toggle avatar audio" style="display:none">
120
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
121
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
122
- <path d="M19.07 4.93a10 10 0 0 1 0 14.14" id="audio-icon-sound"></path>
123
- <path d="M15.53 8.47a4 4 0 0 1 0 7.07" id="audio-icon-muted" style="display:none"></path>
124
- </svg>
125
- </button>
126
  <button id="chat-close-btn" class="icon-btn small" aria-label="Close text chat" title="Close">
127
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
128
  <line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
@@ -133,20 +71,12 @@
133
  <div id="chat-messages"></div>
134
  <div id="chat-input-container">
135
  <input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
136
- <button id="chat-mic-btn" class="icon-btn" aria-label="Nhập bằng giọng nói tiếng Việt" title="Nhập bằng giọng nói (Việt Nam)" hidden>
137
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
138
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
139
- <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>
140
- </svg>
141
- </button>
142
  <button id="chat-send-btn" class="icon-btn" aria-label="Send message">
143
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
144
  <line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
145
  </svg>
146
  </button>
147
  </div>
148
- <!-- Suggested Questions: rendered as dark pills inside the chat (see vaix-rag.js) -->
149
- <div id="suggested-questions" style="padding:8px 12px;display:none;flex-wrap:wrap;gap:6px"></div>
150
  <div id="chat-resize-handle"></div>
151
  </div>
152
 
@@ -154,7 +84,7 @@
154
  <div id="caption">LOADING…</div>
155
  <div id="buttons">
156
  <button id="main-btn" disabled><span id="main-btn-label">Loading…</span></button>
157
- <button id="mute-btn" class="icon-btn" hidden aria-label="Mute avatar audio" title="Mute avatar audio">
158
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
159
  <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
160
  <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>
@@ -194,9 +124,6 @@
194
  <h4 style="font-size:0.9rem;font-weight:700;margin:0">V.AI STUDIO</h4>
195
  <span id="vaistudio-count" style="font-size:0.68rem;opacity:0.75">Đang tải sản phẩm…</span>
196
  </div>
197
- <button id="vaistudio-order-btn" aria-label="Đơn hàng" title="Đơn hàng" style="width:40px;height:40px;min-width:40px;min-height:40px;border-radius:50%;background:rgba(255,255,255,0.25);border:none;color:#fff;cursor:pointer;align-items:center;justify-content:center;font-size:1.05rem;transition:all 0.2s" onmouseover="this.style.background='rgba(255,255,255,0.4)'" onmouseout="this.style.background='rgba(255,255,255,0.25)'">
198
- <i class="fas fa-receipt"></i>
199
- </button>
200
  <button id="vaistudio-close" aria-label="Close" style="width:40px;height:40px;min-width:40px;min-height:40px;border-radius:50%;background:rgba(255,255,255,0.25);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:all 0.2s" onmouseover="this.style.background='rgba(255,255,255,0.4)'" onmouseout="this.style.background='rgba(255,255,255,0.25)'">
201
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
202
  </button>
@@ -247,78 +174,7 @@
247
  <h3 style="font-size:0.8rem;font-weight:700;color:#003f62;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em">Thông số kỹ thuật</h3>
248
  <table id="detail-specs-table" style="width:100%;border-collapse:collapse;font-size:0.78rem"><tbody></tbody></table>
249
  </div>
250
- </div>
251
- </div>
252
-
253
- <!-- CART DRAWER -->
254
- <div class="cart-overlay" id="cartOverlay" onclick="if(window.closeCart)closeCart()"></div>
255
- <div class="cart-drawer" id="cartDrawer">
256
- <div class="cart-header"><h3><i class="fas fa-shopping-cart"></i> Giỏ Hàng</h3><button class="cart-close" onclick="closeCart()"><i class="fas fa-times"></i></button></div>
257
- <div class="cart-items" id="cartItems"></div>
258
- <div class="cart-footer" id="cartFooter" style="display:none">
259
- <div class="cart-total"><span>Tổng cộng:</span><span id="cartTotal">0đ</span></div>
260
- <div class="cart-footer-btns">
261
- <button class="cart-checkout-btn" onclick="saveOrder()" style="background:#059669"><i class="fas fa-save"></i> Lưu</button>
262
- <button class="cart-checkout-btn" onclick="openQuotation()" style="background:var(--p, #003f62)"><i class="fas fa-file-invoice"></i> Báo giá</button>
263
- <button class="cart-checkout-btn" onclick="clearCart()" style="background:#64748b"><i class="fas fa-trash"></i> Xóa</button>
264
- </div>
265
- </div>
266
- </div>
267
-
268
- <!-- QUOTATION MODAL -->
269
- <div class="quote-overlay" id="quoteOverlay" onclick="if(event.target===this)closeQuotation()">
270
- <div class="quote-modal">
271
- <button class="quote-close" onclick="closeQuotation()"><i class="fas fa-times"></i></button>
272
- <div class="quote-head">
273
- <h2><i class="fas fa-file-invoice" style="margin-right:8px"></i>Lập Báo Giá</h2>
274
- <p>Điền thông tin khách hàng và chiết khấu để xuất báo giá</p>
275
- </div>
276
- <div class="quote-body">
277
- <div class="quote-section">
278
- <h3><i class="fas fa-user"></i> Thông tin khách hàng</h3>
279
- <div class="quote-grid">
280
- <div class="quote-field"><label>Họ tên khách hàng</label><input id="qcName" placeholder="Nhập họ tên"></div>
281
- <div class="quote-field"><label>Số điện thoại</label><input id="qcPhone" placeholder="Nhập SĐT"></div>
282
- <div class="quote-field"><label>Email</label><input id="qcEmail" placeholder="Nhập email"></div>
283
- <div class="quote-field"><label>Địa chỉ</label><input id="qcAddr" placeholder="Nhập địa chỉ"></div>
284
- <div class="quote-field"><label>Mã đơn hàng</label><input id="qcCompany" placeholder="Tự động tạo" readonly style="background:#f1f5f9;font-weight:700;color:#003f62"></div>
285
- <div class="quote-field"><label>Ngày báo giá</label><input id="qcDate" type="date"></div>
286
- </div>
287
- <div style="margin-top:12px;padding:12px 14px;background:#f1f5f9;border-radius:10px">
288
- <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
289
- <i class="fas fa-lock" id="qcLockIcon" style="color:#16a34a;font-size:.85rem"></i>
290
- <input id="qcAccessCode" type="password" placeholder="Nhập mã truy cập..." style="width:180px;padding:7px 10px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:.8rem;font-family:inherit;outline:none;background:#fff" oninput="checkQuoteAccess()">
291
- <span id="qcAccessStatus" style="font-size:.72rem;font-weight:600;color:#16a34a">🔒</span>
292
- </div>
293
- <div id="aiPromptBox" style="display:none">
294
- <div style="display:flex;gap:8px">
295
- <textarea id="qcAiPrompt" placeholder="VD: Malloca ck 35%, giao hàng 200k, lắp đặt 500k, cọc 5tr, ghi chú: giao thứ 7" style="flex:1;padding:8px 10px;border:1.5px solid #0077b6;border-radius:8px;font-size:.82rem;font-family:inherit;outline:none;resize:none;height:48px;background:#fff"></textarea>
296
- <button onclick="applyAiDiscount()" style="padding:8px 16px;background:#0077b6;color:#fff;border:none;border-radius:8px;font-size:.8rem;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap;display:flex;align-items:center;gap:6px" id="aiDiscBtn"><i class="fas fa-robot"></i> Áp dụng</button>
297
- </div>
298
- <div id="aiDiscStatus" style="font-size:.72rem;color:#16a34a;margin-top:4px"></div>
299
- </div>
300
- </div>
301
- </div>
302
- <div class="quote-section">
303
- <h3><i class="fas fa-list-check"></i> Chi tiết sản phẩm</h3>
304
- <div style="overflow-x:auto">
305
- <table class="quote-table" id="quoteTable">
306
- <thead><tr><th>STT</th><th>Hình ảnh</th><th style="text-align:left">Tên sản phẩm</th><th>Mã SP</th><th>Thông tin</th><th>SL</th><th>Đơn giá</th><th>Đơn giá CK</th><th>Thành tiền</th><th>Ghi chú</th></tr></thead>
307
- <tbody id="quoteTableBody"></tbody>
308
- <tfoot><tr class="quote-total-row"><td colspan="8" style="text-align:right;padding-right:12px">TỔNG CỘNG:</td><td class="qt-price" id="quoteTotalCell">0₫</td><td></td></tr></tfoot>
309
- </table>
310
- </div>
311
- </div>
312
- <div class="quote-actions">
313
- <button class="quote-btn" onclick="shareQuoteImage()" style="background:#25D366"><i class="fas fa-share-alt"></i> Chia sẻ ảnh</button>
314
- <button class="quote-btn" onclick="shareQuoteLink()" style="background:#0068FF"><i class="fas fa-share-alt"></i> Chia sẻ báo giá</button>
315
- <button class="quote-btn quote-btn-excel" onclick="exportQuoteExcel()"><i class="fas fa-file-excel"></i> Xuất Excel</button>
316
- <button class="quote-btn quote-btn-pdf" onclick="exportQuotePDF()"><i class="fas fa-file-pdf"></i> Xuất PDF</button>
317
- <label class="quote-btn" style="background:#fff;color:#111;border:1px solid #111"><input id="vai-bw-export" type="checkbox" style="width:16px;height:16px;margin-right:6px"> Trắng đen</label>
318
- <button class="quote-btn quote-btn-print" onclick="printQuotation()"><i class="fas fa-print"></i> In báo giá</button>
319
- <button class="quote-btn" onclick="saveOrder()" style="background:#7c3aed"><i class="fas fa-save"></i> Lưu</button>
320
- </div>
321
- </div>
322
  </div>
323
  </div>
324
 
@@ -327,55 +183,65 @@
327
  @keyframes modalIn { from { opacity:0;transform:translateY(20px) scale(0.96) } to { opacity:1;transform:translateY(0) scale(1) } }
328
 
329
  /* ── Welcome Modal ── */
330
- .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; }
331
- .welcome-modal-overlay.show { opacity: 1; visibility: visible; }
332
- .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; }
333
- .welcome-modal-overlay.show .welcome-modal { transform: translateY(0); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  .welcome-avatar-icon { font-size: 3rem; margin-bottom: 16px; }
335
- .welcome-title { color: #fff; font-size: 1.5rem; margin-bottom: 8px; font-family: Inter, sans-serif; font-weight: 600; }
336
- .welcome-subtitle { color: #888; font-size: 0.9rem; margin-bottom: 24px; font-family: Inter, sans-serif; }
337
- .welcome-actions { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
338
- .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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  .welcome-btn:hover { transform: scale(1.02); }
340
  .welcome-btn:active { transform: scale(0.98); }
341
- .welcome-btn-text { background: linear-gradient(135deg, #003f62, #0077b6); color: #fff; box-shadow: 0 4px 16px rgba(0,63,98,0.3); }
 
 
 
 
342
  .welcome-btn-text:hover { box-shadow: 0 6px 24px rgba(0,63,98,0.4); }
343
- .welcome-btn-voice { background: linear-gradient(135deg, #6B21A8, #9333EA); color: #fff; box-shadow: 0 4px 16px rgba(107,33,168,0.3); }
 
 
 
 
344
  .welcome-btn-voice:hover { box-shadow: 0 6px 24px rgba(107,33,168,0.4); }
345
- .welcome-btn-quick { background: linear-gradient(135deg, #047857, #10B981); color: #fff; box-shadow: 0 4px 16px rgba(4,120,87,0.3); }
346
- .welcome-btn-quick:hover { box-shadow: 0 6px 24px rgba(4,120,87,0.4); }
347
  .welcome-btn-icon { font-size: 1.3rem; }
348
  .welcome-btn-text-content { display: flex; flex-direction: column; }
349
  .welcome-btn-main-text { font-size: 1rem; font-weight: 600; }
350
  .welcome-btn-desc { font-size: 0.75rem; font-weight: 400; opacity: 0.8; margin-top: 2px; }
351
- .welcome-hint { color: #555; font-size: 0.75rem; font-family: Inter, sans-serif; }
352
- .welcome-modal { max-height: 90vh; overflow-y: auto; }
353
- .welcome-modal::-webkit-scrollbar { width: 4px; }
354
- .welcome-modal::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
355
- .welcome-features { margin: 0 0 16px; text-align: left; display: flex; flex-direction: column; gap: 10px; }
356
- .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; }
357
- .welcome-feature .wf-icon { flex-shrink: 0; font-size: 1.1rem; width: 22px; text-align: center; }
358
- .welcome-feature .wf-text { color: #aaa; }
359
-
360
- /* ✨ Avatar chooser on the welcome modal */
361
- .welcome-avatars { margin: 0 0 16px; }
362
- .welcome-avatars-title { color: #888; font-size: 0.82rem; font-family: Inter, sans-serif; margin: 0 0 12px; }
363
- .welcome-avatar-row { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
364
- .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; }
365
- .welcome-avatar-card:hover { border-color: #0077b6; transform: translateY(-2px); }
366
- .welcome-avatar-card.selected { border-color: #22d3ee; box-shadow: 0 0 0 2px rgba(34,211,238,0.35); }
367
- .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; }
368
- .welcome-avatar-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
369
- .welcome-avatar-name { color: #e0e0e0; font-size: 0.8rem; font-weight: 600; }
370
-
371
- /* ✨ Avatar loading overlay + 1–100% progress bar */
372
- .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); }
373
- .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); }
374
- .avatar-loading-face { font-size: 2.6rem; margin-bottom: 14px; }
375
- .avatar-loading-msg { color: #ddd; font-size: 1rem; line-height: 1.5; font-family: Inter, sans-serif; margin-bottom: 20px; }
376
- .avatar-loading-track { height: 12px; background: #2a2a2a; border-radius: 999px; overflow: hidden; border: 1px solid #3a3a3a; }
377
- .avatar-loading-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #003f62, #22d3ee); border-radius: 999px; transition: width 0.3s ease; }
378
- .avatar-loading-pct { color: #22d3ee; font-size: 1rem; font-weight: 700; margin-top: 10px; font-family: Inter, sans-serif; }
379
 
380
  #vaistudio-panel.open { display: flex !important; }
381
  #vaistudio-loading.hidden { display: none !important; }
@@ -388,17 +254,19 @@
388
  .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px }
389
  .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 }
390
  .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px }
391
- .product-card .product-card-btn { align-self:center;flex-shrink:0;display:inline-flex;align-items:center;gap:5px;padding:8px 12px;border:none;border-radius:8px;background:#25d366;color:#fff;cursor:pointer;font-size:0.72rem;font-weight:600;font-family:inherit;transition:all 0.15s }
392
- .product-card .product-card-btn:hover { background:#1fb959 }
393
- .product-card .product-card-btn.added { background:#166534 }
394
 
 
395
  .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s }
396
  .vaix-suggestion-item:last-child { border-bottom:none }
397
  .vaix-suggestion-item:hover { background:#f0f9ff }
398
  #vaix-suggestions::-webkit-scrollbar { width:5px }
399
  #vaix-suggestions::-webkit-scrollbar-thumb { background:#e2e8f0;border-radius:3px }
 
 
400
  .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 }
401
  .vaix-category-chip:hover { background:#dbeafe;border-color:#0077b6;transform:translateY(-1px) }
 
 
402
  .chat-product-cards { display:flex;flex-direction:column;gap:8px;margin:6px 0;max-width:100% }
403
  .chat-product-card { background:#1e293b;border:1px solid #334155;border-radius:12px;overflow:hidden;transition:all 0.2s }
404
  .chat-product-card:hover { border-color:#0077b6;box-shadow:0 2px 8px rgba(0,119,182,0.15) }
@@ -409,7 +277,6 @@
409
  .chat-product-card-body { flex:1;min-width:0;display:flex;flex-direction:column;gap:2px }
410
  .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 }
411
  .chat-product-card-meta { display:flex;align-items:center;gap:6px;flex-wrap:wrap }
412
- .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 }
413
  .chat-product-card-brand { font-size:0.68rem;color:#94a3b8 }
414
  .chat-product-card-stars { font-size:0.6rem;color:#f59e0b;letter-spacing:1px }
415
  .chat-product-card-price { font-size:0.82rem;font-weight:700;color:#22d3ee;margin-top:1px }
@@ -420,134 +287,14 @@
420
  .chat-product-card-btn:first-child { border-right:1px solid #334155 }
421
  .chat-product-card-btn.detail-btn:hover { color:#22d3ee }
422
  .chat-product-card-btn.share-btn:hover { color:#f59e0b }
423
- .chat-product-card-btn.askai-btn { color:#a78bfa }
424
- .chat-product-card-btn.askai-btn:hover { background:#4c1d95; color:#e9d5ff }
425
- .chat-product-card-btn.cart-btn { color:#4ade80 }
426
- .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 }
427
- .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff }
428
  .chat-message.assistant .chat-product-card { background:#1e293b;border:1px solid #334155;margin:4px 0 }
429
- /* Combo suggestion section */
430
- .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% }
431
- .chat-combo-title { font-size:0.82rem;font-weight:700;color:#e2e8f0;display:flex;align-items:center;gap:6px;margin-bottom:2px }
432
- .chat-combo-spark { color:#f59e0b }
433
- .chat-combo-sub { font-size:0.68rem;color:#94a3b8;margin-bottom:8px }
434
- .chat-combo-cards { display:flex;flex-direction:column;gap:7px;max-height:260px;overflow-y:auto }
435
- .chat-combo-cards .chat-product-card { margin:0 }
436
- /* Make the "Thêm giỏ" button on combo cards clearly visible */
437
- .chat-combo-cards .chat-product-card-btn.cart-btn {
438
- background: rgba(37,211,102,0.16);
439
- color:#4ade80; font-weight:700; flex:1.3;
440
- }
441
- .chat-combo-cards .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 }
442
- .chat-combo-cards .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff }
443
- .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 }
444
- .chat-combo-random:hover { background:rgba(34,211,238,0.28) }
445
- /* Chatbox mute button — highlighted red when silent mode is ON */
446
- #chatbox-mute-btn { color:#94a3b8 }
447
- #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 }
448
- #detail-share-btn { display:inline-flex!important }
449
 
450
- /* FIX: Suggested Question Pills CSS */
451
- .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; }
452
- .suggested-pill:hover { background:#dbeafe; border-color:#005580; }
453
- </style>
454
-
455
- <style>
456
- /* ── Cart Drawer (V.AISTUDIO layout) ── */
457
- .cart-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;backdrop-filter:blur(3px)}
458
- .cart-drawer{position:fixed;top:0;right:0;width:400px;max-width:90vw;height:100vh;height:100dvh;background:#fff;color:#1e293b;z-index:201;display:flex;flex-direction:column;box-shadow:-4px 0 30px rgba(0,0,0,.15);transform:translateX(100%);transition:transform .35s ease;visibility:hidden}
459
- .cart-drawer.open{transform:translateX(0);visibility:visible}
460
- .cart-header{padding:20px;border-bottom:2px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between}
461
- .cart-header h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:8px;color:#1e293b;margin:0}
462
- .cart-close{width:34px;height:34px;border:none;background:#e2e8f0;border-radius:9px;cursor:pointer;font-size:.9rem;color:#475569}
463
- .cart-items{flex:1;overflow-y:auto;padding:16px;min-height:0}
464
- .cart-item{display:flex;gap:12px;padding:12px;background:#f8fafc;border-radius:12px;margin-bottom:10px;align-items:center}
465
- .cart-item img{width:56px;height:56px;border-radius:8px;object-fit:cover;background:#fff;flex-shrink:0;border:1px solid #e2e8f0}
466
- .cart-item-info{flex:1;min-width:0}
467
- .cart-item-name{font-size:.8rem;font-weight:600;color:#1e293b;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
468
- .cart-item-price{font-size:.88rem;font-weight:800;color:#003f62;margin-top:4px}
469
- .cart-item-qty{display:flex;align-items:center;gap:8px;margin-top:6px}
470
- .qty-btn{width:26px;height:26px;border:1px solid #e2e8f0;border-radius:6px;background:#fff;cursor:pointer;font-size:.8rem;display:flex;align-items:center;justify-content:center;color:#334155}
471
- .qty-val{font-size:.82rem;font-weight:700;min-width:20px;text-align:center;color:#1e293b}
472
- .cart-item-del{background:none;border:none;color:#dc3545;cursor:pointer;font-size:.85rem;padding:4px}
473
- .cart-footer{padding:10px 14px;border-top:2px solid #e2e8f0;background:#fff;flex-shrink:0}
474
- .cart-total{display:flex;justify-content:space-between;font-size:.92rem;font-weight:800;color:#1e293b;margin-bottom:6px}
475
- .cart-footer-btns{display:flex;gap:6px;margin-top:6px}
476
- .cart-checkout-btn{flex:1;padding:10px 6px;font-size:.78rem;border-radius:10px;border:none;color:#fff;font-weight:700;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;gap:4px}
477
- .cart-empty{text-align:center;padding:40px;color:#64748b}
478
- .cart-empty i{font-size:2rem;color:#cbd5e1;margin-bottom:8px}
479
- .cart-empty h3{color:#1e293b;margin:0 0 4px}
480
- #cart-badge-btn svg{display:block}
481
-
482
- /* ── Quotation Modal (V.AISTUDIO layout) ── */
483
- .quote-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:300;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(4px)}
484
- .quote-overlay.open{display:flex}
485
- .quote-modal{background:#fff;border-radius:20px;max-width:960px;width:100%;max-height:92vh;overflow-y:auto;padding:0;position:relative;color:#1e293b}
486
- .quote-close{position:absolute;top:16px;right:16px;width:34px;height:34px;border:none;background:rgba(255,255,255,.2);border-radius:9px;cursor:pointer;color:#fff;font-size:.9rem;display:flex;align-items:center;justify-content:center}
487
- .quote-head{background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;padding:24px 28px;border-radius:20px 20px 0 0}
488
- .quote-head h2{font-size:1.2rem;font-weight:800;margin:0 0 4px;color:#fff}
489
- .quote-head p{font-size:.8rem;opacity:.75;margin:0}
490
- .quote-body{padding:24px 28px}
491
- .quote-section{margin-bottom:20px}
492
- .quote-section h3{font-size:.88rem;font-weight:700;color:#1e293b;margin:0 0 12px;display:flex;align-items:center;gap:8px}
493
- .quote-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
494
- .quote-field label{font-size:.72rem;font-weight:600;color:#475569;display:block;margin-bottom:4px}
495
- .quote-field input{width:100%;padding:9px 12px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:.82rem;font-family:inherit;outline:none;color:#1e293b;background:#fff}
496
- .quote-table{width:100%;border-collapse:collapse;font-size:.78rem;margin-top:8px}
497
- .quote-table th{background:#003f62;color:#fff;padding:8px 6px;font-weight:600;text-align:center;white-space:nowrap}
498
- .quote-table td{padding:8px 6px;border-bottom:1px solid #e2e8f0;vertical-align:middle;color:#334155}
499
- .quote-table tbody tr:nth-child(odd){background:#f8fafc}
500
- .quote-total-row td{font-weight:800;color:#003f62;padding:12px 6px}
501
- .qt-price{text-align:right;white-space:nowrap}
502
- .quote-table img{width:40px;height:40px;object-fit:cover;border-radius:6px}
503
- .quote-actions{display:flex;gap:10px;margin-top:20px;flex-wrap:wrap;justify-content:center}
504
- .quote-btn{padding:12px 24px;border:none;border-radius:10px;font-weight:700;font-size:.85rem;cursor:pointer;font-family:inherit;display:flex;align-items:center;gap:8px;color:#fff}
505
- .quote-btn-excel{background:#217346}
506
- .quote-btn-pdf{background:#dc3545}
507
- .quote-btn-print{background:#003f62}
508
- .quote-btn-print:hover{background:#00293f}
509
- .quote-field input:focus{border-color:#0077b6}
510
- .quote-table .qt-img{width:72px;height:72px;object-fit:contain;border-radius:6px;background:#f1f5f9;border:1px solid #e2e8f0}
511
- .quote-table .qt-disc{width:80px;padding:5px 6px;border:1.5px solid #e2e8f0;border-radius:6px;font-size:.78rem;text-align:right;font-family:inherit;outline:none}
512
- .quote-table .qt-disc:focus{border-color:#0077b6}
513
- .quote-table .qt-note{width:100%;min-width:90px;padding:5px 6px;border:1.5px solid #e2e8f0;border-radius:6px;font-size:.78rem;font-family:inherit;outline:none}
514
- .qt-extra-row td{font-size:.82rem}
515
- @media(max-width:768px){.quote-grid{grid-template-columns:1fr}.quote-modal{border-radius:12px}.quote-table{font-size:.7rem}}
516
-
517
- /* ── Ask AI button on news cards ── */
518
- .source-card-askai{display:flex;align-items:center;gap:6px;margin:8px 4px 2px;padding:7px 12px;background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;border:none;border-radius:10px;font-size:.72rem;font-weight:600;cursor:pointer;font-family:inherit;transition:all .2s;width:fit-content}
519
- .source-card-askai:hover{opacity:.9;transform:translateY(-1px)}
520
-
521
- /* ── Đơn hàng (order) buttons — always visible ── */
522
- #order-btn, #vaistudio-order-btn { display: flex !important; }
523
  </style>
524
 
525
  <!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
526
- <script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
527
- <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
528
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
529
- <script src="./src/cart-quote.js?gc=15"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
530
- <script src="./src/vaix-rag.js?gc=44"></script> <!-- V.AI STUDIO RAG Module - loads FASTER via light index; lazy galleries; spec-aware search -->
531
- <script src="./src/greeting-news.js?gc=20"></script> <!-- Greeting HOT news cards + source links -->
532
- <script src="./src/order-sync.js?gc=12"></script> <!-- Order sync with V.AISTUDIO backend -->
533
- <script type="module" src="./src/app.js?gc=49"></script>
534
- <script src="./src/greeting-source-cards.js?gc=14"></script> <!-- FIX: Nguồn tin cards under greeting -->
535
- <script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
536
-
537
- <script>
538
- // Wire the "Đơn hàng" buttons to the order modal
539
- (function () {
540
- function wire() {
541
- var ob = document.getElementById("order-btn");
542
- if (ob) { ob.onclick = function (e) { e.preventDefault(); e.stopPropagation(); if (window.openOrderModal) window.openOrderModal(); }; }
543
- var vob = document.getElementById("vaistudio-order-btn");
544
- if (vob) { vob.onclick = function (e) { e.preventDefault(); e.stopPropagation(); if (window.openOrderModal) window.openOrderModal(); }; }
545
- }
546
- wire();
547
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire);
548
- // Re-wire after cart-quote.js loads (it runs synchronously before this block)
549
- window.addEventListener("load", wire);
550
- })();
551
- </script>
552
  </body>
553
  </html>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Gemma Avatar + V.AI STUDIO - Chat & Voice</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap" rel="stylesheet" />
10
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
11
+ <link rel="stylesheet" href="./src/style.css" />
12
  </head>
13
  <body>
14
  <main id="app">
 
19
  <div id="welcome-modal" class="welcome-modal-overlay">
20
  <div class="welcome-modal">
21
  <div class="welcome-avatar-icon">🤖</div>
22
+ <h2 class="welcome-title">Chào mừng đến với Gemma Avatar</h2>
23
  <p class="welcome-subtitle">Bạn muốn giao tiếp thế nào?</p>
 
 
 
 
 
 
 
 
 
24
  <div class="welcome-actions">
 
 
 
 
 
 
 
25
  <button id="welcome-chat-btn" class="welcome-btn welcome-btn-text" aria-label="Chat tin nhắn">
26
  <span class="welcome-btn-icon">💬</span>
27
  <span class="welcome-btn-text-content">
 
37
  </span>
38
  </button>
39
  </div>
 
 
 
 
 
 
 
 
40
  <p class="welcome-hint">Bạn luôn có thể chuyển đổi chế độ sau</p>
41
  </div>
42
  </div>
43
 
 
 
 
 
 
 
 
 
 
 
44
  <header id="topbar">
45
  <div id="identity">
46
+ <h1>Gemma&nbsp;Avatar + V.AI STUDIO</h1>
47
+ <p>Chat or talk to <strong>Gemma&nbsp;4</strong>. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </div>
49
+ <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
50
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51
+ <circle cx="12" cy="12" r="3"></circle>
52
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06-.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
53
+ </svg>
54
+ </button>
55
  </header>
56
 
57
  <div id="subtitles" aria-live="polite"></div>
 
61
  <span id="chat-title">Text Chat</span>
62
  <div id="chat-header-actions">
63
  <select id="chat-avatar-select" title="Switch avatar"></select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  <button id="chat-close-btn" class="icon-btn small" aria-label="Close text chat" title="Close">
65
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
66
  <line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
 
71
  <div id="chat-messages"></div>
72
  <div id="chat-input-container">
73
  <input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
 
 
 
 
 
 
74
  <button id="chat-send-btn" class="icon-btn" aria-label="Send message">
75
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
76
  <line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
77
  </svg>
78
  </button>
79
  </div>
 
 
80
  <div id="chat-resize-handle"></div>
81
  </div>
82
 
 
84
  <div id="caption">LOADING…</div>
85
  <div id="buttons">
86
  <button id="main-btn" disabled><span id="main-btn-label">Loading…</span></button>
87
+ <button id="mute-btn" class="icon-btn" hidden aria-label="Mute microphone" title="Mute microphone">
88
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
89
  <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
90
  <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>
 
124
  <h4 style="font-size:0.9rem;font-weight:700;margin:0">V.AI STUDIO</h4>
125
  <span id="vaistudio-count" style="font-size:0.68rem;opacity:0.75">Đang tải sản phẩm…</span>
126
  </div>
 
 
 
127
  <button id="vaistudio-close" aria-label="Close" style="width:40px;height:40px;min-width:40px;min-height:40px;border-radius:50%;background:rgba(255,255,255,0.25);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:all 0.2s" onmouseover="this.style.background='rgba(255,255,255,0.4)'" onmouseout="this.style.background='rgba(255,255,255,0.25)'">
128
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
129
  </button>
 
174
  <h3 style="font-size:0.8rem;font-weight:700;color:#003f62;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em">Thông số kỹ thuật</h3>
175
  <table id="detail-specs-table" style="width:100%;border-collapse:collapse;font-size:0.78rem"><tbody></tbody></table>
176
  </div>
177
+ <!-- Section sản phẩm tương tự và share button được render động bởi vaix-rag.js -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  </div>
179
  </div>
180
 
 
183
  @keyframes modalIn { from { opacity:0;transform:translateY(20px) scale(0.96) } to { opacity:1;transform:translateY(0) scale(1) } }
184
 
185
  /* ── Welcome Modal ── */
186
+ .welcome-modal-overlay {
187
+ position: fixed; inset: 0; z-index: 500;
188
+ display: flex; align-items: center; justify-content: center;
189
+ background: rgba(0,0,0,0.85);
190
+ opacity: 0; visibility: hidden; transition: all 0.3s ease;
191
+ }
192
+ .welcome-modal-overlay.show {
193
+ opacity: 1; visibility: visible;
194
+ }
195
+ .welcome-modal {
196
+ background: #1a1a1a; border-radius: 20px; padding: 40px;
197
+ text-align: center; max-width: 400px; width: 90%;
198
+ border: 1px solid #333; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
199
+ transform: translateY(20px); transition: transform 0.3s ease;
200
+ }
201
+ .welcome-modal-overlay.show .welcome-modal {
202
+ transform: translateY(0);
203
+ }
204
  .welcome-avatar-icon { font-size: 3rem; margin-bottom: 16px; }
205
+ .welcome-title {
206
+ color: #fff; font-size: 1.5rem; margin-bottom: 8px;
207
+ font-family: Inter, sans-serif; font-weight: 600;
208
+ }
209
+ .welcome-subtitle {
210
+ color: #888; font-size: 0.9rem; margin-bottom: 24px;
211
+ font-family: Inter, sans-serif;
212
+ }
213
+ .welcome-actions {
214
+ display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px;
215
+ }
216
+ .welcome-btn {
217
+ padding: 16px 24px; border: none; border-radius: 12px;
218
+ cursor: pointer; font-family: Inter, sans-serif;
219
+ font-size: 1rem; font-weight: 600; text-align: left;
220
+ display: flex; align-items: center; gap: 12px;
221
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
222
+ }
223
  .welcome-btn:hover { transform: scale(1.02); }
224
  .welcome-btn:active { transform: scale(0.98); }
225
+ .welcome-btn-text {
226
+ background: linear-gradient(135deg, #003f62, #0077b6);
227
+ color: #fff;
228
+ box-shadow: 0 4px 16px rgba(0,63,98,0.3);
229
+ }
230
  .welcome-btn-text:hover { box-shadow: 0 6px 24px rgba(0,63,98,0.4); }
231
+ .welcome-btn-voice {
232
+ background: linear-gradient(135deg, #6B21A8, #9333EA);
233
+ color: #fff;
234
+ box-shadow: 0 4px 16px rgba(107,33,168,0.3);
235
+ }
236
  .welcome-btn-voice:hover { box-shadow: 0 6px 24px rgba(107,33,168,0.4); }
 
 
237
  .welcome-btn-icon { font-size: 1.3rem; }
238
  .welcome-btn-text-content { display: flex; flex-direction: column; }
239
  .welcome-btn-main-text { font-size: 1rem; font-weight: 600; }
240
  .welcome-btn-desc { font-size: 0.75rem; font-weight: 400; opacity: 0.8; margin-top: 2px; }
241
+ .welcome-hint {
242
+ color: #555; font-size: 0.75rem;
243
+ font-family: Inter, sans-serif;
244
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
  #vaistudio-panel.open { display: flex !important; }
247
  #vaistudio-loading.hidden { display: none !important; }
 
254
  .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px }
255
  .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 }
256
  .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px }
 
 
 
257
 
258
+ /* ── Search suggestions ── */
259
  .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s }
260
  .vaix-suggestion-item:last-child { border-bottom:none }
261
  .vaix-suggestion-item:hover { background:#f0f9ff }
262
  #vaix-suggestions::-webkit-scrollbar { width:5px }
263
  #vaix-suggestions::-webkit-scrollbar-thumb { background:#e2e8f0;border-radius:3px }
264
+
265
+ /* ── Category chips ── */
266
  .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 }
267
  .vaix-category-chip:hover { background:#dbeafe;border-color:#0077b6;transform:translateY(-1px) }
268
+
269
+ /* ── Chat Product Cards ── */
270
  .chat-product-cards { display:flex;flex-direction:column;gap:8px;margin:6px 0;max-width:100% }
271
  .chat-product-card { background:#1e293b;border:1px solid #334155;border-radius:12px;overflow:hidden;transition:all 0.2s }
272
  .chat-product-card:hover { border-color:#0077b6;box-shadow:0 2px 8px rgba(0,119,182,0.15) }
 
277
  .chat-product-card-body { flex:1;min-width:0;display:flex;flex-direction:column;gap:2px }
278
  .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 }
279
  .chat-product-card-meta { display:flex;align-items:center;gap:6px;flex-wrap:wrap }
 
280
  .chat-product-card-brand { font-size:0.68rem;color:#94a3b8 }
281
  .chat-product-card-stars { font-size:0.6rem;color:#f59e0b;letter-spacing:1px }
282
  .chat-product-card-price { font-size:0.82rem;font-weight:700;color:#22d3ee;margin-top:1px }
 
287
  .chat-product-card-btn:first-child { border-right:1px solid #334155 }
288
  .chat-product-card-btn.detail-btn:hover { color:#22d3ee }
289
  .chat-product-card-btn.share-btn:hover { color:#f59e0b }
 
 
 
 
 
290
  .chat-message.assistant .chat-product-card { background:#1e293b;border:1px solid #334155;margin:4px 0 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
+ /* ── Share button inline style overrides for dark theme ── */
293
+ #detail-share-btn { display:inline-flex!important }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  </style>
295
 
296
  <!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
297
+ <script src="./src/vaix-rag.js"></script>
298
+ <script type="module" src="./src/app.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  </body>
300
  </html>
index.ts CHANGED
@@ -7,20 +7,10 @@
7
  * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access).
8
  * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode.
9
  */
10
- import { readFileSync } from "fs";
11
- // Read index.html directly as text to avoid Bun HTMLBundle object
12
- const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8");
13
  import { readdir } from "fs/promises";
14
  import { join } from "path";
15
  import { existsSync } from "fs";
16
- import { synthesize as edgeSynthesize } from "./edge-tts.mjs";
17
-
18
- // ── Edge TTS memory cache: { key: audioBytes } — bound to prevent growth ──
19
- const TTS_CACHE = new Map<string, Uint8Array>();
20
- const TTS_CACHE_MAX = 400;
21
- function ttsCacheKey(text: string, voice: string) {
22
- return voice + "|" + text.trim().slice(0, 600);
23
- }
24
 
25
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
26
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
@@ -57,8 +47,7 @@ async function loadVaixProducts(): Promise<void> {
57
  const data = await resp.json();
58
  VAIX_PRODUCTS = Array.isArray(data) ? data : [];
59
  VAIX_LAST_SYNC = Date.now();
60
- VAIX_PRODUCTS = VAIX_PRODUCTS.slice(0, 1000); // Limit to 1000 for memory
61
- console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products (capped from " + data.length + ") in " + (Date.now() - startMs) + "ms");
62
  } catch (err: any) {
63
  console.error("[VAIX] Error:", err.message);
64
  setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000);
@@ -86,40 +75,8 @@ async function proxy(path: string, req: Request, init: any = {}): Promise<Respon
86
  return out;
87
  }
88
 
89
- function getMimeType(name: string): string {
90
- const ext = name.split(".").pop()?.toLowerCase() ?? "";
91
- const types: Record<string, string> = {
92
- "js": "application/javascript",
93
- "mjs": "application/javascript",
94
- "cjs": "application/javascript",
95
- "css": "text/css",
96
- "html": "text/html",
97
- "json": "application/json",
98
- "png": "image/png",
99
- "jpg": "image/jpeg",
100
- "gif": "image/gif",
101
- "svg": "image/svg+xml",
102
- "webp": "image/webp",
103
- "ico": "image/x-icon",
104
- "woff2": "font/woff2",
105
- "woff": "font/woff",
106
- "ttf": "font/ttf",
107
- "bin": "application/octet-stream",
108
- "mjs": "application/javascript",
109
- };
110
- return types[ext] ?? "application/octet-stream";
111
- }
112
  function staticFile(dir: string, name: string) {
113
- const path = import.meta.dir + "/public/" + dir + "/" + name;
114
- const mime = getMimeType(name);
115
- // no-cache: always revalidate so a deploy is picked up immediately (this Space
116
- // is edited/deployed frequently). CDN/browser won't serve stale JS/CSS.
117
- return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } });
118
- }
119
- function srcFile(dir: string, name: string) {
120
- const path = import.meta.dir + "/" + dir + "/" + name;
121
- const mime = getMimeType(name);
122
- return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } });
123
  }
124
 
125
  async function listAvatars() {
@@ -159,106 +116,133 @@ function formatProduct(p: any, idx: number) {
159
  };
160
  }
161
 
162
- async function serveProductOG(req: Request): Promise<Response> {
163
- const url = new URL(req.url);
164
- const productSlug = url.searchParams.get("product");
165
 
166
- function esc(s: string): string {
167
- return (s || "")
168
- .replace(/&/g, '&amp;')
169
- .replace(/</g, '&lt;')
170
- .replace(/>/g, '&gt;')
171
- .replace(/"/g, '&quot;')
172
- .replace(/'/g, '&#39;')
173
- .replace(/\n/g, " ")
174
- .replace(/\r/g, " ")
175
- .trim()
176
- .slice(0, 350);
177
  }
178
-
179
- const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp";
180
-
181
- if (productSlug && VAIX_PRODUCTS?.length) {
182
- const foundProduct = VAIX_PRODUCTS.find((p: any) => {
183
- const slug = p.slug || p._source_alias || "";
184
- const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
185
- const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
186
- return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug);
187
- });
188
- if (foundProduct) {
189
- const fp = formatProduct(foundProduct, foundProduct._idx ?? 0);
190
- const fpName = esc(fp.name || fp.model || "Sản phẩm");
191
- const fpBrand = esc(fp.brand || "V.AI STUDIO");
192
- // ── Image URL: use escURL (not esc) because we need proper URL encoding for og:image ──
193
- const fpImageRaw = fp.image || (fp.images && fp.images[0]) || "";
194
- const fpImg = fpImageRaw ? escURL(fpImageRaw) : fallbackImg;
195
-
196
- // ── Description: 600 chars to fit full summary + specs ──
197
- const fpDesc = esc((fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 600));
198
-
199
- // ── Specs: show all specs up to 12 keys ──
200
- const fpSpecs = fp.specs || {};
201
- let specsStr = "";
202
- const specKeys = Object.keys(fpSpecs).slice(0, 12);
203
- for (const sk of specKeys) {
204
- specsStr += (specsStr ? ", " : " | ") + esc(sk) + ": " + esc(String(fpSpecs[sk]));
205
- }
206
- const ogDescription = fpDesc + (specsStr ? " | " + specsStr.trim() : "") || "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp";
207
-
208
- // ── Price: formatted VND string for og:title ──
209
- const priceNum = fp.priceNum || Number(fp.pn) || 0;
210
- const ogPrice = priceNum > 0 ? " - " + Number(priceNum).toLocaleString("vi-VN") + "₫" : "";
211
- const ogTitle = fpName + " | " + fpBrand + ogPrice + " - V.AI STUDIO";
212
-
213
- const ogTags = buildOGTags(ogTitle, ogDescription, fpImg, url.href, true, priceNum);
214
- return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
215
- status: 200,
216
- headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" },
217
- });
218
- }
219
  }
220
-
221
- // Fallback OG tags — always set for any request (no product match or no ?product param)
222
- const fallbackTitle = "Gemma Avatar + V.AI STUDIO - Chat & Voice AI";
223
- const fallbackDesc = "Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng.";
224
- const ogTags = buildOGTags(fallbackTitle, fallbackDesc, fallbackImg, url.href, false);
225
- return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
226
- status: 200,
227
- headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" },
228
- });
229
  }
230
 
231
- function buildOGTags(title: string, desc: string, image: string, href: string, isProduct: boolean, price = 0): string {
232
- return [
233
- '<meta property="og:title" content="' + title + '" />',
234
- '<meta property="og:description" content="' + desc + '" />',
235
- '<meta property="og:url" content="' + escURL(href) + '" />',
236
- '<meta property="og:type" content="' + (isProduct ? 'product' : 'website') + '" />',
237
- '<meta property="og:image" content="' + image + '" />',
238
- '<meta property="og:image:secure_url" content="' + image + '" />',
239
- '<meta property="og:image:type" content="image/jpeg" />',
240
- '<meta property="og:image:width" content="1200" />',
241
- '<meta property="og:image:height" content="630" />',
242
- isProduct ? '<meta property="product:price:amount" content="' + price + '" />' : '',
243
- isProduct ? '<meta property="product:price:currency" content="VND" />' : '',
244
- isProduct ? '<meta property="product:availability" content="in stock" />' : '',
245
- isProduct ? '<meta property="product:condition" content="new" />' : '',
246
- '<meta property="fb:app_id" content="1321688464574422" />',
247
- '<meta name="twitter:card" content="summary_large_image" />',
248
- '<meta name="twitter:site" content="@bep40" />',
249
- '<meta name="twitter:title" content="' + title + '" />',
250
- '<meta name="twitter:description" content="' + desc + '" />',
251
- '<meta name="twitter:image" content="' + image + '" />',
252
- '<meta name="description" content="' + desc + '" />',
253
- ].filter(Boolean).join("\n ");
254
- }
255
 
256
- function escURL(s: string): string {
257
- // Only escape what's needed for HTML attribute — URL-safe
258
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  }
260
 
261
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
  function serveStatic(req: Request): Response {
264
  const url = new URL(req.url);
@@ -292,541 +276,299 @@ async function sessionHandler(req: Request): Promise<Response> {
292
  }
293
  }
294
 
295
- // ── Text-only chat endpoint ─────────────────────────────────────────────
296
- // Chat + vấn nhanh dùng Google Gemma qua HF Inference Providers (router).
297
- // Streaming (SSE) + cascade gemma-4-31B -> gemma-3-12B -> gemma-3-4B for resilience.
298
- const CHAIN: Array<{ model: string }> = [
299
- { model: "google/gemma-4-31B-it" },
300
- { model: "google/gemma-3-12b-it" },
301
- { model: "google/gemma-3-4b-it" },
302
- ];
303
- const ROUTER = "https://router.huggingface.co/v1/chat/completions";
304
- const CHAT_TIMEOUT_MS = 45000;
305
-
306
- // Helper to present one model. Returns { reply, model } or throws.
307
- async function chatOnce(model: string, messages: any[], token: string) {
308
- const body = {
309
- model,
310
- messages,
311
- max_tokens: 700,
312
- temperature: 0.7,
313
- top_p: 0.9,
314
- stream: false,
315
- };
316
- const resp = await fetch(ROUTER, {
317
- method: "POST",
318
- headers: {
319
- "Content-Type": "application/json",
320
- "Authorization": `Bearer ${token}`,
321
- "User-Agent": "gemma-avatar",
322
- },
323
- body: JSON.stringify(body),
324
- signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
325
- });
326
- const text = await resp.text().catch(() => "");
327
- if (!resp.ok) {
328
- const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200));
329
- err.status = resp.status;
330
- throw err;
331
- }
332
- try {
333
- const data = JSON.parse(text);
334
- const raw = data?.choices?.[0]?.message?.content;
335
- if (raw && String(raw).trim()) return { reply: String(raw).trim(), model };
336
- } catch (_) {}
337
- const err: any = new Error("Empty response from " + model);
338
- err.status = 500;
339
- throw err;
340
- }
341
-
342
- // ── Server-side tool execution (restores news/web/product search in /api/chat) ──
343
- const CHAT_TOOLS = [
344
- { type: "function", function: {
345
- name: "search_news",
346
- 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ự.",
347
- parameters: { type: "object", properties: { query: { type: "string", description: "Chủ đề / từ khóa tin tức" } }, required: ["query"] },
348
- }},
349
- { type: "function", function: {
350
- name: "search_web",
351
- description: "Tìm kiếm thông tin trên web cho một câu hỏi hoặc chủ đề bất kỳ.",
352
- parameters: { type: "object", properties: { query: { type: "string", description: "Câu hỏi hoặc từ khóa tìm kiếm" } }, required: ["query"] },
353
- }},
354
- { type: "function", function: {
355
- name: "query_catalog",
356
- 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.",
357
- 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"] },
358
- }},
359
- { type: "function", function: {
360
- name: "show_product",
361
- 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.",
362
- parameters: { type: "object", properties: { product_name: { type: "string", description: "Tên hoặc mã sản phẩm" } }, required: ["product_name"] },
363
- }},
364
- { type: "function", function: {
365
- name: "combo_suggest",
366
- 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.",
367
- 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: [] },
368
- }},
369
- ];
370
-
371
- const sleepMs = (ms: number) => new Promise(r => setTimeout(r, ms));
372
-
373
- async function ddgSearch(query: string) {
374
- const results: Array<{ title: string; snippet: string; url: string; source: string }> = [];
375
- const queries = [
376
- query,
377
- query + " hôm nay",
378
- (query.length < 30 ? "tin tức " + query : query),
379
- ];
380
- await Promise.all(queries.map(q =>
381
- fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) })
382
- .then(r => r.text())
383
- .then(h => {
384
- const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
385
- const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
386
- for (let i = 0; i < Math.min(lm.length, 5); i++) {
387
- let href = lm[i][1].replace(/&amp;/g, "&");
388
- const ru = href.match(/uddg=(https?%3[^&]+)/i);
389
- if (ru) { try { href = decodeURIComponent(ru[1]); } catch (_) {} }
390
- const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
391
- if (!title) continue;
392
- const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
393
- let source = "";
394
- try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {}
395
- results.push({ title, snippet, url: href, source });
396
- }
397
- })
398
- .catch(() => {})
399
- ));
400
- const seen = new Set<string>();
401
- const unique = results.filter(r => {
402
- if (!r.url || seen.has(r.url)) return false;
403
- seen.add(r.url);
404
- return r.source && !/duckduckgo|google/i.test(r.source);
405
- });
406
- return unique.slice(0, 8);
407
- }
408
-
409
- // Reliable Vietnamese news search via Google News RSS (proven reachable from
410
- // HF Spaces — the duckduckgo html endpoint is NOT reliably reachable).
411
- async function searchNewsGoogle(query: string) {
412
- try {
413
- const resp = await fetch(
414
- "https://news.google.com/rss/search?q=" + encodeURIComponent(query) +
415
- "&hl=vi-VN&gl=VN&ceid=VN:vi",
416
- { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" }, signal: AbortSignal.timeout(15000) }
417
- );
418
- if (!resp.ok) throw new Error("RSS failed " + resp.status);
419
- const rssText = await resp.text();
420
- const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
421
- const articles: Array<{ title: string; url: string; source: string; desc: string }> = [];
422
- for (const block of itemBlocks) {
423
- const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
424
- const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
425
- const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i);
426
- const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
427
- if (!tm) continue;
428
- let title = tm[1].replace(/\+|_/g, " ").trim();
429
- let source = "";
430
- if (sm) {
431
- source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim();
432
- if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim();
433
- }
434
- if (!title) continue;
435
- let url = "";
436
- if (lm) url = (lm[1] || "").trim();
437
- let desc = "";
438
- if (dm) {
439
- desc = dm[1].replace(/<[^>]*>/g, " ").replace(/\bhttps?:\/\/\S+/gi, "")
440
- .replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
441
- .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " ")
442
- .replace(/\s+/g, " ").trim().slice(0, 160);
443
- }
444
- if (title && url) articles.push({ title, url, source: source || "", desc });
445
- if (articles.length >= 8) break;
446
- }
447
- return articles;
448
- } catch (e) {
449
- return [];
450
- }
451
- }
452
-
453
- async function executeChatTool(name: string, args: any): Promise<string> {
454
- const clean = (s: any) => String(s || "").trim();
455
- try {
456
- if (name === "search_news") {
457
- const q = clean(args?.query);
458
- if (!q) return "Thiếu từ khóa tìm kiếm tin tức.";
459
- const arts = await searchNewsGoogle(q);
460
- if (!arts.length) return "Không tìm thấy tin tức cho '" + q + "'.";
461
- return arts.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.desc ? "\n " + r.desc : "") + "\n " + r.url).join("\n");
462
- }
463
- if (name === "search_web") {
464
- const q = clean(args?.query);
465
- if (!q) return "Thiếu từ khóa tìm kiếm.";
466
- const res = await ddgSearch(q);
467
- if (!res.length) return "Không tìm thấy kết quả cho '" + q + "'.";
468
- return res.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.snippet ? "\n " + r.snippet : "") + "\n " + r.url).join("\n");
469
- }
470
- if (name === "query_catalog") {
471
- await loadVaixProducts();
472
- const q = clean(args?.query);
473
- if (!q) return "Thiếu từ khóa tìm kiếm sản phẩm.";
474
- const results = serverSearchProducts(q, 6);
475
- 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ã).";
476
- return formatProductLines(results).join("\n");
477
- }
478
- if (name === "show_product") {
479
- await loadVaixProducts();
480
- const q = clean(args?.product_name);
481
- if (!q) return "Thiếu tên sản phẩm.";
482
- const results = serverSearchProducts(q, 3);
483
- if (!results.length) return "Không tìm thấy sản phẩm '" + q + "' trong catalog.";
484
- const p = results[0];
485
- 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 || "");
486
- return [
487
- (p.title_clean || p.name || "") + (p.brand ? " (" + p.brand + ")" : "") + " — mã " + (p.model || p.sku || "?"),
488
- "Giá: " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ"),
489
- specs ? ("Thông tin: " + specs) : "",
490
- p.link ? ("Link: " + p.link) : "",
491
- ].filter(Boolean).join("\n");
492
- }
493
- if (name === "combo_suggest") {
494
- await loadVaixProducts();
495
- const categories: string[] = Array.isArray(args?.categories) ? args.categories.map((c: any) => String(c)) : [];
496
- 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'].";
497
- const brand = clean(args?.brand);
498
- const style: any = {};
499
- if (clean(args?.material)) style.material = clean(args?.material);
500
- if (clean(args?.color)) style.color = clean(args?.color);
501
- if (clean(args?.feature)) style.feature = clean(args?.feature);
502
- const items = serverBuildCombo(categories, brand, Number(args?.minPrice) || 0, Number(args?.maxPrice) || 0, style, args?.sizes);
503
- 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(", ") + ".";
504
-
505
- const note = [
506
- brand ? "thương hiệu " + brand : "",
507
- style.material ? "chất liệu " + style.material : "",
508
- style.feature ? "tính năng " + style.feature : "",
509
- style.color ? "màu " + style.color : "",
510
- ].filter(Boolean).join(", ");
511
- const head = "Combo " + categories.join(" + ") + (note ? " (theo yêu cầu: " + note + ")" : "") + ":";
512
- const total = items.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0);
513
- return head + "\n" + formatProductLines(items).join("\n") + "\nTổng combo (" + items.length + " món): " + (total > 0 ? total.toLocaleString("vi-VN") + " ₫" : "Liên hệ");
514
- }
515
- return "Tool không hỗ trợ: " + name;
516
- } catch (e: any) {
517
- return "Lỗi khi chạy tool " + name + ": " + String(e?.message || e);
518
- }
519
- }
520
-
521
- // ── Server-side catalog search + combo (same logic the client panel uses) ──
522
- // Grounds the text-chat model on REAL V.AI STUDIO products so it never answers
523
- // off-space / invents product info, and keeps product context across follow-ups.
524
- const CAT_HINTS: string[] = [
525
- "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",
526
- "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",
527
- "thùng rác","gia vị","phụ kiện","combo","nồi","lẩu","chảo",
528
- ];
529
- function isGenericCatQuery(q: string): boolean {
530
- const s = String(q || "").toLowerCase();
531
- if (!s) return true;
532
- for (const k of CAT_HINTS) if (s.includes(k)) return false;
533
- return true;
534
- }
535
- function serverSearchProducts(q: string, limit: number): any[] {
536
- if (!VAIX_PRODUCTS?.length) return [];
537
- const qNorm = norm(q);
538
- const terms = qNorm.split(/\s+/).filter((t: string) => t.length > 1);
539
- const scored: Array<{ p: any; s: number }> = [];
540
- for (let i = 0; i < VAIX_PRODUCTS.length; i++) {
541
- const p = VAIX_PRODUCTS[i];
542
- const nm = norm(p.name || p.n || "");
543
- const sk = norm(p.sku || p.mod || p.model || "");
544
- const br = norm(p.brand || "");
545
- const ca = norm(p.c || p.cat || "");
546
- const de = norm(p.desc || "");
547
- const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : "");
548
- let score = 0;
549
- if (nm.includes(qNorm)) score += 20;
550
- if (sk.includes(qNorm)) score += 15;
551
- if (br.includes(qNorm)) score += 10;
552
- if (ca.includes(qNorm)) score += 8;
553
- for (const t of terms) {
554
- if (nm.includes(t)) score += 3;
555
- if (sk.includes(t)) score += 2;
556
- if (br.includes(t)) score += 2;
557
- if (ca.includes(t)) score += 1;
558
- if (de.includes(t)) score += 1;
559
- if (ft.includes(t)) score += 1;
560
- }
561
- if (score > 0) scored.push({ p, s: score });
562
- }
563
- scored.sort((a, b) => b.s - a.s);
564
- return scored.slice(0, limit).map(x => formatProduct(x.p, x.p._idx ?? 0));
565
- }
566
- // Derive a coarse product family from the product NAME for diverse combos.
567
- function serverItemFamily(p: any): string {
568
- const nm = norm(p.name || p.n || "");
569
- const pairs: Array<[string[], string]> = [
570
- [[ "khoacuathongminh","khoadientu","khoathongminh","vantay","faceid","smartlock" ], "khóa cửa thông minh"],
571
- [[ "khoacua","khoatu","okhoa","khoa" ], "khóa"],
572
- [[ "beptu","bephongngoai","bepgas","bepdien","bept" ], "bếp từ"],
573
- [[ "mayhutmui","mayhutkhoi","hutmui","hutkhoi" ], "máy hút mùi"],
574
- [[ "chau ruach","chau rua","bonrua","bonchen" ], "chậu rửa"],
575
- [[ "voirua","voinuoc" ], "vòi rửa"],
576
- [[ "lonuong","lohap","giunong" ], "lò nướng"],
577
- [[ "lovisong","loveda","visong" ], "lò vi sóng"],
578
- [[ "noichien","noi chien" ], "nồi chiên"],
579
- [[ "mayruachen","mayruabat","may sấy chen" ], "máy rửa chén"],
580
- [[ "mayxay","mayep","sinhto" ], "máy xay/ép"],
581
- [[ "tulanh","turuou" ], "tủ lạnh"],
582
- [[ "kexoongnoi","giaxoongnoi","kexoong","giaxoong","xoongnoi" ], "kệ xoong nồi"],
583
- [[ "kechendia","giachendia","giabatdia","kebatdia","kechend","kebechendia" ], "kệ chén dĩa"],
584
- [[ "kedaothot","giadaothot","giadao","kedao" ], "kệ dao thớt"],
585
- [[ "giagoc","kegoc","giagoclienhoan","goclienhoan" ], "giá góc"],
586
- [[ "thungrac","thung rac","thungracamtu" ], "thùng rác"],
587
- [[ "giagiavi","kegiavi","giachailo","kechailo","chailo" ], "giá gia vị"],
588
- [[ "khaychia","khay chiat","khaynhao","tha dian","thiadia","thia nia" ], "khay chia"],
589
- ];
590
- for (const [keys, label] of pairs) for (const k of keys) if (nm.includes(k)) return label;
591
- return "khác";
592
- }
593
- // Check whether a product matches a style requirement (material/color/feature)
594
- // against its title/brand/category/summary/features/specs/description.
595
- function serverStyleMatch(p: any, key: string, term: string): boolean {
596
- if (!term) return true;
597
- const nd = norm(p.name || p.n || "") + " " + norm(p.brand || "") + " " + norm(p.c || p.cat || "") + " " +
598
- norm(p.sum || p.summary || "") + " " + norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " +
599
- norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " +
600
- norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || "");
601
- return nd.includes(norm(term));
602
- }
603
- function serverBuildCombo(categories: string[], brand: string, minPrice: number, maxPrice: number, style?: any, sizes?: any): any[] {
604
- if (!VAIX_PRODUCTS?.length || !categories || !categories.length) return [];
605
- const st = style || {};
606
- const sz = sizes || {};
607
- const hasBudget = (Number(minPrice) > 0) || (Number(maxPrice) > 0);
608
- const picked: any[] = [];
609
- const used = new Set<number>();
610
- // Product must match at least one requested mm per category (dimension token in
611
- // name/specs/description) — STRICT like the client, so "chén dĩa 700mm" never
612
- // returns an 800mm/900mm rack.
613
- function sizeOk(p: any, cat: string): boolean {
614
- const req = sz[cat];
615
- if (!req || !Array.isArray(req) || !req.length) return true;
616
- const hay = norm(p.name || p.n || "") + " " + norm(p.c || p.cat || "") + " " +
617
- norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " +
618
- norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " +
619
- norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || "");
620
- return req.some((mm: number) => hay.includes(String(mm) + "mm") || hay.includes(String(mm) + " mm"));
621
- }
622
- for (const cat of categories) {
623
- const catNorm = norm(cat);
624
- // Prefer products whose family matches the requested category AND style.
625
- let best: any = null; let bestScore = -1;
626
- for (let i = 0; i < VAIX_PRODUCTS.length; i++) {
627
- const p = VAIX_PRODUCTS[i];
628
- if (used.has(i)) continue;
629
- if (brand && norm(p.brand || "") !== norm(brand)) continue;
630
- if (!sizeOk(p, cat)) continue;
631
- const fam = serverItemFamily(p);
632
- let sc = 0;
633
- if (fam === catNorm) sc += 10;
634
- if (norm(p.c || p.cat || "").includes(catNorm)) sc += 6;
635
- if (norm(p.name || p.n || "").includes(catNorm)) sc += 5;
636
- // Style requirements (soft, preference-weighted).
637
- if (st.material && serverStyleMatch(p, "material", st.material)) sc += 20;
638
- if (st.color && serverStyleMatch(p, "color", st.color)) sc += 20;
639
- if (st.feature && serverStyleMatch(p, "feature", st.feature)) sc += 20;
640
- if (sc > bestScore) { bestScore = sc; best = p; best._i = i; }
641
- }
642
- // Only keep the pick if it satisfies every style requirement; otherwise this
643
- // category can't be matched under the requested style. Prefer a match, but
644
- // don't hard-fail — fall back to the best category match if none satisfies.
645
- if (best && st && Object.keys(st).length) {
646
- const okStyle = (!st.material || serverStyleMatch(best, "material", st.material)) &&
647
- (!st.color || serverStyleMatch(best, "color", st.color)) &&
648
- (!st.feature || serverStyleMatch(best, "feature", st.feature));
649
- if (!okStyle) {
650
- // Try to find a styled alternative; if none, relax to the best match.
651
- let styledBest: any = null; let styledScore = -1;
652
- for (let i = 0; i < VAIX_PRODUCTS.length; i++) {
653
- const p = VAIX_PRODUCTS[i];
654
- if (used.has(i)) continue;
655
- if (brand && norm(p.brand || "") !== norm(brand)) continue;
656
- if (serverItemFamily(p) !== catNorm && !norm(p.c || p.cat || "").includes(catNorm)) continue;
657
- if (!sizeOk(p, cat)) continue;
658
- if (!serverStyleMatch(p, "material", st.material)) continue;
659
- if (!serverStyleMatch(p, "color", st.color)) continue;
660
- if (!serverStyleMatch(p, "feature", st.feature)) continue;
661
- const ps = 10 + (norm(p.c || p.cat || "").includes(catNorm) ? 6 : 0) + (norm(p.name || p.n || "").includes(catNorm) ? 5 : 0);
662
- if (ps > styledScore) { styledScore = ps; styledBest = p; styledBest._i = i; }
663
- }
664
- if (styledBest) best = styledBest;
665
- }
666
- }
667
- // Track index on best for used-set (formatProduct doesn't carry original idx).
668
- if (best) {
669
- const idx = best._i ?? VAIX_PRODUCTS.indexOf(best);
670
- used.add(idx);
671
- picked.push(formatProduct(best, best._idx ?? idx));
672
- }
673
- }
674
- if (hasBudget && picked.length) {
675
- const sum = picked.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0);
676
- const mn = Number(minPrice) || 0, mx = Number(maxPrice) || Infinity;
677
- if (sum < mn || sum > mx) {
678
- // Budget mismatch: relax brand + style constraints and retry.
679
- return serverBuildCombo(categories, "", 0, 0, undefined, sizes);
680
- }
681
- }
682
- return picked.slice(0, 4);
683
- }
684
- function formatProductLines(products: any[]): string[] {
685
- return products.map((p: any) =>
686
- "• " + (p.title_clean || p.name || "") +
687
- (p.brand ? " (" + p.brand + ")" : "") +
688
- (p.model ? " — mã " + p.model : "") +
689
- " — " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ")
690
- );
691
- }
692
-
693
- // One chat completion round that supports tool calls. Returns the message content
694
- // and any tool_calls (name + args + id).
695
- async function chatOnceWithTools(model: string, messages: any[], token: string, withTools: boolean) {
696
- const body: any = {
697
- model,
698
- messages,
699
- max_tokens: 800,
700
- temperature: 0.7,
701
- top_p: 0.9,
702
- stream: false,
703
- tools: withTools ? CHAT_TOOLS : undefined,
704
- tool_choice: withTools ? "auto" : undefined,
705
- };
706
- const resp = await fetch(ROUTER, {
707
- method: "POST",
708
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": "gemma-avatar" },
709
- body: JSON.stringify(body),
710
- signal: AbortSignal.timeout(CHAT_TIMEOUT_MS),
711
- });
712
- const text = await resp.text().catch(() => "");
713
- if (!resp.ok) {
714
- const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200));
715
- err.status = resp.status;
716
- throw err;
717
- }
718
- const data = JSON.parse(text);
719
- const msg = data?.choices?.[0]?.message;
720
- const toolCalls: any[] = Array.isArray(msg?.tool_calls) ? msg.tool_calls : [];
721
- return { content: String(msg?.content || "").trim(), toolCalls, model };
722
- }
723
-
724
  async function textChatHandler(req: Request): Promise<Response> {
 
725
  try {
726
  const reqBody = await req.json();
727
- const userMessage = (reqBody.message || "").toString();
728
  if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
729
 
730
- let conversation = Array.isArray(reqBody.history) ? reqBody.history : [];
731
- conversation = conversation.slice(-12).map((m: any) => ({
732
- role: (m.role === "assistant" || m.role === "user") ? m.role : "user",
733
- content: String(m.content || ""),
734
- }));
735
-
736
- const system = [
737
- "Bạn trợ của V.AI STUDIO. Bạn giỏi tư vấn thiết bị nhà bếp và khóa cửa (Grob, Hafele, Eurogold, Malloca...), NHƯNG bạn cũng là trợ lý tổng quát thân thiện có thể trả lời tin tức và câu hỏi thông thường.",
738
- "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.",
739
- "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.",
740
- "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.",
741
- "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.",
742
- "KHI 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.",
743
- "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.",
744
- "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ề.",
745
- "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.",
746
- "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.",
747
- ].join("\n");
748
- const messages = [{ role: "system", content: system }, ...conversation, { role: "user", content: userMessage }];
749
-
750
- const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim();
751
- if (!token) console.error("[chat] No inference token configured (HF_INFERENCE_TOKEN missing).");
752
-
753
- let lastErr: any = null;
754
- // Decide whether to offer tools: news/current-event/product questions benefit
755
- // from search; plain chit-chat doesn't need it. Offer tools always except very
756
- // short greetings — the model decides via tool_choice:"auto".
757
- const withTools = userMessage.trim().length >= 2 && !/^(chao|hello|hi|xin chao|cam on|oke|ok|bye)[.\s]*$/i.test(userMessage.trim());
758
- for (const step of CHAIN) {
759
- let retries = 3;
760
- for (let attempt = 0; attempt <= retries; attempt++) {
761
- try {
762
- // Tool-calling loop: keep asking with tools, execute any tool_calls the
763
- // model requests, and feed results back until it gives a final answer.
764
- let msgs = [...messages];
765
- let finalReply = "";
766
- let finalModel = step.model;
767
- for (let round = 0; round < 3; round++) {
768
- const { content, toolCalls, model } = await chatOnceWithTools(step.model, msgs, token, withTools);
769
- finalModel = model;
770
- if (toolCalls && toolCalls.length) {
771
- 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 } })) });
772
- for (const tc of toolCalls) {
773
- let argsObj: any = {};
774
- try { argsObj = JSON.parse(tc.function?.arguments || "{}"); } catch (_) {}
775
- const resultText = await executeChatTool(tc.function?.name || "", argsObj);
776
- msgs.push({ role: "tool", tool_call_id: tc.id, content: resultText });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  }
778
- continue;
 
 
 
 
 
 
779
  }
780
- finalReply = content;
781
- break;
782
- }
783
- if (!finalReply) {
784
- const e: any = new Error("Empty response after tool loop");
785
- e.status = 500;
786
- throw e;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
787
  }
788
- const clean = String(finalReply)
789
- .replace(/\*\*([^*]+)\*\*/g, "$1")
790
- .replace(/\*([^*]+)\*/g, "$1")
791
- .replace(/__([^_]+)__/g, "$1")
792
- .replace(/`([^`]+)`/g, "$1")
793
- .replace(/^#{1,6}\s+/gm, "")
794
- .replace(/[ \t]+\n/g, "\n")
795
- .replace(/\n{3,}/g, "\n\n")
796
- .trim();
797
- return Response.json({ transcript: clean, raw: String(finalReply).slice(0, 40), model: finalModel, status: "completed" });
798
- } catch (e: any) {
799
- lastErr = e;
800
- const code = e?.status;
801
- console.error("[chat] model " + step.model + " attempt " + attempt + " failed:", code, e?.message);
802
- if (code === 401) {
803
- 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 });
804
  }
805
- if (code === 403) {
806
- 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 });
 
 
 
 
 
 
 
807
  }
808
- if (code === 402) {
809
- 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 });
 
 
 
 
 
 
 
 
 
810
  }
811
- // Retry transient failures with exponential backoff (429/5xx/timeout).
812
- const wait = 250 * Math.pow(2, attempt);
813
- await new Promise(r => setTimeout(r, wait));
 
 
 
 
 
 
 
 
814
  }
815
- }
816
- // Cascade to next (smaller) model.
817
- }
818
- console.error("[chat] All models failed:", lastErr?.status, lastErr?.message);
819
- return Response.json({ error: "Chat service error", status: (lastErr?.status || 502), detail: String(lastErr?.message || "") }, { status: lastErr?.status || 502 });
820
  } catch (err: any) {
821
  console.error("[/api/chat] Error:", err.message);
822
  return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
823
  }
824
  }
825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  const server = Bun.serve({
827
  port: PORT,
828
  routes: {
829
- "/": { GET: serveProductOG },
830
  "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
831
  "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
832
  "/api/vaix/products": { GET: async (req: Request) => {
@@ -836,7 +578,7 @@ const server = Bun.serve({
836
  const url = new URL(req.url);
837
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999);
838
  const total = VAIX_PRODUCTS.length;
839
- const formatted = VAIX_PRODUCTS.map((p, i) => formatProduct(p, i)).slice(0, Math.min(limit, total));
840
  return Response.json({
841
  products: formatted,
842
  total, limit, page: 1, perPage: limit,
@@ -922,15 +664,7 @@ const server = Bun.serve({
922
  if (ru) href = decodeURIComponent(ru[1]);
923
  const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
924
  const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
925
- if (title) {
926
- // Extract source from URL
927
- let source = '';
928
- try {
929
- const urlObj = new URL(href);
930
- source = urlObj.hostname.replace(/^www\./, '');
931
- } catch (_) { source = ''; }
932
- res.push({ title, snippet, url: href, source });
933
- }
934
  }
935
  return Response.json({ results: res });
936
  } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); }
@@ -948,286 +682,9 @@ const server = Bun.serve({
948
  "/api/session": { POST: sessionHandler },
949
  "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
950
  "/api/chat": { POST: textChatHandler },
951
- "/api/tts": { GET: async (req: Request) => {
952
- try {
953
- const url2 = new URL(req.url);
954
- const text = (url2.searchParams.get("text") || "").trim().replace(/\s+/g, " ").slice(0, 2000);
955
- const voice = url2.searchParams.get("voice") || "vi-VN-HoaiMyNeural";
956
- const allowed = voice === "vi-VN-NamMinhNeural" ? voice : "vi-VN-HoaiMyNeural";
957
- if (!text) return Response.json({ error: "Missing ?text=" }, { status: 400 });
958
- const key = ttsCacheKey(text, allowed);
959
- let audio = TTS_CACHE.get(key);
960
- if (!audio) {
961
- try {
962
- // Synthesize in a separate Bun subprocess to isolate WebSocket crashes.
963
- const proc = Bun.spawn(
964
- [process.execPath, join("/app", "tts-worker.mjs"), allowed, text],
965
- { stdout: "pipe", stderr: "pipe" },
966
- );
967
- const exitCode = await proc.exited;
968
- const out = await new Response(proc.stdout).arrayBuffer();
969
- const errText = await new Response(proc.stderr).text();
970
- if (exitCode !== 0) {
971
- console.error("[/api/tts] worker failed code="+exitCode+" err="+errText);
972
- return Response.json({ error: "TTS failed", code: exitCode, detail: errText }, { status: 502 });
973
- }
974
- audio = new Uint8Array(out);
975
- } catch (e: any) {
976
- console.error("[/api/tts] Error:", e?.message || e, e?.stack || "");
977
- return Response.json({ error: "TTS failed", detail: String(e?.message || e), stack: String(e?.stack || "") }, { status: 502 });
978
- }
979
- if (!audio || !audio.length) {
980
- return Response.json({ error: "Empty audio" }, { status: 502 });
981
- }
982
- TTS_CACHE.set(key, audio);
983
- if (TTS_CACHE.size > TTS_CACHE_MAX) {
984
- const firstKey = TTS_CACHE.keys().next().value;
985
- if (firstKey) TTS_CACHE.delete(firstKey);
986
- }
987
- }
988
- return new Response(audio, {
989
- headers: {
990
- "Content-Type": "audio/mpeg",
991
- "Content-Length": String(audio.length),
992
- "Cache-Control": "public, max-age=3600",
993
- },
994
- });
995
- } catch (e2: any) {
996
- return Response.json({ error: "route error", detail: String(e2?.message || e2), stack: String(e2?.stack || "") }, { status: 500 });
997
- }
998
- }},
999
  "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
1000
  "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
1001
- "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!),
1002
  "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!),
1003
- "/src/:name": (req: Request) => srcFile("src", req.params.name!),
1004
- "/src/s2s/:name": (req: Request) => srcFile("src/s2s", req.params.name!),
1005
- "/api/news/hot": { GET: async () => {
1006
- try {
1007
- const resp = await fetch("https://news.google.com/rss?hl=vi-VN&gl=VN&ceid=VN:vi");
1008
- if (!resp.ok) throw new Error("RSS failed");
1009
- const rssText = await resp.text();
1010
- // Parse <item> blocks: title, link, source
1011
- const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
1012
- const titles: string[] = [];
1013
- const articles: Array<{ source: string; title: string; url: string }> = [];
1014
- for (const block of itemBlocks) {
1015
- const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
1016
- const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
1017
- const sm = block.match(/<source[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*url="([^"]*)"[^>]*>/i);
1018
- if (!tm) continue;
1019
- const title = tm[1].replace(/\+|_/g, " ").trim();
1020
- if (title) titles.push(title);
1021
- let url = "";
1022
- if (lm) {
1023
- url = (lm[1] || "").trim();
1024
- // Google News links are /articles/... redirects → keep them, they work in browser
1025
- }
1026
- let source = "";
1027
- if (sm) source = (sm[1] || sm[0] || "").replace(/<[^>]+>/g, "").trim();
1028
- if (title && url) {
1029
- articles.push({ source: source || "", title, url });
1030
- }
1031
- if (titles.length >= 12) break;
1032
- }
1033
- return Response.json({ titles: titles.slice(0, 12), articles });
1034
- } catch (e: any) {
1035
- return Response.json({ error: e.message, titles: [], articles: [] }, { status: 502 });
1036
- }
1037
- }},
1038
- "/api/news/articles": { GET: async (req: Request) => {
1039
- // Search Google News RSS for a specific topic and return SPECIFIC detailed articles
1040
- // with real clickable links (each card = one concrete news article, not a category page).
1041
- const url = new URL(req.url);
1042
- const q = (url.searchParams.get("q") || "").trim();
1043
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
1044
- try {
1045
- const resp = await fetch(
1046
- "https://news.google.com/rss/search?q=" + encodeURIComponent(q) +
1047
- "&hl=vi-VN&gl=VN&ceid=VN:vi",
1048
- { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" } }
1049
- );
1050
- if (!resp.ok) throw new Error("RSS failed " + resp.status);
1051
- const rssText = await resp.text();
1052
- const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
1053
- const articles: Array<{ source: string; title: string; url: string; desc: string }> = [];
1054
- for (const block of itemBlocks) {
1055
- const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
1056
- const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
1057
- const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i);
1058
- const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
1059
- if (!tm) continue;
1060
- let title = tm[1].replace(/\+|_/g, " ").trim();
1061
- // Strip trailing "- SourceName" that Google appends to titles
1062
- let source = "";
1063
- if (sm) {
1064
- source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim();
1065
- if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim();
1066
- }
1067
- if (!title) continue;
1068
- let url = "";
1069
- if (lm) url = (lm[1] || "").trim();
1070
- let desc = "";
1071
- if (dm) {
1072
- // Remove all HTML tags (including malformed Google News CDATA tags)
1073
- desc = dm[1]
1074
- .replace(/<[^>]*>/g, " ")
1075
- .replace(/\bhttps?:\/\/\S+/gi, "")
1076
- .replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
1077
- .replace(/&nbsp;/g, " ")
1078
- .replace(/\s+/g, " ").trim().slice(0, 160);
1079
- }
1080
- if (title && url) {
1081
- articles.push({ source: source || "", title, url, desc });
1082
- }
1083
- if (articles.length >= 8) break;
1084
- }
1085
- return Response.json({ articles, query: q });
1086
- } catch (e: any) {
1087
- return Response.json({ error: e.message, articles: [] }, { status: 502 });
1088
- }
1089
- }},
1090
- "/api/news/images": { GET: async (req: Request) => {
1091
- // Return REAL per-article images for a topic using Vietnamese portal RSS feeds
1092
- // (VnExpress feeds include <enclosure>/<img> with real article thumbnails).
1093
- const url = new URL(req.url);
1094
- const q = (url.searchParams.get("q") || "").trim();
1095
- const topicNorm = q.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d");
1096
- const feeds: Array<{ cat: string; u: string }> = [
1097
- { cat: "tin-moi-nhat", u: "https://vnexpress.net/rss/tin-moi-nhat.rss" },
1098
- { cat: "thoi-su", u: "https://vnexpress.net/rss/thoi-su.rss" },
1099
- { cat: "the-thao", u: "https://vnexpress.net/rss/the-thao.rss" },
1100
- { cat: "kinh-doanh", u: "https://vnexpress.net/rss/kinh-doanh.rss" },
1101
- { cat: "giai-tri", u: "https://vnexpress.net/rss/giai-tri.rss" },
1102
- { cat: "the-gioi", u: "https://vnexpress.net/rss/the-gioi.rss" },
1103
- { cat: "doi-song", u: "https://vnexpress.net/rss/doi-song.rss" },
1104
- { cat: "suc-khoe", u: "https://vnexpress.net/rss/suc-khoe.rss" },
1105
- { cat: "giao-duc", u: "https://vnexpress.net/rss/giao-duc.rss" },
1106
- ];
1107
- const normStr = (s: string) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d");
1108
- const all: Array<{ title: string; url: string; image: string; source: string; desc: string }> = [];
1109
- try {
1110
- await Promise.all(feeds.map(async (f) => {
1111
- try {
1112
- const r = await fetch(f.u, { headers: { "User-Agent": "Mozilla/5.0" } });
1113
- if (!r.ok) return;
1114
- const t = await r.text();
1115
- const items = t.match(/<item>[\s\S]*?<\/item>/gi) || [];
1116
- for (const it of items) {
1117
- const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
1118
- const lm = it.match(/<link>(.*?)<\/link>/i);
1119
- const em = it.match(/<enclosure[^>]*url="([^"]+)/i);
1120
- const im = it.match(/<img[^>]+src="([^"]+)/i);
1121
- if (!tm || !lm) continue;
1122
- const title = tm[1].trim();
1123
- let image = (em && em[1]) ? em[1].replace(/&amp;/g, "&") : "";
1124
- if (!image && im) image = im[1].replace(/&amp;/g, "&");
1125
- if (!title || !image) continue;
1126
- const link = lm[1].trim();
1127
- const dm = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
1128
- let desc = "";
1129
- if (dm) desc = dm[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200);
1130
- // Filter by topic if a query was provided
1131
- if (q && !normStr(title + " " + link).includes(topicNorm)) continue;
1132
- all.push({ title, url: link, image, source: "VnExpress", desc });
1133
- }
1134
- } catch (_) {}
1135
- }));
1136
- } catch (_) {}
1137
-
1138
- // If no exact topic match, fall back to the freshest topical items (always have images)
1139
- if (all.length === 0 && q) {
1140
- try {
1141
- const r = await fetch("https://vnexpress.net/rss/tin-moi-nhat.rss", { headers: { "User-Agent": "Mozilla/5.0" } });
1142
- const t = await r.text();
1143
- const items = t.match(/<item>[\s\S]*?<\/item>/gi) || [];
1144
- for (const it of items) {
1145
- const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
1146
- const lm = it.match(/<link>(.*?)<\/link>/i);
1147
- const em = it.match(/<enclosure[^>]*url="([^"]+)/i);
1148
- if (tm && lm) {
1149
- const image = em ? em[1].replace(/&amp;/g, "&") : "";
1150
- if (image && tm[1].trim()) {
1151
- const dm2 = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
1152
- let desc2 = "";
1153
- if (dm2) desc2 = dm2[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200);
1154
- all.push({ title: tm[1].trim(), url: lm[1].trim(), image, source: "VnExpress", desc: desc2 });
1155
- }
1156
- }
1157
- }
1158
- } catch (_) {}
1159
- }
1160
-
1161
- return Response.json({ images: all.slice(0, 8), query: q });
1162
- }},
1163
- "/api/news/topic": { GET: async (req: Request) => {
1164
- // Search Vietnamese news for a specific topic and return concrete article cards with links.
1165
- const url = new URL(req.url);
1166
- const q = (url.searchParams.get("q") || "").trim();
1167
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
1168
- try {
1169
- const portalQueries = [
1170
- q + " site:vnexpress.net",
1171
- q + " site:tuoitre.vn",
1172
- q + " site:thanhnien.vn",
1173
- q + " site:cafef.vn",
1174
- q + " site:dantri.com.vn",
1175
- "tin tức " + q + " hôm nay",
1176
- ];
1177
- const allResults: Array<{ title: string; snippet: string; url: string; source: string }> = [];
1178
- await Promise.all(portalQueries.map(nq =>
1179
- fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(nq), { headers: { "User-Agent": "Mozilla/5.0" } })
1180
- .then(r => r.text())
1181
- .then(h => {
1182
- const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
1183
- const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
1184
- for (let i = 0; i < Math.min(lm.length, 4); i++) {
1185
- let href = lm[i][1];
1186
- const ru = href.match(/uddg=(https?%3[^&]+)/i);
1187
- if (ru) href = decodeURIComponent(ru[1]);
1188
- const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
1189
- if (!title) continue;
1190
- const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
1191
- let source = "";
1192
- try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {}
1193
- allResults.push({ title, snippet, url: href, source });
1194
- }
1195
- })
1196
- .catch(() => {})
1197
- ));
1198
- // Deduplicate + drop empty
1199
- const seen = new Set<string>();
1200
- const unique = allResults.filter(r => {
1201
- if (!r.url || seen.has(r.url)) return false;
1202
- seen.add(r.url);
1203
- return r.source && !/duckduckgo|google/i.test(r.source);
1204
- });
1205
- return Response.json({ results: unique.slice(0, 8), query: q });
1206
- } catch (e: any) {
1207
- return Response.json({ error: e.message, results: [] }, { status: 502 });
1208
- }
1209
- }},
1210
- "/api/logs/client": { POST: async (req: Request) => {
1211
- try {
1212
- const body = await req.json();
1213
- console.error("[ClientError]", body?.message ?? String(body), body?.details ? JSON.stringify(body) : "");
1214
- return Response.json({ ok: true });
1215
- } catch(e: any) { return Response.json({ error: e.message }, { status: 400 }); }
1216
- }},
1217
- "/api/vaix/json": async () => {
1218
- try {
1219
- const r = await fetch(JSON_URL);
1220
- if (!r.ok) throw new Error("HTTP "+r.status);
1221
- const data = await r.json();
1222
- // Only return top 1000 to reduce server memory
1223
- const prods = Array.isArray(data) ? data.slice(0, 1000) : [];
1224
- return new Response(JSON.stringify(prods), {
1225
- headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }
1226
- });
1227
- } catch(e) {
1228
- return Response.json({ error: "JSON fetch failed: " + e.message }, { status: 502 });
1229
- }
1230
- },
1231
  "/*": { GET: serveStatic },
1232
  },
1233
  });
 
7
  * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access).
8
  * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode.
9
  */
10
+ import index from "./index.html";
 
 
11
  import { readdir } from "fs/promises";
12
  import { join } from "path";
13
  import { existsSync } from "fs";
 
 
 
 
 
 
 
 
14
 
15
  const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
16
  const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
 
47
  const data = await resp.json();
48
  VAIX_PRODUCTS = Array.isArray(data) ? data : [];
49
  VAIX_LAST_SYNC = Date.now();
50
+ console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products in " + (Date.now() - startMs) + "ms");
 
51
  } catch (err: any) {
52
  console.error("[VAIX] Error:", err.message);
53
  setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000);
 
75
  return out;
76
  }
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  function staticFile(dir: string, name: string) {
79
+ return new Response(Bun.file(import.meta.dir + "/public/" + dir + "/" + name));
 
 
 
 
 
 
 
 
 
80
  }
81
 
82
  async function listAvatars() {
 
116
  };
117
  }
118
 
119
+ function normProductSlug(slug: string): string {
120
+ return String(slug || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, "");
121
+ }
122
 
123
+ function findProductBySlug(slug: string): any {
124
+ if (!VAIX_PRODUCTS?.length) return null;
125
+ const target = normProductSlug(slug);
126
+ for (const p of VAIX_PRODUCTS) {
127
+ const ps = normProductSlug(p.slug || "");
128
+ if (ps === target) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p));
 
 
 
 
 
129
  }
130
+ for (const p of VAIX_PRODUCTS) {
131
+ const ps = normProductSlug(p.sku || "");
132
+ if (ps === target) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  }
134
+ for (const p of VAIX_PRODUCTS) {
135
+ const nm = normProductSlug(p.name || p.n || "");
136
+ if (nm.includes(target) || target.includes(nm)) return formatProduct(p, p._idx ?? VAIX_PRODUCTS.indexOf(p));
137
+ }
138
+ return null;
 
 
 
 
139
  }
140
 
141
+ /**
142
+ * Generate OG meta tags snippet for a product (used by index.html template).
143
+ * Returns the inner HTML for <head> with og:title, og:image, og:description,
144
+ * og:url, twitter:card, and JSON-LD schema.org/Product.
145
+ */
146
+ function generateProductOgHead(product: any, baseUrl: string): string {
147
+ const slug = product.slug || product.sku || "";
148
+ const ogUrl = baseUrl + "?product=" + encodeURIComponent(slug);
149
+ const ogImage = product.image || "";
150
+ const title = (product.title_clean || product.name || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
151
+ const desc = (product.summary || product.description || "").slice(0, 300).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
152
+ const htmlImages = product.images && product.images.length ? product.images : (product.image ? [product.image] : []);
153
+ const allImages = htmlImages.filter((i: string) => i);
154
+
155
+ 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, '&quot;')}"},"sku":"${product.sku || ""}","model":"${product.model || ""}","offers":{"@type":"Offer","priceCurrency":"VND","price":${product.priceNum || 0},"availability":"https://schema.org/InStock"}}`;
156
+ if (product.specs && typeof product.specs === "object" && Object.keys(product.specs).length) {
157
+ const props = Object.entries(product.specs)
158
+ .filter(([k, v]) => v && String(v).trim() && String(v).trim() !== "None")
159
+ .map(([k, v]) => `{"@type":"PropertyValue","name":"${k.replace(/"/g, '&quot;')}","value":"${String(v).replace(/^\[|\]$/g, "").replace(/"/g, '&quot;').replace(/\\/g, '\\\\')}"}`);
160
+ if (props.length) jsonLd += ',"additionalProperty":[' + props.join(",") + ']';
161
+ }
 
 
 
162
 
163
+ return ` <!-- Open Graph / Facebook / Zalo / Telegram -->
164
+ <meta property="og:type" content="product" />
165
+ <meta property="og:title" content="${title}" />
166
+ <meta property="og:description" content="${desc}" />
167
+ <meta property="og:url" content="${ogUrl}" />
168
+ <meta property="og:image" content="${ogImage}" />
169
+ <meta property="og:image:secure_url" content="${ogImage}" />
170
+ ${allImages.map((img, i) => ` <meta property="og:image:${i + 1}" content="${img}" />`).join("\n")}
171
+ <meta property="og:site_name" content="V.AI STUDIO" />
172
+ <meta property="og:locale" content="vi_VN" />
173
+ <meta property="og:price:amount" content="${product.priceNum || 0}" />
174
+ <meta property="og:price:currency" content="VND" />
175
+ <meta name="twitter:card" content="summary_large_image" />
176
+ <meta name="twitter:title" content="${title}" />
177
+ <meta name="twitter:description" content="${desc}" />
178
+ <meta name="twitter:image" content="${ogImage}" />
179
+ <!-- JSON-LD Structured Data -->
180
+ <script type="application/ld+json">${jsonLd}</script>`;
181
  }
182
 
183
+ /**
184
+ * Generate a standalone product detail HTML page (used when someone opens
185
+ * the share URL directly in a browser without the V.AI STUDIO app).
186
+ */
187
+ function generateProductDetailHTML(product: any, baseUrl: string): string {
188
+ const ogImage = product.image || "";
189
+ const ogUrl = baseUrl + "?product=" + (product.slug || product.sku || "");
190
+ const priceStr = product.priceNum > 0 ? product.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ";
191
+ const ogDescription = product.summary || product.description || "";
192
+ <style>
193
+ *{box-sizing:border-box;margin:0;padding:0}
194
+ body{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:#f8fafc;color:#1e293b;padding:24px 16px 48px}
195
+ .container{max-width:720px;margin:0 auto}
196
+ .breadcrumb{font-size:0.82rem;color:#64748b;margin-bottom:16px}
197
+ .breadcrumb a{color:#003f62;text-decoration:none}
198
+ .breadcrumb a:hover{text-decoration:underline}
199
+ .card{background:#fff;border-radius:20px;box-shadow:0 2px 12px rgba(0,0,0,0.06);overflow:hidden}
200
+ .card-header{padding:24px 24px 0;text-align:center;background:linear-gradient(135deg,#f8fafc,#eef2ff)}
201
+ .card-body{padding:20px 24px 24px}
202
+ .title{font-size:1.35rem;font-weight:700;color:#1e293b;line-height:1.4;margin-bottom:6px}
203
+ .brand{font-size:0.8rem;color:#64748b;background:#f1f5f9;padding:4px 12px;border-radius:20px;display:inline-block;margin-bottom:8px}
204
+ .model-tag{font-size:0.75rem;color:#94a3b8;margin-left:4px}
205
+ .price{font-size:1.5rem;font-weight:800;color:#c2410c;margin:12px 0 16px}
206
+ .category{font-size:0.72rem;color:#94a3b8;background:#f0f9ff;padding:3px 10px;border-radius:12px;display:inline-block;margin-top:4px;border:1px solid #e0f2fe}
207
+ .section{margin-top:20px;padding-top:16px;border-top:1px solid #f1f5f9}
208
+ .section-title{font-size:0.9rem;font-weight:700;color:#003f62;margin-bottom:10px;display:flex;align-items:center;gap:6px}
209
+ .section-title::before{content:"";width:3px;height:18px;background:#0077b6;border-radius:2px}
210
+ .desc{font-size:0.88rem;line-height:1.7;color:#475569;padding:10px 0}
211
+ .share-box{margin-top:24px;padding:16px 20px;background:linear-gradient(135deg,#eef2ff,#fdf2f8);border-radius:12px;border:1px solid #e0e7ff;text-align:center}
212
+ .share-box p{font-size:0.82rem;color:#6366f1;margin:0 0 10px;font-weight:600}
213
+ .share-box a{display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;border-radius:10px;text-decoration:none;font-size:0.85rem;font-weight:600}
214
+ .back-link{text-align:center;margin-top:20px}
215
+ .back-link a{color:#003f62;font-size:0.85rem}
216
+ @media(max-width:480px){.container{padding:0}.title{font-size:1.1rem}.price{font-size:1.3rem}}
217
+ </style>
218
+ </head>
219
+ <body>
220
+ <div class="container">
221
+ <div class="breadcrumb"><a href="/">🏠 V.AI STUDIO</a> &rsaquo; ${product.category || "Sản phẩm"}</div>
222
+ <div class="card">
223
+ <div class="card-header">${imagesHtml}</div>
224
+ <div class="card-body">
225
+ <h1 class="title">${product.title_clean}</h1>
226
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:6px">
227
+ <span class="brand">${product.brand || "V.AI STUDIO"}</span>
228
+ ${product.model ? `<span class="model-tag">${product.model}</span>` : ""}
229
+ ${product.category ? `<span class="category">${product.category}</span>` : ""}
230
+ </div>
231
+ <div class="price">${priceStr}</div>
232
+ ${product.description ? `<div class="section"><div class="desc">${product.description}</div></div>` : ""}
233
+ ${featuresHtml ? `<div class="section"><div class="section-title">Tính năng nổi bật</div>${featuresHtml}</div>` : ""}
234
+ ${specsHtml ? `<div class="section"><div class="section-title">Thông số kỹ thuật</div>${specsHtml}</div>` : ""}
235
+ <div class="share-box">
236
+ <p>📤 Chia sẻ sản phẩm này</p>
237
+ <a href="${ogUrl}">Mở chi tiết ${product.title_clean}</a>
238
+ </div>
239
+ </div>
240
+ </div>
241
+ <div class="back-link"><a href="/">← Trở về V.AI STUDIO</a></div>
242
+ </div>
243
+ </body>
244
+ </html>`;
245
+ }
246
 
247
  function serveStatic(req: Request): Response {
248
  const url = new URL(req.url);
 
276
  }
277
  }
278
 
279
+ // ── Text-only chat endpoint ──
280
+ // Uses S2S backend in text-only mode: POST /session text chat via WebSocket
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  async function textChatHandler(req: Request): Promise<Response> {
282
+ if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 });
283
  try {
284
  const reqBody = await req.json();
285
+ const userMessage = reqBody.message || "";
286
  if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
287
 
288
+ // Create S2S session in text-only mode
289
+ const sessionResp = await fetch(UPSTREAM + "/session", {
290
+ method: "POST",
291
+ headers: { "Content-Type": "application/json" },
292
+ body: "{}",
293
+ });
294
+ if (!sessionResp.ok) {
295
+ const errBody = await sessionResp.text().catch(() => "");
296
+ return Response.json({
297
+ error: "Failed to create S2S session",
298
+ status: sessionResp.status,
299
+ detail: errBody.slice(0, 500),
300
+ }, { status: 502 });
301
+ }
302
+ const sessionData = await sessionResp.json();
303
+ if (sessionData.state === "queued") {
304
+ // Poll the queue
305
+ let queueResp;
306
+ let attempts = 0;
307
+ do {
308
+ await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000));
309
+ queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), {
310
+ headers: { "Content-Type": "application/json" },
311
+ });
312
+ if (!queueResp.ok) continue;
313
+ const queueData = await queueResp.json();
314
+ if (queueData.state === "queued") {
315
+ attempts++;
316
+ if (attempts > 30) {
317
+ return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 });
318
+ }
319
+ continue;
320
+ }
321
+ const grant = queueData;
322
+ // Connect WebSocket
323
+ const ws = new WebSocket(grant.connect_url);
324
+
325
+ const wsPromise = new Promise<any>((resolve, reject) => {
326
+ ws.binaryType = "arraybuffer";
327
+ let msgReceived = false;
328
+
329
+ ws.onopen = () => {
330
+ // Session created wait for session.updated then send text
331
+ const waitForSession = async () => {
332
+ while (!msgReceived) {
333
+ // Wait for session.created
334
+ // We'll use a timeout approach
335
+ }
336
+ };
337
+
338
+ // Send session.update then user message
339
+ setTimeout(() => {
340
+ ws.send(JSON.stringify({
341
+ type: "session.update",
342
+ session: {
343
+ type: "realtime",
344
+ instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.",
345
+ audio: { output: { voice: "Sohee" } }
346
+ }
347
+ }));
348
+ }, 500);
349
+
350
+ // Send user message
351
+ setTimeout(() => {
352
+ ws.send(JSON.stringify({
353
+ type: "conversation.item.create",
354
+ item: {
355
+ type: "message",
356
+ role: "user",
357
+ content: [{ type: "input_text", text: userMessage }]
358
+ }
359
+ }));
360
+ ws.send(JSON.stringify({ type: "response.create" }));
361
+ }, 1000);
362
+
363
+ // Collect responses
364
+ let fullTranscript = "";
365
+ let audioBuffer = "";
366
+ let responseDone = false;
367
+
368
+ const handleMessage = (raw: string) => {
369
+ try {
370
+ const event = JSON.parse(raw);
371
+ const type = event?.type;
372
+
373
+ if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") {
374
+ const delta = (event.transcript || event.delta || "");
375
+ if (delta) fullTranscript += delta;
376
+ }
377
+ if (type === "response.audio_transcript.done" || type === "response.done") {
378
+ responseDone = true;
379
+ clearTimeout(timeout);
380
+ msgReceived = true;
381
+ resolve({
382
+ transcript: fullTranscript.trim(),
383
+ audio: audioBuffer,
384
+ audioFormat: "pcm16",
385
+ });
386
+ }
387
+ } catch {}
388
+ };
389
+
390
+ ws.onmessage = (e: any) => {
391
+ const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
392
+ handleMessage(data);
393
+ };
394
+
395
+ ws.onerror = () => reject(new Error("WebSocket error"));
396
+
397
+ // Timeout
398
+ const timeout = setTimeout(() => {
399
+ if (!responseDone) {
400
+ msgReceived = true;
401
+ try { ws.close(1000, "timeout"); } catch {}
402
+ resolve({
403
+ transcript: fullTranscript.trim() || "No response received.",
404
+ audio: "",
405
+ });
406
  }
407
+ }, 15000);
408
+ };
409
+
410
+ ws.onclose = () => {
411
+ if (!msgReceived) {
412
+ msgReceived = true;
413
+ resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" });
414
  }
415
+ };
416
+ });
417
+
418
+ return Response.json(await wsPromise);
419
+ } while (true);
420
+ }
421
+
422
+ // Session granted directly — connect WebSocket
423
+ const grant = sessionData;
424
+ const ws = new WebSocket(grant.connect_url);
425
+
426
+ return new Promise<Response>((resolve, reject) => {
427
+ ws.binaryType = "arraybuffer";
428
+ let msgReceived = false;
429
+
430
+ ws.onopen = () => {
431
+ // Send session.update
432
+ setTimeout(() => {
433
+ ws.send(JSON.stringify({
434
+ type: "session.update",
435
+ session: {
436
+ type: "realtime",
437
+ 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.",
438
+ audio: { output: { voice: "Sohee" } }
439
+ }
440
+ }));
441
+ }, 500);
442
+
443
+ // Send user message via text
444
+ setTimeout(() => {
445
+ ws.send(JSON.stringify({
446
+ type: "conversation.item.create",
447
+ item: {
448
+ type: "message",
449
+ role: "user",
450
+ content: [{ type: "input_text", text: userMessage }]
451
+ }
452
+ }));
453
+ ws.send(JSON.stringify({ type: "response.create" }));
454
+ }, 1000);
455
+
456
+ // Collect response
457
+ let fullTranscript = "";
458
+ let responseDone = false;
459
+
460
+ const handleMessage = (raw: string) => {
461
+ try {
462
+ const event = JSON.parse(raw);
463
+ const type = event?.type;
464
+
465
+ if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") {
466
+ const delta = event.delta || "";
467
+ if (delta) fullTranscript += delta;
468
+ }
469
+ if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") {
470
+ const segment = event.transcript || "";
471
+ if (segment) fullTranscript += segment;
472
+ }
473
+ if (type === "response.done") {
474
+ responseDone = true;
475
+ clearTimeout(timeout);
476
+ msgReceived = true;
477
+ try { ws.close(1000, "done"); } catch {}
478
+ resolve(Response.json({
479
+ transcript: fullTranscript.trim(),
480
+ status: event.response?.status ?? "completed",
481
+ }));
482
+ }
483
+ } catch (e: any) {
484
+ console.warn("[chat] parse error:", e.message);
485
  }
486
+ };
487
+
488
+ ws.onmessage = (e: any) => {
489
+ const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
490
+ handleMessage(data);
491
+ };
492
+
493
+ ws.onerror = () => {
494
+ if (!msgReceived) {
495
+ msgReceived = true;
496
+ try { ws.close(1000, "error"); } catch {}
497
+ resolve(Response.json({
498
+ transcript: "Error connecting to chat service.",
499
+ status: "error",
500
+ }));
 
501
  }
502
+ };
503
+
504
+ ws.onclose = () => {
505
+ if (!msgReceived) {
506
+ msgReceived = true;
507
+ resolve(Response.json({
508
+ transcript: fullTranscript.trim() || "Session closed.",
509
+ status: "closed",
510
+ }));
511
  }
512
+ };
513
+
514
+ // Timeout
515
+ const timeout = setTimeout(() => {
516
+ if (!msgReceived) {
517
+ msgReceived = true;
518
+ try { ws.close(1000, "timeout"); } catch {}
519
+ resolve(Response.json({
520
+ transcript: fullTranscript.trim() || "No response received. The service may be busy.",
521
+ status: "timeout",
522
+ }));
523
  }
524
+ }, 20000);
525
+ };
526
+
527
+ ws.onclose = () => {
528
+ if (!msgReceived) {
529
+ msgReceived = true;
530
+ clearTimeout(timeout);
531
+ resolve(Response.json({
532
+ transcript: "Connection closed.",
533
+ status: "closed",
534
+ }));
535
  }
536
+ };
537
+ });
 
 
 
538
  } catch (err: any) {
539
  console.error("[/api/chat] Error:", err.message);
540
  return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
541
  }
542
  }
543
 
544
+ /**
545
+ * Serve the SPA with OG meta tags injected when ?product=xxx is present.
546
+ * This makes share links display proper previews on Facebook, Zalo, Telegram, etc.
547
+ */
548
+ async function serveIndexWithOG(req: Request): Promise<Response> {
549
+ const url = new URL(req.url);
550
+ const productSlug = url.searchParams.get("product");
551
+
552
+ if (productSlug) {
553
+ await loadVaixProducts();
554
+ if (VAIX_LOADED && VAIX_PRODUCTS?.length) {
555
+ const product = findProductBySlug(decodeURIComponent(productSlug));
556
+ if (product) {
557
+ const baseUrl = url.origin + url.pathname;
558
+ const ogHead = generateProductOgHead(product, baseUrl);
559
+ const injected = index.replace("</head>", ogHead + "\n </head>");
560
+ return new Response(injected, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
561
+ }
562
+ }
563
+ }
564
+
565
+ return new Response(index, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } });
566
+ }
567
+
568
  const server = Bun.serve({
569
  port: PORT,
570
  routes: {
571
+ "/": serveIndexWithOG,
572
  "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
573
  "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
574
  "/api/vaix/products": { GET: async (req: Request) => {
 
578
  const url = new URL(req.url);
579
  const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999);
580
  const total = VAIX_PRODUCTS.length;
581
+ const formatted = VAIX_PRODUCTS.slice(0, Math.min(limit, total)).map((p, i) => formatProduct(p, i));
582
  return Response.json({
583
  products: formatted,
584
  total, limit, page: 1, perPage: limit,
 
664
  if (ru) href = decodeURIComponent(ru[1]);
665
  const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
666
  const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
667
+ if (title) res.push({ title, snippet, url: href });
 
 
 
 
 
 
 
 
668
  }
669
  return Response.json({ results: res });
670
  } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); }
 
682
  "/api/session": { POST: sessionHandler },
683
  "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
684
  "/api/chat": { POST: textChatHandler },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
686
  "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
 
687
  "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  "/*": { GET: serveStatic },
689
  },
690
  });
index.ts.patched DELETED
@@ -1,684 +0,0 @@
1
- /**
2
- * Gemma Avatar — realtime voice/text chat with 3D talking-head avatar + V.AI STUDIO.
3
- * Products: frontend loads DIRECTLY from dataset JSON URL (1 HTTP GET, no backend proxy).
4
- * Backend keeps /api/vaix/products for backward compat / debugging.
5
- * Fast, reliable, no WASM, no compiled deps.
6
- *
7
- * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access).
8
- * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode.
9
- */
10
- import index from "./index.html";
11
- import { readdir } from "fs/promises";
12
- import { join } from "path";
13
- import { existsSync } from "fs";
14
-
15
- const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
16
- const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
17
- const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
18
- const PORT = Number(Bun.env.PORT ?? 7860);
19
-
20
- // ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ──
21
- const JSON_URL =
22
- "https://huggingface.co/datasets/bep40/grob-products-updated" +
23
- "/resolve/main/products_with_slugs.json";
24
-
25
- let VAIX_PRODUCTS: any[] | null = null;
26
- let VAIX_LOADING = false;
27
- let VAIX_LOADED = false;
28
- let VAIX_LAST_SYNC = Date.now();
29
-
30
- async function loadVaixProducts(): Promise<void> {
31
- if (VAIX_LOADED && VAIX_PRODUCTS?.length) return;
32
- if (VAIX_LOADING) {
33
- for (let i = 0; i < 120; i++) {
34
- await new Promise(r => setTimeout(r, 500));
35
- if (VAIX_LOADED) return;
36
- }
37
- }
38
- VAIX_LOADING = true;
39
- const startMs = Date.now();
40
- console.log("[VAIX] Fetching JSON from bep40/grob-products-updated...");
41
- try {
42
- const ctrl = new AbortController();
43
- const timer = setTimeout(() => ctrl.abort(), 120_000);
44
- const resp = await fetch(JSON_URL, { signal: ctrl.signal });
45
- clearTimeout(timer);
46
- if (!resp.ok) throw new Error("HTTP " + resp.status);
47
- const data = await resp.json();
48
- VAIX_PRODUCTS = Array.isArray(data) ? data : [];
49
- VAIX_LAST_SYNC = Date.now();
50
- console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products in " + (Date.now() - startMs) + "ms");
51
- } catch (err: any) {
52
- console.error("[VAIX] Error:", err.message);
53
- setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000);
54
- } finally {
55
- VAIX_LOADING = false;
56
- }
57
- }
58
- loadVaixProducts().catch(() => {});
59
- setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000);
60
-
61
- function norm(s: string): string {
62
- return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, "");
63
- }
64
-
65
- async function proxy(path: string, req: Request, init: any = {}): Promise<Response> {
66
- const headers = new Headers(init.headers);
67
- headers.set("Content-Type", "application/json");
68
- const cookie = req.headers.get("cookie");
69
- if (cookie) headers.set("Cookie", cookie);
70
- const resp = await fetch(UPSTREAM + path, { ...init, headers });
71
- const body = await resp.text();
72
- const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
73
- const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
74
- 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("; "));
75
- return out;
76
- }
77
-
78
- function staticFile(dir: string, name: string) {
79
- return new Response(Bun.file(import.meta.dir + "/public/" + dir + "/" + name));
80
- }
81
-
82
- async function listAvatars() {
83
- const avatarsDir = join(import.meta.dir, "public/avatars");
84
- const names: string[] = [];
85
- try {
86
- const dir = await readdir(avatarsDir, { withFileTypes: true });
87
- for (const e of dir) {
88
- if (e.isFile() && e.name.endsWith(".glb")) names.push(e.name);
89
- }
90
- } catch {}
91
- return names.sort();
92
- }
93
-
94
- function formatProduct(p: any, idx: number) {
95
- return {
96
- name: p.name || p.n || "",
97
- title_clean: p.name || p.n || "",
98
- brand: p.brand || "",
99
- price: p.p || p.price || "",
100
- priceNum: Number(p.pn ?? 0),
101
- category: p.c || p.cat || "",
102
- category_slug: p.cs || "",
103
- category_icon: p.ci || "fa-box",
104
- sku: p.sku || "",
105
- model: p.mod || p.model || "",
106
- slug: p.slug || "",
107
- description: p.desc || "",
108
- summary: p.sum || p.summary || "",
109
- features: Array.isArray(p.feats) ? p.feats : [],
110
- specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {},
111
- video: p.vid || "",
112
- image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || (Array.isArray(p.images) ? p.images[0] : ""),
113
- images: p.imgs || p.images || [],
114
- link: p.l || "",
115
- _idx: p._idx ?? idx,
116
- };
117
- }
118
-
119
- async function serveProductOG(req: Request): Promise<Response> {
120
- const url = new URL(req.url);
121
- const productSlug = url.searchParams.get("product");
122
- if (productSlug && VAIX_PRODUCTS?.length) {
123
- const foundProduct = VAIX_PRODUCTS.find((p: any) => {
124
- const slug = p.slug || p._source_alias || "";
125
- const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
126
- const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
127
- return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug);
128
- });
129
- if (foundProduct) {
130
- const fp = formatProduct(foundProduct, foundProduct._idx ?? 0);
131
- const fpName = fp.name || fp.model || "Sản phẩm";
132
- const fpBrand = fp.brand || "V.AI STUDIO";
133
- const fpPriceNum = fp.priceNum || Number(fp.pn) || 0;
134
- const fpDesc = (fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 200);
135
- const fpImg = fp.image || (fp.images && fp.images[0]) || "";
136
- const fpCategory = fp.category || "";
137
- const fpModel = fp.model || "";
138
- const fpSpecs = fp.specs || {};
139
- let specsStr = "";
140
- const specKeys = Object.keys(fpSpecs).slice(0, 8);
141
- for (const sk of specKeys) {
142
- specsStr += `\n* ${sk}: ${fpSpecs[sk]}`;
143
- }
144
- const ogDescription = fpDesc ? fpDesc + specsStr : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp";
145
- const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp";
146
- const ogImage = fpImg || fallbackImg;
147
- const ogTitle = `${fpName} | ${fpBrand} - V.AI STUDIO`;
148
- const ogTags = `
149
- <meta property="og:title" content="${ogTitle}" />
150
- <meta property="og:description" content="${ogDescription}" />
151
- <meta property="og:url" content="${url.href}" />
152
- <meta property="og:type" content="product" />
153
- <meta property="og:image" content="${ogImage}" />
154
- <meta property="og:image:secure_url" content="${ogImage}" />
155
- <meta property="og:image:type" content="image/jpeg" />
156
- <meta property="og:image:width" content="1200" />
157
- <meta property="og:image:height" content="630" />
158
- <meta property="product:price:amount" content="${fpPriceNum}" />
159
- <meta property="product:price:currency" content="VND" />
160
- <meta property="product:availability" content="in stock" />
161
- <meta property="product:condition" content="new" />
162
- <meta property="fb:app_id" content="1321688464574422" />
163
- <meta name="twitter:card" content="summary_large_image" />
164
- <meta name="twitter:site" content="@bep40" />
165
- <meta name="twitter:title" content="${ogTitle}" />
166
- <meta name="twitter:description" content="${ogDescription}" />
167
- <meta name="twitter:image" content="${ogImage}" />
168
- <meta name="description" content="${ogDescription}" />`;
169
- return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
170
- status: 200,
171
- headers: { "Content-Type": "text/html; charset=utf-8" },
172
- });
173
- }
174
- }
175
- return new Response(index, {
176
- status: 200,
177
- headers: { "Content-Type": "text/html; charset=utf-8" },
178
- });
179
- }
180
-
181
- async function serveProductOG(req: Request): Promise<Response> {
182
- const url = new URL(req.url);
183
- const productSlug = url.searchParams.get("product");
184
-
185
- function escapeHTML(str: string): string {
186
- return String(str || "")
187
- .replace(/&/g, "&amp;")
188
- .replace(/</g, "&lt;")
189
- .replace(/>/g, "&gt;")
190
- .replace(/"/g, "&quot;")
191
- .replace(/'/g, "&#39;")
192
- .replace(/\r\n/g, " ")
193
- .replace(/\n/g, " ")
194
- .replace(/\r/g, " ")
195
- .slice(0, 350);
196
- }
197
-
198
- if (productSlug && VAIX_PRODUCTS?.length) {
199
- const foundProduct = VAIX_PRODUCTS.find((p: any) => {
200
- const slug = p.slug || p._source_alias || "";
201
- const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
202
- const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
203
- return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug);
204
- });
205
- if (foundProduct) {
206
- const fp = formatProduct(foundProduct, foundProduct._idx ?? 0);
207
- // HTML-escape ALL values before injecting
208
- const ogTitle = escapeHTML(`${fp.name || fp.model || "Sản phẩm"} | ${fp.brand || "V.AI STUDIO"} - V.AI STUDIO`);
209
- const fpDesc = escapeHTML((fp.summary || fp.description || "").trim().slice(0, 200));
210
- const fpImg = escapeHTML(fp.image || (fp.images && fp.images[0]) || "");
211
- const fpPriceNum = fp.priceNum || Number(fp.pn) || 0;
212
- let specsStr = "";
213
- const specKeys = Object.keys(fp.specs || {}).slice(0, 8);
214
- for (const sk of specKeys) {
215
- const key = escapeHTML(sk || "");
216
- const val = escapeHTML(String(fp.specs[sk] || ""));
217
- specsStr += `${key}: ${val}, `;
218
- }
219
- specsStr = specsStr.slice(0, -2);
220
- const ogDescription = fpDesc ? `${fpDesc}${specsStr ? " -- " + specsStr : ""}` : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp";
221
- const fallbackImg = escapeHTML("https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp");
222
- const ogImage = fpImg || fallbackImg;
223
- const ogTags = `<meta property="og:title" content="${ogTitle}" />
224
- <meta property="og:description" content="${ogDescription}" />
225
- <meta property="og:url" content="${escapeHTML(url.href)}" />
226
- <meta property="og:type" content="product" />
227
- <meta property="og:image" content="${ogImage}" />
228
- <meta property="og:image:secure_url" content="${ogImage}" />
229
- <meta property="og:image:type" content="image/jpeg" />
230
- <meta property="og:image:width" content="1200" />
231
- <meta property="og:image:height" content="630" />
232
- <meta property="product:price:amount" content="${fpPriceNum}" />
233
- <meta property="product:price:currency" content="VND" />
234
- <meta property="product:availability" content="in stock" />
235
- <meta property="product:condition" content="new" />
236
- <meta property="fb:app_id" content="1321688464574422" />
237
- <meta name="twitter:card" content="summary_large_image" />
238
- <meta name="twitter:site" content="@bep40" />
239
- <meta name="twitter:title" content="${ogTitle}" />
240
- <meta name="twitter:description" content="${ogDescription}" />
241
- <meta name="twitter:image" content="${ogImage}" />
242
- <meta name="description" content="${ogDescription}" />`;
243
- return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
244
- status: 200,
245
- headers: { "Content-Type": "text/html; charset=utf-8" },
246
- });
247
- }
248
- }
249
- // No product param — serve default HTML with static OG fallback
250
- const fallbackTitle = escapeHTML("Gemma Avatar + V.AI STUDIO - Chat & Voice AI");
251
- const fallbackDesc = escapeHTML("Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng.");
252
- const fallbackOgTags = `<meta property="og:title" content="${fallbackTitle}" />
253
- <meta property="og:description" content="${fallbackDesc}" />
254
- <meta property="og:url" content="${escapeHTML(url.href)}" />
255
- <meta property="og:type" content="website" />
256
- <meta property="og:image" content="${fallbackImg}" />
257
- <meta name="twitter:card" content="summary_large_image" />`;
258
- return new Response(index.replace(/<\/head>/, fallbackOgTags + "\n </head>"), {
259
- status: 200,
260
- headers: { "Content-Type": "text/html; charset=utf-8" },
261
- });
262
- }`
263
-
264
- function serveStatic(req: Request): Response {
265
- const url = new URL(req.url);
266
- const fullPath = join("/app", url.pathname.slice(1));
267
- if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 });
268
- return new Response(Bun.file(fullPath));
269
- }
270
-
271
- async function queueHandler(req: Request): Promise<Response> {
272
- if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
273
- const url = new URL(req.url);
274
- const parts = url.pathname.split("/");
275
- const id = parts[parts.length - 1];
276
- if (!id) return Response.json({ error: "Missing id" }, { status: 400 });
277
- try {
278
- if (req.method === "DELETE") {
279
- return await proxy("/queue/" + encodeURIComponent(id), req, { method: "DELETE" });
280
- }
281
- return await proxy("/queue/" + encodeURIComponent(id), req);
282
- } catch {
283
- return Response.json({ error: "Speech service unreachable." }, { status: 502 });
284
- }
285
- }
286
-
287
- async function sessionHandler(req: Request): Promise<Response> {
288
- if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
289
- try {
290
- return await proxy("/session", req, { method: "POST", body: "{}" });
291
- } catch {
292
- return Response.json({ error: "Speech service unreachable." }, { status: 502 });
293
- }
294
- }
295
-
296
- // ── Text-only chat endpoint ──
297
- // Uses S2S backend in text-only mode: POST /session → text chat via WebSocket
298
- async function textChatHandler(req: Request): Promise<Response> {
299
- if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 });
300
- try {
301
- const reqBody = await req.json();
302
- const userMessage = reqBody.message || "";
303
- if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
304
-
305
- // Create S2S session in text-only mode
306
- const sessionResp = await fetch(UPSTREAM + "/session", {
307
- method: "POST",
308
- headers: { "Content-Type": "application/json" },
309
- body: "{}",
310
- });
311
- if (!sessionResp.ok) {
312
- const errBody = await sessionResp.text().catch(() => "");
313
- return Response.json({
314
- error: "Failed to create S2S session",
315
- status: sessionResp.status,
316
- detail: errBody.slice(0, 500),
317
- }, { status: 502 });
318
- }
319
- const sessionData = await sessionResp.json();
320
- if (sessionData.state === "queued") {
321
- // Poll the queue
322
- let queueResp;
323
- let attempts = 0;
324
- do {
325
- await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000));
326
- queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), {
327
- headers: { "Content-Type": "application/json" },
328
- });
329
- if (!queueResp.ok) continue;
330
- const queueData = await queueResp.json();
331
- if (queueData.state === "queued") {
332
- attempts++;
333
- if (attempts > 30) {
334
- return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 });
335
- }
336
- continue;
337
- }
338
- const grant = queueData;
339
- // Connect WebSocket
340
- const ws = new WebSocket(grant.connect_url);
341
-
342
- const wsPromise = new Promise<any>((resolve, reject) => {
343
- ws.binaryType = "arraybuffer";
344
- let msgReceived = false;
345
-
346
- ws.onopen = () => {
347
- // Session created — wait for session.updated then send text
348
- const waitForSession = async () => {
349
- while (!msgReceived) {
350
- // Wait for session.created
351
- // We'll use a timeout approach
352
- }
353
- };
354
-
355
- // Send session.update then user message
356
- setTimeout(() => {
357
- ws.send(JSON.stringify({
358
- type: "session.update",
359
- session: {
360
- type: "realtime",
361
- instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.",
362
- audio: { output: { voice: "Sohee" } }
363
- }
364
- }));
365
- }, 500);
366
-
367
- // Send user message
368
- setTimeout(() => {
369
- ws.send(JSON.stringify({
370
- type: "conversation.item.create",
371
- item: {
372
- type: "message",
373
- role: "user",
374
- content: [{ type: "input_text", text: userMessage }]
375
- }
376
- }));
377
- ws.send(JSON.stringify({ type: "response.create" }));
378
- }, 1000);
379
-
380
- // Collect responses
381
- let fullTranscript = "";
382
- let audioBuffer = "";
383
- let responseDone = false;
384
-
385
- const handleMessage = (raw: string) => {
386
- try {
387
- const event = JSON.parse(raw);
388
- const type = event?.type;
389
-
390
- if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") {
391
- const delta = (event.transcript || event.delta || "");
392
- if (delta) fullTranscript += delta;
393
- }
394
- if (type === "response.audio_transcript.done" || type === "response.done") {
395
- responseDone = true;
396
- clearTimeout(timeout);
397
- msgReceived = true;
398
- resolve({
399
- transcript: fullTranscript.trim(),
400
- audio: audioBuffer,
401
- audioFormat: "pcm16",
402
- });
403
- }
404
- } catch {}
405
- };
406
-
407
- ws.onmessage = (e: any) => {
408
- const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
409
- handleMessage(data);
410
- };
411
-
412
- ws.onerror = () => reject(new Error("WebSocket error"));
413
-
414
- // Timeout
415
- const timeout = setTimeout(() => {
416
- if (!responseDone) {
417
- msgReceived = true;
418
- try { ws.close(1000, "timeout"); } catch {}
419
- resolve({
420
- transcript: fullTranscript.trim() || "No response received.",
421
- audio: "",
422
- });
423
- }
424
- }, 15000);
425
- };
426
-
427
- ws.onclose = () => {
428
- if (!msgReceived) {
429
- msgReceived = true;
430
- resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" });
431
- }
432
- };
433
- });
434
-
435
- return Response.json(await wsPromise);
436
- } while (true);
437
- }
438
-
439
- // Session granted directly — connect WebSocket
440
- const grant = sessionData;
441
- const ws = new WebSocket(grant.connect_url);
442
-
443
- return new Promise<Response>((resolve, reject) => {
444
- ws.binaryType = "arraybuffer";
445
- let msgReceived = false;
446
-
447
- ws.onopen = () => {
448
- // Send session.update
449
- setTimeout(() => {
450
- ws.send(JSON.stringify({
451
- type: "session.update",
452
- session: {
453
- type: "realtime",
454
- 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.",
455
- audio: { output: { voice: "Sohee" } }
456
- }
457
- }));
458
- }, 500);
459
-
460
- // Send user message via text
461
- setTimeout(() => {
462
- ws.send(JSON.stringify({
463
- type: "conversation.item.create",
464
- item: {
465
- type: "message",
466
- role: "user",
467
- content: [{ type: "input_text", text: userMessage }]
468
- }
469
- }));
470
- ws.send(JSON.stringify({ type: "response.create" }));
471
- }, 1000);
472
-
473
- // Collect response
474
- let fullTranscript = "";
475
- let responseDone = false;
476
-
477
- const handleMessage = (raw: string) => {
478
- try {
479
- const event = JSON.parse(raw);
480
- const type = event?.type;
481
-
482
- if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") {
483
- const delta = event.delta || "";
484
- if (delta) fullTranscript += delta;
485
- }
486
- if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") {
487
- const segment = event.transcript || "";
488
- if (segment) fullTranscript += segment;
489
- }
490
- if (type === "response.done") {
491
- responseDone = true;
492
- clearTimeout(timeout);
493
- msgReceived = true;
494
- try { ws.close(1000, "done"); } catch {}
495
- resolve(Response.json({
496
- transcript: fullTranscript.trim(),
497
- status: event.response?.status ?? "completed",
498
- }));
499
- }
500
- } catch (e: any) {
501
- console.warn("[chat] parse error:", e.message);
502
- }
503
- };
504
-
505
- ws.onmessage = (e: any) => {
506
- const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
507
- handleMessage(data);
508
- };
509
-
510
- ws.onerror = () => {
511
- if (!msgReceived) {
512
- msgReceived = true;
513
- try { ws.close(1000, "error"); } catch {}
514
- resolve(Response.json({
515
- transcript: "Error connecting to chat service.",
516
- status: "error",
517
- }));
518
- }
519
- };
520
-
521
- ws.onclose = () => {
522
- if (!msgReceived) {
523
- msgReceived = true;
524
- resolve(Response.json({
525
- transcript: fullTranscript.trim() || "Session closed.",
526
- status: "closed",
527
- }));
528
- }
529
- };
530
-
531
- // Timeout
532
- const timeout = setTimeout(() => {
533
- if (!msgReceived) {
534
- msgReceived = true;
535
- try { ws.close(1000, "timeout"); } catch {}
536
- resolve(Response.json({
537
- transcript: fullTranscript.trim() || "No response received. The service may be busy.",
538
- status: "timeout",
539
- }));
540
- }
541
- }, 20000);
542
- };
543
-
544
- ws.onclose = () => {
545
- if (!msgReceived) {
546
- msgReceived = true;
547
- clearTimeout(timeout);
548
- resolve(Response.json({
549
- transcript: "Connection closed.",
550
- status: "closed",
551
- }));
552
- }
553
- };
554
- });
555
- } catch (err: any) {
556
- console.error("[/api/chat] Error:", err.message);
557
- return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
558
- }
559
- }
560
-
561
- const server = Bun.serve({
562
- port: PORT,
563
- routes: {
564
- "/": { GET: serveProductOG },
565
- "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
566
- "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
567
- "/api/vaix/products": { GET: async (req: Request) => {
568
- try {
569
- await loadVaixProducts();
570
- if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 });
571
- const url = new URL(req.url);
572
- const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999);
573
- const total = VAIX_PRODUCTS.length;
574
- const formatted = VAIX_PRODUCTS.slice(0, Math.min(limit, total)).map((p, i) => formatProduct(p, i));
575
- return Response.json({
576
- products: formatted,
577
- total, limit, page: 1, perPage: limit,
578
- totalPages: 1,
579
- });
580
- } catch { return Response.json({ products: [], total: 0 }); }
581
- }},
582
- "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) },
583
- "/api/vaix/search": { GET: async (req: Request) => {
584
- const url = new URL(req.url);
585
- const q = url.searchParams.get("q");
586
- if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 });
587
- try {
588
- await loadVaixProducts();
589
- if (!VAIX_PRODUCTS?.length) return Response.json({ results: [], aiAnswer: "No products." });
590
- const qNorm = norm(q);
591
- const terms = qNorm.split(/\s+/).filter(t => t.length > 1);
592
- const results: Array<{ product: any; score: number }> = [];
593
- for (let i = 0; i < VAIX_PRODUCTS.length; i++) {
594
- const p = VAIX_PRODUCTS[i];
595
- let score = 0;
596
- const nm = norm(p.name || p.n || "");
597
- const sk = norm(p.sku || p.mod || p.model || "");
598
- const br = norm(p.brand || "");
599
- const ca = norm(p.c || p.cat || "");
600
- const de = norm(p.desc || "");
601
- const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : "");
602
- if (nm.includes(qNorm)) score += 20;
603
- if (sk.includes(qNorm)) score += 15;
604
- if (br.includes(qNorm)) score += 10;
605
- if (ca.includes(qNorm)) score += 8;
606
- for (const t of terms) {
607
- if (nm.includes(t)) score += 3;
608
- if (sk.includes(t)) score += 2;
609
- if (br.includes(t)) score += 2;
610
- if (ca.includes(t)) score += 1;
611
- if (de.includes(t)) score += 1;
612
- if (ft.includes(t)) score += 1;
613
- }
614
- if (score > 0) results.push({ product: formatProduct(p, i), score });
615
- }
616
- results.sort((a, b) => b.score - a.score);
617
- return Response.json({ results: results.slice(0, 50), aiAnswer: results.length > 0 ? "Found " + results.length + " products." : "No products found." });
618
- } catch (err: any) { return Response.json({ results: [], error: err.message }); }
619
- }},
620
- "/api/vaix/sync": { POST: async () => {
621
- VAIX_PRODUCTS = null;
622
- VAIX_LOADED = false;
623
- VAIX_LOADING = false;
624
- await loadVaixProducts();
625
- return Response.json({ synced: true, count: VAIX_PRODUCTS?.length ?? 0 });
626
- }},
627
- "/api/wiki/search": { GET: async (req: Request) => {
628
- const q = new URL(req.url).searchParams.get("q");
629
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
630
- try {
631
- const r = await fetch("https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + encodeURIComponent(q) + "&format=json&srlimit=5&origin=*");
632
- const d = await r.json();
633
- return Response.json({ results: (d.query?.search ?? []).map((r: any) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })) });
634
- } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
635
- }},
636
- "/api/wiki/summary": { GET: async (req: Request) => {
637
- const t = new URL(req.url).searchParams.get("title");
638
- if (!t) return Response.json({ error: "Missing ?title=" }, { status: 400 });
639
- try {
640
- const r = await fetch("https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(t) + "?origin=*");
641
- const d = await r.json();
642
- return Response.json({ title: d.title, extract: d.extract, url: d.content_urls?.desktop?.page });
643
- } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
644
- }},
645
- "/api/web/search": { GET: async (req: Request) => {
646
- const q = new URL(req.url).searchParams.get("q");
647
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
648
- try {
649
- const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" } });
650
- const h = await r.text();
651
- const res: Array<{ title: string; snippet: string; url: string }> = [];
652
- const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
653
- const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
654
- for (let i = 0; i < Math.min(lm.length, 5); i++) {
655
- let href = lm[i][1];
656
- const ru = href.match(/uddg=(https?%3[^&]+)/i);
657
- if (ru) href = decodeURIComponent(ru[1]);
658
- const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
659
- const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
660
- if (title) res.push({ title, snippet, url: href });
661
- }
662
- return Response.json({ results: res });
663
- } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); }
664
- }},
665
- "/api/web/content": { GET: async (req: Request) => {
666
- const u = new URL(req.url).searchParams.get("url");
667
- if (!u) return Response.json({ error: "Missing ?url=" }, { status: 400 });
668
- try {
669
- const r = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(8000) });
670
- const h = await r.text();
671
- 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();
672
- return Response.json({ content: t.slice(0, 3000), url: u });
673
- } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); }
674
- }},
675
- "/api/session": { POST: sessionHandler },
676
- "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
677
- "/api/chat": { POST: textChatHandler },
678
- "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
679
- "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
680
- "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!),
681
- "/*": { GET: serveStatic },
682
- },
683
- });
684
- console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none"));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
package.json CHANGED
@@ -15,8 +15,7 @@
15
  },
16
  "dependencies": {
17
  "@met4citizen/talkinghead": "^1.7.0",
18
- "three": "~0.180.0",
19
- "edge-tts-universal": "^1.4.0"
20
  },
21
  "version": "1.5.1"
22
  }
 
15
  },
16
  "dependencies": {
17
  "@met4citizen/talkinghead": "^1.7.0",
18
+ "three": "~0.180.0"
 
19
  },
20
  "version": "1.5.1"
21
  }
public/avatars/grit-b6c6c4.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9e128f8a7263b679c94701b67ed6b9379e639d251cdf5129a92daef0b7a16876
3
- size 11247792
 
 
 
 
public/avatars/lisamy.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f
3
- size 8320540
 
 
 
 
public/avatars/lisamy.jpg DELETED
Binary file (21.7 kB)
 
public/avatars/vaistudio.png DELETED

Git LFS Details

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

Git LFS Details

  • SHA256: f719575626c5bc3f2ed6e028206d4fc02086ceb1618b43a7cc3a92e4a440ae2f
  • Pointer size: 132 Bytes
  • Size of remote file: 6.89 MB
public/avatars/vuong.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a
3
- size 6964484
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06
3
+ size 7137424
public/avatars/vuong.jpg DELETED
Binary file (14.9 kB)
 
public/avatars/vuong2.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06
3
- size 7137424
 
 
 
 
public/quote_logo.png DELETED
Binary file (66.8 kB)
 
public/vendor/headaudio.min.mjs DELETED
@@ -1 +0,0 @@
1
- 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};
 
 
public/worklets/audio-playback.js CHANGED
@@ -39,7 +39,6 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
39
  this._fadeIn = 0;
40
  this._fadeOut = 0;
41
  this._lastSample = 0;
42
- this._muted = false;
43
 
44
  this.port.onmessage = (e) => {
45
  const data = e.data;
@@ -67,12 +66,6 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
67
  this._fracPos = 0;
68
  this._fadeOut = FADE_FRAMES;
69
  break;
70
- case "mute":
71
- this._muted = true;
72
- break;
73
- case "unmute":
74
- this._muted = false;
75
- break;
76
  }
77
  };
78
  }
@@ -124,8 +117,7 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
124
  for (let i = 0; i < out.length; i++) {
125
  let sample = 0;
126
 
127
- if (this._muted) { sample = 0; }
128
- else if (this._playing) {
129
  const v = this._readInterpolated();
130
  if (v === null) {
131
  // Underrun: try to ramp out cleanly to avoid clicks.
 
39
  this._fadeIn = 0;
40
  this._fadeOut = 0;
41
  this._lastSample = 0;
 
42
 
43
  this.port.onmessage = (e) => {
44
  const data = e.data;
 
66
  this._fracPos = 0;
67
  this._fadeOut = FADE_FRAMES;
68
  break;
 
 
 
 
 
 
69
  }
70
  };
71
  }
 
117
  for (let i = 0; i < out.length; i++) {
118
  let sample = 0;
119
 
120
+ if (this._playing) {
 
121
  const v = this._readInterpolated();
122
  if (v === null) {
123
  // Underrun: try to ramp out cleanly to avoid clicks.
san-pham/canzy-cz-03t/index.html DELETED
@@ -1,20 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-03T | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-03T | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 15.980.000₫ - Canzy CZ-03T
8
- CZ - Bếp từ">
9
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ03T_v2026.png">
10
- <meta property="product:price:amount" content="15980000">
11
- <meta property="product:price:currency" content="VND">
12
- <meta name="twitter:card" content="summary_large_image">
13
- <meta name="twitter:title" content="CZ-03T | Canzy | V.AI STUDIO">
14
- <meta name="twitter:description" content="Giá: 15.980.000₫ - Canzy CZ-03T
15
- CZ - Bếp từ">
16
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ03T_v2026.png">
17
- <script>location.replace('/?product=canzy-cz-03t')</script>
18
- </head>
19
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
20
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/canzy-cz-816sdr/index.html DELETED
@@ -1,20 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ 816SDR | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ 816SDR | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 9.980.000₫ - Canzy CZ 816SDR
8
- CZ815TDB - Đồ gia dụng - Xuất xứ KOREA">
9
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ816SDR_v2026.png">
10
- <meta property="product:price:amount" content="9980000">
11
- <meta property="product:price:currency" content="VND">
12
- <meta name="twitter:card" content="summary_large_image">
13
- <meta name="twitter:title" content="CZ 816SDR | Canzy | V.AI STUDIO">
14
- <meta name="twitter:description" content="Giá: 9.980.000₫ - Canzy CZ 816SDR
15
- CZ815TDB - Đồ gia dụng - Xuất xứ KOREA">
16
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ816SDR_v2026.png">
17
- <script>location.replace('/?product=canzy-cz-816sdr')</script>
18
- </head>
19
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
20
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-007r-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ 007R | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ 007R | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 6.980.000₫ - Đồ gia dụng Canzy CZ 007R. Made in PrC Giá bán: 6.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy ép chậm điều khiển bằng phím bấm • Sử dụng cối ép bằng nhựa nguyên sinh, an toàn khi sử dụng • Lưỡi dao bằng Inox không gỉ, kết cấu 3 lưỡi dao răng cưa chắc chắn • Hệ thống bảo vệ quá nhiệt • Máy đi kèm 2 cốc bao gồm cốc đựng nước và cốc đựng bã THÔNG SỐ KỸ THUẬT Công suất 250W Vòng quay 50rpm Dung tích 1.75L">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ007R_v2026.png">
9
- <meta property="product:price:amount" content="6980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ 007R | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 6.980.000₫ - Đồ gia dụng Canzy CZ 007R. Made in PrC Giá bán: 6.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy ép chậm điều khiển bằng phím bấm • Sử dụng cối ép bằng nhựa nguyên sinh, an toàn khi sử dụng • Lưỡi dao bằng Inox không gỉ, kết cấu 3 lưỡi dao răng cưa chắc chắn • Hệ thống bảo vệ quá nhiệt • Máy đi kèm 2 cốc bao gồm cốc đựng nước và cốc đựng bã THÔNG SỐ KỸ THUẬT Công suất 250W Vòng quay 50rpm Dung tích 1.75L">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ007R_v2026.png">
15
- <script>location.replace('/?product=cz-007r-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-09dl-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZO9DL | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZO9DL | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 15.980.000₫ - Đồ gia dụng Canzy CZ-09DL. Made in PrC Giá bán: 15.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò nướng âm tủ • Hệ thống quạt thông gió • Cài đặt thời gian nướng linh hoạt • Điều khiển cơ 9 chức năng: - Đèn - Rã đông - Nướng thanh nhiệt trên dưới - Nướng quạt - Nướng thanh nhiệt dưới - Nướng tất cả các thanh nhiệt - Nướng các thanh nhiệt và quạt - Nướng thanh nhiệt dưới và quạt - Nướng thanh nhiệt trên dưới và quạt THÔNG SỐ KỸ THUẬT Dung tích 70L Điện áp 220/240V Công suất 2800W Nhiệt độ nướng 50-250°C KT sản phẩm (SxNxC) 570x595x590 (mm) KT âm tủ (SxNxC) 580x560x585 (mm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZO9DL_v2026.png">
9
- <meta property="product:price:amount" content="15980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZO9DL | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 15.980.000₫ - Đồ gia dụng Canzy CZ-09DL. Made in PrC Giá bán: 15.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò nướng âm tủ • Hệ thống quạt thông gió • Cài đặt thời gian nướng linh hoạt • Điều khiển cơ 9 chức năng: - Đèn - Rã đông - Nướng thanh nhiệt trên dưới - Nướng quạt - Nướng thanh nhiệt dưới - Nướng tất cả các thanh nhiệt - Nướng các thanh nhiệt và quạt - Nướng thanh nhiệt dưới và quạt - Nướng thanh nhiệt trên dưới và quạt THÔNG SỐ KỸ THUẬT Dung tích 70L Điện áp 220/240V Công suất 2800W Nhiệt độ nướng 50-250°C KT sản phẩm (SxNxC) 570x595x590 (mm) KT âm tủ (SxNxC) 580x560x585 (mm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZO9DL_v2026.png">
15
- <script>location.replace('/?product=cz-09dl-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-11ebn-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-11EBN | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-11EBN | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 22.980.000₫ - Đồ gia dụng Canzy CZ-11EBN. Made in PrC Giá bán: 22.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò nướng âm tủ • Điều khiển cảm ứng với 11 chức năng: - Nướng đối lưu - Nướng đối lưu kết hợp quạt - Nướng nhanh kết hợp quạt, nướng dưới - Nướng dưới kết hợp quạt - Nướng nhanh - Quạt gió đối lưu - Nướng nhanh kết hợp nướng đối lưu - Nướng nhanh kết hợp từ trên xuống - Nướng thức ăn từ dưới lên - Nướng nhanh hỗ trợ quạt - Rã đông • Hệ thống quạt thông gió • Hẹn giờ linh hoạt • Khóa trẻ em • Hệ thống làm sạch thông minh • Kính 3 lớp chịu lực, chịu nhiệt, có thể tháo rời THÔNG SỐ KỸ THUẬT Dung tích 68L Công suất 2950W Nhiệt độ nướng 50-250°C KT sản phẩm (RxCxS) 595x595x575 (mm) KT âm tủ (RxCxS) 560x583x550 (mm) 55">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ11EBN_v2026.png">
9
- <meta property="product:price:amount" content="22980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ-11EBN | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 22.980.000₫ - Đồ gia dụng Canzy CZ-11EBN. Made in PrC Giá bán: 22.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò nướng âm tủ • Điều khiển cảm ứng với 11 chức năng: - Nướng đối lưu - Nướng đối lưu kết hợp quạt - Nướng nhanh kết hợp quạt, nướng dưới - Nướng dưới kết hợp quạt - Nướng nhanh - Quạt gió đối lưu - Nướng nhanh kết hợp nướng đối lưu - Nướng nhanh kết hợp từ trên xuống - Nướng thức ăn từ dưới lên - Nướng nhanh hỗ trợ quạt - Rã đông • Hệ thống quạt thông gió • Hẹn giờ linh hoạt • Khóa trẻ em • Hệ thống làm sạch thông minh • Kính 3 lớp chịu lực, chịu nhiệt, có thể tháo rời THÔNG SỐ KỸ THUẬT Dung tích 68L Công suất 2950W Nhiệt độ nướng 50-250°C KT sản phẩm (RxCxS) 595x595x575 (mm) KT âm tủ (RxCxS) 560x583x550 (mm) 55">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ11EBN_v2026.png">
15
- <script>location.replace('/?product=cz-11ebn-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-11qam-serial-8-0-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ 11QAM SERIAL 8.0 | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ 11QAM SERIAL 8.0 | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 19.980.000₫ - Máy rửa bát Canzy CZ 11QAM SERIAL 8.0. Made in PrC Giá bán: 19.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy rửa bát độc lập, có thể đặt âm tủ • Dung tích: 11 bộ, 2 tầng giá rửa • Điều khiển cảm ứng với màn hình LED • Sấy nóng khử trùng ở nhiệt độ cao (PTC) • Công nghệ Fresh Air Drying: Sấy khí tươi (Máy tự động điều chỉnh nhiệt độ và lưu thông khí tươi bên trong để sấy khô theo chu kỳ 2 giờ/lần trong vòng 72 giờ) • Hẹn giờ khởi động máy lên tới 24 giờ • Khóa phím an toàn trẻ em • Khởi động (Start) và Tạm dừng (Pause) khi đang rửa 5 CHƯƠNG TRÌNH RỬA • ECO – Chế độ rửa tiết kiệm điện • Normal – Chế độ rửa thường • Intensive - Chế độ rửa chuyên sâu • Glass – Chế độ rửa ly, đồ thủy tinh Hẹn giờ Điều khiển Công nghệ Sấy PTC khởi động cảm ứng + LED Soft Water nhiệt độ cao • Rapid – Chế độ rửa siêu tốc 525 mm THÔNG SỐ KỸ THUẬT 550 mm Dung tích 11 bộ bát đĩa Công suất 1270W Độ ồn 50dB(A) 600 mm Kích thước 600x550x525 (mm) Trọng lượng 29 kg 8">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ11QAMSERIAL80_v2026.png">
9
- <meta property="product:price:amount" content="19980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ 11QAM SERIAL 8.0 | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 19.980.000₫ - Máy rửa bát Canzy CZ 11QAM SERIAL 8.0. Made in PrC Giá bán: 19.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy rửa bát độc lập, có thể đặt âm tủ • Dung tích: 11 bộ, 2 tầng giá rửa • Điều khiển cảm ứng với màn hình LED • Sấy nóng khử trùng ở nhiệt độ cao (PTC) • Công nghệ Fresh Air Drying: Sấy khí tươi (Máy tự động điều chỉnh nhiệt độ và lưu thông khí tươi bên trong để sấy khô theo chu kỳ 2 giờ/lần trong vòng 72 giờ) • Hẹn giờ khởi động máy lên tới 24 giờ • Khóa phím an toàn trẻ em • Khởi động (Start) và Tạm dừng (Pause) khi đang rửa 5 CHƯƠNG TRÌNH RỬA • ECO – Chế độ rửa tiết kiệm điện • Normal – Chế độ rửa thường • Intensive - Chế độ rửa chuyên sâu • Glass – Chế độ rửa ly, đồ thủy tinh Hẹn giờ Điều khiển Công nghệ Sấy PTC khởi động cảm ứng + LED Soft Water nhiệt độ cao • Rapid – Chế độ rửa siêu tốc 525 mm THÔNG SỐ KỸ THUẬT 550 mm Dung tích 11 bộ bát đĩa Công suất 1270W Độ ồn 50dB(A) 600 mm Kích thước 600x550x525 (mm) Trọng lượng 29 kg 8">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ11QAMSERIAL80_v2026.png">
15
- <script>location.replace('/?product=cz-11qam-serial-8-0-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-15ya-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ15YA | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ15YA | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 5.898.000₫ - Đồ gia dụng Canzy CZ 15YA. Made in PrC Giá bán: 5.898.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy ép chậm sử dụng công nghệ ép tốc độ thấp • Ép trái cây lạnh, giữ nguyên dưỡng chất • Độ ồn thấp • Hệ thống bảo vệ quá nhiệt • Vỏ bọc bằng nhựa ABS, chất liệu chịu nhiệt, đảm bảo an toàn cho thực phẩm THÔNG SỐ KỸ THUẬT Công suất 240W Vòng quay 55rpm Dung tích 700ml 62">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ15YA_v2026.png">
9
- <meta property="product:price:amount" content="5898000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ15YA | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 5.898.000₫ - Đồ gia dụng Canzy CZ 15YA. Made in PrC Giá bán: 5.898.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy ép chậm sử dụng công nghệ ép tốc độ thấp • Ép trái cây lạnh, giữ nguyên dưỡng chất • Độ ồn thấp • Hệ thống bảo vệ quá nhiệt • Vỏ bọc bằng nhựa ABS, chất liệu chịu nhiệt, đảm bảo an toàn cho thực phẩm THÔNG SỐ KỸ THUẬT Công suất 240W Vòng quay 55rpm Dung tích 700ml 62">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ15YA_v2026.png">
15
- <script>location.replace('/?product=cz-15ya-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-16eugr-unique-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-16EUGR UNIQUE | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-16EUGR UNIQUE | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 27.980.000₫ - Máy rửa bát Canzy CZ-16EUGR UNIQUE. Made in MALAYSIA Giá bán: 27.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy rửa bát độc lập, có thể lắp âm tủ • Dung tích 15 bộ bát đĩa • 3 giàn rửa tiện dụng, giàn giữa có thể điều chỉnh độ cao, tối ưu không gian sắp xếp • Hệ thống 3 tay phun chính + 2 tay phun phụ đáy + 1 tay phun thành khoang (dạng mới), tăng hiệu quả làm sạch toàn diện • Bảng điều khiển cảm ứng màu hiện đại, icon trực quan, dễ thao tác • Màn hình TFT trung tâm hiển thị thời gian, nhiệt độ, giai đoạn rửa và trạng thái vận hành • Hỗ trợ điều khiển từ xa qua Wi-Fi • Công nghệ sấy PTC cho hiệu quả sấy khô cao • Sterilize – Khử trùng bằng nhiệt độ cao, hỗ trợ làm sạch sâu • Tự động hé cửa sau sấy và tự đóng sau thoát hơi, hạn chế đọng ẩm • Lưu thông không khí 168 giờ, giữ khoang luôn khô thoáng, ngăn mùi hôi • Công nghệ bơm ly tâm không cần mồi nước giúp bơm bền bỉ gấp 3 lần bơm truyền thống • Khay chứa nước rửa bát tự động, không cần bổ sung cho từng lần sử dụng • Động cơ bơm rửa BLDC Inverter vận hành êm ái, tiết kiệm điện năng Động cơ Bơm rửa Tự động Hệ thống tay phun 7 CHƯƠNG TRÌNH RỬA BLDC Inverter không cần mồi hé – đóng cửa đa tầng • 90 Min – Chế độ rửa tiêu chuẩn trong 90 phút • ECO – Chế độ rửa tiết kiệm điện và nước • Normal – Chế độ rửa thông thường • Heavy – Chế độ rửa tăng cường cho nồi niêu, đồ bẩn Điều khiển Kết nối nhiều cảm ứng màu Wi-Fi • Glass – Chế độ rửa ly, đồ thủy tinh nhẹ nhàng • Instant – Chế độ rửa nhanh • Rapid – Chế độ rửa siêu tốc THÔNG SỐ KỸ THUẬT 600 mm 598 mm Dung tích 15 bộ bát đĩa Động cơ BLDC Inverter Công suất 1900W Độ ồn 50dB(A) 845 mm Kích thước 845x598x600 (mm) Trọng lượng 55.5 kg 5">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ16EUGRUNIQUE_v2026.png">
9
- <meta property="product:price:amount" content="27980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ-16EUGR UNIQUE | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 27.980.000₫ - Máy rửa bát Canzy CZ-16EUGR UNIQUE. Made in MALAYSIA Giá bán: 27.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy rửa bát độc lập, có thể lắp âm tủ • Dung tích 15 bộ bát đĩa • 3 giàn rửa tiện dụng, giàn giữa có thể điều chỉnh độ cao, tối ưu không gian sắp xếp • Hệ thống 3 tay phun chính + 2 tay phun phụ đáy + 1 tay phun thành khoang (dạng mới), tăng hiệu quả làm sạch toàn diện • Bảng điều khiển cảm ứng màu hiện đại, icon trực quan, dễ thao tác • Màn hình TFT trung tâm hiển thị thời gian, nhiệt độ, giai đoạn rửa và trạng thái vận hành • Hỗ trợ điều khiển từ xa qua Wi-Fi • Công nghệ sấy PTC cho hiệu quả sấy khô cao • Sterilize – Khử trùng bằng nhiệt độ cao, hỗ trợ làm sạch sâu • Tự động hé cửa sau sấy và tự đóng sau thoát hơi, hạn chế đọng ẩm • Lưu thông không khí 168 giờ, giữ khoang luôn khô thoáng, ngăn mùi hôi • Công nghệ bơm ly tâm không cần mồi nước giúp bơm bền bỉ gấp 3 lần bơm truyền thống • Khay chứa nước rửa bát tự động, không cần bổ sung cho từng lần sử dụng • Động cơ bơm rửa BLDC Inverter vận hành êm ái, tiết kiệm điện năng Động cơ Bơm rửa Tự động Hệ thống tay phun 7 CHƯƠNG TRÌNH RỬA BLDC Inverter không cần mồi hé – đóng cửa đa tầng • 90 Min – Chế độ rửa tiêu chuẩn trong 90 phút • ECO – Chế độ rửa tiết kiệm điện và nước • Normal – Chế độ rửa thông thường • Heavy – Chế độ rửa tăng cường cho nồi niêu, đồ bẩn Điều khiển Kết nối nhiều cảm ứng màu Wi-Fi • Glass – Chế độ rửa ly, đồ thủy tinh nhẹ nhàng • Instant – Chế độ rửa nhanh • Rapid – Chế độ rửa siêu tốc THÔNG SỐ KỸ THUẬT 600 mm 598 mm Dung tích 15 bộ bát đĩa Động cơ BLDC Inverter Công suất 1900W Độ ồn 50dB(A) 845 mm Kích thước 845x598x600 (mm) Trọng lượng 55.5 kg 5">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ16EUGRUNIQUE_v2026.png">
15
- <script>location.replace('/?product=cz-16eugr-unique-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-34gss-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ34GSS | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ34GSS | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 13.800.000₫ - Đồ gia dụng Canzy CZ-34GSS. Made in PrC Giá bán: 13.800.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò vi sóng âm tủ • Dung tích 34L • Điều khiển bán điện tử cảm ứng • Vi sóng với 5 mức công suất - Rã đông theo thời gian - Rã đông theo khối lượng - Nướng đối lưu - Nướng kết hợp - Nướng • Hệ thống quạt thông gió • Đồng hồ hẹn giờ lên tới 60 phút • Khóa trẻ em thông minh THÔNG SỐ KỸ THUẬT Dung tích 34L Công suất đầu vào 1000W Công suất đầu ra 1550W Đường kính đĩa xoay Φ315 (mm) Tần số vi sóng 2450MHz KT sản phẩm (RxCxS) 595x388x490 (mm) KT âm tủ (RxCxS) 560x380x550 (mm) 58">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ34GSS_v2026.png">
9
- <meta property="product:price:amount" content="13800000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ34GSS | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 13.800.000₫ - Đồ gia dụng Canzy CZ-34GSS. Made in PrC Giá bán: 13.800.000 VNĐ TÍNH NĂNG SẢN PHẨM • Lò vi sóng âm tủ • Dung tích 34L • Điều khiển bán điện tử cảm ứng • Vi sóng với 5 mức công suất - Rã đông theo thời gian - Rã đông theo khối lượng - Nướng đối lưu - Nướng kết hợp - Nướng • Hệ thống quạt thông gió • Đồng hồ hẹn giờ lên tới 60 phút • Khóa trẻ em thông minh THÔNG SỐ KỸ THUẬT Dung tích 34L Công suất đầu vào 1000W Công suất đầu ra 1550W Đường kính đĩa xoay Φ315 (mm) Tần số vi sóng 2450MHz KT sản phẩm (RxCxS) 595x388x490 (mm) KT âm tủ (RxCxS) 560x380x550 (mm) 58">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ34GSS_v2026.png">
15
- <script>location.replace('/?product=cz-34gss-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-68dt-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ68DT | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ68DT | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 11.980.000₫ - Máy hút mùi Canzy CZ-68DT. Made in PrC Giá bán: 11.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi âm tủ • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Đèn LED dài siêu sáng tiết kiệm điện • Lưới lọc mỡ nhôm 3 lớp • Thiết kế đặc biệt với mặt kính phía dưới giúp hạn chế dầu mỡ THÔNG SỐ KỸ THUẬT Chất liệu Thép không gỉ kết hợp mặt kính trắng ngọc Công suất motor 190W Đèn LED Độ ồn ≤45dB Ống thoát Φ150 (mm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ68DT_v2026.png">
9
- <meta property="product:price:amount" content="11980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ68DT | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 11.980.000₫ - Máy hút mùi Canzy CZ-68DT. Made in PrC Giá bán: 11.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi âm tủ • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Đèn LED dài siêu sáng tiết kiệm điện • Lưới lọc mỡ nhôm 3 lớp • Thiết kế đặc biệt với mặt kính phía dưới giúp hạn chế dầu mỡ THÔNG SỐ KỸ THUẬT Chất liệu Thép không gỉ kết hợp mặt kính trắng ngọc Công suất motor 190W Đèn LED Độ ồn ≤45dB Ống thoát Φ150 (mm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ68DT_v2026.png">
15
- <script>location.replace('/?product=cz-68dt-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-700-auto-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-700 AUTO | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-700 AUTO | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 9.980.000₫ - Máy hút mùi Canzy CZ-700 AUTO. Made in PrC Giá bán: 9.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi âm tủ kéo rút tự động • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm ứng 3 cấp độ • Điều khiển cảm biến vẫy tay thông minh • Cơ chế kéo rút tự động dạng thanh ray, vận hành linh hoạt • Hẹn giờ tiện lợi • Đèn LED dài siêu sáng, tiết kiệm điện • Lưới lọc inox ô vuông bền bỉ, dễ vệ sinh 150 mm THÔNG SỐ KỸ THUẬT Chất liệu Thép sơn tĩnh điện đen + Kính đen 228 mm Công suất motor 230W 40 mm Đèn LED 41 mm Độ ồn ≤60dB 284 mm 700 mm Ống thoát Φ150 (mm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ700AUTO_v2026.png">
9
- <meta property="product:price:amount" content="9980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ-700 AUTO | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 9.980.000₫ - Máy hút mùi Canzy CZ-700 AUTO. Made in PrC Giá bán: 9.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi âm tủ kéo rút tự động • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm ứng 3 cấp độ • Điều khiển cảm biến vẫy tay thông minh • Cơ chế kéo rút tự động dạng thanh ray, vận hành linh hoạt • Hẹn giờ tiện lợi • Đèn LED dài siêu sáng, tiết kiệm điện • Lưới lọc inox ô vuông bền bỉ, dễ vệ sinh 150 mm THÔNG SỐ KỸ THUẬT Chất liệu Thép sơn tĩnh điện đen + Kính đen 228 mm Công suất motor 230W 40 mm Đèn LED 41 mm Độ ồn ≤60dB 284 mm 700 mm Ống thoát Φ150 (mm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ700AUTO_v2026.png">
15
- <script>location.replace('/?product=cz-700-auto-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-70b50-60b50-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ70B50 | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ70B50 | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 4.350.000₫ - Máy hút mùi Canzy CZ-70B50/60B50. Made in PrC Giá bán: 4.350.000/4.250.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi cổ điển • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm biến vẫy tay thông minh • Hẹn giờ linh hoạt • Đèn LED tròn siêu sáng tiết kiệm điện • Lưới lọc mỡ bằng nhôm • Tấm kính chắn trước hỗ trợ ngăn dầu mỡ hiệu quả 698/598 mm THÔNG SỐ KỸ THUẬT 120 mm Chất liệu Thép sơn tĩnh điện đen + Kính 140 mm Công suất motor 180W Đèn LED 465 mm Độ ồn ≤60dB 660/560 mm Ống thoát Φ120 (mm) 52">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70B50_v2026.png">
9
- <meta property="product:price:amount" content="4350000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ70B50 | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 4.350.000₫ - Máy hút mùi Canzy CZ-70B50/60B50. Made in PrC Giá bán: 4.350.000/4.250.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi cổ điển • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm biến vẫy tay thông minh • Hẹn giờ linh hoạt • Đèn LED tròn siêu sáng tiết kiệm điện • Lưới lọc mỡ bằng nhôm • Tấm kính chắn trước hỗ trợ ngăn dầu mỡ hiệu quả 698/598 mm THÔNG SỐ KỸ THUẬT 120 mm Chất liệu Thép sơn tĩnh điện đen + Kính 140 mm Công suất motor 180W Đèn LED 465 mm Độ ồn ≤60dB 660/560 mm Ống thoát Φ120 (mm) 52">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70B50_v2026.png">
15
- <script>location.replace('/?product=cz-70b50-60b50-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-70c66-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-70C66 | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-70C66 | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 10.980.000₫ - Máy hút mùi Canzy CZ-70C66. Made in MALAYSIA Giá bán: 10.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi kính cong • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Điều khiển cảm biến vẫy tay thông minh • Tự động làm sạch • Hẹn giờ linh hoạt • Đèn LED dài siêu sáng tiết kiệm điện • Lưới lọc mỡ dạng thanh 2 lớp màu đen nhũ thiết kế hình kim tự tháp giúp định hướng luồng khí tập trung và nâng cao hiệu quả hút mùi • Cốc hứng dầu bằng sắt 380 mm 326 mm THÔNG SỐ KỸ THUẬT 450 mm Chất liệu Thép sơn tĩnh điện đen + Kính đen Công suất motor 230W Đèn LED Độ ồn ≤72dB 700 mm 450 mm Ống thoát Φ160 (mm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70C66_v2026.png">
9
- <meta property="product:price:amount" content="10980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ-70C66 | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 10.980.000₫ - Máy hút mùi Canzy CZ-70C66. Made in MALAYSIA Giá bán: 10.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi kính cong • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Điều khiển cảm biến vẫy tay thông minh • Tự động làm sạch • Hẹn giờ linh hoạt • Đèn LED dài siêu sáng tiết kiệm điện • Lưới lọc mỡ dạng thanh 2 lớp màu đen nhũ thiết kế hình kim tự tháp giúp định hướng luồng khí tập trung và nâng cao hiệu quả hút mùi • Cốc hứng dầu bằng sắt 380 mm 326 mm THÔNG SỐ KỸ THUẬT 450 mm Chất liệu Thép sơn tĩnh điện đen + Kính đen Công suất motor 230W Đèn LED Độ ồn ≤72dB 700 mm 450 mm Ống thoát Φ160 (mm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70C66_v2026.png">
15
- <script>location.replace('/?product=cz-70c66-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-70i50-60i50-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ70I50 | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ70I50 | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 4.450.000₫ - Máy hút mùi Canzy CZ-70I50/60I50. Made in PrC Giá bán: 4.450.000/4.350.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi cổ điển • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm biến vẫy tay thông minh • Hẹn giờ linh hoạt • Đèn LED tròn siêu sáng tiết kiệm điện • Lưới lọc mỡ bằng nhôm • Tấm kính chắn trước hỗ trợ ngăn dầu mỡ hiệu quả 698/598 mm 120 mm THÔNG SỐ KỸ THUẬT Chất liệu Inox + Kính 140 mm Công suất motor 180W Đèn LED 465 mm 660/560 mm Độ ồn ≤60dB Φ120 (mm) Ống thoát 51">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70I50_v2026.png">
9
- <meta property="product:price:amount" content="4450000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ70I50 | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 4.450.000₫ - Máy hút mùi Canzy CZ-70I50/60I50. Made in PrC Giá bán: 4.450.000/4.350.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi cổ điển • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài hoặc khử mùi bằng than hoạt tính • Điều khiển cảm biến vẫy tay thông minh • Hẹn giờ linh hoạt • Đèn LED tròn siêu sáng tiết kiệm điện • Lưới lọc mỡ bằng nhôm • Tấm kính chắn trước hỗ trợ ngăn dầu mỡ hiệu quả 698/598 mm 120 mm THÔNG SỐ KỸ THUẬT Chất liệu Inox + Kính 140 mm Công suất motor 180W Đèn LED 465 mm 660/560 mm Độ ồn ≤60dB Φ120 (mm) Ống thoát 51">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ70I50_v2026.png">
15
- <script>location.replace('/?product=cz-70i50-60i50-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-780k-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ780K | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ780K | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 12.980.000₫ - Đồ gia dụng Canzy CZ 780K. Made in PrC Giá bán: 12.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy xay sữa hạt tích hợp 10 chức năng: • Dry bean, Grain, Rice paste, Coffee, Milk shake, Juice, Fish soup, Fragrant milk, Soaked bean, Baked bean • Bảng điều khiển cảm ứng thông minh • Hệ thống tiệt trùng ở nhiệt độ cao • Chế độ làm chín bằng nước nóng • Chế độ tự làm sạch thông minh • Công nghệ giảm độ ồn • Công nghệ gia nhiệt 3D xung quanh 360 độ, công nghệ xay siêu mịn • Thời gian hẹn giờ bật lên đến 24h • Chống quá nhiệt và chân cao su chống trượt hoàn hảo THÔNG SỐ KỸ THUẬT Công suất 1200W Công suất xay lạnh 250W Cấp độ 11 Chất liệu Nhựa ABS, trục và lưỡi dao bằng thép không gỉ 61">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ780K_v2026.png">
9
- <meta property="product:price:amount" content="12980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ780K | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 12.980.000₫ - Đồ gia dụng Canzy CZ 780K. Made in PrC Giá bán: 12.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy xay sữa hạt tích hợp 10 chức năng: • Dry bean, Grain, Rice paste, Coffee, Milk shake, Juice, Fish soup, Fragrant milk, Soaked bean, Baked bean • Bảng điều khiển cảm ứng thông minh • Hệ thống tiệt trùng ở nhiệt độ cao • Chế độ làm chín bằng nước nóng • Chế độ tự làm sạch thông minh • Công nghệ giảm độ ồn • Công nghệ gia nhiệt 3D xung quanh 360 độ, công nghệ xay siêu mịn • Thời gian hẹn giờ bật lên đến 24h • Chống quá nhiệt và chân cao su chống trượt hoàn hảo THÔNG SỐ KỸ THUẬT Công suất 1200W Công suất xay lạnh 250W Cấp độ 11 Chất liệu Nhựa ABS, trục và lưỡi dao bằng thép không gỉ 61">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ780K_v2026.png">
15
- <script>location.replace('/?product=cz-780k-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-806-a5g-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ-806 A5G | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ-806 A5G | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 6.680.000₫ - Máy hút mùi Canzy CZ-806 A5G. Made in PrC Giá bán: 6.680.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi kính cong • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Điều khiển cảm biến vẫy tay thông minh • Tự động làm sạch • Hẹn giờ linh hoạt • 2 đèn LED siêu sáng tiết kiệm điện • Lưới lọc mỡ dạng thanh hình kim tự tháp giúp định hướng luồng khí tập trung và nâng cao hiệu quả hút mùi • Cốc hứng dầu bằng inox 365 mm 286 mm THÔNG SỐ KỸ THUẬT Chất liệu Thép sơn tĩnh điện đen + 390 mm Kính đen Công suất motor 180W Đèn LED Độ ồn ≤60dB 680 mm Ống thoát Φ150 (mm) 400 mm">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ806A5G_v2026.png">
9
- <meta property="product:price:amount" content="6680000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ-806 A5G | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 6.680.000₫ - Máy hút mùi Canzy CZ-806 A5G. Made in PrC Giá bán: 6.680.000 VNĐ TÍNH NĂNG SẢN PHẨM • Máy hút mùi kính cong • Điều khiển cảm ứng 3 cấp độ (Touch Control) • Chế độ hút đẩy ra ngoài trực tiếp • Điều khiển cảm biến vẫy tay thông minh • Tự động làm sạch • Hẹn giờ linh hoạt • 2 đèn LED siêu sáng tiết kiệm điện • Lưới lọc mỡ dạng thanh hình kim tự tháp giúp định hướng luồng khí tập trung và nâng cao hiệu quả hút mùi • Cốc hứng dầu bằng inox 365 mm 286 mm THÔNG SỐ KỸ THUẬT Chất liệu Thép sơn tĩnh điện đen + 390 mm Kính đen Công suất motor 180W Đèn LED Độ ồn ≤60dB 680 mm Ống thoát Φ150 (mm) 400 mm">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ806A5G_v2026.png">
15
- <script>location.replace('/?product=cz-806-a5g-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-815tdb-canzy-c-y-n-c-n-ng-l-nh/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ815TDB | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ815TDB | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 8.980.000₫ - Cây nước nóng lạnh Canzy CZ 815TDB. Made in Korea">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ815TDB_v2026.png">
9
- <meta property="product:price:amount" content="8980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ815TDB | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 8.980.000₫ - Cây nước nóng lạnh Canzy CZ 815TDB. Made in Korea">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ815TDB_v2026.png">
15
- <script>location.replace('/?product=cz-815tdb-canzy-c-y-n-c-n-ng-l-nh')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-899-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ899 | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ899 | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 2.480.000₫ - Đồ gia dụng Canzy CZ 899. Made in PrC Giá bán: 2.480.000 VNĐ TÍNH NĂNG SẢN PHẨM THÔNG SỐ KỸ THUẬT Bộ 5 món 3 nồi+1 chảo+1 quánh • Bộ xoong nồi cao cấp Canzy gồm 5 món • Thân nồi Inox 201 dày 5mm KT quánh 18x9 (cm) • Cấu trúc đáy 4 lớp • Chảo có tay cầm Silicone cách nhiệt KT nồi lớn 24x12 (cm) • Nắp nồi bằng thủy tinh cường lực dày 4mm, chịu nhiệt tốt KT nồi nhỏ 22x11 (cm) • Sử dụng được trên tất cả các loại bếp KT nồi hấp 24x9 (cm) KT chảo chống dính 24x6 (cm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ899_v2026.png">
9
- <meta property="product:price:amount" content="2480000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ899 | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 2.480.000₫ - Đồ gia dụng Canzy CZ 899. Made in PrC Giá bán: 2.480.000 VNĐ TÍNH NĂNG SẢN PHẨM THÔNG SỐ KỸ THUẬT Bộ 5 món 3 nồi+1 chảo+1 quánh • Bộ xoong nồi cao cấp Canzy gồm 5 món • Thân nồi Inox 201 dày 5mm KT quánh 18x9 (cm) • Cấu trúc đáy 4 lớp • Chảo có tay cầm Silicone cách nhiệt KT nồi lớn 24x12 (cm) • Nắp nồi bằng thủy tinh cường lực dày 4mm, chịu nhiệt tốt KT nồi nhỏ 22x11 (cm) • Sử dụng được trên tất cả các loại bếp KT nồi hấp 24x9 (cm) KT chảo chống dính 24x6 (cm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ899_v2026.png">
15
- <script>location.replace('/?product=cz-899-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-9979-grh-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ9979GRH | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ9979GRH | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 26.980.000₫ - Bếp từ Canzy CZ-9979 GRH. Made in GERMANY Giá bán: 26.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Hâm nóng Warm ở 65°C • Tạm dừng khi nấu Pause • Hẹn giờ linh hoạt • Hệ thống mạch và mâm từ E.G.O Germany nền tảng Basic 4, vận hành ổn định • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định THÔNG SỐ KỸ THUẬT TÍNH NĂNG AN TOÀN Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm) BOOSTER Gia nhiệt tức thì, tối ưu thời gian nấu 11">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRH_v2026.png">
9
- <meta property="product:price:amount" content="26980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ9979GRH | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 26.980.000₫ - Bếp từ Canzy CZ-9979 GRH. Made in GERMANY Giá bán: 26.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Hâm nóng Warm ở 65°C • Tạm dừng khi nấu Pause • Hẹn giờ linh hoạt • Hệ thống mạch và mâm từ E.G.O Germany nền tảng Basic 4, vận hành ổn định • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định THÔNG SỐ KỸ THUẬT TÍNH NĂNG AN TOÀN Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm) BOOSTER Gia nhiệt tức thì, tối ưu thời gian nấu 11">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRH_v2026.png">
15
- <script>location.replace('/?product=cz-9979-grh-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-9979-grs-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ9979GRS | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ9979GRS | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 26.980.000₫ - Bếp từ Canzy CZ-9979 GRS. Made in GERMANY Giá bán: 26.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Trang bị bản mạch EGO Germany nền tảng Basic 4 cho độ ổn định cao • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định • Hâm nóng Warm ở 65°C • Tạm dừng khi nấu Pause • Hẹn giờ linh hoạt TÍNH NĂNG AN TOÀN THÔNG SỐ KỸ THUẬT Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm) 10">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRS_v2026.png">
9
- <meta property="product:price:amount" content="26980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ9979GRS | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 26.980.000₫ - Bếp từ Canzy CZ-9979 GRS. Made in GERMANY Giá bán: 26.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Trang bị bản mạch EGO Germany nền tảng Basic 4 cho độ ổn định cao • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định • Hâm nóng Warm ở 65°C • Tạm dừng khi nấu Pause • Hẹn giờ linh hoạt TÍNH NĂNG AN TOÀN THÔNG SỐ KỸ THUẬT Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm) 10">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRS_v2026.png">
15
- <script>location.replace('/?product=cz-9979-grs-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
san-pham/cz-9979-grt-canzy/index.html DELETED
@@ -1,18 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>CZ9979GRT | Canzy | V.AI STUDIO</title>
6
- <meta property="og:title" content="CZ9979GRT | Canzy | V.AI STUDIO">
7
- <meta property="og:description" content="Giá: 28.980.000₫ - Bếp từ Canzy CZ-9979 GRT. Made in GERMANY Giá bán: 28.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Trang bị bản mạch EGO Germany nền tảng Basic 4 ổn định, bền bỉ • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định • Nấu cơm thông minh • Hẹn giờ linh hoạt THÔNG SỐ KỸ THUẬT TÍNH NĂNG AN TOÀN Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm)">
8
- <meta property="og:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRT_v2026.png">
9
- <meta property="product:price:amount" content="28980000">
10
- <meta property="product:price:currency" content="VND">
11
- <meta name="twitter:card" content="summary_large_image">
12
- <meta name="twitter:title" content="CZ9979GRT | Canzy | V.AI STUDIO">
13
- <meta name="twitter:description" content="Giá: 28.980.000₫ - Bếp từ Canzy CZ-9979 GRT. Made in GERMANY Giá bán: 28.980.000 VNĐ TÍNH NĂNG SẢN PHẨM • Bếp đôi: 2 từ • Mặt kính Schott Ceran Germany vát cạnh sang trọng • Ứng dụng công nghệ Inverter thông minh, tiết kiệm ~30% điện năng • Điều khiển cảm ứng trượt slide • Trang bị bản mạch EGO Germany nền tảng Basic 4 ổn định, bền bỉ • Công nghệ biến tần Inverter cộng hưởng điều khiển tần số tiên tiến giúp bếp hoạt động ổn định • Nấu cơm thông minh • Hẹn giờ linh hoạt THÔNG SỐ KỸ THUẬT TÍNH NĂNG AN TOÀN Vùng nấu trái Booster 3000W • Tự động ngắt, tự nhận diện vùng nấu • Cảnh báo dư nhiệt vùng nấu Vùng nấu phải Booster 3000W • Chống tràn thông minh • Khóa an toàn trẻ em, khóa phím điều khiển Điện áp 220/240V • Bảo vệ an toàn quá nhiệt, quá áp KT mặt bếp 730x430 (mm) KT cắt đá 710x410 (mm)">
14
- <meta name="twitter:image" content="https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/canzy_products_img/canzy_product_CZ9979GRT_v2026.png">
15
- <script>location.replace('/?product=cz-9979-grt-canzy')</script>
16
- </head>
17
- <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
18
- </html>