Restore index.ts to commit 413b67e3 state (remove V.AIX endpoints)

#3
by bep40 - opened
Files changed (6) hide show
  1. bun.lock +6 -6
  2. index.html +123 -83
  3. index.ts +1 -146
  4. src/app.js +154 -151
  5. src/vaix.css +0 -457
  6. src/vaix.js +0 -324
bun.lock CHANGED
@@ -19,16 +19,16 @@
19
  "packages": {
20
  "@met4citizen/talkinghead": ["@met4citizen/talkinghead@1.7.0", "", { "dependencies": { "three": "^0.180.0" } }, "sha512-iBQhTjvwOO2lLht/YeWqAUmsH0jk4FGpxiPTPMMp3QYVjst/u2rDhn4up59BMJwfdeW3DyU7Im6y+TWmnNllZA=="],
21
 
22
- "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
23
 
24
- "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
25
 
26
- "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
27
 
28
  "three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
29
 
30
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
31
 
32
- "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
33
  }
34
- }
 
19
  "packages": {
20
  "@met4citizen/talkinghead": ["@met4citizen/talkinghead@1.7.0", "", { "dependencies": { "three": "^0.180.0" } }, "sha512-iBQhTjvwOO2lLht/YeWqAUmsH0jk4FGpxiPTPMMp3QYVjst/u2rDhn4up59BMJwfdeW3DyU7Im6y+TWmnNllZA=="],
21
 
22
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
23
 
24
+ "@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "O0A0A1G3xPGy4w7AgQdAQYUlQ+BKP8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
25
 
26
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "S4N0ig0igEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEvg8QXtVsrUDb95MBOZGPvr6KQ=="],
27
 
28
  "three": ["three@0.180.0", "", {}, "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w=="],
29
 
30
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjVBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TtSw=="],
31
 
32
+ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejXWJ7BbJqFYpTEUkS1Ge8bpZQ=="],
33
  }
34
+ }
index.html CHANGED
@@ -3,16 +3,15 @@
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.AIX - Chat & Voice Assistant</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
10
  <link
11
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap"
12
  rel="stylesheet"
13
  />
 
14
  <link rel="stylesheet" href="./src/style.css" />
15
- <link rel="stylesheet" href="./src/vaix.css" />
16
  </head>
17
  <body>
18
  <main id="app">
@@ -21,13 +20,13 @@
21
 
22
  <header id="topbar">
23
  <div id="identity">
24
- <h1>Gemma&nbsp;Avatar + V.AIX</h1>
25
- <p>Chat or talk to <strong>Gemma&nbsp;4</strong>. Text → Qwen3-TTS → lip-sync avatar. Control V.AIX catalog.</p>
26
  </div>
27
  <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
28
  <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">
29
  <circle cx="12" cy="12" r="3"></circle>
30
- <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>
31
  </svg>
32
  </button>
33
  </header>
@@ -95,103 +94,144 @@
95
  </dialog>
96
  </main>
97
 
98
- <!-- V.AIX Toggle Button -->
99
- <button id="vaix-toggle" aria-label="Open V.AIX catalog" title="Open V.AIX catalog" class="vaix-toggle">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
101
- <path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"></path>
102
- <rect width="6" height="8" x="12" y="3" rx="1"></rect>
103
  </svg>
104
  </button>
105
 
106
- <!-- V.AIX Panel — lightweight search + products -->
107
- <div id="vaix-panel" role="dialog" aria-label="V.AIX Product Catalog" class="vaix-panel">
108
- <div class="vaix-header">
109
- <div class="vaix-header-avatar"><i class="fas fa-store"></i></div>
110
- <div class="vaix-header-info">
111
- <h4>V.AIX</h4>
112
- <span id="vaix-count">0 sản phẩm</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  </div>
114
- <button id="vaix-close" aria-label="Close V.AIX" class="vaix-close-btn">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
116
  <line x1="18" y1="6" x2="6" y2="18"></line>
117
  <line x1="6" y1="6" x2="18" y2="18"></line>
118
  </svg>
119
  </button>
120
  </div>
121
-
122
- <div class="vaix-body">
123
- <!-- Search -->
124
- <div class="vaix-search-box">
125
- <input
126
- id="vaix-search-input"
127
- type="text"
128
- placeholder="🔍 Tìm nhanh: bếp từ Malloca dưới 5 triệu, khóa thông minh..."
129
- autocomplete="off"
130
- />
131
- <button class="vaix-search-btn" title="Tìm kiếm">
132
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
133
- <circle cx="11" cy="11" r="8"></circle>
134
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
135
- </svg>
136
- </button>
137
- </div>
138
-
139
- <!-- Quick category pills -->
140
- <div id="vaix-quick-cats" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px; padding-right:4px">
141
- <!-- Filled by vaix.js -->
142
- </div>
143
-
144
- <!-- Product list -->
145
- <div id="vaix-product-list" style="min-height:300px"></div>
146
-
147
- <!-- Product detail -->
148
- <div id="vaix-product-detail"></div>
149
-
150
- <!-- Inquiry suggestion -->
151
- <div id="vaix-inquiry-suggest"></div>
152
-
153
- <!-- Loading -->
154
- <div id="vaix-loading" class="vaix-loading">
155
- <div class="vaix-loading-spinner"></div>
156
- <div>Đang tải sản phẩm V.AIX…</div>
157
  </div>
 
 
 
 
 
 
158
  </div>
159
  </div>
160
 
161
  <style>
162
- .vaix-toggle {
163
- position: fixed;
164
- bottom: 24px;
165
- right: 24px;
166
- z-index: 180;
167
- width: 56px;
168
- height: 56px;
169
- border-radius: 50%;
170
- background: linear-gradient(135deg, #003f62, #0077b6);
171
- color: #fff;
172
- border: none;
173
- cursor: pointer;
174
- box-shadow: 0 4px 20px rgba(0, 63, 98, 0.4);
175
- display: flex;
176
- align-items: center;
177
- justify-content: center;
178
- font-size: 1.4rem;
179
- transition: all 0.25s ease;
180
- }
181
- .vaix-toggle:hover {
182
- transform: scale(1.1);
183
- box-shadow: 0 6px 28px rgba(0, 63, 98, 0.5);
184
- }
185
- .vaix-toggle.active {
186
- background: linear-gradient(135deg, #db9815, #f0b840);
187
- box-shadow: 0 4px 20px rgba(219, 152, 21, 0.4);
188
  }
189
- .vaix-toggle.active:hover {
190
- box-shadow: 0 6px 28px rgba(219, 152, 21, 0.5);
 
 
 
191
  }
192
  </style>
193
 
194
  <script type="module" src="./src/app.js"></script>
195
- <script src="./src/vaix.js" defer></script>
196
  </body>
197
  </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
10
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@500&display=swap"
11
  rel="stylesheet"
12
  />
13
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
14
  <link rel="stylesheet" href="./src/style.css" />
 
15
  </head>
16
  <body>
17
  <main id="app">
 
20
 
21
  <header id="topbar">
22
  <div id="identity">
23
+ <h1>Gemma&nbsp;Avatar + V.AI STUDIO</h1>
24
+ <p>Chat or talk to <strong>Gemma&nbsp;4</strong>. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.</p>
25
  </div>
26
  <button id="settings-btn" class="icon-btn" aria-label="Settings" title="Settings">
27
  <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">
28
  <circle cx="12" cy="12" r="3"></circle>
29
+ <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>
30
  </svg>
31
  </button>
32
  </header>
 
94
  </dialog>
95
  </main>
96
 
97
+ <!-- V.AISTUDIO Toggle Button -->
98
+ <button id="vaistudio-toggle" aria-label="Open V.AI STUDIO catalog" title="Open V.AI STUDIO catalog" style="
99
+ position: fixed;
100
+ bottom: 24px;
101
+ right: 24px;
102
+ z-index: 180;
103
+ width: 56px;
104
+ height: 56px;
105
+ border-radius: 50%;
106
+ background: linear-gradient(135deg, #003f62, #0077b6);
107
+ color: #fff;
108
+ border: none;
109
+ cursor: pointer;
110
+ box-shadow: 0 4px 20px rgba(0, 63, 98, 0.4);
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ font-size: 1.4rem;
115
+ transition: all 0.25s ease;
116
+ " onmouseover="this.style.transform='scale(1.1)';this.style.boxShadow='0 6px 28px rgba(0,63,98,0.5)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 4px 20px rgba(0,63,98,0.4)'">
117
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
118
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
119
+ <polyline points="17 8 12 13 7 8"></polyline>
120
  </svg>
121
  </button>
122
 
123
+ <!-- V.AISTUDIO Panel -->
124
+ <div id="vaistudio-panel" role="dialog" aria-label="V.AI STUDIO Catalog" style="
125
+ position: fixed;
126
+ bottom: 0;
127
+ right: 0;
128
+ width: 400px;
129
+ max-width: 100vw;
130
+ height: 560px;
131
+ max-height: calc(100vh - 48px);
132
+ background: #fff;
133
+ border-radius: 20px 20px 0 0;
134
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
135
+ z-index: 160;
136
+ display: none;
137
+ flex-direction: column;
138
+ overflow: hidden;
139
+ ">
140
+ <div id="vaistudio-header" style="
141
+ background: linear-gradient(135deg, #003f62, #0077b6);
142
+ color: #fff;
143
+ padding: 14px 16px;
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 12px;
147
+ flex-shrink: 0;
148
+ ">
149
+ <div id="vaistudio-header-avatar" style="
150
+ width: 38px;
151
+ height: 38px;
152
+ border-radius: 12px;
153
+ background: rgba(255, 255, 255, 0.15);
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ font-size: 1.1rem;
158
+ flex-shrink: 0;
159
+ "><i class="fas fa-store"></i></div>
160
+ <div id="vaistudio-header-info">
161
+ <h4 style="font-size: 0.9rem; font-weight: 700; margin: 0;">V.AI STUDIO</h4>
162
+ <span style="font-size: 0.68rem; opacity: 0.75;">8000+ sản phẩm - Malloca, Eurogold, Grob, Canzy, Demax & Điện Máy Xanh</span>
163
  </div>
164
+ <button id="vaistudio-close" aria-label="Close V.AI STUDIO" style="
165
+ margin-left: auto;
166
+ width: 40px;
167
+ height: 40px;
168
+ min-width: 40px;
169
+ min-height: 40px;
170
+ border-radius: 50%;
171
+ background: rgba(255, 255, 255, 0.25);
172
+ border: none;
173
+ color: #fff;
174
+ cursor: pointer;
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ font-size: 1.1rem;
179
+ transition: all 0.2s ease;
180
+ " onmouseover="this.style.background='rgba(255,255,255,0.4)'" onmouseout="this.style.background='rgba(255,255,255,0.25)'">
181
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
182
  <line x1="18" y1="6" x2="6" y2="18"></line>
183
  <line x1="6" y1="6" x2="18" y2="18"></line>
184
  </svg>
185
  </button>
186
  </div>
187
+ <div id="vaistudio-body" style="
188
+ flex: 1;
189
+ overflow: hidden;
190
+ position: relative;
191
+ background: #f8fafc;
192
+ ">
193
+ <div id="vaistudio-loading" style="
194
+ position: absolute;
195
+ inset: 0;
196
+ background: #fff;
197
+ display: flex;
198
+ flex-direction: column;
199
+ align-items: center;
200
+ justify-content: center;
201
+ gap: 12px;
202
+ z-index: 10;
203
+ ">
204
+ <div id="vaistudio-loading-spinner" style="
205
+ width: 32px;
206
+ height: 32px;
207
+ border: 3px solid #e2e8f0;
208
+ border-top-color: #003f62;
209
+ border-radius: 50%;
210
+ animation: spin 1s linear infinite;
211
+ "></div>
212
+ <div id="vaistudio-loading-text" style="font-size: 0.85rem; color: #64748b;">Đang tải V.AI STUDIO…</div>
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
+ <iframe id="vaistudio-iframe" src="https://bep40-v-aistudio.static.hf.space" allow="camera; microphone; clipboard-read; clipboard-write" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads" style="
215
+ width: 100%;
216
+ height: 100%;
217
+ border: none;
218
+ background: #fff;
219
+ "></iframe>
220
  </div>
221
  </div>
222
 
223
  <style>
224
+ @keyframes spin {
225
+ to { transform: rotate(360deg); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  }
227
+ #vaistudio-panel.open { display: flex; }
228
+ #vaistudio-loading.hidden { display: none; }
229
+ @media (max-width: 768px) {
230
+ #vaistudio-toggle { bottom: 16px; right: 16px; width: 50px; height: 50px; font-size: 1.2rem; }
231
+ #vaistudio-panel { width: 100%; max-width: 100%; height: 100%; max-height: 100vh; border-radius: 0; }
232
  }
233
  </style>
234
 
235
  <script type="module" src="./src/app.js"></script>
 
236
  </body>
237
  </html>
index.ts CHANGED
@@ -1,10 +1,5 @@
1
  /**
2
  * Gemma Avatar — realtime voice/text chat with a 3D talking-head avatar.
3
- *
4
- * V.AIX integration:
5
- * - Proxies V.AIX API for product catalog search
6
- * - Caches products for fast client-side search
7
- * - Provides /api/vaix/* endpoints for gemma-avatar frontend
8
  */
9
  import index from "./index.html";
10
  import { readdir } from "fs/promises";
@@ -87,66 +82,6 @@ async function fetchHotNews() {
87
  return Array.from(titles).slice(0, 10);
88
  }
89
 
90
- /** Simple memory cache for V.AIX products */
91
- let vaixProductCache = {
92
- products: [],
93
- timestamp: 0,
94
- TTL: 30 * 60 * 1000, // 30 minutes cache
95
- };
96
-
97
- async function fetchVAXProducts(force = false) {
98
- if (!force && vaixProductCache.products.length > 0 && (Date.now() - vaixProductCache.timestamp < vaixProductCache.TTL)) {
99
- return vaixProductCache.products;
100
- }
101
-
102
- console.log("[V.AIX] Fetching products from V.AIX API...");
103
- try {
104
- const startTime = Date.now();
105
- const resp = await fetch("https://bep40-vaix.hf.space/products", {
106
- signal: AbortSignal.timeout(15000), // 15s timeout
107
- });
108
- const fetchTime = Date.now() - startTime;
109
-
110
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
111
-
112
- const data = await resp.json();
113
- const products = Array.isArray(data) ? data : (data.products || data);
114
-
115
- if (products.length > 0) {
116
- vaixProductCache = { products, timestamp: Date.now() };
117
- console.log(`[V.AIX] Fetched ${products.length} products in ${fetchTime}ms`);
118
- }
119
-
120
- return products;
121
- } catch (e) {
122
- console.error("[V.AIX] Fetch failed:", e.message);
123
- // Return cached data on error
124
- return vaixProductCache.products;
125
- }
126
- }
127
-
128
- /** In-memory search index for fast queries */
129
- let searchIndex = new Map();
130
-
131
- function buildSearchIndex(products) {
132
- searchIndex.clear();
133
- products.forEach((p, i) => {
134
- const searchable = [p.title_clean || p.title, p.sku, p.brand, p.category, p.features]
135
- .filter(Boolean)
136
- .join(" ")
137
- .toLowerCase()
138
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '');
139
-
140
- searchable.split(/\s+/).forEach(word => {
141
- if (word.length > 1) {
142
- if (!searchIndex.has(word)) searchIndex.set(word, []);
143
- searchIndex.get(word).push(i);
144
- }
145
- });
146
- });
147
- console.log(`[V.AIX] Search index: ${searchIndex.size} terms`);
148
- }
149
-
150
  const server = Bun.serve({
151
  port: PORT,
152
  routes: {
@@ -164,86 +99,6 @@ const server = Bun.serve({
164
  return Response.json({ avatars: names });
165
  },
166
  },
167
- // V.AIX API proxy endpoints
168
- "/api/vaix/products": {
169
- GET: async () => {
170
- const products = await fetchVAXProducts();
171
- return Response.json({ products, count: products.length });
172
- },
173
- },
174
- "/api/vaix/search": {
175
- GET: async (req) => {
176
- const url = new URL(req.url);
177
- const q = url.searchParams.get("q");
178
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
179
-
180
- const products = await fetchVAXProducts();
181
-
182
- if (products.length === 0) {
183
- return Response.json({ results: [], total: 0 });
184
- }
185
-
186
- // Build index on first search
187
- if (searchIndex.size === 0) {
188
- buildSearchIndex(products);
189
- }
190
-
191
- // Client-side search with scoring
192
- const norm = q.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
193
- const terms = norm.split(/\s+/).filter(w => w.length > 1);
194
-
195
- const results = [];
196
- products.forEach((p, i) => {
197
- const searchable = [p.title_clean || p.title, p.sku, p.brand, p.category, p.features, p.description]
198
- .filter(Boolean)
199
- .join(" ")
200
- .toLowerCase()
201
- .normalize('NFD')
202
- .replace(/[\u0300-\u036f]/g, '');
203
-
204
- let score = 0;
205
- terms.forEach(term => {
206
- if (p.title_clean && p.title_clean.toLowerCase().includes(term)) score += 10 * term.length;
207
- if (p.sku && p.sku.toLowerCase().includes(term)) score += 8 * term.length;
208
- if (p.brand && p.brand.toLowerCase().includes(term)) score += 6 * term.length;
209
- if (p.category && p.category.toLowerCase().includes(term)) score += 4 * term.length;
210
- if (p.features && p.features.toLowerCase().includes(term)) score += 2 * term.length;
211
- });
212
-
213
- if (score > 0) {
214
- results.push({ product: p, score });
215
- }
216
- });
217
-
218
- results.sort((a, b) => b.score - a.score);
219
- return Response.json({ results: results.slice(0, 50), total: results.length, cached: (Date.now() - vaixProductCache.timestamp < vaixProductCache.TTL) });
220
- },
221
- },
222
- "/api/vaix/categories": {
223
- GET: async () => {
224
- const products = await fetchVAXProducts();
225
- const cats = new Map();
226
- products.forEach(p => {
227
- const cat = p.category || "Khác";
228
- if (!cats.has(cat)) cats.set(cat, 0);
229
- cats.set(cat, cats.get(cat) + 1);
230
- });
231
- return Response.json({ categories: Array.from(cats.entries()) });
232
- },
233
- },
234
- "/api/vaix/brands": {
235
- GET: async () => {
236
- const products = await fetchVAXProducts();
237
- const brands = new Map();
238
- products.forEach(p => {
239
- if (p.brand) {
240
- if (!brands.has(p.brand)) brands.set(p.brand, 0);
241
- brands.set(p.brand, brands.get(p.brand) + 1);
242
- }
243
- });
244
- return Response.json({ brands: Array.from(brands.entries()) });
245
- },
246
- },
247
  "/api/wiki/search": {
248
  GET: async (req) => {
249
  const url = new URL(req.url);
@@ -362,4 +217,4 @@ const server = Bun.serve({
362
  });
363
 
364
  console.log(`gemma-avatar listening on ${server.url}`);
365
- console.log(UPSTREAM ? `session backend: ${LOAD_BALANCER_URL ? "load balancer" : "session proxy"} (${UPSTREAM})` : "session backend: none");
 
1
  /**
2
  * Gemma Avatar — realtime voice/text chat with a 3D talking-head avatar.
 
 
 
 
 
3
  */
4
  import index from "./index.html";
5
  import { readdir } from "fs/promises";
 
82
  return Array.from(titles).slice(0, 10);
83
  }
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  const server = Bun.serve({
86
  port: PORT,
87
  routes: {
 
99
  return Response.json({ avatars: names });
100
  },
101
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  "/api/wiki/search": {
103
  GET: async (req) => {
104
  const url = new URL(req.url);
 
217
  });
218
 
219
  console.log(`gemma-avatar listening on ${server.url}`);
220
+ console.log(UPSTREAM ? `session backend: ${LOAD_BALANCER_URL ? "load balancer" : "session proxy"} (${UPSTREAM})` : "session backend: none");
src/app.js CHANGED
@@ -1,9 +1,16 @@
1
  // @ts-check
2
  /**
3
- * App wiring: the avatar stage + the speech-to-speech session + V.AIX catalog.
4
- *
5
- * CHANGED: Replaced V.AISTUDIO iframe with native V.AIX panel for instant product search.
6
- * No more iframe search is client-side from cached products (sub-100ms).
 
 
 
 
 
 
 
7
  */
8
 
9
  import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
@@ -20,104 +27,156 @@ const DEFAULT_VOICE = "Sohee";
20
  const _urlParams = new URLSearchParams(location.search);
21
  const FAKEMIC_MODE = _urlParams.has("fakemic");
22
 
23
- // ── V.AIX Panel control ────────────────────────────────────────────────
24
- const VAIX_PANEL_ID = "vaix-panel";
25
- const VAIX_TOGGLE_ID = "vaix-toggle";
 
 
 
26
 
 
 
27
  /** @type {HTMLDivElement | null} */
28
- let vaixPanel = null;
29
  /** @type {HTMLButtonElement | null} */
30
- let vaixToggle = null;
 
 
31
  /** @type {boolean} */
32
- let vaixPanelOpen = false;
 
 
 
 
33
 
34
  /**
35
- * Initialize V.AIX panel controls
36
  */
37
- function initVaixPanel() {
38
- vaixPanel = /** @type {HTMLDivElement} */ (document.getElementById(VAIX_PANEL_ID));
39
- vaixToggle = /** @type {HTMLButtonElement} */ (document.getElementById(VAIX_TOGGLE_ID));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- if (!vaixPanel || !vaixToggle) {
42
- console.warn("[VAIX] Required elements not found");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return;
44
  }
45
 
46
  // Toggle panel
47
- vaixToggle.addEventListener("click", () => {
48
- vaixPanelOpen = !vaixPanelOpen;
49
- vaixPanel.classList.toggle("open", vaixPanelOpen);
50
- vaixToggle.classList.toggle("active", vaixPanelOpen);
51
- vaixToggle.setAttribute("aria-label", vaixPanelOpen ? "Close V.AIX" : "Open V.AIX");
52
- vaixToggle.title = vaixPanelOpen ? "Close V.AIX" : "Open V.AIX";
53
  });
54
 
55
  // Close button
56
- const closeBtn = document.getElementById("vaix-close");
57
  if (closeBtn) {
58
  closeBtn.addEventListener("click", () => {
59
- vaixPanelOpen = false;
60
- vaixPanel.classList.remove("open");
61
- vaixToggle.classList.remove("active");
62
- vaixToggle.setAttribute("aria-label", "Open V.AIX");
63
- vaixToggle.title = "Open V.AIX";
64
  });
65
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  }
67
 
68
  /**
69
- * V.AIX control functions for avatar tools
70
  */
71
- async function vaixFetchProducts() {
72
- // Always use server API to stay current (cache is 30min)
73
- try {
74
- const resp = await fetch("/api/vaix/products");
75
- if (resp.ok) {
76
- const data = await resp.json();
77
- return data.products || [];
78
- }
79
- } catch (e) {
80
- console.warn("[VAIX] Fetch failed:", e);
81
- }
82
- return [];
83
  }
84
 
85
- async function vaixSearch(query) {
86
- if (!query) return { results: [], total: 0 };
87
- try {
88
- const resp = await fetch(`/api/vaix/search?q=${encodeURIComponent(query)}`);
89
- if (resp.ok) {
90
- const data = await resp.json();
91
- return data;
92
- }
93
- } catch (e) {
94
- console.warn("[VAIX] Search failed:", e);
95
- }
96
- return { results: [], total: 0 };
97
  }
98
 
99
- async function vaixGetCategories() {
100
- try {
101
- const resp = await fetch("/api/vaix/categories");
102
- if (resp.ok) {
103
- const data = await resp.json();
104
- return data.categories || [];
105
- }
106
- } catch (e) {
107
- console.warn("[VAIX] Categories failed:", e);
108
- }
109
- return [];
110
  }
111
 
112
- // Expose globally for avatar tools
113
- window.vaix = {
114
- open: () => { if (!vaixPanelOpen) vaixToggle.click(); },
115
- close: () => { if (vaixPanelOpen) vaixToggle.click(); },
116
- isOpen: () => vaixPanelOpen,
117
- search: vaixSearch,
118
- fetchProducts: vaixFetchProducts,
119
- getCategories: vaixGetCategories,
120
- };
121
 
122
  // ── Greeting ─────────────────────────────────────────────────────────────
123
  async function getHotNewsGreeting() {
@@ -140,8 +199,7 @@ async function getHotNewsGreeting() {
140
 
141
  const DEFAULT_INSTRUCTIONS = [
142
  "You are a friendly voice assistant with a visible, human-like 3D avatar.",
143
- "You also have access to V.AIX - a product catalog with Vietnamese goods. Use it to help users find products naturally.",
144
- "When showing products, mention key details: name, price, brand, and why it's a good match.",
145
  "CRITICAL: Always respond in the SAME LANGUAGE the user writes or speaks.",
146
  "If Vietnamese, respond in Vietnamese. If English, respond in English.",
147
  "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
@@ -151,7 +209,7 @@ const DEFAULT_INSTRUCTIONS = [
151
  "currency as 'năm mươi nghìn đồng' NOT '50.000đ'.",
152
  "Keep replies short, natural, warm. Never list-like.",
153
  "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression.",
154
- "You can control V.AIX catalog with tools: open_catalog, search_catalog, open_product, navigate_catalog.",
155
  "Use them naturally to express yourself and help users browse products.",
156
  "NEVER guess. Use search_web or search_wikipedia for ANY factual question.",
157
  "First call get_current_datetime to know today's date, then search.",
@@ -170,11 +228,11 @@ const TOOL_DEFS = [
170
  { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } },
171
  { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
172
  { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
173
- // V.AIX Catalog tools
174
- { type: "function", name: "open_catalog", description: "Open V.AIX catalog, optionally filtered by category.", parameters: { type: "object", properties: { category: { type: "string", description: "Category name (e.g., 'Bếp từ', 'Khóa thông minh'). Empty for all products." } }, required: ["category"] } },
175
- { type: "function", name: "search_catalog", description: "Search V.AIX catalog for products.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query (product name, brand, feature, price range)" } }, required: ["query"] } },
176
- { type: "function", name: "open_product", description: "Open a specific product detail in V.AIX.", parameters: { type: "object", properties: { product_id: { type: "string", description: "Product ID or title search" } }, required: ["product_id"] } },
177
- { type: "function", name: "navigate_catalog", description: "Navigate V.AIX catalog.", parameters: { type: "object", properties: { page: { type: "string", enum: ["products", "catalogue", "contact"], description: "Page to navigate to" }, data: { type: "object", description: "Additional data for navigation" } }, required: ["page"] } },
178
  ];
179
 
180
  // ── DOM ──────────────────────────────────────────────────────────────────
@@ -384,88 +442,33 @@ function runTool(name, argsJson, callId) {
384
  client.requestResponse();
385
  }).catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return;
386
  }
387
- // V.AIX Catalog tools
388
  if (name === "open_catalog") {
389
- vaixFetchProducts().then(products => {
390
- const cat = args.category;
391
- let msg = `Đã mở danh mục V.AIX.`;
392
- if (cat) {
393
- const matched = products.filter(p => (p.category || "").toLowerCase().includes(cat.toLowerCase()));
394
- if (matched.length > 0) {
395
- msg = `Tìm thấy ${matched.length} sản phẩm trong "${cat}": ${matched.slice(0,3).map(p => p.title).join(", ")}.`;
396
- } else {
397
- msg = `Không tìm thấy sản phẩm nào trong "${cat}". Hãy thử danh mục khác.`;
398
- }
399
- }
400
- client.sendToolOutput(callId, msg);
401
- vaixPanel?.classList.add("open");
402
- vaixToggle?.classList.add("active");
403
- vaixPanelOpen = true;
404
- client.requestResponse();
405
  }).catch(err => {
406
- client.sendToolOutput(callId, `Không thể tải danh mục: ${err.message}`);
407
- client.requestResponse();
408
  }); return;
409
  }
410
  if (name === "open_product") {
411
- const pid = args.product_id;
412
- vaixFetchProducts().then(products => {
413
- if (!pid) { client.sendToolOutput(callId, `Cần product_id để mở sản phẩm.`); client.requestResponse(); return; }
414
- // First try exact SKU match
415
- const found = products.find(p => p.sku === pid || p.title === pid);
416
- let msg = found
417
- ? `Đã mở ${found.title} (${found.price ? `${found.price.toLocaleString("vi-VN")}đ` : "Liên hệ"}).`
418
- : `Không tìm thấy sản phẩm "${pid}". Hãy thử tên hoặc mã sản phẩm khác.`;
419
- client.sendToolOutput(callId, msg);
420
- client.requestResponse();
421
  }).catch(err => {
422
- client.sendToolOutput(callId, `Lỗi khi tìm sản phẩm: ${err.message}`);
423
- client.requestResponse();
424
  }); return;
425
  }
426
  if (name === "search_catalog") {
427
- const query = args.query;
428
- if (!query) {
429
- client.sendToolOutput(callId, `Cần từ khóa để tìm kiếm.`);
430
- client.requestResponse();
431
- return;
432
- }
433
-
434
- vaixSearch(query).then(data => {
435
- if (data.results?.length > 0) {
436
- const results = data.results.slice(0, 3);
437
- const details = results.map(r => {
438
- const p = r.product;
439
- return `${p.title} (${p.price ? `${p.price.toLocaleString("vi-VN")}đ` : "Liên hệ"})`;
440
- });
441
- client.sendToolOutput(callId, `Tìm thấy ${data.total} kết quả cho "${query}":\n${details.join(", ")}.`);
442
- } else {
443
- client.sendToolOutput(callId, `Không tìm thấy sản phẩm nào cho "${query}".`);
444
- }
445
- // Open panel to show results
446
- vaixPanel?.classList.add("open");
447
- vaixToggle?.classList.add("active");
448
- vaixPanelOpen = true;
449
- client.requestResponse();
450
  }).catch(err => {
451
- client.sendToolOutput(callId, `Lỗi tìm kiếm: ${err.message}`);
452
- client.requestResponse();
453
  }); return;
454
  }
455
  if (name === "navigate_catalog") {
456
- vaixFetchProducts().then(products => {
457
- let msg = `Đã mở danh mục V.AIX với ${products.length} sản phẩm.`;
458
- if (args.page === "products" || args.page === "contact") {
459
- msg = `Đã chuyển đến trang ${args.page}.`;
460
- }
461
- client.sendToolOutput(callId, msg);
462
- vaixPanel?.classList.add("open");
463
- vaixToggle?.classList.add("active");
464
- vaixPanelOpen = true;
465
- client.requestResponse();
466
  }).catch(err => {
467
- client.sendToolOutput(callId, `Lỗi điều hướng: ${err.message}`);
468
- client.requestResponse();
469
  }); return;
470
  }
471
  const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
@@ -589,7 +592,7 @@ async function boot() {
589
  preFetchedGreeting = await newsPromise;
590
  loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
591
 
592
- // Initialize V.AIX panel
593
- initVaixPanel();
594
  }
595
  void boot();
 
1
  // @ts-check
2
  /**
3
+ * App wiring: the avatar stage + the speech-to-speech session + V.AISTUDIO iframe control.
4
+ * Modes: voice (mic + VAD) OR text (keyboard only, no mic needed).
5
+ *
6
+ * KEY DESIGN: On direct hf.space domains, AudioContext starts "suspended"
7
+ * because there's no user gesture on page load. Instead of hanging or
8
+ * silently failing, we wait for the user to click "Start talking" before
9
+ * any WebSocket connection — that click resumes AudioContext, audio plays,
10
+ * and HeadAudio lip-sync works.
11
+ *
12
+ * ?fakemic=1 — opt-in URL query param for testing text-only mode
13
+ * WITHOUT a microphone. Only activates when user explicitly adds it to URL.
14
  */
15
 
16
  import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
 
27
  const _urlParams = new URLSearchParams(location.search);
28
  const FAKEMIC_MODE = _urlParams.has("fakemic");
29
 
30
+ // ── V.AISTUDIO iframe bridge ─────────────────────────────────────────────
31
+ const VAISTUDIO_ORIGIN = "https://bep40-v-aistudio.static.hf.space";
32
+ const VAISTUDIO_IFRAME_ID = "vaistudio-iframe";
33
+ const VAISTUDIO_PANEL_ID = "vaistudio-panel";
34
+ const VAISTUDIO_TOGGLE_ID = "vaistudio-toggle";
35
+ const VAISTUDIO_LOADING_ID = "vaistudio-loading";
36
 
37
+ /** @type {HTMLIFrameElement | null} */
38
+ let vaistudioIframe = null;
39
  /** @type {HTMLDivElement | null} */
40
+ let vaistudioPanel = null;
41
  /** @type {HTMLButtonElement | null} */
42
+ let vaistudioToggle = null;
43
+ /** @type {HTMLDivElement | null} */
44
+ let vaistudioLoading = null;
45
  /** @type {boolean} */
46
+ let vaistudioPanelOpen = false;
47
+ /** @type {Map<string, Function>} */
48
+ const vaistudioResponseHandlers = new Map();
49
+ /** @type {number} */
50
+ let vaistudioMessageId = 0;
51
 
52
  /**
53
+ * Send a command to V.AISTUDIO iframe via postMessage
54
  */
55
+ function vaistudioSend(action, data = {}) {
56
+ if (!vaistudioIframe || !vaistudioIframe.contentWindow) {
57
+ console.warn("[VAISTUDIO] iframe not ready");
58
+ return Promise.reject(new Error("iframe not ready"));
59
+ }
60
+ const id = ++vaistudioMessageId;
61
+ const message = { id, action, data, timestamp: Date.now() };
62
+ return new Promise((resolve, reject) => {
63
+ const timeout = setTimeout(() => {
64
+ vaistudioResponseHandlers.delete(id);
65
+ reject(new Error(`VAISTUDIO timeout: ${action}`));
66
+ }, 10000);
67
+ vaistudioResponseHandlers.set(id, (response) => {
68
+ clearTimeout(timeout);
69
+ if (response.error) reject(new Error(response.error));
70
+ else resolve(response.data);
71
+ });
72
+ try {
73
+ vaistudioIframe.contentWindow.postMessage(message, VAISTUDIO_ORIGIN);
74
+ } catch (e) {
75
+ vaistudioResponseHandlers.delete(id);
76
+ clearTimeout(timeout);
77
+ reject(e);
78
+ }
79
+ });
80
+ }
81
 
82
+ /**
83
+ * Handle responses from V.AISTUDIO iframe
84
+ */
85
+ function vaistudioOnMessage(event) {
86
+ if (event.origin !== VAISTUDIO_ORIGIN) return;
87
+ const msg = event.data;
88
+ if (!msg || typeof msg !== "object") return;
89
+ if (msg.id && vaistudioResponseHandlers.has(msg.id)) {
90
+ const handler = vaistudioResponseHandlers.get(msg.id);
91
+ vaistudioResponseHandlers.delete(msg.id);
92
+ handler(msg);
93
+ }
94
+ // Handle unsolicited events from iframe
95
+ if (msg.event) {
96
+ console.log("[VAISTUDIO] Event:", msg.event, msg.data);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Initialize V.AISTUDIO iframe controls
102
+ */
103
+ function initVAISTUDIO() {
104
+ vaistudioIframe = /** @type {HTMLIFrameElement} */ (document.getElementById(VAISTUDIO_IFRAME_ID));
105
+ vaistudioPanel = /** @type {HTMLDivElement} */ (document.getElementById(VAISTUDIO_PANEL_ID));
106
+ vaistudioToggle = /** @type {HTMLButtonElement} */ (document.getElementById(VAISTUDIO_TOGGLE_ID));
107
+ vaistudioLoading = /** @type {HTMLDivElement} */ (document.getElementById(VAISTUDIO_LOADING_ID));
108
+
109
+ if (!vaistudioIframe || !vaistudioPanel || !vaistudioToggle) {
110
+ console.warn("[VAISTUDIO] Required elements not found");
111
  return;
112
  }
113
 
114
  // Toggle panel
115
+ vaistudioToggle.addEventListener("click", () => {
116
+ vaistudioPanelOpen = !vaistudioPanelOpen;
117
+ vaistudioPanel.classList.toggle("open", vaistudioPanelOpen);
118
+ vaistudioToggle.classList.toggle("active", vaistudioPanelOpen);
119
+ vaistudioToggle.setAttribute("aria-label", vaistudioPanelOpen ? "Close V.AI STUDIO" : "Open V.AI STUDIO");
120
+ vaistudioToggle.title = vaistudioPanelOpen ? "Close V.AI STUDIO" : "Open V.AI STUDIO";
121
  });
122
 
123
  // Close button
124
+ const closeBtn = document.getElementById("vaistudio-close");
125
  if (closeBtn) {
126
  closeBtn.addEventListener("click", () => {
127
+ vaistudioPanelOpen = false;
128
+ vaistudioPanel.classList.remove("open");
129
+ vaistudioToggle.classList.remove("active");
130
+ vaistudioToggle.setAttribute("aria-label", "Open V.AI STUDIO");
131
+ vaistudioToggle.title = "Open V.AI STUDIO";
132
  });
133
  }
134
+
135
+ // Listen for iframe load
136
+ vaistudioIframe.addEventListener("load", () => {
137
+ if (vaistudioLoading) vaistudioLoading.classList.add("hidden");
138
+ console.log("[VAISTUDIO] iframe loaded");
139
+ vaistudioSend("ready", { source: "gemma-avatar" }).catch(() => {});
140
+ });
141
+
142
+ // Listen for postMessage
143
+ window.addEventListener("message", vaistudioOnMessage);
144
+
145
+ // Expose globally for avatar tools
146
+ window.vaistudio = {
147
+ send: vaistudioSend,
148
+ open: () => { if (!vaistudioPanelOpen) vaistudioToggle.click(); },
149
+ close: () => { if (vaistudioPanelOpen) vaistudioToggle.click(); },
150
+ isOpen: () => vaistudioPanelOpen,
151
+ };
152
  }
153
 
154
  /**
155
+ * V.AISTUDIO control functions for avatar tools
156
  */
157
+ async function vaistudioOpenCatalog(category = "") {
158
+ await vaistudioSend("navigate", { page: "products", category });
159
+ if (!vaistudioPanelOpen) vaistudioToggle.click();
160
+ return { success: true, message: `Opened catalog${category ? `: ${category}` : ""}` };
 
 
 
 
 
 
 
 
161
  }
162
 
163
+ async function vaistudioOpenProduct(productId) {
164
+ await vaistudioSend("navigate", { page: "product", productId });
165
+ if (!vaistudioPanelOpen) vaistudioToggle.click();
166
+ return { success: true, message: `Opened product ${productId}` };
 
 
 
 
 
 
 
 
167
  }
168
 
169
+ async function vaistudioSearch(query) {
170
+ await vaistudioSend("search", { query });
171
+ if (!vaistudioPanelOpen) vaistudioToggle.click();
172
+ return { success: true, message: `Searching: ${query}` };
 
 
 
 
 
 
 
173
  }
174
 
175
+ async function vaistudioNavigate(page, data = {}) {
176
+ await vaistudioSend("navigate", { page, ...data });
177
+ if (!vaistudioPanelOpen) vaistudioToggle.click();
178
+ return { success: true, message: `Navigated to ${page}` };
179
+ }
 
 
 
 
180
 
181
  // ── Greeting ─────────────────────────────────────────────────────────────
182
  async function getHotNewsGreeting() {
 
199
 
200
  const DEFAULT_INSTRUCTIONS = [
201
  "You are a friendly voice assistant with a visible, human-like 3D avatar.",
202
+ "You also have access to V.AI STUDIO - an e-commerce catalog with 8000+ kitchen appliances & smart locks from Malloca, Eurogold, Grob, Canzy, Demax & Dien May Xanh.",
 
203
  "CRITICAL: Always respond in the SAME LANGUAGE the user writes or speaks.",
204
  "If Vietnamese, respond in Vietnamese. If English, respond in English.",
205
  "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
 
209
  "currency as 'năm mươi nghìn đồng' NOT '50.000đ'.",
210
  "Keep replies short, natural, warm. Never list-like.",
211
  "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression.",
212
+ "You can control V.AI STUDIO catalog with tools: open_catalog, open_product, search_catalog, navigate_catalog.",
213
  "Use them naturally to express yourself and help users browse products.",
214
  "NEVER guess. Use search_web or search_wikipedia for ANY factual question.",
215
  "First call get_current_datetime to know today's date, then search.",
 
228
  { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } },
229
  { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
230
  { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } },
231
+ // V.AISTUDIO Catalog tools
232
+ { type: "function", name: "open_catalog", description: "Open V.AI STUDIO catalog, optionally filtered by category.", parameters: { type: "object", properties: { category: { type: "string", description: "Category name (e.g., 'Bếp từ', 'Máy hút mùi', 'Khóa thông minh', 'Nồi chiên không dầu'). Empty for all products." } }, required: ["category"] } },
233
+ { type: "function", name: "open_product", description: "Open a specific product detail page in V.AI STUDIO.", parameters: { type: "object", properties: { product_id: { type: "string", description: "Product ID or SKU from V.AI STUDIO catalog" } }, required: ["product_id"] } },
234
+ { type: "function", name: "search_catalog", description: "Search V.AI STUDIO catalog for products.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query (product name, brand, feature, price range)" } }, required: ["query"] } },
235
+ { type: "function", name: "navigate_catalog", description: "Navigate V.AI STUDIO to a specific page.", parameters: { type: "object", properties: { page: { type: "string", enum: ["products", "catalogue", "contact", "cart", "home"], description: "Page to navigate to" }, data: { type: "object", description: "Additional data for navigation" } }, required: ["page"] } },
236
  ];
237
 
238
  // ── DOM ──────────────────────────────────────────────────────────────────
 
442
  client.requestResponse();
443
  }).catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return;
444
  }
445
+ // V.AISTUDIO Catalog tools
446
  if (name === "open_catalog") {
447
+ vaistudioOpenCatalog(args.category || "").then(result => {
448
+ client.sendToolOutput(callId, result.message); client.requestResponse();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  }).catch(err => {
450
+ client.sendToolOutput(callId, `Failed to open catalog: ${err.message}`); client.requestResponse();
 
451
  }); return;
452
  }
453
  if (name === "open_product") {
454
+ vaistudioOpenProduct(args.product_id).then(result => {
455
+ client.sendToolOutput(callId, result.message); client.requestResponse();
 
 
 
 
 
 
 
 
456
  }).catch(err => {
457
+ client.sendToolOutput(callId, `Failed to open product: ${err.message}`); client.requestResponse();
 
458
  }); return;
459
  }
460
  if (name === "search_catalog") {
461
+ vaistudioSearch(args.query).then(result => {
462
+ client.sendToolOutput(callId, result.message); client.requestResponse();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  }).catch(err => {
464
+ client.sendToolOutput(callId, `Failed to search catalog: ${err.message}`); client.requestResponse();
 
465
  }); return;
466
  }
467
  if (name === "navigate_catalog") {
468
+ vaistudioNavigate(args.page, args.data || {}).then(result => {
469
+ client.sendToolOutput(callId, result.message); client.requestResponse();
 
 
 
 
 
 
 
 
470
  }).catch(err => {
471
+ client.sendToolOutput(callId, `Failed to navigate: ${err.message}`); client.requestResponse();
 
472
  }); return;
473
  }
474
  const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
 
592
  preFetchedGreeting = await newsPromise;
593
  loading.classList.add("done"); setCaption(CAPTIONS.idle); setMainButton("start", "Start talking");
594
 
595
+ // Initialize V.AISTUDIO iframe bridge
596
+ initVAISTUDIO();
597
  }
598
  void boot();
src/vaix.css DELETED
@@ -1,457 +0,0 @@
1
- /* V.AIX Panel styles — compact, mobile-friendly product catalog */
2
-
3
- .vaix-panel {
4
- position: fixed;
5
- bottom: 0;
6
- right: 0;
7
- width: 100%;
8
- max-width: 100vw;
9
- height: 100%;
10
- max-height: 100vh;
11
- background: #fff;
12
- border-radius: 20px 20px 0 0;
13
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.18);
14
- z-index: 160;
15
- display: flex;
16
- flex-direction: column;
17
- overflow: hidden;
18
- visibility: hidden;
19
- transition: visibility 0.3s;
20
- }
21
-
22
- .vaix-panel.open {
23
- visibility: visible;
24
- }
25
-
26
- .vaix-header {
27
- background: linear-gradient(135deg, #003f62, #0077b6);
28
- color: #fff;
29
- padding: 14px 16px;
30
- display: flex;
31
- align-items: center;
32
- gap: 12px;
33
- flex-shrink: 0;
34
- }
35
-
36
- .vaix-header-avatar {
37
- width: 38px;
38
- height: 38px;
39
- border-radius: 12px;
40
- background: rgba(255, 255, 255, 0.15);
41
- display: flex;
42
- align-items: center;
43
- justify-content: center;
44
- font-size: 1.1rem;
45
- flex-shrink: 0;
46
- }
47
-
48
- .vaix-header-info {
49
- flex: 1;
50
- }
51
-
52
- .vaix-header-info h4 {
53
- font-size: 0.9rem;
54
- font-weight: 700;
55
- margin: 0;
56
- }
57
-
58
- .vaix-header-info span {
59
- font-size: 0.68rem;
60
- opacity: 0.75;
61
- }
62
-
63
- .vaix-close-btn {
64
- width: 40px;
65
- height: 40px;
66
- min-width: 40px;
67
- min-height: 40px;
68
- border-radius: 50%;
69
- background: rgba(255, 255, 255, 0.25);
70
- border: none;
71
- color: #fff;
72
- cursor: pointer;
73
- display: flex;
74
- align-items: center;
75
- justify-content: center;
76
- font-size: 1.1rem;
77
- transition: all 0.2s ease;
78
- }
79
-
80
- .vaix-close-btn:hover {
81
- background: rgba(255, 255, 255, 0.4);
82
- }
83
-
84
- .vaix-body {
85
- flex: 1;
86
- overflow-y: auto;
87
- padding: 16px;
88
- background: #f8fafc;
89
- }
90
-
91
- .vaix-search-box {
92
- position: relative;
93
- margin-bottom: 16px;
94
- }
95
-
96
- .vaix-search-box input {
97
- width: 100%;
98
- padding: 10px 40px 10px 14px;
99
- border: 2px solid #e2e8f0;
100
- border-radius: 12px;
101
- font-size: 0.88rem;
102
- font-family: inherit;
103
- outline: none;
104
- transition: border-color 0.2s;
105
- }
106
-
107
- .vaix-search-box input:focus {
108
- border-color: #003f62;
109
- box-shadow: 0 0 0 3px rgba(0, 63, 98, 0.1);
110
- }
111
-
112
- .vaix-search-btn {
113
- position: absolute;
114
- right: 4px;
115
- top: 4px;
116
- bottom: 4px;
117
- width: 34px;
118
- border-radius: 8px;
119
- background: #003f62;
120
- color: #fff;
121
- border: none;
122
- cursor: pointer;
123
- display: flex;
124
- align-items: center;
125
- justify-content: center;
126
- font-size: 0.9rem;
127
- }
128
-
129
- .vaix-search-btn:hover {
130
- background: #005a8c;
131
- }
132
-
133
- .vaix-loading {
134
- display: none;
135
- flex-direction: column;
136
- align-items: center;
137
- justify-content: center;
138
- gap: 12px;
139
- padding: 60px 20px;
140
- color: #64748b;
141
- }
142
-
143
- .vaix-loading-spinner {
144
- width: 32px;
145
- height: 32px;
146
- border: 3px solid #e2e8f0;
147
- border-top-color: #003f62;
148
- border-radius: 50%;
149
- animation: vaix-spin 1s linear infinite;
150
- }
151
-
152
- @keyframes vaix-spin {
153
- to { transform: rotate(360deg); }
154
- }
155
-
156
- .vaix-empty {
157
- text-align: center;
158
- padding: 50px 20px;
159
- color: #64748b;
160
- }
161
-
162
- .vaix-empty i {
163
- font-size: 2.5rem;
164
- opacity: 0.3;
165
- margin-bottom: 14px;
166
- display: block;
167
- }
168
-
169
- .vaix-empty h3 {
170
- font-size: 1rem;
171
- font-weight: 700;
172
- color: #0f172a;
173
- margin-bottom: 6px;
174
- }
175
-
176
- .vaix-clear-btn {
177
- padding: 8px 16px;
178
- background: #f1f5f9;
179
- border: 1px solid #e2e8f0;
180
- border-radius: 8px;
181
- font-size: 0.82rem;
182
- cursor: pointer;
183
- font-family: inherit;
184
- color: #64748b;
185
- }
186
-
187
- .vaix-clear-btn:hover {
188
- background: #e2e8f0;
189
- }
190
-
191
- .vaix-grid {
192
- display: flex;
193
- flex-direction: column;
194
- gap: 16px;
195
- }
196
-
197
- .vaix-category {
198
- background: #fff;
199
- border-radius: 12px;
200
- overflow: hidden;
201
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
202
- }
203
-
204
- .vaix-category h3 {
205
- font-size: 0.85rem;
206
- font-weight: 700;
207
- color: #003f62;
208
- padding: 12px 14px 10px;
209
- background: #f8fafc;
210
- border-bottom: 1px solid #e2e8f0;
211
- }
212
-
213
- .vaix-category h3 .vaix-cat-name::after {
214
- content: "";
215
- display: inline-block;
216
- width: 4px;
217
- height: 14px;
218
- background: #db9815;
219
- border-radius: 2px;
220
- margin-left: 8px;
221
- vertical-align: middle;
222
- }
223
-
224
- .vaix-products {
225
- padding: 8px;
226
- display: grid;
227
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
228
- gap: 8px;
229
- }
230
-
231
- .vaix-product-card {
232
- background: #fff;
233
- border: 1px solid #e2e8f0;
234
- border-radius: 10px;
235
- overflow: hidden;
236
- cursor: pointer;
237
- transition: all 0.2s ease;
238
- display: flex;
239
- flex-direction: column;
240
- }
241
-
242
- .vaix-product-card:hover {
243
- border-color: #003f62;
244
- box-shadow: 0 4px 12px rgba(0, 63, 98, 0.15);
245
- }
246
-
247
- .vaix-product-card img {
248
- width: 100%;
249
- height: 100px;
250
- object-fit: contain;
251
- background: #f8fafc;
252
- padding: 6px;
253
- }
254
-
255
- .vaix-product-info {
256
- padding: 8px;
257
- flex: 1;
258
- min-width: 0;
259
- }
260
-
261
- .vaix-product-title {
262
- font-size: 0.74rem;
263
- font-weight: 600;
264
- color: #0f172a;
265
- line-height: 1.3;
266
- margin-bottom: 4px;
267
- display: -webkit-box;
268
- -webkit-line-clamp: 2;
269
- -webkit-box-orient: vertical;
270
- overflow: hidden;
271
- }
272
-
273
- .vaix-product-brand {
274
- font-size: 0.64rem;
275
- font-weight: 500;
276
- color: #64748b;
277
- margin-bottom: 2px;
278
- }
279
-
280
- .vaix-product-price {
281
- font-size: 0.78rem;
282
- font-weight: 800;
283
- color: #003f62;
284
- }
285
-
286
- .vaix-product-sku {
287
- font-size: 0.62rem;
288
- color: #94a3b8;
289
- margin-top: 2px;
290
- }
291
-
292
- /* Product detail modal */
293
- .vaix-product-detail {
294
- background: #f8fafc;
295
- border-radius: 16px;
296
- padding: 16px;
297
- min-height: 200px;
298
- }
299
-
300
- .vaix-detail-grid {
301
- display: flex;
302
- flex-direction: column;
303
- gap: 12px;
304
- }
305
-
306
- .vaix-detail-grid img {
307
- width: 100%;
308
- height: auto;
309
- max-height: 200px;
310
- object-fit: contain;
311
- border-radius: 12px;
312
- background: #fff;
313
- padding: 12px;
314
- }
315
-
316
- .vaix-detail-info {
317
- padding: 8px 0;
318
- }
319
-
320
- .vaix-detail-info h2 {
321
- font-size: 1.1rem;
322
- font-weight: 700;
323
- color: #0f172a;
324
- margin: 0 0 8px;
325
- }
326
-
327
- .vaix-detail-sku {
328
- font-size: 0.74rem;
329
- color: #64748b;
330
- margin-bottom: 4px;
331
- }
332
-
333
- .vaix-detail-brand {
334
- font-size: 0.82rem;
335
- font-weight: 600;
336
- color: #003f62;
337
- margin-bottom: 8px;
338
- }
339
-
340
- .vaix-detail-price {
341
- font-size: 1.2rem;
342
- font-weight: 800;
343
- color: #003f62;
344
- margin-bottom: 12px;
345
- }
346
-
347
- .vaix-detail-category {
348
- display: inline-block;
349
- background: #e0f2fe;
350
- color: #003f62;
351
- padding: 4px 12px;
352
- border-radius: 20px;
353
- font-size: 0.74rem;
354
- font-weight: 600;
355
- margin-bottom: 12px;
356
- }
357
-
358
- .vaix-detail-features {
359
- display: flex;
360
- align-items: center;
361
- gap: 8px;
362
- font-size: 0.82rem;
363
- color: #0f172a;
364
- margin-bottom: 12px;
365
- }
366
-
367
- .vaix-detail-features i {
368
- color: #db9815;
369
- }
370
-
371
- .vaix-detail-description {
372
- font-size: 0.82rem;
373
- color: #64748b;
374
- line-height: 1.6;
375
- margin-bottom: 16px;
376
- }
377
-
378
- .vaix-inquire-btn {
379
- width: 100%;
380
- padding: 12px 16px;
381
- background: linear-gradient(135deg, #003f62, #0077b6);
382
- color: #fff;
383
- border: none;
384
- border-radius: 10px;
385
- font-size: 0.88rem;
386
- font-weight: 700;
387
- cursor: pointer;
388
- font-family: inherit;
389
- display: flex;
390
- align-items: center;
391
- justify-content: center;
392
- gap: 8px;
393
- }
394
-
395
- .vaix-inquire-btn:hover {
396
- background: linear-gradient(135deg, #005a8c, #0086c9);
397
- }
398
-
399
- .vaix-back-btn {
400
- display: inline-flex;
401
- align-items: center;
402
- gap: 6px;
403
- padding: 8px 12px;
404
- background: #fff;
405
- border: 1px solid #e2e8f0;
406
- border-radius: 8px;
407
- font-size: 0.82rem;
408
- cursor: pointer;
409
- font-family: inherit;
410
- color: #64748b;
411
- margin-bottom: 12px;
412
- }
413
-
414
- .vaix-back-btn:hover {
415
- background: #f1f5f9;
416
- }
417
-
418
- /* Inquiry suggestion */
419
- .vaix-inquiry-suggest {
420
- display: none;
421
- padding: 10px 14px;
422
- background: #fffbeb;
423
- border: 1px solid #fde68a;
424
- border-radius: 10px;
425
- font-size: 0.82rem;
426
- color: #92400e;
427
- margin-top: 12px;
428
- }
429
-
430
- /* Small screen adjustments */
431
- @media (max-width: 768px) {
432
- .vaix-panel {
433
- width: 100%;
434
- max-width: 100%;
435
- height: 100%;
436
- max-height: 100vh;
437
- border-radius: 0;
438
- }
439
-
440
- .vaix-products {
441
- grid-template-columns: repeat(2, 1fr);
442
- }
443
-
444
- .vaix-product-card img {
445
- height: 80px;
446
- }
447
- }
448
-
449
- @media (max-width: 480px) {
450
- .vaix-products {
451
- grid-template-columns: 1fr;
452
- }
453
-
454
- .vaix-product-card img {
455
- height: 120px;
456
- }
457
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/vaix.js DELETED
@@ -1,324 +0,0 @@
1
- /**
2
- * V.AIX Panel — lightweight product search & display for gemma-avatar.
3
- *
4
- * Data source: /api/vaix/* endpoints (serving from HF Dataset via index.ts).
5
- *
6
- * Caching strategy:
7
- * 1. Load products from /api/vaix/products on first open
8
- * 2. Subsequent opens load from localStorage (instant)
9
- * 3. Cache refreshes every 24h via background fetch
10
- */
11
-
12
- // Use local server API endpoints (index.ts serves these)
13
- const API_PRODUCTS = "/api/vaix/products";
14
- const API_SEARCH = "/api/vaix/search";
15
-
16
- // Cache keys
17
- const CACHE_KEY = "vaix_products_cache";
18
- const CACHE_TIMESTAMP_KEY = "vaix_cache_timestamp";
19
- const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
20
-
21
- // State
22
- let allProducts = [];
23
- let filteredProducts = [];
24
- let isLoading = false;
25
- let searchDebounceTimer = null;
26
-
27
- // Search index (pre-built for fast client-side search)
28
- let searchIndex = new Map();
29
-
30
- function initSearchIndex(products) {
31
- searchIndex.clear();
32
- products.forEach((p, i) => {
33
- const searchable = [p.title_clean, p.sku, p.brand, p.category, p.features]
34
- .filter(Boolean)
35
- .join(" ")
36
- .toLowerCase()
37
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '');
38
- searchable.split(/\s+/).forEach(word => {
39
- if (word.length > 1) {
40
- if (!searchIndex.has(word)) searchIndex.set(word, []);
41
- searchIndex.get(word).push(i);
42
- }
43
- });
44
- });
45
- console.log(`[V.AIX] Search index built: ${searchIndex.size} terms, ${products.length} products`);
46
- }
47
-
48
- function searchLocal(query) {
49
- if (!query || !searchIndex.size) return allProducts.slice(0, 50);
50
-
51
- const norm = query.toLowerCase().normalize('NFD').replace(/[\u0300-\u06f]/g, '');
52
- const terms = norm.split(/\s+/).filter(w => w.length > 0);
53
-
54
- const scores = new Map();
55
- allProducts.forEach((p, i) => {
56
- const searchable = [p.title_clean, p.sku, p.brand, p.category, p.features, p.description]
57
- .filter(Boolean)
58
- .join(" ")
59
- .toLowerCase()
60
- .normalize('NFD')
61
- .replace(/[\u0300-\u036f]/g, '');
62
-
63
- let score = 0;
64
- terms.forEach(term => {
65
- const termLen = term.length;
66
- if (p.title_clean && p.title_clean.toLowerCase().includes(term)) score += 10 * termLen;
67
- if (p.sku && p.sku.toLowerCase().includes(term)) score += 8 * termLen;
68
- if (p.brand && p.brand.toLowerCase().includes(term)) score += 6 * termLen;
69
- if (p.category && p.category.toLowerCase().includes(term)) score += 4 * termLen;
70
- if (p.features && p.features.toLowerCase().includes(term)) score += 2 * termLen;
71
- });
72
-
73
- if (score > 0) scores.set(i, score);
74
- });
75
-
76
- const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
77
- return sorted.slice(0, 100).map(([idx]) => allProducts[idx]);
78
- }
79
-
80
- function saveToCache() {
81
- try {
82
- localStorage.setItem(CACHE_KEY, JSON.stringify({
83
- products: allProducts.map(p => ({
84
- title_clean: p.title_clean,
85
- title: p.title,
86
- sku: p.sku,
87
- brand: p.brand,
88
- category: p.category,
89
- price: p.price,
90
- image: p.image || "",
91
- features: p.features,
92
- description: p.description,
93
- })),
94
- timestamp: Date.now(),
95
- }));
96
- localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
97
- console.log(`[V.AIX] Cached ${allProducts.length} products`);
98
- } catch (e) { console.warn("[V.AIX] Cache save failed:", e); }
99
- }
100
-
101
- function loadFromCache() {
102
- try {
103
- const data = localStorage.getItem(CACHE_KEY);
104
- const ts = localStorage.getItem(CACHE_TIMESTAMP_KEY);
105
- if (!data || !ts) return null;
106
-
107
- const cache = JSON.parse(data);
108
- const age = Date.now() - parseInt(ts);
109
-
110
- if (age > CACHE_EXPIRY_MS) {
111
- localStorage.removeItem(CACHE_KEY);
112
- localStorage.removeItem(CACHE_TIMESTAMP_KEY);
113
- return null;
114
- }
115
-
116
- console.log(`[V.AIX] Loaded ${cache.products.length} products from cache (${Math.round(age / 60000)}m old)`);
117
- return cache.products;
118
- } catch (e) {
119
- console.warn("[V.AIX] Cache load failed:", e);
120
- return null;
121
- }
122
- }
123
-
124
- async function fetchProducts() {
125
- if (isLoading) return;
126
- isLoading = true;
127
-
128
- // Try cache first
129
- const cached = loadFromCache();
130
- if (cached && cached.length > 0) {
131
- allProducts = cached;
132
- initSearchIndex(allProducts);
133
- updateProductCount(cached.length);
134
- isLoading = false;
135
- return;
136
- }
137
-
138
- showLoadingState(true);
139
-
140
- try {
141
- const resp = await fetch(API_PRODUCTS);
142
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
143
-
144
- const data = await resp.json();
145
- allProducts = Array.isArray(data) ? data : (data.products || []);
146
-
147
- console.log(`[V.AIX V2] Fetched ${allProducts.length} products from ${API_PRODUCTS}`);
148
-
149
- if (allProducts.length > 0) {
150
- initSearchIndex(allProducts);
151
- saveToCache();
152
- updateProductCount(allProducts.length);
153
- } else {
154
- console.warn("[V.AIX V2] No products returned from API");
155
- }
156
- } catch (e) {
157
- console.error("[V.AIX V2] Fetch failed:", e);
158
- }
159
-
160
- showLoadingState(false);
161
- isLoading = false;
162
- }
163
-
164
- function updateProductCount(count) {
165
- const el = document.getElementById("vaix-count");
166
- if (el) el.textContent = `${count.toLocaleString("vi-VN")} sản phẩm`;
167
- }
168
-
169
- function showLoadingState(show) {
170
- const el = document.getElementById("vaix-loading");
171
- if (el) el.style.display = show ? "flex" : "none";
172
- }
173
-
174
- function showProducts(productList) {
175
- const container = document.getElementById("vaix-product-list");
176
- if (!container) return;
177
-
178
- if (!productList || productList.length === 0) {
179
- container.innerHTML = `
180
- <div class="vaix-empty">
181
- <i class="fas fa-search"></i>
182
- <h3>Không tìm thấy sản phẩm</h3>
183
- <p>Thử từ khóa khác hoặc duyệt theo danh mục</p>
184
- </div>
185
- `;
186
- return;
187
- }
188
-
189
- // Group by category for compact display
190
- const categories = {};
191
- productList.forEach(p => {
192
- const cat = p.category || "Khác";
193
- if (!categories[cat]) categories[cat] = [];
194
- categories[cat].push(p);
195
- });
196
-
197
- let html = `<div class="vaix-grid">`;
198
-
199
- Object.entries(categories).forEach(([cat, products]) => {
200
- html += `<div class="vaix-category">
201
- <h3><span class="vaix-cat-name">${escHtml(cat)}</span> (${products.length})</h3>
202
- <div class="vaix-products">`;
203
-
204
- products.forEach(p => {
205
- const priceStr = formatPrice(p.price);
206
- const imgSrc = p.image || 'https://via.placeholder.com/120x120?text=V.AIX';
207
- html += `
208
- <div class="vaix-product-card" onclick="openProduct(${allProducts.indexOf(p)})">
209
- <img src="${imgSrc}" alt="${escHtml(p.title)}" loading="lazy" />
210
- <div class="vaix-product-info">
211
- <div class="vaix-product-title">${escHtml(p.title_clean || p.title)}</div>
212
- <div class="vaix-product-brand">${escHtml(p.brand || "")}</div>
213
- <div class="vaix-product-price">${priceStr}</div>
214
- ${p.sku ? `<div class="vaix-product-sku">${escHtml(p.sku)}</div>` : ""}
215
- </div>
216
- </div>
217
- `;
218
- });
219
-
220
- html += `</div></div>`;
221
- });
222
-
223
- html += `</div>`;
224
- container.innerHTML = html;
225
- }
226
-
227
- function formatPrice(price) {
228
- if (!price || price <= 0) return "Liên hệ";
229
- return price.toLocaleString("vi-VN") + "đ";
230
- }
231
-
232
- function escHtml(str) {
233
- if (!str) return "";
234
- const div = document.createElement("div");
235
- div.textContent = str;
236
- return div.innerHTML;
237
- }
238
-
239
- function openProduct(index) {
240
- if (index < 0 || index >= allProducts.length) return;
241
- showProductDetail(allProducts[index]);
242
- }
243
-
244
- function showProductDetail(product) {
245
- const container = document.getElementById("vaix-product-detail");
246
- if (!container) return;
247
-
248
- container.innerHTML = `
249
- <div class="vaix-product-detail">
250
- <button class="vaix-back-btn" onclick="closeDetail()"><i class="fas fa-arrow-left"></i> Quay lại</button>
251
- <div class="vaix-detail-grid">
252
- <img src="${product.image || 'https://via.placeholder.com/300x300?text=V.AIX'}" alt="${product.title}" />
253
- <div class="vaix-detail-info">
254
- <h2>${escHtml(product.title_clean || product.title)}</h2>
255
- <div class="vaix-detail-sku">${escHtml(product.sku || "")}</div>
256
- <div class="vaix-detail-brand">${escHtml(product.brand || "")}</div>
257
- <div class="vaix-detail-price">${formatPrice(product.price)}</div>
258
- ${product.category ? `<div class="vaix-detail-category">${escHtml(product.category)}</div>` : ""}
259
- ${product.features ? `<div class="vaix-detail-features">${escHtml(product.features)}</div>` : ""}
260
- ${product.description ? `<div class="vaix-detail-description">${escHtml(product.description)}</div>` : ""}
261
- <button class="vaix-inquire-btn" onclick="inquireProduct('${escHtml(product.title_clean || product.title)}')">
262
- <i class="fas fa-comments"></i> Tư vấn sản phẩm
263
- </button>
264
- </div>
265
- </div>
266
- </div>
267
- `;
268
- }
269
-
270
- function closeDetail() {
271
- document.getElementById("vaix-product-detail").innerHTML = "";
272
- }
273
-
274
- function inquireProduct(title) {
275
- console.log("[V.AIX] Inquiring about:", title);
276
- if (window.parent && window.parent.postMessage) {
277
- window.parent.postMessage({
278
- type: "VAIX_INQUIRY",
279
- product: title,
280
- }, "*");
281
- }
282
-
283
- const suggestEl = document.getElementById("vaix-inquiry-suggest");
284
- if (suggestEl) {
285
- suggestEl.textContent = `Bạn có muốn hỏi Gemma về "${title}"?`;
286
- suggestEl.style.display = "block";
287
- }
288
- }
289
-
290
- function clearSearch() {
291
- const input = document.getElementById("vaix-search-input");
292
- if (input) input.value = "";
293
- const container = document.getElementById("vaix-product-list");
294
- if (container) container.innerHTML = "";
295
- const detailViewBtn = document.getElementById("vaix-detail-view-btn");
296
- if (detailViewBtn) detailViewBtn.style.display = "flex";
297
- document.getElementById("vaix-product-detail").innerHTML = "";
298
- }
299
-
300
- const VAX_SEARCH = (e) => {
301
- const q = e.target.value.trim();
302
- clearTimeout(searchDebounceTimer);
303
-
304
- searchDebounceTimer = setTimeout(() => {
305
- const results = q ? searchLocal(q) : allProducts.slice(0, 20);
306
- filteredProducts = results;
307
- showProducts(results);
308
- }, 200);
309
- };
310
-
311
- window.vaix = {
312
- fetchProducts,
313
- searchLocal,
314
- openProduct,
315
- showProducts,
316
- getProducts: () => allProducts,
317
- search: (q) => searchLocal(q),
318
- };
319
-
320
- document.addEventListener("DOMContentLoaded", () => {
321
- const searchEl = document.getElementById("vaix-search-input");
322
- if (searchEl) searchEl.addEventListener("input", VAX_SEARCH);
323
- fetchProducts();
324
- });