Spaces:
Running
Running
restore to c1013cc7ebfdc93cbb55645441528a84d871a052: 100% restore of target commit tree
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .build_trigger +1 -0
- .cache_bust +1 -0
- .dockerignore +7 -0
- .gitattributes +0 -3
- .gitignore +34 -0
- CLAUDE.md +111 -0
- Dockerfile +14 -0
- HOT_TAGS_V3_TRIGGER.txt +3 -0
- README.md +94 -1
- REBUILD.md +1 -0
- TRIGGER_REBUILD.txt +1 -0
- build-trigger +1 -0
- index.html +529 -0
- index.ts +910 -0
- index.ts.patched +684 -0
- package.json +21 -0
- public/avatars/Ella.glb +3 -0
- public/avatars/Lisa.glb +3 -0
- public/avatars/avatarsdk.glb +3 -0
- public/avatars/avaturn.glb +3 -0
- public/avatars/bruce_avaturn.glb +3 -0
- public/avatars/brunette-t.glb +3 -0
- public/avatars/brunette.glb +3 -0
- public/avatars/david.glb +3 -0
- public/avatars/fabio_avaturn.glb +3 -0
- public/avatars/grit-b6c6c4.glb +3 -0
- public/avatars/julia.glb +3 -0
- public/avatars/lisamy.glb +3 -0
- public/avatars/lisamy.jpg +0 -0
- public/avatars/model (6).glb +3 -0
- public/avatars/mpfb.glb +3 -0
- public/avatars/ready_player_me_1.glb +3 -0
- public/avatars/ready_player_me_2.glb +3 -0
- public/avatars/vuong.glb +3 -0
- public/avatars/vuong.jpg +0 -0
- public/avatars/vuong2.glb +3 -0
- public/vendor/headaudio.min.mjs +1 -0
- public/vendor/headworklet.min.mjs +1 -0
- public/vendor/model-en-mixed.bin +3 -0
- public/worklets/audio-playback.js +179 -0
- public/worklets/mic-capture.js +159 -0
- san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html +56 -0
- san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg +3 -0
- san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html +60 -0
- san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg +0 -0
- san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html +56 -0
- san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg +3 -0
- san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html +56 -0
- san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg +3 -0
- 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 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, '&')
|
| 159 |
+
.replace(/</g, '<')
|
| 160 |
+
.replace(/>/g, '>')
|
| 161 |
+
.replace(/"/g, '"')
|
| 162 |
+
.replace(/'/g, ''')
|
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
| 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(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">")
|
| 753 |
+
.replace(/ /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(/&/g, "&") : "";
|
| 800 |
+
if (!image && im) image = im[1].replace(/&/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(/&/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, "&")
|
| 188 |
+
.replace(/</g, "<")
|
| 189 |
+
.replace(/>/g, ">")
|
| 190 |
+
.replace(/"/g, """)
|
| 191 |
+
.replace(/'/g, "'")
|
| 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
|
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
|
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
|
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>
|