bep40 commited on
Commit
9b44afa
·
verified ·
1 Parent(s): edce012

restore to c1013cc7ebfdc93cbb55645441528a84d871a052: 100% restore of target commit tree

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .build_trigger +1 -0
  2. .cache_bust +1 -0
  3. .dockerignore +7 -0
  4. .gitattributes +0 -3
  5. .gitignore +34 -0
  6. CLAUDE.md +111 -0
  7. Dockerfile +14 -0
  8. HOT_TAGS_V3_TRIGGER.txt +3 -0
  9. README.md +94 -1
  10. REBUILD.md +1 -0
  11. TRIGGER_REBUILD.txt +1 -0
  12. build-trigger +1 -0
  13. index.html +529 -0
  14. index.ts +910 -0
  15. index.ts.patched +684 -0
  16. package.json +21 -0
  17. public/avatars/Ella.glb +3 -0
  18. public/avatars/Lisa.glb +3 -0
  19. public/avatars/avatarsdk.glb +3 -0
  20. public/avatars/avaturn.glb +3 -0
  21. public/avatars/bruce_avaturn.glb +3 -0
  22. public/avatars/brunette-t.glb +3 -0
  23. public/avatars/brunette.glb +3 -0
  24. public/avatars/david.glb +3 -0
  25. public/avatars/fabio_avaturn.glb +3 -0
  26. public/avatars/grit-b6c6c4.glb +3 -0
  27. public/avatars/julia.glb +3 -0
  28. public/avatars/lisamy.glb +3 -0
  29. public/avatars/lisamy.jpg +0 -0
  30. public/avatars/model (6).glb +3 -0
  31. public/avatars/mpfb.glb +3 -0
  32. public/avatars/ready_player_me_1.glb +3 -0
  33. public/avatars/ready_player_me_2.glb +3 -0
  34. public/avatars/vuong.glb +3 -0
  35. public/avatars/vuong.jpg +0 -0
  36. public/avatars/vuong2.glb +3 -0
  37. public/vendor/headaudio.min.mjs +1 -0
  38. public/vendor/headworklet.min.mjs +1 -0
  39. public/vendor/model-en-mixed.bin +3 -0
  40. public/worklets/audio-playback.js +179 -0
  41. public/worklets/mic-capture.js +159 -0
  42. san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html +56 -0
  43. san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg +3 -0
  44. san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html +60 -0
  45. san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg +0 -0
  46. san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html +56 -0
  47. san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg +3 -0
  48. san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html +56 -0
  49. san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg +3 -0
  50. san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html +58 -0
.build_trigger ADDED
@@ -0,0 +1 @@
 
 
1
+ 1785805873
.cache_bust ADDED
@@ -0,0 +1 @@
 
 
1
+ v12-deferred-avatar+lisamy-sohee+reliability
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .git
3
+ .cache
4
+ *.log
5
+ Dockerfile
6
+ .dockerignore
7
+ .bun
.gitattributes CHANGED
@@ -63,6 +63,3 @@ san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg filter=lfs diff=lfs me
63
  public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
64
  public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
65
  public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text
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
 
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
 
 
 
.gitignore ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dependencies (bun install)
2
+ node_modules
3
+
4
+ # output
5
+ out
6
+ dist
7
+ *.tgz
8
+
9
+ # code coverage
10
+ coverage
11
+ *.lcov
12
+
13
+ # logs
14
+ logs
15
+ _.log
16
+ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17
+
18
+ # dotenv environment variable files
19
+ .env
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+ .env.local
24
+
25
+ # caches
26
+ .eslintcache
27
+ .cache
28
+ *.tsbuildinfo
29
+
30
+ # IntelliJ based IDEs
31
+ .idea
32
+
33
+ # Finder (MacOS) folder config
34
+ .DS_Store
CLAUDE.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: Use Bun instead of Node.js, npm, pnpm, or vite.
3
+ globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ Default to using Bun instead of Node.js.
8
+
9
+ - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
10
+ - Use `bun test` instead of `jest` or `vitest`
11
+ - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
12
+ - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
13
+ - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
14
+ - Use `bunx <package> <command>` instead of `npx <package> <command>`
15
+ - Bun automatically loads .env, so don't use dotenv.
16
+
17
+ ## APIs
18
+
19
+ - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
20
+ - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
21
+ - `Bun.redis` for Redis. Don't use `ioredis`.
22
+ - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
23
+ - `WebSocket` is built-in. Don't use `ws`.
24
+ - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
25
+ - Bun.$`ls` instead of execa.
26
+
27
+ ## Testing
28
+
29
+ Use `bun test` to run tests.
30
+
31
+ ```ts#index.test.ts
32
+ import { test, expect } from "bun:test";
33
+
34
+ test("hello world", () => {
35
+ expect(1).toBe(1);
36
+ });
37
+ ```
38
+
39
+ ## Frontend
40
+
41
+ Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
42
+
43
+ Server:
44
+
45
+ ```ts#index.ts
46
+ import index from "./index.html"
47
+
48
+ Bun.serve({
49
+ routes: {
50
+ "/": index,
51
+ "/api/users/:id": {
52
+ GET: (req) => {
53
+ return new Response(JSON.stringify({ id: req.params.id }));
54
+ },
55
+ },
56
+ },
57
+ // optional websocket support
58
+ websocket: {
59
+ open: (ws) => {
60
+ ws.send("Hello, world!");
61
+ },
62
+ message: (ws, message) => {
63
+ ws.send(message);
64
+ },
65
+ close: (ws) => {
66
+ // handle close
67
+ }
68
+ },
69
+ development: {
70
+ hmr: true,
71
+ console: true,
72
+ }
73
+ })
74
+ ```
75
+
76
+ HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
77
+
78
+ ```html#index.html
79
+ <html>
80
+ <body>
81
+ <h1>Hello, world!</h1>
82
+ <script type="module" src="./frontend.tsx"></script>
83
+ </body>
84
+ </html>
85
+ ```
86
+
87
+ With the following `frontend.tsx`:
88
+
89
+ ```tsx#frontend.tsx
90
+ import React from "react";
91
+ import { createRoot } from "react-dom/client";
92
+
93
+ // import .css files directly and it works
94
+ import './index.css';
95
+
96
+ const root = createRoot(document.body);
97
+
98
+ export default function Frontend() {
99
+ return <h1>Hello, world!</h1>;
100
+ }
101
+
102
+ root.render(<Frontend />);
103
+ ```
104
+
105
+ Then, run index.ts
106
+
107
+ ```sh
108
+ bun --hot ./index.ts
109
+ ```
110
+
111
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
HOT_TAGS_V3_TRIGGER.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # HOT Tags V3 Fix Trigger
2
+ # Generated: 9ef64b29ef1be950
3
+ # Files: app.js (59382 bytes), style.css (15138 bytes)
README.md CHANGED
@@ -1,6 +1,99 @@
1
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  tags:
3
  - ml-intern
4
  ---
5
 
6
- # bep40/vai-avatar2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Gemma Avatar
3
+ emoji: 🗣️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ thumbnail: https://huggingface.co/spaces/victor/gemma-avatar/resolve/main/thumbnail.webp
10
+ short_description: Talk to Gemma 4 face to face, with a 3D lip-synced avatar
11
+ models:
12
+ - google/gemma-4-31B-it
13
+ - nvidia/parakeet-tdt-1.1b
14
+ - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
15
  tags:
16
  - ml-intern
17
  ---
18
 
19
+ # Gemma Avatar
20
+
21
+ Realtime voice chat with a 3D talking-head avatar. Same AI stack as the
22
+ [smolagents/hf-realtime-voice](https://huggingface.co/spaces/smolagents/hf-realtime-voice)
23
+ Space ([blog post](https://huggingface.co/blog/cerebras-gemma4-voice-ai)), but the
24
+ orb visualization is replaced by a [TalkingHead](https://github.com/met4citizen/TalkingHead)
25
+ 3D avatar with real-time audio-driven lip-sync.
26
+
27
+ ## The pipeline
28
+
29
+ ```
30
+ you speak → silero-VAD → parakeet-tdt-1.1b (STT) → gemma-4-31B-it on Cerebras → Qwen3-TTS → avatar speaks
31
+ ```
32
+
33
+ Transport is the OpenAI Realtime GA protocol over WebSocket against Hugging
34
+ Face's speech-to-speech backend: mic PCM16 @ 16 kHz goes up as
35
+ `input_audio_buffer.append`, TTS PCM16 @ 16 kHz comes back as
36
+ `response.output_audio.delta`, transcripts stream alongside.
37
+
38
+ ## How the avatar works
39
+
40
+ - **Rendering / body language** — [TalkingHead](https://github.com/met4citizen/TalkingHead)
41
+ (three.js). Blinking, breathing, idle sway, moods, hand gestures, and emoji
42
+ expressions are its built-in animation system.
43
+ - **Lip-sync** — the backend sends raw PCM only (no word timings, no visemes),
44
+ so the mouth is driven from the audio itself with
45
+ [HeadAudio](https://github.com/met4citizen/HeadAudio): an AudioWorklet that
46
+ classifies MFCC frames into Oculus visemes (~50 ms latency, fully in-browser).
47
+ The s2s playback worklet is routed into TalkingHead's audio graph
48
+ (`audioAnalyzerNode → audioSpeechGainNode → reverb → speakers`) and HeadAudio
49
+ taps the speech gain node.
50
+ - **The model plays the avatar** — three function tools are declared to the
51
+ backend: `set_mood`, `make_hand_gesture`, `make_facial_expression`. Gemma
52
+ calls them mid-conversation (smiles when greeting, shrugs when unsure,
53
+ thumbs-up when agreeing).
54
+ - **Choreography** — client statuses drive presence: the avatar makes eye
55
+ contact when you start talking, gestures with its hands on new utterances,
56
+ and barge-in clears the playback buffer so the mouth settles instantly.
57
+
58
+ ## Run it
59
+
60
+ ```bash
61
+ bun install
62
+
63
+ # Pick a backend (one of):
64
+ LOAD_BALANCER_URL=https://… bun run dev # a speech-to-speech load balancer
65
+ SESSION_PROXY_URL=https://…/api bun run dev # piggyback another deployment's /api (dev)
66
+ bun run dev # direct mode: paste a ws:// URL in Settings
67
+ ```
68
+
69
+ Open http://localhost:3000 and tap **Start talking**.
70
+
71
+ \`?fakemic=1\` starts a session with a silent synthetic mic (useful for testing
72
+ the full loop without a microphone — trigger a reply from the console with
73
+ \`getClient().requestResponse()\`).
74
+
75
+ ## Layout
76
+
77
+ ```
78
+ index.ts Bun server: HTML import + /api/session proxy + static assets
79
+ index.html App shell (avatar hero, caption, subtitles, settings)
80
+ src/app.js Session wiring, tool executor, UI state
81
+ src/avatar.js AvatarStage: TalkingHead + HeadAudio + choreography
82
+ src/s2s/s2s-ws-client.js Realtime WS client (vendored from the Space; orb removed,
83
+ injectable output node + worklet base URL + shared-ctx close)
84
+ src/s2s/codec.js PCM/base64 + transcript helpers (vendored, unchanged)
85
+ src/vendor/headaudio.min.mjs HeadAudio node class (bundled)
86
+ public/worklets/ mic-capture + audio-playback AudioWorklets (vendored, unchanged)
87
+ public/vendor/ HeadAudio worklet processor + viseme model (runtime-loaded)
88
+ public/avatars/brunette.glb Default avatar (Ready Player Me; CC BY-NC 4.0 — non-commercial)
89
+ ```
90
+
91
+ ## Notes
92
+
93
+ - The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme
94
+ blend shapes. Ready Player Me avatars work with
95
+ \`?morphTargets=ARKit,Oculus%20Visemes\` on the GLB URL.
96
+ - TalkingHead owns the AudioContext; the s2s client is handed \`head.audioCtx\`
97
+ and never closes it.
98
+ - Everything animation-related runs on requestAnimationFrame — a backgrounded
99
+ tab freezes the avatar (audio keeps playing).
REBUILD.md ADDED
@@ -0,0 +1 @@
 
 
1
+ TRIGGER
TRIGGER_REBUILD.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 1785850254749
build-trigger ADDED
@@ -0,0 +1 @@
 
 
1
+ rebuild=1754000000000
index.html ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
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=12" />
12
+ </head>
13
+ <body>
14
+ <main id="app">
15
+ <div id="stage" aria-label="3D avatar"></div>
16
+ <div id="loading">Loading avatar…</div>
17
+
18
+ <!-- Welcome Modal -->
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">
44
+ <span class="welcome-btn-main-text">Chat tin nhắn</span>
45
+ <span class="welcome-btn-desc">Không cần mic, trò chuyện bằng chữ với AI</span>
46
+ </span>
47
+ </button>
48
+ <button id="welcome-voice-btn" class="welcome-btn welcome-btn-voice" aria-label="Gọi voice">
49
+ <span class="welcome-btn-icon">🎙️</span>
50
+ <span class="welcome-btn-text-content">
51
+ <span class="welcome-btn-main-text">Gọi voice</span>
52
+ <span class="welcome-btn-desc">Cần mic & loa, nói chuyện trực tiếp</span>
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>
106
+
107
+ <div id="text-chat" hidden>
108
+ <div id="chat-header">
109
+ <span id="chat-title">Text Chat</span>
110
+ <div id="chat-header-actions">
111
+ <select id="chat-avatar-select" title="Switch avatar"></select>
112
+ <button id="chat-audio-toggle-btn" class="icon-btn small" aria-label="Toggle avatar audio" title="Toggle avatar audio" style="display:none">
113
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
114
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
115
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" id="audio-icon-sound"></path>
116
+ <path d="M15.53 8.47a4 4 0 0 1 0 7.07" id="audio-icon-muted" style="display:none"></path>
117
+ </svg>
118
+ </button>
119
+ <button id="chat-close-btn" class="icon-btn small" aria-label="Close text chat" title="Close">
120
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
121
+ <line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
122
+ </svg>
123
+ </button>
124
+ </div>
125
+ </div>
126
+ <div id="chat-messages"></div>
127
+ <div id="chat-input-container">
128
+ <input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
129
+ <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>
130
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
131
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
132
+ <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>
133
+ </svg>
134
+ </button>
135
+ <button id="chat-send-btn" class="icon-btn" aria-label="Send message">
136
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
137
+ <line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
138
+ </svg>
139
+ </button>
140
+ </div>
141
+ <!-- Suggested Questions: rendered as dark pills inside the chat (see vaix-rag.js) -->
142
+ <div id="suggested-questions" style="padding:8px 12px;display:none;flex-wrap:wrap;gap:6px"></div>
143
+ <div id="chat-resize-handle"></div>
144
+ </div>
145
+
146
+ <footer id="controls">
147
+ <div id="caption">LOADING…</div>
148
+ <div id="buttons">
149
+ <button id="main-btn" disabled><span id="main-btn-label">Loading…</span></button>
150
+ <button id="mute-btn" class="icon-btn" hidden aria-label="Mute avatar audio" title="Mute avatar audio">
151
+ <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">
152
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
153
+ <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>
154
+ </svg>
155
+ </button>
156
+ <button id="text-mode-btn" class="icon-btn" hidden aria-label="Text chat mode" title="Text chat mode">
157
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
158
+ <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>
159
+ </svg>
160
+ </button>
161
+ </div>
162
+ </footer>
163
+
164
+ <dialog id="settings">
165
+ <form method="dialog">
166
+ <h2>Settings</h2>
167
+ <label>Voice<select id="voice"></select></label>
168
+ <label>Avatar model<select id="settings-avatar-select"></select></label>
169
+ <label class="check-row"><input type="checkbox" id="subtitles-toggle" />Show subtitles while she speaks</label>
170
+ <label>Extra instructions<textarea id="instructions" rows="4" placeholder="Anything you want her to know or do (persona stays)."></textarea></label>
171
+ <label id="direct-url-row" hidden>Direct server URL<input id="direct-url" type="url" placeholder="ws://localhost:8080/v1/realtime" spellcheck="false" /></label>
172
+ <div class="dialog-actions"><button value="close" class="primary">Done</button></div>
173
+ </form>
174
+ </dialog>
175
+ </main>
176
+
177
+ <!-- V.AISTUDIO floating button -->
178
+ <button id="vaistudio-toggle" aria-label="Open V.AI STUDIO catalog" title="Open V.AI STUDIO catalog" style="position:fixed;bottom:24px;right:24px;z-index:180;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;border:none;cursor:pointer;box-shadow:0 4px 20px rgba(0,63,98,0.4);display:flex;align-items:center;justify-content:center;font-size:1.4rem;transition:all 0.25s ease" 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)'">
179
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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><polyline points="17 8 12 13 7 8"></polyline></svg>
180
+ </button>
181
+
182
+ <!-- V.AISTUDIO Panel -->
183
+ <div id="vaistudio-panel" role="dialog" aria-label="V.AI STUDIO Catalog" style="position:fixed;bottom:0;right:0;width:400px;max-width:100vw;height:560px;max-height:calc(100vh - 48px);background:#fff;border-radius:20px 20px 0 0;box-shadow:0 8px 40px rgba(0,0,0,0.18);z-index:160;display:none;flex-direction:column;overflow:hidden">
184
+ <div id="vaistudio-header" style="background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;padding:14px 16px;display:flex;align-items:center;gap:12px;flex-shrink:0">
185
+ <div style="width:38px;height:38px;border-radius:12px;background:rgba(255,255,255,0.15);display:flex;align-items:center;justify-content:center;font-size:1.1rem;flex-shrink:0"><i class="fas fa-store"></i></div>
186
+ <div style="flex:1;min-width:0">
187
+ <h4 style="font-size:0.9rem;font-weight:700;margin:0">V.AI STUDIO</h4>
188
+ <span id="vaistudio-count" style="font-size:0.68rem;opacity:0.75">Đang tải sản phẩm…</span>
189
+ </div>
190
+ <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)'">
191
+ <i class="fas fa-receipt"></i>
192
+ </button>
193
+ <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)'">
194
+ <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>
195
+ </button>
196
+ </div>
197
+
198
+ <!-- Search + Suggestions dropdown -->
199
+ <div style="padding:8px 12px 4px;flex-shrink:0;background:#f8fafc;border-bottom:1px solid #e2e8f0;position:relative">
200
+ <input id="vaix-search" type="text" placeholder="🔍 Tìm sản phẩm..." autocomplete="off" style="width:100%;padding:10px 14px;border:1px solid #e2e8f0;border-radius:10px;font-size:0.85rem;outline:none;background:#fff;box-sizing:border-box" onfocus="this.style.borderColor='#0077b6'" onblur="this.style.borderColor='#e2e8f0'" />
201
+ <div id="vaix-suggestions" style="position:absolute;top:100%;left:12px;right:12px;z-index:50;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.12);display:none;max-height:360px;overflow-y:auto"></div>
202
+ </div>
203
+ <div id="vaix-category-chips" style="padding:4px 12px 8px;flex-shrink:0;background:#f8fafc;border-bottom:1px solid #e2e8f0;display:none;flex-wrap:wrap;gap:4px;overflow-x:auto"></div>
204
+
205
+ <div id="vaistudio-body" style="flex:1;overflow:hidden;position:relative;background:#f8fafc">
206
+ <div id="vaistudio-loading" style="position:absolute;inset:0;background:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;z-index:10">
207
+ <div style="width:32px;height:32px;border:3px solid #e2e8f0;border-top-color:#003f62;border-radius:50%;animation:spin 1s linear infinite"></div>
208
+ <div style="font-size:0.85rem;color:#64748b">Đang tải sản phẩm V.AI STUDIO…</div>
209
+ </div>
210
+ <div id="vaistudio-products" style="overflow-y:auto;height:100%;padding:12px;display:none"></div>
211
+ <div id="vaistudio-error" style="position:absolute;inset:0;background:#fff;display:none;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:24px;text-align:center">
212
+ <div style="font-size:2rem;margin-bottom:8px">⚠️</div>
213
+ <div id="vaistudio-error-text" style="font-size:0.9rem;color:#64748b">Không thể tải sản phẩm</div>
214
+ <button id="vaistudio-retry-btn" style="padding:8px 20px;background:#003f62;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:0.85rem">Thử lại</button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- Product Detail Modal -->
220
+ <div id="vaistudio-detail-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200;display:none;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)this.style.display='none'">
221
+ <div id="vaistudio-detail-modal" style="background:#fff;border-radius:20px;max-width:480px;width:100%;max-height:85vh;overflow-y:auto;padding:0;box-shadow:0 20px 60px rgba(0,0,0,0.25);animation:modalIn 0.2s ease">
222
+ <div style="position:sticky;top:0;z-index:2;display:flex;justify-content:flex-end;padding:12px" onclick="document.getElementById('vaistudio-detail-overlay').style.display='none'">
223
+ <button style="width:36px;height:36px;border-radius:50%;border:none;background:rgba(0,0,0,0.06);cursor:pointer;font-size:1.1rem;display:flex;align-items:center;justify-content:center">✕</button>
224
+ </div>
225
+ <div id="detail-images" style="display:flex;gap:8px;overflow-x:auto;padding:0 16px 12px;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch"></div>
226
+ <div style="padding:0 16px 12px;border-bottom:1px solid #e2e8f0">
227
+ <h2 id="detail-title" style="font-size:1.05rem;font-weight:700;color:#1e293b;margin:0 0 6px;line-height:1.4"></h2>
228
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
229
+ <span id="detail-brand" style="font-size:0.75rem;color:#64748b;background:#f1f5f9;padding:3px 10px;border-radius:8px"></span>
230
+ <span id="detail-model" style="font-size:0.7rem;color:#94a3b8"></span>
231
+ </div>
232
+ <div id="detail-price" style="font-size:1.2rem;font-weight:800;color:#c2410c;margin-top:8px"></div>
233
+ </div>
234
+ <div id="detail-summary" style="padding:12px 16px;font-size:0.82rem;color:#475569;line-height:1.6;border-bottom:1px solid #f1f5f9;display:none"></div>
235
+ <div id="detail-features" style="padding:12px 16px;border-bottom:1px solid #f1f5f9;display:none">
236
+ <h3 style="font-size:0.8rem;font-weight:700;color:#003f62;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.04em">Tính năng nổi bật</h3>
237
+ <ul id="detail-features-list" style="margin:0;padding:0;list-style:none"></ul>
238
+ </div>
239
+ <div id="detail-specs" style="padding:12px 16px 20px;display:none">
240
+ <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>
241
+ <table id="detail-specs-table" style="width:100%;border-collapse:collapse;font-size:0.78rem"><tbody></tbody></table>
242
+ </div>
243
+ </div>
244
+ </div>
245
+
246
+ <!-- CART DRAWER -->
247
+ <div class="cart-overlay" id="cartOverlay" onclick="if(window.closeCart)closeCart()"></div>
248
+ <div class="cart-drawer" id="cartDrawer">
249
+ <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>
250
+ <div class="cart-items" id="cartItems"></div>
251
+ <div class="cart-footer" id="cartFooter" style="display:none">
252
+ <div class="cart-total"><span>Tổng cộng:</span><span id="cartTotal">0đ</span></div>
253
+ <div class="cart-footer-btns">
254
+ <button class="cart-checkout-btn" onclick="saveOrder()" style="background:#059669"><i class="fas fa-save"></i> Lưu</button>
255
+ <button class="cart-checkout-btn" onclick="openQuotation()" style="background:var(--p, #003f62)"><i class="fas fa-file-invoice"></i> Báo giá</button>
256
+ <button class="cart-checkout-btn" onclick="clearCart()" style="background:#64748b"><i class="fas fa-trash"></i> Xóa</button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <!-- QUOTATION MODAL -->
262
+ <div class="quote-overlay" id="quoteOverlay" onclick="if(event.target===this)closeQuotation()">
263
+ <div class="quote-modal">
264
+ <button class="quote-close" onclick="closeQuotation()"><i class="fas fa-times"></i></button>
265
+ <div class="quote-head">
266
+ <h2><i class="fas fa-file-invoice" style="margin-right:8px"></i>Lập Báo Giá</h2>
267
+ <p>Điền thông tin khách hàng và chiết khấu để xuất báo giá</p>
268
+ </div>
269
+ <div class="quote-body">
270
+ <div class="quote-section">
271
+ <h3><i class="fas fa-user"></i> Thông tin khách hàng</h3>
272
+ <div class="quote-grid">
273
+ <div class="quote-field"><label>Họ tên khách hàng</label><input id="qcName" placeholder="Nhập họ tên"></div>
274
+ <div class="quote-field"><label>Số điện thoại</label><input id="qcPhone" placeholder="Nhập SĐT"></div>
275
+ <div class="quote-field"><label>Email</label><input id="qcEmail" placeholder="Nhập email"></div>
276
+ <div class="quote-field"><label>Địa chỉ</label><input id="qcAddr" placeholder="Nhập địa chỉ"></div>
277
+ <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>
278
+ <div class="quote-field"><label>Ngày báo giá</label><input id="qcDate" type="date"></div>
279
+ </div>
280
+ <div style="margin-top:12px;padding:12px 14px;background:#f1f5f9;border-radius:10px">
281
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
282
+ <i class="fas fa-lock" id="qcLockIcon" style="color:#16a34a;font-size:.85rem"></i>
283
+ <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()">
284
+ <span id="qcAccessStatus" style="font-size:.72rem;font-weight:600;color:#16a34a">🔒</span>
285
+ </div>
286
+ <div id="aiPromptBox" style="display:none">
287
+ <div style="display:flex;gap:8px">
288
+ <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>
289
+ <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>
290
+ </div>
291
+ <div id="aiDiscStatus" style="font-size:.72rem;color:#16a34a;margin-top:4px"></div>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ <div class="quote-section">
296
+ <h3><i class="fas fa-list-check"></i> Chi tiết sản phẩm</h3>
297
+ <div style="overflow-x:auto">
298
+ <table class="quote-table" id="quoteTable">
299
+ <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>
300
+ <tbody id="quoteTableBody"></tbody>
301
+ <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>
302
+ </table>
303
+ </div>
304
+ </div>
305
+ <div class="quote-actions">
306
+ <button class="quote-btn" onclick="shareQuoteImage()" style="background:#25D366"><i class="fas fa-share-alt"></i> Chia sẻ ảnh</button>
307
+ <button class="quote-btn" onclick="shareQuoteLink()" style="background:#0068FF"><i class="fas fa-share-alt"></i> Chia sẻ báo giá</button>
308
+ <button class="quote-btn quote-btn-excel" onclick="exportQuoteExcel()"><i class="fas fa-file-excel"></i> Xuất Excel</button>
309
+ <button class="quote-btn quote-btn-pdf" onclick="exportQuotePDF()"><i class="fas fa-file-pdf"></i> Xuất PDF</button>
310
+ <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>
311
+ <button class="quote-btn quote-btn-print" onclick="printQuotation()"><i class="fas fa-print"></i> In báo giá</button>
312
+ <button class="quote-btn" onclick="saveOrder()" style="background:#7c3aed"><i class="fas fa-save"></i> Lưu</button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ </div>
317
+
318
+ <style>
319
+ @keyframes spin { to { transform: rotate(360deg); } }
320
+ @keyframes modalIn { from { opacity:0;transform:translateY(20px) scale(0.96) } to { opacity:1;transform:translateY(0) scale(1) } }
321
+
322
+ /* ── Welcome Modal ── */
323
+ .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; }
324
+ .welcome-modal-overlay.show { opacity: 1; visibility: visible; }
325
+ .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; }
326
+ .welcome-modal-overlay.show .welcome-modal { transform: translateY(0); }
327
+ .welcome-avatar-icon { font-size: 3rem; margin-bottom: 16px; }
328
+ .welcome-title { color: #fff; font-size: 1.5rem; margin-bottom: 8px; font-family: Inter, sans-serif; font-weight: 600; }
329
+ .welcome-subtitle { color: #888; font-size: 0.9rem; margin-bottom: 24px; font-family: Inter, sans-serif; }
330
+ .welcome-actions { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
331
+ .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; }
332
+ .welcome-btn:hover { transform: scale(1.02); }
333
+ .welcome-btn:active { transform: scale(0.98); }
334
+ .welcome-btn-text { background: linear-gradient(135deg, #003f62, #0077b6); color: #fff; box-shadow: 0 4px 16px rgba(0,63,98,0.3); }
335
+ .welcome-btn-text:hover { box-shadow: 0 6px 24px rgba(0,63,98,0.4); }
336
+ .welcome-btn-voice { background: linear-gradient(135deg, #6B21A8, #9333EA); color: #fff; box-shadow: 0 4px 16px rgba(107,33,168,0.3); }
337
+ .welcome-btn-voice:hover { box-shadow: 0 6px 24px rgba(107,33,168,0.4); }
338
+ .welcome-btn-quick { background: linear-gradient(135deg, #047857, #10B981); color: #fff; box-shadow: 0 4px 16px rgba(4,120,87,0.3); }
339
+ .welcome-btn-quick:hover { box-shadow: 0 6px 24px rgba(4,120,87,0.4); }
340
+ .welcome-btn-icon { font-size: 1.3rem; }
341
+ .welcome-btn-text-content { display: flex; flex-direction: column; }
342
+ .welcome-btn-main-text { font-size: 1rem; font-weight: 600; }
343
+ .welcome-btn-desc { font-size: 0.75rem; font-weight: 400; opacity: 0.8; margin-top: 2px; }
344
+ .welcome-hint { color: #555; font-size: 0.75rem; font-family: Inter, sans-serif; }
345
+ .welcome-modal { max-height: 90vh; overflow-y: auto; }
346
+ .welcome-modal::-webkit-scrollbar { width: 4px; }
347
+ .welcome-modal::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
348
+ .welcome-features { margin: 0 0 16px; text-align: left; display: flex; flex-direction: column; gap: 10px; }
349
+ .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; }
350
+ .welcome-feature .wf-icon { flex-shrink: 0; font-size: 1.1rem; width: 22px; text-align: center; }
351
+ .welcome-feature .wf-text { color: #aaa; }
352
+
353
+ /* ✨ Avatar chooser on the welcome modal */
354
+ .welcome-avatars { margin: 0 0 16px; }
355
+ .welcome-avatars-title { color: #888; font-size: 0.82rem; font-family: Inter, sans-serif; margin: 0 0 12px; }
356
+ .welcome-avatar-row { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
357
+ .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; }
358
+ .welcome-avatar-card:hover { border-color: #0077b6; transform: translateY(-2px); }
359
+ .welcome-avatar-card.selected { border-color: #22d3ee; box-shadow: 0 0 0 2px rgba(34,211,238,0.35); }
360
+ .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; }
361
+ .welcome-avatar-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
362
+ .welcome-avatar-name { color: #e0e0e0; font-size: 0.8rem; font-weight: 600; }
363
+
364
+ /* ✨ Avatar loading overlay + 1–100% progress bar */
365
+ .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); }
366
+ .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); }
367
+ .avatar-loading-face { font-size: 2.6rem; margin-bottom: 14px; }
368
+ .avatar-loading-msg { color: #ddd; font-size: 1rem; line-height: 1.5; font-family: Inter, sans-serif; margin-bottom: 20px; }
369
+ .avatar-loading-track { height: 12px; background: #2a2a2a; border-radius: 999px; overflow: hidden; border: 1px solid #3a3a3a; }
370
+ .avatar-loading-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #003f62, #22d3ee); border-radius: 999px; transition: width 0.3s ease; }
371
+ .avatar-loading-pct { color: #22d3ee; font-size: 1rem; font-weight: 700; margin-top: 10px; font-family: Inter, sans-serif; }
372
+
373
+ #vaistudio-panel.open { display: flex !important; }
374
+ #vaistudio-loading.hidden { display: none !important; }
375
+ @media (max-width:768px) { #vaistudio-toggle { bottom:16px;right:16px;width:50px;height:50px } #vaistudio-panel { width:100%;max-width:100%;height:100%;max-height:100vh;border-radius:0 } }
376
+ .product-card { display:flex;gap:12px;padding:12px;background:#fff;border-radius:12px;margin-bottom:8px;border:1px solid #e2e8f0;cursor:pointer;transition:all 0.2s }
377
+ .product-card:hover { border-color:#0077b6;box-shadow:0 2px 8px rgba(0,63,98,0.12) }
378
+ .product-card-img { width:80px;height:80px;border-radius:8px;object-fit:cover;background:#f1f5f9;flex-shrink:0 }
379
+ .product-card-info { flex:1;min-width:0 }
380
+ .product-card-title { font-size:0.85rem;font-weight:600;color:#1e293b;margin:0 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap }
381
+ .product-card-brand { font-size:0.75rem;color:#64748b;margin:0 0 4px }
382
+ .product-card-price { font-size:0.85rem;font-weight:700;color:#003f62;margin:0 }
383
+ .product-card-cat { font-size:0.7rem;color:#94a3b8;background:#f1f5f9;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px }
384
+ .vaix-suggestion-item { display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px solid #f1f5f9;transition:background 0.15s }
385
+ .vaix-suggestion-item:last-child { border-bottom:none }
386
+ .vaix-suggestion-item:hover { background:#f0f9ff }
387
+ #vaix-suggestions::-webkit-scrollbar { width:5px }
388
+ #vaix-suggestions::-webkit-scrollbar-thumb { background:#e2e8f0;border-radius:3px }
389
+ .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 }
390
+ .vaix-category-chip:hover { background:#dbeafe;border-color:#0077b6;transform:translateY(-1px) }
391
+ .chat-product-cards { display:flex;flex-direction:column;gap:8px;margin:6px 0;max-width:100% }
392
+ .chat-product-card { background:#1e293b;border:1px solid #334155;border-radius:12px;overflow:hidden;transition:all 0.2s }
393
+ .chat-product-card:hover { border-color:#0077b6;box-shadow:0 2px 8px rgba(0,119,182,0.15) }
394
+ .chat-product-card-inner { display:flex;gap:10px;padding:10px;cursor:pointer }
395
+ .chat-product-card-img-wrap { width:64px;height:64px;border-radius:8px;overflow:hidden;flex-shrink:0;background:#1a1f2e }
396
+ .chat-product-card-img { width:100%;height:100%;object-fit:cover }
397
+ .chat-product-card-img-placeholder { width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:1.3rem }
398
+ .chat-product-card-body { flex:1;min-width:0;display:flex;flex-direction:column;gap:2px }
399
+ .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 }
400
+ .chat-product-card-meta { display:flex;align-items:center;gap:6px;flex-wrap:wrap }
401
+ .chat-product-card-brand { font-size:0.68rem;color:#94a3b8 }
402
+ .chat-product-card-stars { font-size:0.6rem;color:#f59e0b;letter-spacing:1px }
403
+ .chat-product-card-price { font-size:0.82rem;font-weight:700;color:#22d3ee;margin-top:1px }
404
+ .chat-product-card-cat { font-size:0.6rem;color:#64748b;background:#2d3748;padding:1px 6px;border-radius:4px;display:inline-block;width:fit-content;margin-top:1px }
405
+ .chat-product-card-actions { display:flex;gap:0;border-top:1px solid #334155 }
406
+ .chat-product-card-btn { flex:1;padding:7px 8px;background:transparent;border:none;color:#94a3b8;cursor:pointer;font-size:0.7rem;font-family:inherit;transition:all 0.15s;display:flex;align-items:center;justify-content:center;gap:4px }
407
+ .chat-product-card-btn:hover { background:#2d3748;color:#e2e8f0 }
408
+ .chat-product-card-btn:first-child { border-right:1px solid #334155 }
409
+ .chat-product-card-btn.detail-btn:hover { color:#22d3ee }
410
+ .chat-product-card-btn.share-btn:hover { color:#f59e0b }
411
+ .chat-product-card-btn.cart-btn { color:#4ade80 }
412
+ .chat-product-card-btn.cart-btn:hover { background:#14532d; color:#4ade80 }
413
+ .chat-product-card-btn.cart-btn.added { background:#166534; color:#fff }
414
+ .chat-message.assistant .chat-product-card { background:#1e293b;border:1px solid #334155;margin:4px 0 }
415
+ /* Combo suggestion section */
416
+ .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% }
417
+ .chat-combo-title { font-size:0.82rem;font-weight:700;color:#e2e8f0;display:flex;align-items:center;gap:6px;margin-bottom:2px }
418
+ .chat-combo-spark { color:#f59e0b }
419
+ .chat-combo-sub { font-size:0.68rem;color:#94a3b8;margin-bottom:8px }
420
+ .chat-combo-cards { display:flex;flex-direction:column;gap:7px;max-height:260px;overflow-y:auto }
421
+ .chat-combo-cards .chat-product-card { margin:0 }
422
+ .chat-combo-random { margin-top:8px;padding:6px 12px;background:rgba(34,211,238,0.15);color:#22d3ee;border:1px solid rgba(34,211,238,0.35);border-radius:10px;cursor:pointer;font-size:0.72rem;font-family:inherit;font-weight:600;transition:all 0.15s }
423
+ .chat-combo-random:hover { background:rgba(34,211,238,0.28) }
424
+ #detail-share-btn { display:inline-flex!important }
425
+
426
+ /* ✨ FIX: Suggested Question Pills CSS */
427
+ .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; }
428
+ .suggested-pill:hover { background:#dbeafe; border-color:#005580; }
429
+ </style>
430
+
431
+ <style>
432
+ /* ── Cart Drawer (V.AISTUDIO layout) ── */
433
+ .cart-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;backdrop-filter:blur(3px)}
434
+ .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}
435
+ .cart-drawer.open{transform:translateX(0);visibility:visible}
436
+ .cart-header{padding:20px;border-bottom:2px solid #e2e8f0;display:flex;align-items:center;justify-content:space-between}
437
+ .cart-header h3{font-size:1rem;font-weight:700;display:flex;align-items:center;gap:8px;color:#1e293b;margin:0}
438
+ .cart-close{width:34px;height:34px;border:none;background:#e2e8f0;border-radius:9px;cursor:pointer;font-size:.9rem;color:#475569}
439
+ .cart-items{flex:1;overflow-y:auto;padding:16px;min-height:0}
440
+ .cart-item{display:flex;gap:12px;padding:12px;background:#f8fafc;border-radius:12px;margin-bottom:10px;align-items:center}
441
+ .cart-item img{width:56px;height:56px;border-radius:8px;object-fit:cover;background:#fff;flex-shrink:0;border:1px solid #e2e8f0}
442
+ .cart-item-info{flex:1;min-width:0}
443
+ .cart-item-name{font-size:.8rem;font-weight:600;color:#1e293b;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
444
+ .cart-item-price{font-size:.88rem;font-weight:800;color:#003f62;margin-top:4px}
445
+ .cart-item-qty{display:flex;align-items:center;gap:8px;margin-top:6px}
446
+ .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}
447
+ .qty-val{font-size:.82rem;font-weight:700;min-width:20px;text-align:center;color:#1e293b}
448
+ .cart-item-del{background:none;border:none;color:#dc3545;cursor:pointer;font-size:.85rem;padding:4px}
449
+ .cart-footer{padding:10px 14px;border-top:2px solid #e2e8f0;background:#fff;flex-shrink:0}
450
+ .cart-total{display:flex;justify-content:space-between;font-size:.92rem;font-weight:800;color:#1e293b;margin-bottom:6px}
451
+ .cart-footer-btns{display:flex;gap:6px;margin-top:6px}
452
+ .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}
453
+ .cart-empty{text-align:center;padding:40px;color:#64748b}
454
+ .cart-empty i{font-size:2rem;color:#cbd5e1;margin-bottom:8px}
455
+ .cart-empty h3{color:#1e293b;margin:0 0 4px}
456
+ #cart-badge-btn svg{display:block}
457
+
458
+ /* ── Quotation Modal (V.AISTUDIO layout) ── */
459
+ .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)}
460
+ .quote-overlay.open{display:flex}
461
+ .quote-modal{background:#fff;border-radius:20px;max-width:960px;width:100%;max-height:92vh;overflow-y:auto;padding:0;position:relative;color:#1e293b}
462
+ .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}
463
+ .quote-head{background:linear-gradient(135deg,#003f62,#0077b6);color:#fff;padding:24px 28px;border-radius:20px 20px 0 0}
464
+ .quote-head h2{font-size:1.2rem;font-weight:800;margin:0 0 4px;color:#fff}
465
+ .quote-head p{font-size:.8rem;opacity:.75;margin:0}
466
+ .quote-body{padding:24px 28px}
467
+ .quote-section{margin-bottom:20px}
468
+ .quote-section h3{font-size:.88rem;font-weight:700;color:#1e293b;margin:0 0 12px;display:flex;align-items:center;gap:8px}
469
+ .quote-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
470
+ .quote-field label{font-size:.72rem;font-weight:600;color:#475569;display:block;margin-bottom:4px}
471
+ .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}
472
+ .quote-table{width:100%;border-collapse:collapse;font-size:.78rem;margin-top:8px}
473
+ .quote-table th{background:#003f62;color:#fff;padding:8px 6px;font-weight:600;text-align:center;white-space:nowrap}
474
+ .quote-table td{padding:8px 6px;border-bottom:1px solid #e2e8f0;vertical-align:middle;color:#334155}
475
+ .quote-table tbody tr:nth-child(odd){background:#f8fafc}
476
+ .quote-total-row td{font-weight:800;color:#003f62;padding:12px 6px}
477
+ .qt-price{text-align:right;white-space:nowrap}
478
+ .quote-table img{width:40px;height:40px;object-fit:cover;border-radius:6px}
479
+ .quote-actions{display:flex;gap:10px;margin-top:20px;flex-wrap:wrap;justify-content:center}
480
+ .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}
481
+ .quote-btn-excel{background:#217346}
482
+ .quote-btn-pdf{background:#dc3545}
483
+ .quote-btn-print{background:#003f62}
484
+ .quote-btn-print:hover{background:#00293f}
485
+ .quote-field input:focus{border-color:#0077b6}
486
+ .quote-table .qt-img{width:72px;height:72px;object-fit:contain;border-radius:6px;background:#f1f5f9;border:1px solid #e2e8f0}
487
+ .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}
488
+ .quote-table .qt-disc:focus{border-color:#0077b6}
489
+ .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}
490
+ .qt-extra-row td{font-size:.82rem}
491
+ @media(max-width:768px){.quote-grid{grid-template-columns:1fr}.quote-modal{border-radius:12px}.quote-table{font-size:.7rem}}
492
+
493
+ /* ── Ask AI button on news cards ── */
494
+ .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}
495
+ .source-card-askai:hover{opacity:.9;transform:translateY(-1px)}
496
+
497
+ /* ── Đơn hàng (order) buttons — always visible ── */
498
+ #order-btn, #vaistudio-order-btn { display: flex !important; }
499
+ </style>
500
+
501
+ <!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
502
+ <script src="https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js"></script>
503
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
504
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
505
+ <script src="./src/cart-quote.js?gc=12"></script> <!-- Cart + Quote module (Giỏ hàng / Báo giá) -->
506
+ <script src="./src/vaix-rag.js?gc=14"></script> <!-- V.AI STUDIO RAG Module - loads products + integrates search/showProduct -->
507
+ <script src="./src/greeting-news.js?gc=12"></script> <!-- Greeting HOT news cards + source links -->
508
+ <script src="./src/order-sync.js?gc=12"></script> <!-- Order sync with V.AISTUDIO backend -->
509
+ <script type="module" src="./src/app.js?gc=13"></script>
510
+ <script src="./src/greeting-source-cards.js?gc=12"></script> <!-- FIX: Nguồn tin cards under greeting -->
511
+ <script src="./src/avatar-picker.js?gc=12"></script> <!-- ✨ Circular avatar picker (Mr V / Lisamy) + loading progress bar -->
512
+
513
+ <script>
514
+ // Wire the "Đơn hàng" buttons to the order modal
515
+ (function () {
516
+ function wire() {
517
+ var ob = document.getElementById("order-btn");
518
+ if (ob) { ob.onclick = function (e) { e.preventDefault(); e.stopPropagation(); if (window.openOrderModal) window.openOrderModal(); }; }
519
+ var vob = document.getElementById("vaistudio-order-btn");
520
+ if (vob) { vob.onclick = function (e) { e.preventDefault(); e.stopPropagation(); if (window.openOrderModal) window.openOrderModal(); }; }
521
+ }
522
+ wire();
523
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", wire);
524
+ // Re-wire after cart-quote.js loads (it runs synchronously before this block)
525
+ window.addEventListener("load", wire);
526
+ })();
527
+ </script>
528
+ </body>
529
+ </html>
index.ts ADDED
@@ -0,0 +1,910 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { 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
+
17
+ const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
18
+ const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, "");
19
+ const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
20
+ const PORT = Number(Bun.env.PORT ?? 7860);
21
+
22
+ // ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ──
23
+ const JSON_URL =
24
+ "https://huggingface.co/datasets/bep40/grob-products-updated" +
25
+ "/resolve/main/products_with_slugs.json";
26
+
27
+ let VAIX_PRODUCTS: any[] | null = null;
28
+ let VAIX_LOADING = false;
29
+ let VAIX_LOADED = false;
30
+ let VAIX_LAST_SYNC = Date.now();
31
+
32
+ async function loadVaixProducts(): Promise<void> {
33
+ if (VAIX_LOADED && VAIX_PRODUCTS?.length) return;
34
+ if (VAIX_LOADING) {
35
+ for (let i = 0; i < 120; i++) {
36
+ await new Promise(r => setTimeout(r, 500));
37
+ if (VAIX_LOADED) return;
38
+ }
39
+ }
40
+ VAIX_LOADING = true;
41
+ const startMs = Date.now();
42
+ console.log("[VAIX] Fetching JSON from bep40/grob-products-updated...");
43
+ try {
44
+ const ctrl = new AbortController();
45
+ const timer = setTimeout(() => ctrl.abort(), 120_000);
46
+ const resp = await fetch(JSON_URL, { signal: ctrl.signal });
47
+ clearTimeout(timer);
48
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
49
+ const data = await resp.json();
50
+ VAIX_PRODUCTS = Array.isArray(data) ? data : [];
51
+ VAIX_LAST_SYNC = Date.now();
52
+ VAIX_PRODUCTS = VAIX_PRODUCTS.slice(0, 1000); // Limit to 1000 for memory
53
+ console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products (capped from " + data.length + ") in " + (Date.now() - startMs) + "ms");
54
+ } catch (err: any) {
55
+ console.error("[VAIX] Error:", err.message);
56
+ setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000);
57
+ } finally {
58
+ VAIX_LOADING = false;
59
+ }
60
+ }
61
+ loadVaixProducts().catch(() => {});
62
+ setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000);
63
+
64
+ function norm(s: string): string {
65
+ return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, "");
66
+ }
67
+
68
+ async function proxy(path: string, req: Request, init: any = {}): Promise<Response> {
69
+ const headers = new Headers(init.headers);
70
+ headers.set("Content-Type", "application/json");
71
+ const cookie = req.headers.get("cookie");
72
+ if (cookie) headers.set("Cookie", cookie);
73
+ const resp = await fetch(UPSTREAM + path, { ...init, headers });
74
+ const body = await resp.text();
75
+ const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
76
+ const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
77
+ 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("; "));
78
+ return out;
79
+ }
80
+
81
+ function getMimeType(name: string): string {
82
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
83
+ const types: Record<string, string> = {
84
+ "js": "application/javascript",
85
+ "mjs": "application/javascript",
86
+ "cjs": "application/javascript",
87
+ "css": "text/css",
88
+ "html": "text/html",
89
+ "json": "application/json",
90
+ "png": "image/png",
91
+ "jpg": "image/jpeg",
92
+ "gif": "image/gif",
93
+ "svg": "image/svg+xml",
94
+ "webp": "image/webp",
95
+ "ico": "image/x-icon",
96
+ "woff2": "font/woff2",
97
+ "woff": "font/woff",
98
+ "ttf": "font/ttf",
99
+ "bin": "application/octet-stream",
100
+ "mjs": "application/javascript",
101
+ };
102
+ return types[ext] ?? "application/octet-stream";
103
+ }
104
+ function staticFile(dir: string, name: string) {
105
+ const path = import.meta.dir + "/public/" + dir + "/" + name;
106
+ const mime = getMimeType(name);
107
+ return new Response(Bun.file(path), { headers: { "Content-Type": mime } });
108
+ }
109
+ function srcFile(dir: string, name: string) {
110
+ const path = import.meta.dir + "/" + dir + "/" + name;
111
+ const mime = getMimeType(name);
112
+ return new Response(Bun.file(path), { headers: { "Content-Type": mime } });
113
+ }
114
+
115
+ async function listAvatars() {
116
+ const avatarsDir = join(import.meta.dir, "public/avatars");
117
+ const names: string[] = [];
118
+ try {
119
+ const dir = await readdir(avatarsDir, { withFileTypes: true });
120
+ for (const e of dir) {
121
+ if (e.isFile() && e.name.endsWith(".glb")) names.push(e.name);
122
+ }
123
+ } catch {}
124
+ return names.sort();
125
+ }
126
+
127
+ function formatProduct(p: any, idx: number) {
128
+ return {
129
+ name: p.name || p.n || "",
130
+ title_clean: p.name || p.n || "",
131
+ brand: p.brand || "",
132
+ price: p.p || p.price || "",
133
+ priceNum: Number(p.pn ?? 0),
134
+ category: p.c || p.cat || "",
135
+ category_slug: p.cs || "",
136
+ category_icon: p.ci || "fa-box",
137
+ sku: p.sku || "",
138
+ model: p.mod || p.model || "",
139
+ slug: p.slug || "",
140
+ description: p.desc || "",
141
+ summary: p.sum || p.summary || "",
142
+ features: Array.isArray(p.feats) ? p.feats : [],
143
+ specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {},
144
+ video: p.vid || "",
145
+ image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || (Array.isArray(p.images) ? p.images[0] : ""),
146
+ images: p.imgs || p.images || [],
147
+ link: p.l || "",
148
+ _idx: p._idx ?? idx,
149
+ };
150
+ }
151
+
152
+ async function serveProductOG(req: Request): Promise<Response> {
153
+ const url = new URL(req.url);
154
+ const productSlug = url.searchParams.get("product");
155
+
156
+ function esc(s: string): string {
157
+ return (s || "")
158
+ .replace(/&/g, '&amp;')
159
+ .replace(/</g, '&lt;')
160
+ .replace(/>/g, '&gt;')
161
+ .replace(/"/g, '&quot;')
162
+ .replace(/'/g, '&#39;')
163
+ .replace(/\n/g, " ")
164
+ .replace(/\r/g, " ")
165
+ .trim()
166
+ .slice(0, 350);
167
+ }
168
+
169
+ const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp";
170
+
171
+ if (productSlug && VAIX_PRODUCTS?.length) {
172
+ const foundProduct = VAIX_PRODUCTS.find((p: any) => {
173
+ const slug = p.slug || p._source_alias || "";
174
+ const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
175
+ const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
176
+ return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug);
177
+ });
178
+ if (foundProduct) {
179
+ const fp = formatProduct(foundProduct, foundProduct._idx ?? 0);
180
+ const fpName = esc(fp.name || fp.model || "Sản phẩm");
181
+ const fpBrand = esc(fp.brand || "V.AI STUDIO");
182
+ // ── Image URL: use escURL (not esc) because we need proper URL encoding for og:image ──
183
+ const fpImageRaw = fp.image || (fp.images && fp.images[0]) || "";
184
+ const fpImg = fpImageRaw ? escURL(fpImageRaw) : fallbackImg;
185
+
186
+ // ── Description: 600 chars to fit full summary + specs ──
187
+ const fpDesc = esc((fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 600));
188
+
189
+ // ── Specs: show all specs up to 12 keys ──
190
+ const fpSpecs = fp.specs || {};
191
+ let specsStr = "";
192
+ const specKeys = Object.keys(fpSpecs).slice(0, 12);
193
+ for (const sk of specKeys) {
194
+ specsStr += (specsStr ? ", " : " | ") + esc(sk) + ": " + esc(String(fpSpecs[sk]));
195
+ }
196
+ const ogDescription = fpDesc + (specsStr ? " | " + specsStr.trim() : "") || "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp";
197
+
198
+ // ── Price: formatted VND string for og:title ──
199
+ const priceNum = fp.priceNum || Number(fp.pn) || 0;
200
+ const ogPrice = priceNum > 0 ? " - " + Number(priceNum).toLocaleString("vi-VN") + "₫" : "";
201
+ const ogTitle = fpName + " | " + fpBrand + ogPrice + " - V.AI STUDIO";
202
+
203
+ const ogTags = buildOGTags(ogTitle, ogDescription, fpImg, url.href, true, priceNum);
204
+ return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
205
+ status: 200,
206
+ headers: { "Content-Type": "text/html; charset=utf-8" },
207
+ });
208
+ }
209
+ }
210
+
211
+ // Fallback OG tags — always set for any request (no product match or no ?product param)
212
+ const fallbackTitle = "Gemma Avatar + V.AI STUDIO - Chat & Voice AI";
213
+ const fallbackDesc = "Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng.";
214
+ const ogTags = buildOGTags(fallbackTitle, fallbackDesc, fallbackImg, url.href, false);
215
+ return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), {
216
+ status: 200,
217
+ headers: { "Content-Type": "text/html; charset=utf-8" },
218
+ });
219
+ }
220
+
221
+ function buildOGTags(title: string, desc: string, image: string, href: string, isProduct: boolean, price = 0): string {
222
+ return [
223
+ '<meta property="og:title" content="' + title + '" />',
224
+ '<meta property="og:description" content="' + desc + '" />',
225
+ '<meta property="og:url" content="' + escURL(href) + '" />',
226
+ '<meta property="og:type" content="' + (isProduct ? 'product' : 'website') + '" />',
227
+ '<meta property="og:image" content="' + image + '" />',
228
+ '<meta property="og:image:secure_url" content="' + image + '" />',
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
+ isProduct ? '<meta property="product:price:amount" content="' + price + '" />' : '',
233
+ isProduct ? '<meta property="product:price:currency" content="VND" />' : '',
234
+ isProduct ? '<meta property="product:availability" content="in stock" />' : '',
235
+ isProduct ? '<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="' + title + '" />',
240
+ '<meta name="twitter:description" content="' + desc + '" />',
241
+ '<meta name="twitter:image" content="' + image + '" />',
242
+ '<meta name="description" content="' + desc + '" />',
243
+ ].filter(Boolean).join("\n ");
244
+ }
245
+
246
+ function escURL(s: string): string {
247
+ // Only escape what's needed for HTML attribute — URL-safe
248
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
249
+ }
250
+
251
+
252
+
253
+ function serveStatic(req: Request): Response {
254
+ const url = new URL(req.url);
255
+ const fullPath = join("/app", url.pathname.slice(1));
256
+ if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 });
257
+ return new Response(Bun.file(fullPath));
258
+ }
259
+
260
+ async function queueHandler(req: Request): Promise<Response> {
261
+ if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
262
+ const url = new URL(req.url);
263
+ const parts = url.pathname.split("/");
264
+ const id = parts[parts.length - 1];
265
+ if (!id) return Response.json({ error: "Missing id" }, { status: 400 });
266
+ try {
267
+ if (req.method === "DELETE") {
268
+ return await proxy("/queue/" + encodeURIComponent(id), req, { method: "DELETE" });
269
+ }
270
+ return await proxy("/queue/" + encodeURIComponent(id), req);
271
+ } catch {
272
+ return Response.json({ error: "Speech service unreachable." }, { status: 502 });
273
+ }
274
+ }
275
+
276
+ async function sessionHandler(req: Request): Promise<Response> {
277
+ if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
278
+ try {
279
+ return await proxy("/session", req, { method: "POST", body: "{}" });
280
+ } catch {
281
+ return Response.json({ error: "Speech service unreachable." }, { status: 502 });
282
+ }
283
+ }
284
+
285
+ // ── Text-only chat endpoint ──
286
+ // Uses S2S backend in text-only mode: POST /session → text chat via WebSocket
287
+ async function textChatHandler(req: Request): Promise<Response> {
288
+ if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 });
289
+ try {
290
+ const reqBody = await req.json();
291
+ const userMessage = reqBody.message || "";
292
+ if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 });
293
+
294
+ // Create S2S session in text-only mode
295
+ const sessionResp = await fetch(UPSTREAM + "/session", {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: "{}",
299
+ });
300
+ if (!sessionResp.ok) {
301
+ const errBody = await sessionResp.text().catch(() => "");
302
+ return Response.json({
303
+ error: "Failed to create S2S session",
304
+ status: sessionResp.status,
305
+ detail: errBody.slice(0, 500),
306
+ }, { status: 502 });
307
+ }
308
+ const sessionData = await sessionResp.json();
309
+ if (sessionData.state === "queued") {
310
+ // Poll the queue
311
+ let queueResp;
312
+ let attempts = 0;
313
+ do {
314
+ await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000));
315
+ queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), {
316
+ headers: { "Content-Type": "application/json" },
317
+ });
318
+ if (!queueResp.ok) continue;
319
+ const queueData = await queueResp.json();
320
+ if (queueData.state === "queued") {
321
+ attempts++;
322
+ if (attempts > 30) {
323
+ return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 });
324
+ }
325
+ continue;
326
+ }
327
+ const grant = queueData;
328
+ // Connect WebSocket
329
+ const ws = new WebSocket(grant.connect_url);
330
+
331
+ const wsPromise = new Promise<any>((resolve, reject) => {
332
+ ws.binaryType = "arraybuffer";
333
+ let msgReceived = false;
334
+
335
+ ws.onopen = () => {
336
+ // Session created — wait for session.updated then send text
337
+ const waitForSession = async () => {
338
+ while (!msgReceived) {
339
+ // Wait for session.created
340
+ // We'll use a timeout approach
341
+ }
342
+ };
343
+
344
+ // Send session.update then user message
345
+ setTimeout(() => {
346
+ ws.send(JSON.stringify({
347
+ type: "session.update",
348
+ session: {
349
+ type: "realtime",
350
+ instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.",
351
+ audio: { output: { voice: "Dylan" } }
352
+ }
353
+ }));
354
+ }, 500);
355
+
356
+ // Send user message
357
+ setTimeout(() => {
358
+ ws.send(JSON.stringify({
359
+ type: "conversation.item.create",
360
+ item: {
361
+ type: "message",
362
+ role: "user",
363
+ content: [{ type: "input_text", text: userMessage }]
364
+ }
365
+ }));
366
+ ws.send(JSON.stringify({ type: "response.create" }));
367
+ }, 1000);
368
+
369
+ // Collect responses
370
+ let fullTranscript = "";
371
+ let audioBuffer = "";
372
+ let responseDone = false;
373
+
374
+ const handleMessage = (raw: string) => {
375
+ try {
376
+ const event = JSON.parse(raw);
377
+ const type = event?.type;
378
+
379
+ if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") {
380
+ const delta = (event.transcript || event.delta || "");
381
+ if (delta) fullTranscript += delta;
382
+ }
383
+ if (type === "response.audio_transcript.done" || type === "response.done") {
384
+ responseDone = true;
385
+ clearTimeout(timeout);
386
+ msgReceived = true;
387
+ resolve({
388
+ transcript: fullTranscript.trim(),
389
+ audio: audioBuffer,
390
+ audioFormat: "pcm16",
391
+ });
392
+ }
393
+ } catch {}
394
+ };
395
+
396
+ ws.onmessage = (e: any) => {
397
+ const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
398
+ handleMessage(data);
399
+ };
400
+
401
+ ws.onerror = () => reject(new Error("WebSocket error"));
402
+
403
+ // Timeout
404
+ const timeout = setTimeout(() => {
405
+ if (!responseDone) {
406
+ msgReceived = true;
407
+ try { ws.close(1000, "timeout"); } catch {}
408
+ resolve({
409
+ transcript: fullTranscript.trim() || "No response received.",
410
+ audio: "",
411
+ });
412
+ }
413
+ }, 15000);
414
+ };
415
+
416
+ ws.onclose = () => {
417
+ if (!msgReceived) {
418
+ msgReceived = true;
419
+ resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" });
420
+ }
421
+ };
422
+ });
423
+
424
+ return Response.json(await wsPromise);
425
+ } while (true);
426
+ }
427
+
428
+ // Session granted directly — connect WebSocket
429
+ const grant = sessionData;
430
+ const ws = new WebSocket(grant.connect_url);
431
+
432
+ return new Promise<Response>((resolve, reject) => {
433
+ ws.binaryType = "arraybuffer";
434
+ let msgReceived = false;
435
+
436
+ ws.onopen = () => {
437
+ // Send session.update
438
+ setTimeout(() => {
439
+ ws.send(JSON.stringify({
440
+ type: "session.update",
441
+ session: {
442
+ type: "realtime",
443
+ instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short, natural, and warm. Only respond directly to the user's message in text form. Do not ask questions or request audio input.",
444
+ audio: { output: { voice: "Dylan" } }
445
+ }
446
+ }));
447
+ }, 500);
448
+
449
+ // Send user message via text
450
+ setTimeout(() => {
451
+ ws.send(JSON.stringify({
452
+ type: "conversation.item.create",
453
+ item: {
454
+ type: "message",
455
+ role: "user",
456
+ content: [{ type: "input_text", text: userMessage }]
457
+ }
458
+ }));
459
+ ws.send(JSON.stringify({ type: "response.create" }));
460
+ }, 1000);
461
+
462
+ // Collect response
463
+ let fullTranscript = "";
464
+ let responseDone = false;
465
+
466
+ const handleMessage = (raw: string) => {
467
+ try {
468
+ const event = JSON.parse(raw);
469
+ const type = event?.type;
470
+
471
+ if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") {
472
+ const delta = event.delta || "";
473
+ if (delta) fullTranscript += delta;
474
+ }
475
+ if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") {
476
+ const segment = event.transcript || "";
477
+ if (segment) fullTranscript += segment;
478
+ }
479
+ if (type === "response.done") {
480
+ responseDone = true;
481
+ clearTimeout(timeout);
482
+ msgReceived = true;
483
+ try { ws.close(1000, "done"); } catch {}
484
+ resolve(Response.json({
485
+ transcript: fullTranscript.trim(),
486
+ status: event.response?.status ?? "completed",
487
+ }));
488
+ }
489
+ } catch (e: any) {
490
+ console.warn("[chat] parse error:", e.message);
491
+ }
492
+ };
493
+
494
+ ws.onmessage = (e: any) => {
495
+ const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data);
496
+ handleMessage(data);
497
+ };
498
+
499
+ ws.onerror = () => {
500
+ if (!msgReceived) {
501
+ msgReceived = true;
502
+ try { ws.close(1000, "error"); } catch {}
503
+ resolve(Response.json({
504
+ transcript: "Error connecting to chat service.",
505
+ status: "error",
506
+ }));
507
+ }
508
+ };
509
+
510
+ ws.onclose = () => {
511
+ if (!msgReceived) {
512
+ msgReceived = true;
513
+ resolve(Response.json({
514
+ transcript: fullTranscript.trim() || "Session closed.",
515
+ status: "closed",
516
+ }));
517
+ }
518
+ };
519
+
520
+ // Timeout
521
+ const timeout = setTimeout(() => {
522
+ if (!msgReceived) {
523
+ msgReceived = true;
524
+ try { ws.close(1000, "timeout"); } catch {}
525
+ resolve(Response.json({
526
+ transcript: fullTranscript.trim() || "No response received. The service may be busy.",
527
+ status: "timeout",
528
+ }));
529
+ }
530
+ }, 20000);
531
+ };
532
+
533
+ ws.onclose = () => {
534
+ if (!msgReceived) {
535
+ msgReceived = true;
536
+ clearTimeout(timeout);
537
+ resolve(Response.json({
538
+ transcript: "Connection closed.",
539
+ status: "closed",
540
+ }));
541
+ }
542
+ };
543
+ });
544
+ } catch (err: any) {
545
+ console.error("[/api/chat] Error:", err.message);
546
+ return Response.json({ error: "Chat service error: " + err.message }, { status: 500 });
547
+ }
548
+ }
549
+
550
+ const server = Bun.serve({
551
+ port: PORT,
552
+ routes: {
553
+ "/": { GET: serveProductOG },
554
+ "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
555
+ "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) },
556
+ "/api/vaix/products": { GET: async (req: Request) => {
557
+ try {
558
+ await loadVaixProducts();
559
+ if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 });
560
+ const url = new URL(req.url);
561
+ const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999);
562
+ const total = VAIX_PRODUCTS.length;
563
+ const formatted = VAIX_PRODUCTS.map((p, i) => formatProduct(p, i)).slice(0, Math.min(limit, total));
564
+ return Response.json({
565
+ products: formatted,
566
+ total, limit, page: 1, perPage: limit,
567
+ totalPages: 1,
568
+ });
569
+ } catch { return Response.json({ products: [], total: 0 }); }
570
+ }},
571
+ "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) },
572
+ "/api/vaix/search": { GET: async (req: Request) => {
573
+ const url = new URL(req.url);
574
+ const q = url.searchParams.get("q");
575
+ if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 });
576
+ try {
577
+ await loadVaixProducts();
578
+ if (!VAIX_PRODUCTS?.length) return Response.json({ results: [], aiAnswer: "No products." });
579
+ const qNorm = norm(q);
580
+ const terms = qNorm.split(/\s+/).filter(t => t.length > 1);
581
+ const results: Array<{ product: any; score: number }> = [];
582
+ for (let i = 0; i < VAIX_PRODUCTS.length; i++) {
583
+ const p = VAIX_PRODUCTS[i];
584
+ let score = 0;
585
+ const nm = norm(p.name || p.n || "");
586
+ const sk = norm(p.sku || p.mod || p.model || "");
587
+ const br = norm(p.brand || "");
588
+ const ca = norm(p.c || p.cat || "");
589
+ const de = norm(p.desc || "");
590
+ const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : "");
591
+ if (nm.includes(qNorm)) score += 20;
592
+ if (sk.includes(qNorm)) score += 15;
593
+ if (br.includes(qNorm)) score += 10;
594
+ if (ca.includes(qNorm)) score += 8;
595
+ for (const t of terms) {
596
+ if (nm.includes(t)) score += 3;
597
+ if (sk.includes(t)) score += 2;
598
+ if (br.includes(t)) score += 2;
599
+ if (ca.includes(t)) score += 1;
600
+ if (de.includes(t)) score += 1;
601
+ if (ft.includes(t)) score += 1;
602
+ }
603
+ if (score > 0) results.push({ product: formatProduct(p, i), score });
604
+ }
605
+ results.sort((a, b) => b.score - a.score);
606
+ return Response.json({ results: results.slice(0, 50), aiAnswer: results.length > 0 ? "Found " + results.length + " products." : "No products found." });
607
+ } catch (err: any) { return Response.json({ results: [], error: err.message }); }
608
+ }},
609
+ "/api/vaix/sync": { POST: async () => {
610
+ VAIX_PRODUCTS = null;
611
+ VAIX_LOADED = false;
612
+ VAIX_LOADING = false;
613
+ await loadVaixProducts();
614
+ return Response.json({ synced: true, count: VAIX_PRODUCTS?.length ?? 0 });
615
+ }},
616
+ "/api/wiki/search": { GET: async (req: Request) => {
617
+ const q = new URL(req.url).searchParams.get("q");
618
+ if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
619
+ try {
620
+ const r = await fetch("https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + encodeURIComponent(q) + "&format=json&srlimit=5&origin=*");
621
+ const d = await r.json();
622
+ return Response.json({ results: (d.query?.search ?? []).map((r: any) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })) });
623
+ } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
624
+ }},
625
+ "/api/wiki/summary": { GET: async (req: Request) => {
626
+ const t = new URL(req.url).searchParams.get("title");
627
+ if (!t) return Response.json({ error: "Missing ?title=" }, { status: 400 });
628
+ try {
629
+ const r = await fetch("https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(t) + "?origin=*");
630
+ const d = await r.json();
631
+ return Response.json({ title: d.title, extract: d.extract, url: d.content_urls?.desktop?.page });
632
+ } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
633
+ }},
634
+ "/api/web/search": { GET: async (req: Request) => {
635
+ const q = new URL(req.url).searchParams.get("q");
636
+ if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
637
+ try {
638
+ const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" } });
639
+ const h = await r.text();
640
+ const res: Array<{ title: string; snippet: string; url: string }> = [];
641
+ const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
642
+ const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
643
+ for (let i = 0; i < Math.min(lm.length, 5); i++) {
644
+ let href = lm[i][1];
645
+ const ru = href.match(/uddg=(https?%3[^&]+)/i);
646
+ if (ru) href = decodeURIComponent(ru[1]);
647
+ const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
648
+ const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
649
+ if (title) {
650
+ // Extract source from URL
651
+ let source = '';
652
+ try {
653
+ const urlObj = new URL(href);
654
+ source = urlObj.hostname.replace(/^www\./, '');
655
+ } catch (_) { source = ''; }
656
+ res.push({ title, snippet, url: href, source });
657
+ }
658
+ }
659
+ return Response.json({ results: res });
660
+ } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); }
661
+ }},
662
+ "/api/web/content": { GET: async (req: Request) => {
663
+ const u = new URL(req.url).searchParams.get("url");
664
+ if (!u) return Response.json({ error: "Missing ?url=" }, { status: 400 });
665
+ try {
666
+ const r = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(8000) });
667
+ const h = await r.text();
668
+ 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();
669
+ return Response.json({ content: t.slice(0, 3000), url: u });
670
+ } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); }
671
+ }},
672
+ "/api/session": { POST: sessionHandler },
673
+ "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler },
674
+ "/api/chat": { POST: textChatHandler },
675
+ "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!),
676
+ "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!),
677
+ "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!),
678
+ "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!),
679
+ "/src/:name": (req: Request) => srcFile("src", req.params.name!),
680
+ "/src/s2s/:name": (req: Request) => srcFile("src/s2s", req.params.name!),
681
+ "/api/news/hot": { GET: async () => {
682
+ try {
683
+ const resp = await fetch("https://news.google.com/rss?hl=vi-VN&gl=VN&ceid=VN:vi");
684
+ if (!resp.ok) throw new Error("RSS failed");
685
+ const rssText = await resp.text();
686
+ // Parse <item> blocks: title, link, source
687
+ const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
688
+ const titles: string[] = [];
689
+ const articles: Array<{ source: string; title: string; url: string }> = [];
690
+ for (const block of itemBlocks) {
691
+ const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
692
+ const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
693
+ const sm = block.match(/<source[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*url="([^"]*)"[^>]*>/i);
694
+ if (!tm) continue;
695
+ const title = tm[1].replace(/\+|_/g, " ").trim();
696
+ if (title) titles.push(title);
697
+ let url = "";
698
+ if (lm) {
699
+ url = (lm[1] || "").trim();
700
+ // Google News links are /articles/... redirects → keep them, they work in browser
701
+ }
702
+ let source = "";
703
+ if (sm) source = (sm[1] || sm[0] || "").replace(/<[^>]+>/g, "").trim();
704
+ if (title && url) {
705
+ articles.push({ source: source || "", title, url });
706
+ }
707
+ if (titles.length >= 12) break;
708
+ }
709
+ return Response.json({ titles: titles.slice(0, 12), articles });
710
+ } catch (e: any) {
711
+ return Response.json({ error: e.message, titles: [], articles: [] }, { status: 502 });
712
+ }
713
+ }},
714
+ "/api/news/articles": { GET: async (req: Request) => {
715
+ // Search Google News RSS for a specific topic and return SPECIFIC detailed articles
716
+ // with real clickable links (each card = one concrete news article, not a category page).
717
+ const url = new URL(req.url);
718
+ const q = (url.searchParams.get("q") || "").trim();
719
+ if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
720
+ try {
721
+ const resp = await fetch(
722
+ "https://news.google.com/rss/search?q=" + encodeURIComponent(q) +
723
+ "&hl=vi-VN&gl=VN&ceid=VN:vi",
724
+ { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" } }
725
+ );
726
+ if (!resp.ok) throw new Error("RSS failed " + resp.status);
727
+ const rssText = await resp.text();
728
+ const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || [];
729
+ const articles: Array<{ source: string; title: string; url: string; desc: string }> = [];
730
+ for (const block of itemBlocks) {
731
+ const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
732
+ const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i);
733
+ const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i);
734
+ const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
735
+ if (!tm) continue;
736
+ let title = tm[1].replace(/\+|_/g, " ").trim();
737
+ // Strip trailing "- SourceName" that Google appends to titles
738
+ let source = "";
739
+ if (sm) {
740
+ source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim();
741
+ if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim();
742
+ }
743
+ if (!title) continue;
744
+ let url = "";
745
+ if (lm) url = (lm[1] || "").trim();
746
+ let desc = "";
747
+ if (dm) {
748
+ // Remove all HTML tags (including malformed Google News CDATA tags)
749
+ desc = dm[1]
750
+ .replace(/<[^>]*>/g, " ")
751
+ .replace(/\bhttps?:\/\/\S+/gi, "")
752
+ .replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
753
+ .replace(/&nbsp;/g, " ")
754
+ .replace(/\s+/g, " ").trim().slice(0, 160);
755
+ }
756
+ if (title && url) {
757
+ articles.push({ source: source || "", title, url, desc });
758
+ }
759
+ if (articles.length >= 8) break;
760
+ }
761
+ return Response.json({ articles, query: q });
762
+ } catch (e: any) {
763
+ return Response.json({ error: e.message, articles: [] }, { status: 502 });
764
+ }
765
+ }},
766
+ "/api/news/images": { GET: async (req: Request) => {
767
+ // Return REAL per-article images for a topic using Vietnamese portal RSS feeds
768
+ // (VnExpress feeds include <enclosure>/<img> with real article thumbnails).
769
+ const url = new URL(req.url);
770
+ const q = (url.searchParams.get("q") || "").trim();
771
+ const topicNorm = q.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d");
772
+ const feeds: Array<{ cat: string; u: string }> = [
773
+ { cat: "tin-moi-nhat", u: "https://vnexpress.net/rss/tin-moi-nhat.rss" },
774
+ { cat: "thoi-su", u: "https://vnexpress.net/rss/thoi-su.rss" },
775
+ { cat: "the-thao", u: "https://vnexpress.net/rss/the-thao.rss" },
776
+ { cat: "kinh-doanh", u: "https://vnexpress.net/rss/kinh-doanh.rss" },
777
+ { cat: "giai-tri", u: "https://vnexpress.net/rss/giai-tri.rss" },
778
+ { cat: "the-gioi", u: "https://vnexpress.net/rss/the-gioi.rss" },
779
+ { cat: "doi-song", u: "https://vnexpress.net/rss/doi-song.rss" },
780
+ { cat: "suc-khoe", u: "https://vnexpress.net/rss/suc-khoe.rss" },
781
+ { cat: "giao-duc", u: "https://vnexpress.net/rss/giao-duc.rss" },
782
+ ];
783
+ const normStr = (s: string) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d");
784
+ const all: Array<{ title: string; url: string; image: string; source: string; desc: string }> = [];
785
+ try {
786
+ await Promise.all(feeds.map(async (f) => {
787
+ try {
788
+ const r = await fetch(f.u, { headers: { "User-Agent": "Mozilla/5.0" } });
789
+ if (!r.ok) return;
790
+ const t = await r.text();
791
+ const items = t.match(/<item>[\s\S]*?<\/item>/gi) || [];
792
+ for (const it of items) {
793
+ const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
794
+ const lm = it.match(/<link>(.*?)<\/link>/i);
795
+ const em = it.match(/<enclosure[^>]*url="([^"]+)/i);
796
+ const im = it.match(/<img[^>]+src="([^"]+)/i);
797
+ if (!tm || !lm) continue;
798
+ const title = tm[1].trim();
799
+ let image = (em && em[1]) ? em[1].replace(/&amp;/g, "&") : "";
800
+ if (!image && im) image = im[1].replace(/&amp;/g, "&");
801
+ if (!title || !image) continue;
802
+ const link = lm[1].trim();
803
+ const dm = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
804
+ let desc = "";
805
+ if (dm) desc = dm[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200);
806
+ // Filter by topic if a query was provided
807
+ if (q && !normStr(title + " " + link).includes(topicNorm)) continue;
808
+ all.push({ title, url: link, image, source: "VnExpress", desc });
809
+ }
810
+ } catch (_) {}
811
+ }));
812
+ } catch (_) {}
813
+
814
+ // If no exact topic match, fall back to the freshest topical items (always have images)
815
+ if (all.length === 0 && q) {
816
+ try {
817
+ const r = await fetch("https://vnexpress.net/rss/tin-moi-nhat.rss", { headers: { "User-Agent": "Mozilla/5.0" } });
818
+ const t = await r.text();
819
+ const items = t.match(/<item>[\s\S]*?<\/item>/gi) || [];
820
+ for (const it of items) {
821
+ const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i);
822
+ const lm = it.match(/<link>(.*?)<\/link>/i);
823
+ const em = it.match(/<enclosure[^>]*url="([^"]+)/i);
824
+ if (tm && lm) {
825
+ const image = em ? em[1].replace(/&amp;/g, "&") : "";
826
+ if (image && tm[1].trim()) {
827
+ const dm2 = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i);
828
+ let desc2 = "";
829
+ if (dm2) desc2 = dm2[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200);
830
+ all.push({ title: tm[1].trim(), url: lm[1].trim(), image, source: "VnExpress", desc: desc2 });
831
+ }
832
+ }
833
+ }
834
+ } catch (_) {}
835
+ }
836
+
837
+ return Response.json({ images: all.slice(0, 8), query: q });
838
+ }},
839
+ "/api/news/topic": { GET: async (req: Request) => {
840
+ // Search Vietnamese news for a specific topic and return concrete article cards with links.
841
+ const url = new URL(req.url);
842
+ const q = (url.searchParams.get("q") || "").trim();
843
+ if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
844
+ try {
845
+ const portalQueries = [
846
+ q + " site:vnexpress.net",
847
+ q + " site:tuoitre.vn",
848
+ q + " site:thanhnien.vn",
849
+ q + " site:cafef.vn",
850
+ q + " site:dantri.com.vn",
851
+ "tin tức " + q + " hôm nay",
852
+ ];
853
+ const allResults: Array<{ title: string; snippet: string; url: string; source: string }> = [];
854
+ await Promise.all(portalQueries.map(nq =>
855
+ fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(nq), { headers: { "User-Agent": "Mozilla/5.0" } })
856
+ .then(r => r.text())
857
+ .then(h => {
858
+ const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)];
859
+ const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)];
860
+ for (let i = 0; i < Math.min(lm.length, 4); i++) {
861
+ let href = lm[i][1];
862
+ const ru = href.match(/uddg=(https?%3[^&]+)/i);
863
+ if (ru) href = decodeURIComponent(ru[1]);
864
+ const title = lm[i][2].replace(/<[^>]+>/g, "").trim();
865
+ if (!title) continue;
866
+ const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : "";
867
+ let source = "";
868
+ try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {}
869
+ allResults.push({ title, snippet, url: href, source });
870
+ }
871
+ })
872
+ .catch(() => {})
873
+ ));
874
+ // Deduplicate + drop empty
875
+ const seen = new Set<string>();
876
+ const unique = allResults.filter(r => {
877
+ if (!r.url || seen.has(r.url)) return false;
878
+ seen.add(r.url);
879
+ return r.source && !/duckduckgo|google/i.test(r.source);
880
+ });
881
+ return Response.json({ results: unique.slice(0, 8), query: q });
882
+ } catch (e: any) {
883
+ return Response.json({ error: e.message, results: [] }, { status: 502 });
884
+ }
885
+ }},
886
+ "/api/logs/client": { POST: async (req: Request) => {
887
+ try {
888
+ const body = await req.json();
889
+ console.error("[ClientError]", body?.message ?? String(body), body?.details ? JSON.stringify(body) : "");
890
+ return Response.json({ ok: true });
891
+ } catch(e: any) { return Response.json({ error: e.message }, { status: 400 }); }
892
+ }},
893
+ "/api/vaix/json": async () => {
894
+ try {
895
+ const r = await fetch(JSON_URL);
896
+ if (!r.ok) throw new Error("HTTP "+r.status);
897
+ const data = await r.json();
898
+ // Only return top 1000 to reduce server memory
899
+ const prods = Array.isArray(data) ? data.slice(0, 1000) : [];
900
+ return new Response(JSON.stringify(prods), {
901
+ headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }
902
+ });
903
+ } catch(e) {
904
+ return Response.json({ error: "JSON fetch failed: " + e.message }, { status: 502 });
905
+ }
906
+ },
907
+ "/*": { GET: serveStatic },
908
+ },
909
+ });
910
+ console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none"));
index.ts.patched ADDED
@@ -0,0 +1,684 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "gemma-avatar",
3
+ "module": "index.ts",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "bun --hot ./index.ts",
8
+ "start": "bun ./index.ts"
9
+ },
10
+ "devDependencies": {
11
+ "@types/bun": "latest"
12
+ },
13
+ "peerDependencies": {
14
+ "typescript": "^5"
15
+ },
16
+ "dependencies": {
17
+ "@met4citizen/talkinghead": "^1.7.0",
18
+ "three": "~0.180.0"
19
+ },
20
+ "version": "1.5.1"
21
+ }
public/avatars/Ella.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c084db10674b11d030014cabea42cd52323ac4f279024c59470408668197d38
3
+ size 2926564
public/avatars/Lisa.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:24470df495fa6b848e842fb56d6ce56653db8bc7a0d10aff7bd6c1d0af57caea
3
+ size 3282620
public/avatars/avatarsdk.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:193c07c351f1f94b12b499ece3f8813432dbe902afe0b5dd6957e788a9a36605
3
+ size 12284040
public/avatars/avaturn.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f56e7a34e249597cff08ba7ef7ce597b61b66b455a21023f186e769caca0cdda
3
+ size 13823336
public/avatars/bruce_avaturn.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:db6821e81f7a6185cc9673d88b11875d8d57ccfe750ea1806926a0c305c5764d
3
+ size 13574312
public/avatars/brunette-t.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ca13a1e3ad8f57e50266294b823641680dd3f3f174eb4be908ddca7afc0c82a
3
+ size 2875236
public/avatars/brunette.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8864c504b5c11daa2f0037afffdc0815bb0fa2e6062e37a584746116a3c2f538
3
+ size 4721528
public/avatars/david.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2875f23a2da5960fd860b0a1106e309a027226367e6c48761868426d16502b8e
3
+ size 4785200
public/avatars/fabio_avaturn.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:39c64e808cc4bcee03665653b877205f2c38ba730a977a9d7ae50ce325305b71
3
+ size 13772844
public/avatars/grit-b6c6c4.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e128f8a7263b679c94701b67ed6b9379e639d251cdf5129a92daef0b7a16876
3
+ size 11247792
public/avatars/julia.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8864c504b5c11daa2f0037afffdc0815bb0fa2e6062e37a584746116a3c2f538
3
+ size 4721528
public/avatars/lisamy.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f
3
+ size 8320540
public/avatars/lisamy.jpg ADDED
public/avatars/model (6).glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f0333fe126ab2dac578f6ec69482a8a397706d849ac3f71919f37d77f2f5bdcd
3
+ size 14048464
public/avatars/mpfb.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:63c645a2a863b9972e9a9c2ed576a1de4c390b8475508e1473e69c87a3ee299c
3
+ size 36815920
public/avatars/ready_player_me_1.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:50ca75ebbf13b133e4eedfc3af8121ccf0f02bd2f469d1ef680b762b7ee87632
3
+ size 8810736
public/avatars/ready_player_me_2.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a30b59d6a0c1e23f214101d64a01092e09b3bfb9aafa9f399420685ec2486ff
3
+ size 9618120
public/avatars/vuong.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a
3
+ size 6964484
public/avatars/vuong.jpg ADDED
public/avatars/vuong2.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06
3
+ size 7137424
public/vendor/headaudio.min.mjs ADDED
@@ -0,0 +1 @@
 
 
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/vendor/headworklet.min.mjs ADDED
@@ -0,0 +1 @@
 
 
1
+ var t={d:(e,s)=>{for(var i in s)t.o(s,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:s[i]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=16e3,s=32,i=64,a=!0,n=.97,r=512,o=256,l=12,h=40,c=22,d=!1,u=!1,p=12,v=!0,m=1,f=15,g=14,b=0,M=2,w=8,y=p,k=56,F=78,A=368,E=92;class RingBuffer{constructor(t,e=void 0,s=!1){this.buf=Array.from({length:t},e),this.capacity=t,this.head=0,this.tail=0,this.count=s?t:0}add(t){const e=this.capacity;this.buf[this.tail]=t,this.tail=(this.tail+1)%e,this.count<e?this.count++:this.head=(this.head+1)%e}allocate(){const t=this.capacity,e=this.buf[this.tail];return this.tail=(this.tail+1)%t,this.count<t?this.count++:this.head=(this.head+1)%t,e}isAvailable(t){return this.count>=t}isFull(){return this.count===this.capacity}getHead(t=0){const e=this.capacity,s=(this.head+t%e+e)%e;return this.buf[s]}getTail(t=0){const e=this.capacity,s=(this.tail+(t-1)%e+e)%e;return this.buf[s]}getLatest(t,e=null){const s=t.length;if(s>this.count)return null;const i=this.capacity;this.head=(this.head+(this.count-s))%i;for(let e=0;e<s;e++){const s=(this.head+e)%i;t[e]=this.buf[s]}return null!==e&&e>=0&&e<=s&&(this.head=(this.head+e)%i,this.count=s-e),t}clear(t=0){this.count=t,this.tail=(this.head+t)%this.capacity}}class MFCC{constructor(t){const e=Object.assign({speakerMeanHz:150},t||{});this.cossin=this._buildCosSin(),this.bitrev=this._buildBitrev(),this.spectrum=new Float32Array(1024),this.powerSpec=new Float32Array(256),this.melFilters=Array.from({length:h},()=>new Float32Array(256)),this._buildMelFilterbank(e.speakerMeanHz),this.melEnergies=new Float32Array(h),this.hamming=this._buildWindowHamming(),this.dctMatrix=this._buildDCTMatrix(),this.lifter=this._buildLifter()}_buildCosSin(){const t=new Float32Array(1024),e=-2*Math.PI/r;for(let s=0;s<r;s++){const i=e*s;t[2*s]=Math.cos(i),t[2*s+1]=Math.sin(i)}return t}_buildBitrev(){const t=new Uint32Array(r),e=Math.log2(r);for(let s=0;s<r;s++){let i=0;for(let t=0;t<e;t++)i=i<<1|s>>t&1;t[s]=i}return t}_hammingAndFFT(t){const e=this.cossin,s=this.bitrev,i=this.hamming,a=this.spectrum;for(let e=0;e<r;e++){const n=s[e];a[2*n]=t[e]*i[e],a[2*n+1]=0}for(let t=1;t<r;t<<=1){const s=t<<1,i=r/s;for(let n=0;n<t;n++){const o=n*i*2,l=e[o],h=e[o+1];for(let e=n;e<r;e+=s){const s=e<<1,i=e+t<<1,n=a[s],r=a[s+1],o=a[i],c=a[i+1],d=o*l-c*h,u=o*h+c*l;a[s]=n+d,a[s+1]=r+u,a[i]=n-d,a[i+1]=r-u}}}}_buildWindowHamming(){return Float32Array.from({length:r},(t,e)=>.54-.46*Math.cos(2*Math.PI*e/511))}_buildMelFilterbank(t=150){const s=this.melFilters,i=150,a=Math.min(Math.max(t/i,.6),1.8),n=undefined,o=7800,l=t=>2595*Math.log10(1+t/700),c=t=>700*(Math.pow(10,t/2595)-1),d=l(30),u=undefined,p=l(o)-d,v=undefined,m=Float32Array.from({length:42},(t,e)=>{const s=undefined,i=undefined;return c(d+(d+p/41*e-d)*a)}).map(t=>Math.floor(t/e*r));for(let t=0;t<h;t++){const e=s[t];for(let s=m[t];s<m[t+1];s++)e[s]=(s-m[t])/(m[t+1]-m[t]);for(let s=m[t+1];s<m[t+2];s++)e[s]=(m[t+2]-s)/(m[t+2]-m[t+1])}}_buildDCTMatrix(){const t=[],e=Math.sqrt(.05);for(let s=0;s<=l;s++){const i=new Float32Array(h);for(let t=0;t<h;t++)i[t]=e*Math.cos(Math.PI*s*(t+.5)/h);t.push(i)}return t}_buildLifter(){const t=new Float32Array(l);for(let e=1;e<=l;e++)t[e-1]=1+11*Math.sin(Math.PI*e/c);return t}update(t){for(const e in t)if("speakerMeanHz"===e){const s=t[e];this._buildMelFilterbank(s)}}compute(t,e){const s=256;this._hammingAndFFT(t);const i=this.spectrum,a=this.powerSpec;let n=0;for(let t=0;t<s;t++){const e=i[2*t],s=i[2*t+1];a[t]=(e*e+s*s)/r,n+=a[t]}const o=Math.log10(n+1e-10),c=this.melFilters,d=this.melEnergies;for(let t=0;t<h;t++){const e=c[t],s=e.length;let i=0;for(let t=0;t<s;t++)i+=a[t]*e[t];d[t]=Math.log10(i+1e-10)}const u=this.dctMatrix,p=e||new Float32Array(l),v=this.lifter;for(let t=0;t<l;t++){const e=u[t+1];let s=0;for(let t=0;t<h;t++)s+=e[t]*d[t];p[t]=s*v[t]}return{le:o,mfcc:p}}}class VadGate{constructor(t){const e=Object.assign({vadGateActiveDb:-40,vadGateActiveMs:10,vadGateInactiveDb:-50,vadGateInactiveMs:10},t||{});this.activeLE=e.vadGateActiveDb/10,this.activeFrames=Math.max(1,Math.round(62.5*e.vadGateActiveMs/1e3)),this.inactiveLE=e.vadGateInactiveDb/10,this.inactiveFrames=Math.max(1,Math.round(62.5*e.vadGateInactiveMs/1e3)),this.active=0,this.inactive=1,this.preActive=0,this.preInactive=0}update(t){for(const e in t){const s=t[e];switch(e){case"vadGateActiveDb":this.activeLE=s/10;break;case"vadGateActiveMs":this.activeFrames=62.5*s/1e3;break;case"vadGateInactiveDb":this.inactiveLE=s/10;break;case"vadGateInactiveMs":this.inactiveFrames=62.5*s/1e3}}}process(t){return this.active?(this.active++,t<this.inactiveLE?(this.preInactive++,this.preInactive>=this.inactiveFrames&&(this.active=0,this.inactive=1,this.preActive=0)):this.preInactive>0&&this.preInactive--):(this.inactive++,t>this.activeLE?(this.preActive++,this.preActive>=this.activeFrames&&(this.active=1,this.inactive=0,this.preInactive=0)):this.preActive>0&&this.preActive--),{active:this.active,inactive:this.inactive}}}class Classifier{constructor(t){const e=Object.assign({silSensitivity:1.2},t||{});this.prototypes=[],this.prototypesN=0,this.vectorDiff=new Float32Array(p),this.indexSigmaInvLower=this._buildSigmaInvLowerLookup(),this.ringPredictions=new RingBuffer(6,()=>g,!0),this.counts=Array(f).fill(0),this.predictionLast=g,this.silSensitivity=e.silSensitivity}_buildSigmaInvLowerLookup(){const t=Array.from({length:p},()=>new Uint16Array(p));let e=0;for(let s=0;s<p;s++)for(let i=0;i<=s;i++)t[s][i]=e,t[i][s]=e,e++;return t}clear(){this.prototypes=[],this.prototypesN=0}import(t){t?.reset&&this.clear();const e=t?.model,s=e?.length||0,i=[];for(let t=0;t<s;t++){const s=e[t];s.hasOwnProperty("group")&&i.push(s.group)}i.length&&(this.prototypes=this.prototypes.filter(t=>!i.includes(t.group)));for(let t=0;t<s;t++){const s=e[t];this.prototypes.push(s)}this.prototypesN=this.prototypes.length,this.distances=Array(this.prototypesN).fill(1/0)}update(t){for(const e in t)if("silSentivity"===e){const s=t[e];this.silSensitivity=s}}distanceMahalanobis(t,e,s){let i=0;const a=this.indexSigmaInvLower,n=this.vectorDiff;for(let s=0;s<p;++s)n[s]=t[s]-e[s];for(let t=0;t<p;++t){const e=a[t][t],r=n[t];let o=s[e]*r*r;for(let e=0;e<t;++e){const i=undefined;o+=2*s[a[t][e]]*r*n[e]}i+=o}return i}predict(t){const e=this.prototypesN;if(0===e)return{viseme:null,distances:[]};const s=this.prototypes,i=this.distances;let a=1/0,n=0;for(let r=0;r<e;r++){const e=s[r];let o=this.distanceMahalanobis(t,e.mu,e.sigmaInvLower);e.viseme===g&&(o/=this.silSensitivity),i[r]=o,o<=a&&(a=o,n=r)}const r=this.ringPredictions,o=s[n].viseme;r.add(o);const l=r.capacity,h=this.counts.fill(0);for(let t=0;t<l;t++)h[r.buf[t]]++;let c=0,d=0;for(let t=0;t<f;t++){const e=h[t];e>=d&&(c=t,d=e)}return c===this.predictionLast&&c===g?c=null:this.predictionLast=c,{viseme:c,distances:i}}}class Processor{constructor(t,i=null){const a=Object.assign({vadEventsEnabled:!1,frameEventsEnabled:!1,featureEventsEnabled:!1,visemeEventsEnabled:!1},t?.processorOptions||{}),n=Object.assign({vadMode:1,vadGateActiveDb:-40,vadGateActiveMs:10,vadGateInactiveDb:-50,vadGateInactiveMs:10,silMode:1,silCalibrationWindowSec:3,silSensitivity:1.2},t?.parameterData||{});this.worklet=i||{port:{onmessage:null,postMessage:t=>{}}},this.sampleRate=t.sampleRate,this.samplesN=t.samplesN||r,this.samplesHop=t.samplesHop||o,this.preemphasisPrevValue=0,this.downsampleRatio=this.sampleRate/e,this.downsamplePhaseAccumulator=0,this.downsamplePolyFilter=this.designPolyphaseFilter(),this.ringPolyFilter=new RingBuffer(s,()=>0,!0),this.ringSamples=new RingBuffer(this.samplesN,()=>0),this.block=new Float32Array(this.samplesN),this.mfcc=new MFCC(t),this.ringVectors=new RingBuffer(3,()=>new Float32Array(p),!0),this.vectorDuration=this.samplesN/e,this.vadMode=n.vadMode,this.vadGate=new VadGate(n),this.isRunning=!0,this.isSpeaking=!1,this.silMode=n.silMode;const l=Math.max(100,Math.floor(n.silCalibrationWindowSec*o/e));this.ringCalibration=new RingBuffer(l),this.isCalibrationRunning=!1,this.frameEventsEnabled=!!a.frameEventsEnabled,this.vadEventsEnabled=!!a.vadEventsEnabled,this.featureEventsEnabled=!!a.featureEventsEnabled,this.visemeEventsEnabled=!!a.visemeEventsEnabled,this.frameEventsEnabled&&(this.ringFrame=new RingBuffer(128)),this.sampleCount=0,this.classifier=new Classifier(n)}_onmessage(t){const s=t.data;switch(s.event){case"stop":if(this.isRunning=!1,this.isSpeaking){this.isSpeaking=!1;const t=this.sampleCount/e;this.worklet.port.postMessage({event:"end",t})}this.ringVectors.clear();break;case"start":this.isRunning=!0;break;case"calibrate":this.ringCalibration.clear(),this.isCalibrationRunning=!0;break;case"model":this.classifier.import(s);break;case"reset":this.ringSamples.clear(),this.ringVectors.clear(),this.sampleCount=0;break;default:console.warn("Processor received an unknown message: '"+s.event+"'.")}}designPolyphaseFilter(){const t=[],e=15.5,a=.45;for(let n=0;n<i;n++){const r=n/i,o=new Float32Array(s);for(let t=0;t<s;t++){const s=t-e-r;o[t]=0===s?.9:Math.sin(2*Math.PI*a*s)/(Math.PI*s),o[t]*=.54-.46*Math.cos(2*Math.PI*t/31)}t.push(o)}return t}update(t){for(const s in t){const i=t[s];switch(s){case"vadMode":this.vadMode=i;break;case"silMode":this.silMode=i;break;case"silSensitivity":this.silSensitivity=i;break;case"silCalibrationWindowSec":const t=Math.max(100,Math.floor(i*o/e));this.ringCalibration=new RingBuffer(t);break;case"speakerMeanHz":this.mfcc.update({speakerMeanHz:i});break;case"timerReset":this.sampleCount=0;break;default:if(s.startsWith("vadGate")){const t={};t[s]=i,this.vadGate.update(t)}}}}process(t){const a=t.length;if(0!==a){this.frameEventsEnabled&&this.ringFrame.clear();for(let r=0;r<a;r++){let a=t[r];{const t=a;a-=n*this.preemphasisPrevValue,this.preemphasisPrevValue=t}for(this.ringPolyFilter.add(a),this.downsamplePhaseAccumulator+=1/this.downsampleRatio;this.downsamplePhaseAccumulator>=1;){const t=(this.downsamplePhaseAccumulator-1)*i,a=Math.floor(t),n=this.downsamplePolyFilter[a];let r=0;const o=this.ringPolyFilter;for(let t=0;t<s;t++)r+=o.getHead(t)*n[t];if(this.downsamplePhaseAccumulator-=1,this.frameEventsEnabled&&this.ringFrame.add(r),this.ringSamples.add(r),this.sampleCount++,this.isRunning&&this.ringSamples.isFull()){const t=this.sampleCount/e,s=this.block;this.ringSamples.getLatest(s,this.samplesHop);const i=this.ringVectors.allocate(),{le:a}=this.mfcc.compute(s,i);{const t=1;for(let e=0;e<l;++e){const s=i[e]*t;i[e]=1*Math.tanh(s)}}if(this.isCalibrationRunning&&1===this.silMode){const t=i.slice(),e=this.ringCalibration;e.add(t),e.isFull()&&(this.worklet.port.postMessage({event:"calibrated",vs:e.buf}),this.isCalibrationRunning=!1)}if(1===this.vadMode){const{active:e,inactive:s}=this.vadGate.process(a);if(this.vadEventsEnabled&&this.worklet.port.postMessage({event:"vad",active:e,inactive:s,db:10*a,t}),s){this.isSpeaking&&(this.isSpeaking=!1,this.worklet.port.postMessage({event:"ended",t}));continue}}this.featureEventsEnabled&&this.worklet.port.postMessage({event:"feature",vector:i,le:a,t:t-this.vectorDuration/2,d:this.vectorDuration});const{viseme:n,distances:r}=this.classifier.predict(i);if(this.isSpeaking?n===g&&(this.isSpeaking=!1,this.worklet.port.postMessage({event:"ended",t})):null!==n&&n!==g&&(this.isSpeaking=!0,this.worklet.port.postMessage({event:"started",t})),this.isSpeaking){let e={event:"viseme",viseme:n,t:t-this.vectorDuration/2,d:this.vectorDuration};this.visemeEventsEnabled&&(e.vector=i,e.distances=r),this.worklet.port.postMessage(e)}}}}if(this.frameEventsEnabled){const t=this.sampleCount/e,s=new Float32Array(this.ringFrame.count);this.ringFrame.getLatest(s),this.worklet.port.postMessage({event:"frame",frame:s,t})}}}}class HeadWorklet extends AudioWorkletProcessor{constructor(t){super(t),(t=t||{}).sampleRate=sampleRate,this.detectors={};for(const e of HeadWorklet.parameterDescriptors){const s=e.name,i=e.value;this.detectors[s]=i,t[s]=i}this.processor=new Processor(t,this),this.port.onmessage=this.processor._onmessage.bind(this.processor)}process(t,e,s){const i=t[0];if(!i)return!0;const a=i[0];if(!a)return!0;const n=undefined;if(!a.length)return!0;const r=this.detectors;let o=!1;const l={};for(const t in s){const e=undefined,i=s[t][0],a=undefined;i!==r[t]&&(o=!0,r[t]=l[t]=i)}return o&&this.processor.update(l),this.processor.process(a),!0}static get parameterDescriptors(){return[{name:"timerReset",defaultValue:-9999,automationRate:"k-rate"},{name:"vadMode",defaultValue:1,minValue:0,maxValue:1,automationRate:"k-rate"},{name:"vadGateActiveDb",defaultValue:-40,minValue:-100,maxValue:0,automationRate:"k-rate"},{name:"vadGateActiveMs",defaultValue:10,minValue:0,maxValue:1e3,automationRate:"k-rate"},{name:"vadGateInactiveDb",defaultValue:-50,minValue:-100,maxValue:0,automationRate:"k-rate"},{name:"vadGateInactiveMs",defaultValue:10,minValue:0,maxValue:1e3,automationRate:"k-rate"},{name:"silMode",defaultValue:1,minValue:0,maxValue:2,automationRate:"k-rate"},{name:"silCalibrationWindowSec",defaultValue:3,minValue:.1,maxValue:10,automationRate:"k-rate"},{name:"silSensitivity",defaultValue:1.2,minValue:0,maxValue:4,automationRate:"k-rate"},{name:"speakerMeanHz",defaultValue:150,minValue:50,maxValue:500,automationRate:"k-rate"}]}}registerProcessor("headworklet",HeadWorklet);export{HeadWorklet};
public/vendor/model-en-mixed.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0358f68989b5861f9b7d18871b010fa6cbf88a53bda4954a954d8c548bbcf251
3
+ size 14352
public/worklets/audio-playback.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * AudioWorkletProcessor that plays back Float32 mono samples received from
4
+ * the main thread, upsampling whatever incoming rate the server uses
5
+ * (typically 24 kHz PCM16) to the AudioContext rate (typically 48 kHz).
6
+ *
7
+ * Lifecycle / messaging:
8
+ *
9
+ * main -> worklet:
10
+ * { kind: "config", inputRate: 24000 } one-shot at startup
11
+ * { kind: "audio", samples: Float32Array } (transferable) per chunk
12
+ * { kind: "clear" } wipe queue (barge-in)
13
+ *
14
+ * worklet -> main:
15
+ * { kind: "stats", queuedMs, played } every ~250 ms
16
+ * { kind: "underrun" } every time the queue
17
+ * runs dry mid-playback
18
+ *
19
+ * Underrun strategy: output silence. We do NOT hold the last sample (that
20
+ * tends to produce audible clicks/buzzes when long gaps appear between
21
+ * TTS chunks). A short ramp-out + ramp-in at boundaries would be nicer but
22
+ * the server's 30 ms cadence makes underruns visible only at end of turn.
23
+ */
24
+
25
+ const STATS_INTERVAL_FRAMES = 12000;
26
+ const FADE_FRAMES = 32;
27
+
28
+ class AudioPlaybackProcessor extends AudioWorkletProcessor {
29
+ constructor() {
30
+ super();
31
+ this._inputRate = 24000;
32
+ this._stepRatio = this._inputRate / sampleRate;
33
+ this._queue = [];
34
+ this._readIdx = 0;
35
+ this._fracPos = 0;
36
+ this._playing = false;
37
+ this._framesSinceStats = 0;
38
+ this._totalPlayed = 0;
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;
46
+ if (!data || typeof data !== "object") return;
47
+ switch (data.kind) {
48
+ case "config":
49
+ if (typeof data.inputRate === "number" && data.inputRate > 0) {
50
+ this._inputRate = data.inputRate;
51
+ this._stepRatio = this._inputRate / sampleRate;
52
+ }
53
+ break;
54
+ case "audio":
55
+ if (data.samples instanceof Float32Array && data.samples.length > 0) {
56
+ this._queue.push(data.samples);
57
+ if (!this._playing) {
58
+ this._playing = true;
59
+ this._fadeIn = FADE_FRAMES;
60
+ this._fadeOut = 0;
61
+ }
62
+ }
63
+ break;
64
+ case "clear":
65
+ this._queue.length = 0;
66
+ this._readIdx = 0;
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
+ }
79
+
80
+ _queuedSamples() {
81
+ let total = -this._readIdx;
82
+ for (const buf of this._queue) total += buf.length;
83
+ return Math.max(0, total);
84
+ }
85
+
86
+ /** Linear-interp read at the current fractional position. */
87
+ _readInterpolated() {
88
+ if (this._queue.length === 0) return null;
89
+ const head = this._queue[0];
90
+ const idx = this._readIdx;
91
+ const frac = this._fracPos;
92
+
93
+ let a = head[idx];
94
+ let b;
95
+ if (idx + 1 < head.length) {
96
+ b = head[idx + 1];
97
+ } else if (this._queue.length > 1) {
98
+ b = this._queue[1][0];
99
+ } else {
100
+ b = a;
101
+ }
102
+ return a + (b - a) * frac;
103
+ }
104
+
105
+ /** Advance the read position by `stepRatio`; pop consumed buffers. */
106
+ _advance() {
107
+ this._fracPos += this._stepRatio;
108
+ while (this._fracPos >= 1) {
109
+ this._fracPos -= 1;
110
+ this._readIdx += 1;
111
+ }
112
+ while (this._queue.length > 0 && this._readIdx >= this._queue[0].length) {
113
+ this._readIdx -= this._queue[0].length;
114
+ this._queue.shift();
115
+ }
116
+ }
117
+
118
+ process(_, outputs) {
119
+ const channels = outputs[0];
120
+ if (!channels || channels.length === 0) return true;
121
+ const out = channels[0];
122
+ const stereo = channels.length > 1 ? channels[1] : null;
123
+
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.
132
+ sample = this._lastSample * Math.max(0, 1 - 1 / FADE_FRAMES);
133
+ this._lastSample = sample;
134
+ if (Math.abs(sample) < 1e-4) {
135
+ this._playing = false;
136
+ this._lastSample = 0;
137
+ this.port.postMessage({ kind: "underrun" });
138
+ }
139
+ } else {
140
+ sample = v;
141
+ this._lastSample = v;
142
+ this._advance();
143
+ }
144
+
145
+ if (this._fadeIn > 0) {
146
+ const gain = 1 - this._fadeIn / FADE_FRAMES;
147
+ sample *= gain;
148
+ this._fadeIn -= 1;
149
+ }
150
+ if (this._fadeOut > 0) {
151
+ const gain = this._fadeOut / FADE_FRAMES;
152
+ sample *= gain;
153
+ this._fadeOut -= 1;
154
+ if (this._fadeOut === 0) {
155
+ this._playing = false;
156
+ this._lastSample = 0;
157
+ }
158
+ }
159
+
160
+ this._totalPlayed += 1;
161
+ }
162
+
163
+ out[i] = sample;
164
+ if (stereo) stereo[i] = sample;
165
+ }
166
+
167
+ this._framesSinceStats += out.length;
168
+ if (this._framesSinceStats >= STATS_INTERVAL_FRAMES) {
169
+ this._framesSinceStats = 0;
170
+ const queuedSamples = this._queuedSamples();
171
+ const queuedMs = (queuedSamples / this._inputRate) * 1000;
172
+ this.port.postMessage({ kind: "stats", queuedMs, played: this._totalPlayed });
173
+ }
174
+
175
+ return true;
176
+ }
177
+ }
178
+
179
+ registerProcessor("audio-playback", AudioPlaybackProcessor);
public/worklets/mic-capture.js ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz)
4
+ * down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it
5
+ * back to the main thread in fixed-size chunks.
6
+ *
7
+ * The Hugging Face speech-to-speech WebSocket route expects the
8
+ * `input_audio_buffer.append` payload at 16 kHz PCM16 mono.
9
+ *
10
+ * Design notes:
11
+ * - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a
12
+ * cheap low-pass before decimating. Good enough for voice STT; we lose
13
+ * a tiny bit of >8 kHz content which the pipeline discards anyway.
14
+ * - Output frames are emitted at the cadence dictated by `chunkMs`
15
+ * (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime
16
+ * server batches incoming audio so the cadence is flexible; 20-100 ms
17
+ * is the sweet spot.
18
+ * - Float -> Int16 saturates to [-1, 1] before scaling.
19
+ * - Optional noise gate: per-chunk RMS decides open/closed against a
20
+ * threshold; the gain ramps (fast attack, hold, slow release) so word
21
+ * onsets aren't clipped and quiet tails don't click. The gate only
22
+ * affects the audio we SEND; the main-thread visualiser taps the raw
23
+ * mic separately. We post the chunk RMS up every frame so the Settings
24
+ * mic meter can show the live level against the threshold.
25
+ */
26
+
27
+ const TARGET_RATE = 16000;
28
+ const DEFAULT_CHUNK_MS = 40;
29
+ // Gate envelope timing (fixed; only the threshold is user-tunable).
30
+ const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive
31
+ const GATE_HOLD_MS = 250; // stay open this long after the level drops back under
32
+ const GATE_RELEASE_MS = 80; // then fade closed over this long (no click)
33
+
34
+ class MicCaptureProcessor extends AudioWorkletProcessor {
35
+ constructor(options) {
36
+ super();
37
+ const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS;
38
+ this._inputRate = sampleRate;
39
+ this._ratio = this._inputRate / TARGET_RATE;
40
+ this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000);
41
+ this._scratch = new Float32Array(0);
42
+ this._decimated = new Float32Array(this._chunkSamples16k);
43
+ this._enabled = true;
44
+
45
+ // Noise gate state. Disabled by default (pure passthrough).
46
+ this._gateEnabled = false;
47
+ this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open
48
+ this._gateGain = 1; // smoothed gain currently applied
49
+ this._holdRemaining = 0; // samples left before the gate may start closing
50
+ this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE));
51
+ this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE));
52
+ this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE);
53
+
54
+ this.port.onmessage = (e) => {
55
+ const data = e.data;
56
+ if (data?.kind === "enable") this._enabled = !!data.value;
57
+ else if (data?.kind === "gate") {
58
+ this._gateEnabled = !!data.enabled;
59
+ // dB -> linear amplitude. When off, threshold 0 keeps the gate open.
60
+ this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0;
61
+ }
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Append `incoming` to the internal scratch buffer, then emit as many
67
+ * full output chunks as we have material for.
68
+ * @param {Float32Array} incoming
69
+ */
70
+ _ingest(incoming) {
71
+ if (incoming.length === 0) return;
72
+ const next = new Float32Array(this._scratch.length + incoming.length);
73
+ next.set(this._scratch, 0);
74
+ next.set(incoming, this._scratch.length);
75
+ this._scratch = next;
76
+ this._maybeEmit();
77
+ }
78
+
79
+ _maybeEmit() {
80
+ const r = this._ratio;
81
+ const n = this._chunkSamples16k;
82
+ const needIn = Math.ceil(n * r);
83
+ const dec = this._decimated;
84
+ while (this._scratch.length >= needIn) {
85
+ // 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter.
86
+ let sumSq = 0;
87
+ if (Math.abs(r - 3) < 1e-6) {
88
+ // 48 kHz -> 16 kHz fast path with boxcar lowpass.
89
+ for (let i = 0; i < n; i++) {
90
+ const idx = i * 3;
91
+ const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3;
92
+ dec[i] = s;
93
+ sumSq += s * s;
94
+ }
95
+ } else {
96
+ // Generic path: linear interpolation. Slower but works at any rate
97
+ // (e.g. some Windows boxes report sampleRate=44100).
98
+ for (let i = 0; i < n; i++) {
99
+ const srcPos = i * r;
100
+ const idx = Math.floor(srcPos);
101
+ const frac = srcPos - idx;
102
+ const a = this._scratch[idx];
103
+ const b = this._scratch[idx + 1] ?? a;
104
+ const s = a + (b - a) * frac;
105
+ dec[i] = s;
106
+ sumSq += s * s;
107
+ }
108
+ }
109
+ const rms = Math.sqrt(sumSq / n);
110
+
111
+ // 2. Decide the gate target for this chunk, then ramp sample-by-sample.
112
+ let target = 1;
113
+ if (this._gateEnabled) {
114
+ if (rms >= this._thresholdLin) {
115
+ this._holdRemaining = this._holdSamples; // re-arm the hold
116
+ } else if (this._holdRemaining > 0) {
117
+ this._holdRemaining -= n; // coasting through the hold window
118
+ } else {
119
+ target = 0;
120
+ }
121
+ }
122
+
123
+ // 3. Apply the (smoothed) gain and pack to Int16.
124
+ const out = new Int16Array(n);
125
+ let gain = this._gateGain;
126
+ for (let i = 0; i < n; i++) {
127
+ const coef = target > gain ? this._attackCoef : this._releaseCoef;
128
+ gain = target + (gain - target) * coef;
129
+ const s = dec[i] * gain;
130
+ const clamped = s < -1 ? -1 : s > 1 ? 1 : s;
131
+ out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff;
132
+ }
133
+ this._gateGain = gain;
134
+
135
+ // Shift the scratch buffer to keep only the trailing unused samples.
136
+ const consumed = Math.floor(n * r);
137
+ this._scratch = this._scratch.slice(consumed);
138
+
139
+ // Live input level for the Settings meter (raw RMS, pre-gate).
140
+ this.port.postMessage({ kind: "level", rms });
141
+
142
+ if (this._enabled) {
143
+ this.port.postMessage(out.buffer, [out.buffer]);
144
+ }
145
+ // When disabled (mic muted) we silently consume input so the worklet
146
+ // stays alive and the buffer never grows unbounded.
147
+ }
148
+ }
149
+
150
+ process(inputs) {
151
+ const input = inputs[0];
152
+ if (!input || input.length === 0 || !input[0]) return true;
153
+ const mono = input[0];
154
+ if (mono.length > 0) this._ingest(mono);
155
+ return true;
156
+ }
157
+ }
158
+
159
+ registerProcessor("mic-capture", MicCaptureProcessor);
san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>MI593ES | Malloca | Bếp từ 3 vùng nấu 59cm | V.AI STUDIO</title>
6
+ <meta property="og:title" content="MI593ES | Malloca | Bếp từ 3 vùng nấu 59cm | V.AI STUDIO">
7
+ <meta property="og:description" content="Giá: 23,000,000đ - Thông số kỹ thuật
8
+ Thông Tin Chung
9
+ Loại sản phẩm:
10
+ Bếp từ
11
+ Lắp đặt:
12
+ Âm
13
+ Nguồn năng lượng:
14
+ Điện
15
+ Tần số (Hz):
16
+ 50/60
17
+ Nguồn điện (V):
18
+ 220-240
19
+ Tổng công suất (W):
20
+ 6600
21
+ Thiết Kế
22
+ Dạng điều khiển:
23
+ Cảm ứng
24
+ Chất liệu bề mặt:
25
+ Mặt kính EuroKera Onyka
26
+ Điều Khiê">
27
+ <meta property="og:image" content="https://bizweb.dktcdn.net/100/567/847/products/5072d9b8-c862-4932-a36e-bee43dd2726e-1fd05cf4-1168-41a1-8b5f-40cf6d1dc15b.png?v=1785210349263">
28
+ <meta property="product:price:amount" content="23000000">
29
+ <meta property="product:price:currency" content="VND">
30
+ <meta name="twitter:card" content="summary_large_image">
31
+ <meta name="twitter:title" content="MI593ES | Malloca | Bếp từ 3 vùng nấu 59cm | V.AI STUDIO">
32
+ <meta name="twitter:description" content="Giá: 23,000,000đ - Thông số kỹ thuật
33
+ Thông Tin Chung
34
+ Loại sản phẩm:
35
+ Bếp từ
36
+ Lắp đặt:
37
+ Âm
38
+ Nguồn năng lượng:
39
+ Điện
40
+ Tần số (Hz):
41
+ 50/60
42
+ Nguồn điện (V):
43
+ 220-240
44
+ Tổng công suất (W):
45
+ 6600
46
+ Thiết Kế
47
+ Dạng điều khiển:
48
+ Cảm ứng
49
+ Chất liệu bề mặt:
50
+ Mặt kính EuroKera Onyka
51
+ Điều Khiê">
52
+ <meta name="twitter:image" content="https://bizweb.dktcdn.net/100/567/847/products/5072d9b8-c862-4932-a36e-bee43dd2726e-1fd05cf4-1168-41a1-8b5f-40cf6d1dc15b.png?v=1785210349263">
53
+ <script>location.replace('/?product=mi593es-malloca-bep-tu-3-vung-nau-59cm')</script>
54
+ </head>
55
+ <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
56
+ </html>
san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg ADDED

Git LFS Details

  • SHA256: 06d583e5543e95843dda84249ff7a73c7e849345453a77b429536fca1d307858
  • Pointer size: 131 Bytes
  • Size of remote file: 568 kB
san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>MI73TWES | Malloca | Bếp từ 2 vùng nấu 73cm EcoKitchen | V.AI STUDIO</title>
6
+ <meta property="og:title" content="MI73TWES | Malloca | Bếp từ 2 vùng nấu 73cm EcoKitchen | V.AI STUDIO">
7
+ <meta property="og:description" content="Giá: 9,850,000đ - Thông số kỹ thuật
8
+ Thông Tin Chung
9
+ Loại sản phẩm:
10
+ Bếp từ
11
+ Lắp đặt:
12
+ Âm
13
+ Nguồn năng lượng:
14
+ Điện
15
+ Tần số (Hz):
16
+ 50
17
+ Nguồn điện (V):
18
+ 220-240
19
+ Tổng công suất (W):
20
+ 5000
21
+ Thiết Kế
22
+ Dạng điều khiển:
23
+ Cảm ứng trượt
24
+ Màu sắc:
25
+ Đen
26
+ Chất liệu bề mặt:
27
+ Kính DuraGlass
28
+ Điều Kh">
29
+ <meta property="og:image" content="https://bizweb.dktcdn.net/100/567/847/products/mih73twes.jpg?v=1785124677980">
30
+ <meta property="product:price:amount" content="9850000">
31
+ <meta property="product:price:currency" content="VND">
32
+ <meta name="twitter:card" content="summary_large_image">
33
+ <meta name="twitter:title" content="MI73TWES | Malloca | Bếp từ 2 vùng nấu 73cm EcoKitchen | V.AI STUDIO">
34
+ <meta name="twitter:description" content="Giá: 9,850,000đ - Thông số kỹ thuật
35
+ Thông Tin Chung
36
+ Loại sản phẩm:
37
+ Bếp từ
38
+ Lắp đặt:
39
+ Âm
40
+ Nguồn năng lượng:
41
+ Điện
42
+ Tần số (Hz):
43
+ 50
44
+ Nguồn điện (V):
45
+ 220-240
46
+ Tổng công suất (W):
47
+ 5000
48
+ Thiết Kế
49
+ Dạng điều khiển:
50
+ Cảm ứng trượt
51
+ Màu sắc:
52
+ Đen
53
+ Chất liệu bề mặt:
54
+ Kính DuraGlass
55
+ Điều Kh">
56
+ <meta name="twitter:image" content="https://bizweb.dktcdn.net/100/567/847/products/mih73twes.jpg?v=1785124677980">
57
+ <script>location.replace('/?product=mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen')</script>
58
+ </head>
59
+ <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
60
+ </html>
san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg ADDED
san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>MI7502ES | Malloca | Bếp từ 2 vùng nấu 75cm | V.AI STUDIO</title>
6
+ <meta property="og:title" content="MI7502ES | Malloca | Bếp từ 2 vùng nấu 75cm | V.AI STUDIO">
7
+ <meta property="og:description" content="Giá: 22,000,000đ - Thông số kỹ thuật
8
+ Thông Tin Chung
9
+ Loại sản phẩm:
10
+ Bếp từ
11
+ Lắp đặt:
12
+ Âm
13
+ Nguồn năng lượng:
14
+ Điện
15
+ Tần số (Hz):
16
+ 50/60
17
+ Nguồn điện (V):
18
+ 220-240
19
+ Tổng công suất (W):
20
+ 3600
21
+ Thiết Kế
22
+ Dạng điều khiển:
23
+ Cảm ứng
24
+ Chất liệu bề mặt:
25
+ Mặt kính EuroKera Onyka
26
+ Điều Khiê">
27
+ <meta property="og:image" content="https://bizweb.dktcdn.net/100/567/847/products/226e8f7d-87fe-4993-99e5-893c5ab9b30f-eb3b31b7-acaa-4566-b03e-df8afeb18c46.png?v=1784948288763">
28
+ <meta property="product:price:amount" content="22000000">
29
+ <meta property="product:price:currency" content="VND">
30
+ <meta name="twitter:card" content="summary_large_image">
31
+ <meta name="twitter:title" content="MI7502ES | Malloca | Bếp từ 2 vùng nấu 75cm | V.AI STUDIO">
32
+ <meta name="twitter:description" content="Giá: 22,000,000đ - Thông số kỹ thuật
33
+ Thông Tin Chung
34
+ Loại sản phẩm:
35
+ Bếp từ
36
+ Lắp đặt:
37
+ Âm
38
+ Nguồn năng lượng:
39
+ Điện
40
+ Tần số (Hz):
41
+ 50/60
42
+ Nguồn điện (V):
43
+ 220-240
44
+ Tổng công suất (W):
45
+ 3600
46
+ Thiết Kế
47
+ Dạng điều khiển:
48
+ Cảm ứng
49
+ Chất liệu bề mặt:
50
+ Mặt kính EuroKera Onyka
51
+ Điều Khiê">
52
+ <meta name="twitter:image" content="https://bizweb.dktcdn.net/100/567/847/products/226e8f7d-87fe-4993-99e5-893c5ab9b30f-eb3b31b7-acaa-4566-b03e-df8afeb18c46.png?v=1784948288763">
53
+ <script>location.replace('/?product=mi7502es-malloca-bep-tu-2-vung-nau-75cm')</script>
54
+ </head>
55
+ <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
56
+ </html>
san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg ADDED

Git LFS Details

  • SHA256: ee503ee49a04f82190607c93d63dd9b4f70a935be4e82e3dbd9129c525e03ef2
  • Pointer size: 131 Bytes
  • Size of remote file: 467 kB
san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>MI803ES | Malloca | Bếp từ 3 vùng nấu 80cm | V.AI STUDIO</title>
6
+ <meta property="og:title" content="MI803ES | Malloca | Bếp từ 3 vùng nấu 80cm | V.AI STUDIO">
7
+ <meta property="og:description" content="Giá: 25,500,000đ - bếThông số kỹ thuật
8
+ Thông Tin Chung
9
+ Loại sản phẩm:
10
+ Bếp từ
11
+ Lắp đặt:
12
+ Âm
13
+ Nguồn năng lượng:
14
+ Điện
15
+ Tần số (Hz):
16
+ 50/60
17
+ Nguồn điện (V):
18
+ 220-240
19
+ Tổng công suất (W):
20
+ 4600
21
+ Thiết Kế
22
+ Dạng điều khiển:
23
+ Cảm ứng
24
+ Chất liệu bề mặt:
25
+ Mặt kính EuroKera Onyka
26
+ Điều Kh">
27
+ <meta property="og:image" content="https://bizweb.dktcdn.net/100/567/847/products/c0dc0fc4-8a84-4025-8020-10348d3cbfec-e8983d34-6c60-4133-881b-dc96efbfd100.png?v=1785210719080">
28
+ <meta property="product:price:amount" content="25500000">
29
+ <meta property="product:price:currency" content="VND">
30
+ <meta name="twitter:card" content="summary_large_image">
31
+ <meta name="twitter:title" content="MI803ES | Malloca | Bếp từ 3 vùng nấu 80cm | V.AI STUDIO">
32
+ <meta name="twitter:description" content="Giá: 25,500,000đ - bếThông số kỹ thuật
33
+ Thông Tin Chung
34
+ Loại sản phẩm:
35
+ Bếp từ
36
+ Lắp đặt:
37
+ Âm
38
+ Nguồn năng lượng:
39
+ Điện
40
+ Tần số (Hz):
41
+ 50/60
42
+ Nguồn điện (V):
43
+ 220-240
44
+ Tổng công suất (W):
45
+ 4600
46
+ Thiết Kế
47
+ Dạng điều khiển:
48
+ Cảm ứng
49
+ Chất liệu bề mặt:
50
+ Mặt kính EuroKera Onyka
51
+ Điều Kh">
52
+ <meta name="twitter:image" content="https://bizweb.dktcdn.net/100/567/847/products/c0dc0fc4-8a84-4025-8020-10348d3cbfec-e8983d34-6c60-4133-881b-dc96efbfd100.png?v=1785210719080">
53
+ <script>location.replace('/?product=mi803es-malloca-bep-tu-3-vung-nau-80cm')</script>
54
+ </head>
55
+ <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
56
+ </html>
san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg ADDED

Git LFS Details

  • SHA256: 4330cd3f85759168321f8aee17377e0e01517e9e153681ea4d30f1fbcf9de5ad
  • Pointer size: 131 Bytes
  • Size of remote file: 588 kB
san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>MIR9021ES | Malloca | Bếp từ kết hợp hồng ngoại 3 vùng nấu 80cm | V.AI STUDIO</title>
6
+ <meta property="og:title" content="MIR9021ES | Malloca | Bếp từ kết hợp hồng ngoại 3 vùng nấu 80cm | V.AI STUDIO">
7
+ <meta property="og:description" content="Giá: 23,000,000đ - Thông số kỹ thuật
8
+ Thông Tin Chung
9
+ Loại sản phẩm:
10
+ Bếp điện từ
11
+ Lắp đặt:
12
+ Âm
13
+ Nguồn năng lượng:
14
+ Điện
15
+ Tần số (Hz):
16
+ 50/60
17
+ Nguồn điện (V):
18
+ 220-240
19
+ Tổng công suất (W):
20
+ 5800
21
+ Thiết Kế
22
+ Dạng điều khiển:
23
+ Cảm ứng
24
+ Màu sắc:
25
+ Đen
26
+ Chất liệu bề mặt:
27
+ Mặt kính EuroKe">
28
+ <meta property="og:image" content="https://bizweb.dktcdn.net/100/567/847/products/52048a3f-55df-4647-91f3-bd486c4155eb-5a9061f3-3904-4fb4-a2a3-438eaf7ed7a6.png?v=1784947627033">
29
+ <meta property="product:price:amount" content="23000000">
30
+ <meta property="product:price:currency" content="VND">
31
+ <meta name="twitter:card" content="summary_large_image">
32
+ <meta name="twitter:title" content="MIR9021ES | Malloca | Bếp từ kết hợp hồng ngoại 3 vùng nấu 80cm | V.AI STUDIO">
33
+ <meta name="twitter:description" content="Giá: 23,000,000đ - Thông số kỹ thuật
34
+ Thông Tin Chung
35
+ Loại sản phẩm:
36
+ Bếp điện từ
37
+ Lắp đặt:
38
+ Âm
39
+ Nguồn năng lượng:
40
+ Điện
41
+ Tần số (Hz):
42
+ 50/60
43
+ Nguồn điện (V):
44
+ 220-240
45
+ Tổng công suất (W):
46
+ 5800
47
+ Thiết Kế
48
+ Dạng điều khiển:
49
+ Cảm ứng
50
+ Màu sắc:
51
+ Đen
52
+ Chất liệu bề mặt:
53
+ Mặt kính EuroKe">
54
+ <meta name="twitter:image" content="https://bizweb.dktcdn.net/100/567/847/products/52048a3f-55df-4647-91f3-bd486c4155eb-5a9061f3-3904-4fb4-a2a3-438eaf7ed7a6.png?v=1784947627033">
55
+ <script>location.replace('/?product=mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm')</script>
56
+ </head>
57
+ <body style="font-family:sans-serif;padding:40px;text-align:center;color:#666">Đang chuyển hướng...</body>
58
+ </html>