Restore main to commit 891774c

#7
by bep40 - opened
.gitattributes CHANGED
@@ -51,9 +51,3 @@ public/avatars/ready_player_me_1.glb filter=lfs diff=lfs merge=lfs -text
51
  public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text
52
  public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
53
  public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
54
- public/avatars/viverse_avatar_model_209370.vrm filter=lfs diff=lfs merge=lfs -text
55
- public/avatars/scene.glb filter=lfs diff=lfs merge=lfs -text
56
- public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text
57
- public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
58
- public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
59
- public/avatars/lisamy1.glb filter=lfs diff=lfs merge=lfs -text
 
51
  public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text
52
  public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
53
  public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
.gitignore CHANGED
@@ -31,4 +31,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
31
  .idea
32
 
33
  # Finder (MacOS) folder config
34
- .DS_Store
 
31
  .idea
32
 
33
  # Finder (MacOS) folder config
34
+ .DS_Store
.rebuild-trigger CHANGED
@@ -1 +1 @@
1
- Trigger rebuild - fix picker + thumbnails
 
1
+ rebuild-b'\xbaD\x8a+lY\xa0%B\xdd\x1c\xfa\xd5\xd2ny'
CLAUDE.md CHANGED
@@ -108,4 +108,4 @@ Then, run index.ts
108
  bun --hot ./index.ts
109
  ```
110
 
111
- For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
 
108
  bun --hot ./index.ts
109
  ```
110
 
111
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
Dockerfile CHANGED
@@ -1,32 +1,27 @@
1
- # Multi-service: Bun server + Python sidecar for dataset uploads
 
2
  FROM oven/bun:1-slim
3
 
4
  WORKDIR /app
5
 
6
- # Install Python and supervisor for sidecar
7
- RUN apt-get update && apt-get install -y python3 python3-pip python3-venv supervisor && rm -rf /var/lib/apt/lists/*
8
-
9
- # Create virtual environment for Python
10
- RUN python3 -m venv /app/venv
11
-
12
- # Copy package files for Bun
13
  COPY package.json bun.lock ./
 
14
  RUN bun install --frozen-lockfile --production || (sleep 3 && bun install --frozen-lockfile --production)
15
 
16
- # Copy Python requirements and install in venv
17
- COPY requirements.txt ./
18
- RUN /app/venv/bin/pip install --no-cache-dir -r requirements.txt
19
-
20
- # Copy application code
21
  COPY . .
22
 
23
- # Supervisor config
24
- RUN echo '[supervisord]\nnodaemon=true\n\n[program:sidecar]\ncommand=/app/venv/bin/python upload_service.py\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautostart=true\nautorestart=true\n\n[program:bun]\ncommand=bun index.ts\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautostart=true\nautorestart=true\n' > /etc/supervisor/conf.d/supervisord.conf
25
-
26
  ENV NODE_ENV=production
27
  ENV PORT=7860
28
- ENV SIDECAR_URL=http://localhost:7861
 
 
 
 
 
 
29
 
30
- EXPOSE 7860 7861
31
 
32
- CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
 
1
+ # Single container: Bun serves the bundled front-end AND the /api/session
2
+ # proxy. HF Spaces (sdk: docker) routes traffic to app_port, set to 7860.
3
  FROM oven/bun:1-slim
4
 
5
  WORKDIR /app
6
 
7
+ # Copy just the lockfile and package.json first for layer caching
 
 
 
 
 
 
8
  COPY package.json bun.lock ./
9
+ # Retry install once if it fails (network flakiness)
10
  RUN bun install --frozen-lockfile --production || (sleep 3 && bun install --frozen-lockfile --production)
11
 
12
+ # Copy the rest of the application
 
 
 
 
13
  COPY . .
14
 
 
 
 
15
  ENV NODE_ENV=production
16
  ENV PORT=7860
17
+ # Point at a speech-to-speech backend by setting ONE of these in the Space
18
+ # settings (index.ts prefers LOAD_BALANCER_URL):
19
+ # LOAD_BALANCER_URL a load balancer's base URL (a secret) — talks to the pool directly
20
+ # SESSION_PROXY_URL another deployment's /api to piggyback (e.g. a public demo Space)
21
+ # With neither set, the app runs in direct mode (paste a ws:// URL in Settings).
22
+
23
+ EXPOSE 7860
24
 
25
+ USER bun
26
 
27
+ CMD ["bun", "index.ts"]
README.md CHANGED
@@ -4,14 +4,97 @@ emoji: 🗣️
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  app_port: 7860
 
 
 
 
 
 
 
9
  tags:
10
  - ml-intern
11
  ---
12
 
13
- # Gemma Multi-Avatar
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- 3D talking-head avatar with real-time voice/text chat.
16
 
17
- Users can switch between ~20 `.glb` avatar models, capture snapshots as thumbnails, and chat with a Gemma 4 speech-to-speech backend.
 
 
 
 
 
 
 
 
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).
100
+ # Rebuild trigger Thu Jul 23 07:49:41 UTC 2026
app.js ADDED
@@ -0,0 +1,2102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
2
+ import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
3
+ import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
4
+
5
+ const VOICES = [
6
+ "Aiden", "Ryan", "Dylan", "Eric",
7
+ "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian",
8
+ ];
9
+ const DEFAULT_VOICE = "Sohee";
10
+
11
+ // ── VNEWS integration ───────────────────────────────────────────────────
12
+ // The VNEWS space (bep40/vnews) is reachable at this subdomain. It exposes a
13
+ // clean JSON API (no key required) that we use to pull AI/tech news, answer
14
+ // from VNEWS data, and rewrite selected chat content into VNEWS "wall" posts.
15
+ const VNEWS_BASE = "https://bep40-vnews.hf.space";
16
+
17
+ // ── News fetching ───────────────────────────────────────────────────────
18
+ const NEWS_PAGE_SIZE = 10;
19
+
20
+ async function getHotNews() {
21
+ let items = [];
22
+ try {
23
+ const resp = await fetch("/api/news/hot");
24
+ if (resp.ok) {
25
+ const data = await resp.json();
26
+ if (data.items && data.items.length > 0) {
27
+ items = data.items.slice(0, NEWS_PAGE_SIZE);
28
+ }
29
+ }
30
+ } catch (e) { console.warn("News API failed:", e); }
31
+ return items;
32
+ }
33
+
34
+ async function getMoreNews(offset, limit = NEWS_PAGE_SIZE) {
35
+ try {
36
+ const resp = await fetch(`/api/news/more?offset=${offset}&limit=${limit}`);
37
+ if (resp.ok) {
38
+ const data = await resp.json();
39
+ return {
40
+ items: Array.isArray(data.items) ? data.items : [],
41
+ nextOffset: data.offset ?? offset + (data.items?.length ?? 0),
42
+ hasMore: Boolean(data.hasMore),
43
+ };
44
+ }
45
+ } catch (e) { console.warn("News more API failed:", e); }
46
+ return { items: [], nextOffset: offset, hasMore: false };
47
+ }
48
+
49
+ async function getFilteredNews(query, limit = NEWS_PAGE_SIZE) {
50
+ try {
51
+ const resp = await fetch(`/api/news/filter?q=${encodeURIComponent(query)}&limit=${limit}`);
52
+ if (resp.ok) {
53
+ const data = await resp.json();
54
+ return Array.isArray(data.items) ? data.items : [];
55
+ }
56
+ } catch (e) { console.warn("News filter API failed:", e); }
57
+ return [];
58
+ }
59
+
60
+ // ── VNEWS fetch helpers ─────────────────────────────────────────────────
61
+ let vnewsConnected = false;
62
+ let vnewsArticles = []; // flattened articles from VNEWS homepage
63
+ let vnewsStatusTimer = 0;
64
+
65
+ async function vnewsFetch(path) {
66
+ try {
67
+ const resp = await fetch(`${VNEWS_BASE}${path}`, { headers: { "Accept": "application/json" } });
68
+ if (!resp.ok) return null;
69
+ return await resp.json();
70
+ } catch (e) {
71
+ console.warn("VNEWS fetch failed:", path, e);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** Pull the GenK "AI" feed (source = genk) — used for the "tin tức về AI" topic. */
77
+ async function getVnewsAIFeed(limit = NEWS_PAGE_SIZE) {
78
+ const data = await vnewsFetch(`/api/category/cong-nghe`);
79
+ if (!Array.isArray(data)) return [];
80
+ return data
81
+ .filter((a) => a && a.title && a.link)
82
+ .map((a) => ({
83
+ title: a.title,
84
+ link: a.link,
85
+ image: a.img || "",
86
+ source: a.source === "genk" ? "GenK AI" : (a.source || "VNEWS"),
87
+ vnews: true,
88
+ }))
89
+ .slice(0, limit);
90
+ }
91
+
92
+ /** Pull the VNEWS homepage (all categories) and flatten into a searchable list. */
93
+
94
+ // ── Topic tags (chủ đề HOT thời gian thực) — click mở modal tin theo chủ đề ──
95
+ // Fetch realtime trending topics from the backend API (extracted from today's news).
96
+ async function getTrendingTopics() {
97
+ try {
98
+ const resp = await fetch("/api/topics/trending");
99
+ if (resp.ok) {
100
+ const data = await resp.json();
101
+ if (Array.isArray(data.topics) && data.topics.length > 0) {
102
+ return data.topics;
103
+ }
104
+ }
105
+ } catch (e) { console.warn("[topic] API failed:", e); }
106
+ return [];
107
+ }
108
+
109
+ async function openTopic(slug, label) {
110
+ try {
111
+ setCaption("ĐANG TẢI TIN: " + label, "live");
112
+ // Pass the slug (not the label) to the backend so SLUG_MAP can resolve
113
+ // expanded search terms. The backend also falls back to label matching.
114
+ const raw = await getFilteredNews(slug);
115
+ const items = (Array.isArray(raw) ? raw : []).map((n) => ({
116
+ title: n.title, link: n.link, image: n.image || "",
117
+ source: n.source || "Tin tức", vnews: Boolean(n.vnews),
118
+ }));
119
+ showNewsPanel(items);
120
+ const nt = document.getElementById("news-title");
121
+ if (nt) nt.textContent = "📰 Tin: " + label;
122
+ document.querySelectorAll(".topic-chip").forEach((c) => c.classList.toggle("active", c.dataset.slug === slug));
123
+ if (!items.length) setCaption("Không có tin cho: " + label, "error");
124
+ } catch (e) {
125
+ console.warn("[topic] openTopic:", e);
126
+ setCaption("LỖI TẢI TIN: " + label, "error");
127
+ }
128
+ }
129
+
130
+ // Render today's real trending topics INSIDE the chat, right under the avatar's
131
+ // greeting, so they are always visible. Clicking a chip opens that topic's news.
132
+ let _topicTagsRendered = false;
133
+ async function renderTopicTags() {
134
+ if (_topicTagsRendered) return;
135
+ let topics = [];
136
+ try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); }
137
+ if (!topics.length) return;
138
+ const wrap = document.createElement("div");
139
+ wrap.className = "topic-tags";
140
+ wrap.id = "topic-tags-inline";
141
+ for (const c of topics) {
142
+ const chip = document.createElement("button");
143
+ chip.type = "button";
144
+ chip.className = "topic-chip";
145
+ chip.dataset.slug = c.slug;
146
+ chip.textContent = "#" + c.label.replace(/\s+/g, "_");
147
+ chip.addEventListener("click", () => void openTopic(c.slug, c.label));
148
+ wrap.appendChild(chip);
149
+ }
150
+ const oldEl = chatMessages.querySelector("#topic-tags-inline");
151
+ if (oldEl) oldEl.remove();
152
+ chatMessages.appendChild(wrap);
153
+ chatMessages.scrollTop = chatMessages.scrollHeight;
154
+ _topicTagsRendered = true;
155
+ }
156
+
157
+ async function getVnewsHomepage() {
158
+ const data = await vnewsFetch(`/api/homepage`);
159
+ if (!Array.isArray(data)) return [];
160
+ return data
161
+ .filter((a) => a && a.title && a.link)
162
+ .map((a) => ({
163
+ title: a.title,
164
+ link: a.link,
165
+ image: a.img || "",
166
+ source: a.group || a.source || "VNEWS",
167
+ vnews: true,
168
+ }));
169
+ }
170
+
171
+ /** Search VNEWS articles by a free-text query (case-insensitive, in title). */
172
+ function searchVnews(query, limit = 8) {
173
+ const q = (query || "").toLowerCase().trim();
174
+ if (!q) return vnewsArticles.slice(0, limit);
175
+ return vnewsArticles
176
+ .filter((a) => a.title.toLowerCase().includes(q))
177
+ .slice(0, limit);
178
+ }
179
+
180
+ /** Format VNEWS articles as clickable link cards for the avatar to cite. */
181
+ function vnewsLinksAsHtml(articles) {
182
+ if (!articles.length) return "(không có bài viết VNEWS phù hợp)";
183
+ return articles
184
+ .map((a, i) => {
185
+ const short = `${VNEWS_BASE}/?p=${encodeURIComponent(a.link)}`;
186
+ return `${i + 1}. <a href="${a.link}" target="_blank" rel="noopener">${a.title}</a> — nguồn ${a.source} (link rút gọn: ${short})`;
187
+ })
188
+ .join("<br>");
189
+ }
190
+
191
+ const DEFAULT_INSTRUCTIONS = [
192
+ "You are a friendly voice assistant with a visible, human-like 3D avatar.",
193
+ "CRITICAL: Always respond in the SAME LANGUAGE that the user writes or speaks.",
194
+ "If Vietnamese, respond in Vietnamese. If English, respond in English.",
195
+ "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
196
+ "CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35'). This ensures the TTS voice reads them correctly.",
197
+ "Keep replies short, natural, warm. Never list-like.",
198
+ "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression. Use them naturally.",
199
+ "NEVER guess. Use search_web, search_wikipedia, or get_vnews_news for ANY factual question.",
200
+ "When you cite a VNEWS article, ALWAYS include the direct link as a clickable <a href> tag AND mention the short link form.",
201
+ "Never mention tools or that you control an avatar.",
202
+ "Source links should be formatted as popup links with <a href> tags.",
203
+ ].join(" ");
204
+
205
+ const STORAGE_KEYS = {
206
+ voice: "avatar.voice", avatar: "avatar.model", instructions: "avatar.instructions",
207
+ directUrl: "avatar.directUrl", subtitles: "avatar.subtitles",
208
+ };
209
+
210
+ const TOOL_DEFS = [
211
+ { type: "function", name: "set_mood", description: "Change your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS } }, required: ["mood"] } },
212
+ { type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES } }, required: ["gesture"] } },
213
+ { type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string" } }, required: ["emoji"] } },
214
+ { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {} } },
215
+ { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
216
+ { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
217
+ { type: "function", name: "get_vnews_news", description: "Search the connected VNEWS site for Vietnamese news articles (AI, tech, sports, world...). Returns article titles with direct links and short links. Use this when the user asks about news, especially AI/technology, or after VNEWS is connected.", parameters: { type: "object", properties: { query: { type: "string", description: "Keyword to search, e.g. 'AI', 'bóng đá', 'thời sự'. Empty = latest." } }, required: ["query"] } },
218
+ ];
219
+
220
+ // ── DOM elements ─────────────────────────────────────────────────────────
221
+ const $ = (sel) => document.querySelector(sel);
222
+ const stageNode = $("#stage");
223
+ const mainBtn = $("#main-btn");
224
+ const mainBtnLabel = $("#main-btn-label");
225
+ const muteBtn = $("#mute-btn");
226
+ const textModeBtn = $("#text-mode-btn");
227
+ const caption = $("#caption");
228
+ const subtitles = $("#subtitles");
229
+ const loading = $("#loading");
230
+ const settingsBtn = $("#settings-btn");
231
+ const settingsDialog = $("#settings");
232
+ const inputVoice = $("#voice");
233
+ const inputInstructions = $("#instructions");
234
+ const inputDirectUrl = $("#direct-url");
235
+ const inputSubtitles = $("#subtitles-toggle");
236
+ const directUrlRow = $("#direct-url-row");
237
+ const textChat = $("#text-chat");
238
+ const chatHeader = $("#chat-header");
239
+ const chatMessages = $("#chat-messages");
240
+ const chatInput = $("#chat-input");
241
+ const chatSendBtn = $("#chat-send-btn");
242
+ const chatCloseBtn = $("#chat-close-btn");
243
+ const chatResizeHandle = $("#chat-resize-handle");
244
+ const chatAvatarSelect = $("#chat-avatar-select");
245
+ const settingsAvatarSelect = $("#settings-avatar-select");
246
+ const newsPanel = $("#news-panel");
247
+ const newsList = $("#news-list");
248
+ const newsCloseBtn = $("#news-close-btn");
249
+ const newsToggleBtn = $("#news-toggle-btn");
250
+ const newsLoadMore = $("#news-loadmore");
251
+ const newsLoadMoreBtn = $("#news-loadmore-btn");
252
+ const newsSentinel = $("#news-sentinel");
253
+ const newsResizeHandle = $("#news-resize-handle");
254
+
255
+ // ── State ───────────────────────────────────────────────────────────────
256
+ const stage = new AvatarStage(stageNode);
257
+ let client = null;
258
+ let muted = false;
259
+ let subtitleTimer = 0;
260
+ let textMode = false;
261
+ let config = { lb: false, allowDirect: true };
262
+ let avatarList = [];
263
+ let sessionInProgress = false;
264
+ let autoGreetingSent = false;
265
+ let preFetchedNews = [];
266
+ let latestNewsUrl = null;
267
+ let latestNewsSource = null;
268
+
269
+ // ── News box state ──────────────────────────────────────────────────────
270
+ let newsOffset = 0; // how many items already rendered
271
+ let newsLoading = false; // guard against concurrent loads
272
+ let newsHasMore = true;
273
+ let newsObserver = null;
274
+
275
+ // ── News sequence (for walking images through the conversation) ──────────
276
+ let newsSeq = []; // ordered list of news items to surface images from
277
+ let newsSeqIdx = -1; // index of the NEXT image to show
278
+ let newsTopicQuery = ""; // current topic keyword (empty = general hot news)
279
+
280
+ function loadSettings() {
281
+ return {
282
+ voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
283
+ avatar: localStorage.getItem(STORAGE_KEYS.avatar) || "vuong.glb",
284
+ instructions: localStorage.getItem(STORAGE_KEYS.instructions) || "",
285
+ directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
286
+ subtitles: localStorage.getItem(STORAGE_KEYS.subtitles) === "1",
287
+ };
288
+ }
289
+ let settings = loadSettings();
290
+
291
+ function saveSettings() {
292
+ localStorage.setItem(STORAGE_KEYS.voice, settings.voice);
293
+ localStorage.setItem(STORAGE_KEYS.avatar, settings.avatar);
294
+ localStorage.setItem(STORAGE_KEYS.instructions, settings.instructions);
295
+ localStorage.setItem(STORAGE_KEYS.directUrl, settings.directUrl);
296
+ localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0");
297
+ }
298
+
299
+ function getSourceName(hostname) {
300
+ const name = hostname.replace(/^www\./, "").toLowerCase();
301
+ const known = {
302
+ "vnexpress.net": "VnExpress",
303
+ "dantri.com.vn": "Dân trí",
304
+ "tuoitre.vn": "Tuổi Trẻ",
305
+ "thanhnien.vn": "Thanh Niên",
306
+ "vietnamnet.vn": "VietNamNet",
307
+ "zingnews.vn": "ZingNews",
308
+ "cafef.vn": "CafeF",
309
+ "genk.vn": "GenK",
310
+ "kenh14.vn": "Kênh 14",
311
+ "afamily.vn": "Afamily",
312
+ "plo.vn": "PLO",
313
+ "vtc.vn": "VTC",
314
+ };
315
+ return known[name] || name;
316
+ }
317
+
318
+ /** Short label/initial used for the avatar bubble when no image is available. */
319
+ function sourceInitial(source) {
320
+ const map = {
321
+ "VnExpress": "Vn", "Dân trí": "DT", "Tuổi Trẻ": "TT", "Thanh Niên": "TN",
322
+ "VietNamNet": "VN", "ZingNews": "Z", "Kênh 14": "K14", "CafeF": "CF",
323
+ "GenK": "GK", "Afamily": "AF", "PLO": "PLO", "VTC": "VTC", "GenK AI": "AI",
324
+ };
325
+ return map[source] || (source ? source.slice(0, 2).toUpperCase() : "?");
326
+ }
327
+
328
+ /** Vietnamese date: "thứ Sáu, ngày 17 tháng 7 năm 2026". */
329
+ function formatVietnameseDate(now) {
330
+ const weekdays = ["Chủ nhật", "thứ Hai", "thứ Ba", "thứ Tư", "thứ Năm", "thứ Sáu", "thứ Bảy"];
331
+ const wd = weekdays[now.getDay()];
332
+ return `${wd}, ngày ${now.getDate()} tháng ${now.getMonth() + 1} năm ${now.getFullYear()}`;
333
+ }
334
+
335
+ /** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */
336
+ function formatVietnameseTime(now) {
337
+ let h = now.getHours();
338
+ const m = now.getMinutes();
339
+ let part;
340
+ if (h >= 0 && h < 5) part = "đêm";
341
+ else if (h < 10) part = "sáng";
342
+ else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa
343
+ else if (h < 18) part = "chiều";
344
+ else part = "tối"; // 18h trở đi -> tối
345
+ const hh = h === 0 ? 12 : h;
346
+ if (m === 0) return `${hh}h ${part}`;
347
+ return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
348
+ }
349
+
350
+ function effectiveInstructions(newsItems) {
351
+ const now = new Date();
352
+ const dateStr = formatVietnameseDate(now);
353
+ const timeStr = formatVietnameseTime(now);
354
+ const dateLine = `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`;
355
+ const extra = settings.instructions.trim();
356
+ const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
357
+ // Trending topics are now fetched from the backend API /api/topics/trending
358
+ // which extracts real topics from today's news. The frontend getTrendingTopics()
359
+ // calls this API and renders the topic chips.
360
+
361
+ let newsInst = "";
362
+ if (newsItems && newsItems.length > 0) {
363
+ const topNews = newsItems[0];
364
+ const sourceName = getSourceName(new URL(topNews.link).hostname);
365
+ newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}.\nTin HOT nhất hôm nay: ${topNews.title} (nguồn ${sourceName})"\nInclude the full title and source name in parentheses. After greeting, offer to help with questions.`;
366
+ } else {
367
+ newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}."\nThen offer to help with questions.`;
368
+ }
369
+
370
+
371
+
372
+
373
+ let vnewsInst = "";
374
+ if (vnewsConnected && vnewsArticles.length > 0) {
375
+ const sample = vnewsArticles.slice(0, 6).map((a) => `• ${a.title} (${a.source})`).join("\n");
376
+ vnewsInst = `\n\nVNEWS is connected. You can answer using the latest VNEWS headlines and MUST cite them with direct <a href> links. Latest VNEWS headlines:\n${sample}`;
377
+ }
378
+
379
+ const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}${vnewsInst}`;
380
+ return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
381
+ }
382
+
383
+ // ── News image card (shown inside chat) ─────────────────────────────────
384
+ function newsImageCardHtml(item) {
385
+ const imgUrl = item.image || (item.images && item.images[0]) || "";
386
+ const src = imgUrl
387
+ ? `<img class="news-card-img" src="${imgUrl}" alt="" loading="lazy" onerror="this.style.display='none'" />`
388
+ : `<div class="news-card-img news-card-img--fallback">${sourceInitial(item.source)}</div>`;
389
+ const link = item.link
390
+ ? `data-link="${item.link.replace(/"/g, "&quot;")}"`
391
+ : "";
392
+ return `<div class="news-card" ${link}>${src}<div class="news-card-meta"><span class="news-card-source">${item.source || "Tin tức"}</span><span class="news-card-title">${item.title}</span></div></div>`;
393
+ }
394
+
395
+ // ── News Panel ─────────────────────────────────────────────────────────
396
+ function buildNewsItem(item, idx) {
397
+ const el = document.createElement("div");
398
+ el.className = "news-item";
399
+
400
+ const thumb = document.createElement("div");
401
+ thumb.className = "news-thumb";
402
+ if (item.image) {
403
+ const img = document.createElement("img");
404
+ img.loading = "lazy";
405
+ img.alt = "";
406
+ img.src = item.image;
407
+ img.addEventListener("error", () => {
408
+ thumb.classList.add("news-thumb--fallback");
409
+ thumb.textContent = sourceInitial(item.source);
410
+ img.remove();
411
+ });
412
+ thumb.appendChild(img);
413
+ } else {
414
+ thumb.classList.add("news-thumb--fallback");
415
+ thumb.textContent = sourceInitial(item.source);
416
+ }
417
+
418
+ const body = document.createElement("div");
419
+ body.className = "news-body";
420
+
421
+ const meta = document.createElement("div");
422
+ meta.className = "news-meta";
423
+ meta.innerHTML = `<span class="news-source">${item.source || "Tin tức"}</span>` +
424
+ (typeof idx === "number" ? `<span class="news-index">#${idx + 1}</span>` : "");
425
+
426
+ const title = document.createElement("div");
427
+ title.className = "news-title";
428
+ title.textContent = item.title;
429
+
430
+ if (item.description) {
431
+ const desc = document.createElement("div");
432
+ desc.className = "news-desc";
433
+ desc.textContent = item.description;
434
+ body.appendChild(desc);
435
+ }
436
+
437
+ body.appendChild(meta);
438
+ body.appendChild(title);
439
+
440
+ el.appendChild(thumb);
441
+ el.appendChild(body);
442
+
443
+ // Action row: open source + ask in chat
444
+ const actions = document.createElement("div");
445
+ actions.className = "news-actions";
446
+
447
+ const srcBtn = document.createElement("button");
448
+ srcBtn.className = "news-act-btn";
449
+ srcBtn.type = "button";
450
+ srcBtn.textContent = "🔗 Nguồn";
451
+ srcBtn.addEventListener("click", (e) => {
452
+ e.stopPropagation();
453
+ // VNEWS items open the short link so highlights/videos play on VNEWS.
454
+ const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
455
+ if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
456
+ });
457
+
458
+ const askBtn = document.createElement("button");
459
+ askBtn.className = "news-act-btn news-act-btn--ask";
460
+ askBtn.type = "button";
461
+ askBtn.textContent = "💬 Hỏi";
462
+ askBtn.addEventListener("click", (e) => {
463
+ e.stopPropagation();
464
+ askAboutNews(item);
465
+ });
466
+
467
+ actions.appendChild(srcBtn);
468
+ actions.appendChild(askBtn);
469
+ el.appendChild(actions);
470
+
471
+ // Clicking the card body opens the source (VNEWS short link for VNEWS items).
472
+ el.addEventListener("click", () => {
473
+ const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
474
+ if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
475
+ });
476
+ return el;
477
+ }
478
+
479
+ /** Fill the chat input with a question about a news item and focus/start chat. */
480
+ function askAboutNews(item) {
481
+ const q = `Kể chi tiết về tin này: ${item.title}`;
482
+ chatInput.value = q;
483
+ showTextChat(true);
484
+ textModeBtn.classList.add("active");
485
+ textMode = true;
486
+ chatInput.focus();
487
+ if (!client || !sessionInProgress) {
488
+ const url = new URL(location.href);
489
+ url.searchParams.set("fakemic", "1");
490
+ history.replaceState(null, "", url.href);
491
+ void startTextSession(q);
492
+ } else {
493
+ addChatMessage("user", q);
494
+ void maybeSwitchTopic(q);
495
+ if (!sendTextViaSession(q)) setCaption("QUEUED…");
496
+ }
497
+ }
498
+
499
+ function showNewsPanel(items) {
500
+ if (!newsPanel || !newsList) return;
501
+
502
+ newsList.innerHTML = "";
503
+ newsOffset = 0;
504
+ newsHasMore = true;
505
+
506
+ if (!items || items.length === 0) {
507
+ const empty = document.createElement("div");
508
+ empty.className = "news-empty";
509
+ empty.textContent = "Không có tin tức cho chủ đề này.";
510
+ newsList.appendChild(empty);
511
+ newsLoadMore.hidden = true;
512
+ newsPanel.hidden = false;
513
+ return;
514
+ }
515
+
516
+ items.forEach((item, i) => newsList.appendChild(buildNewsItem(item, i)));
517
+ newsOffset = items.length;
518
+ newsHasMore = items.length >= NEWS_PAGE_SIZE;
519
+ newsLoadMore.hidden = !newsHasMore;
520
+
521
+ newsPanel.hidden = false;
522
+ setupNewsObserver();
523
+ }
524
+
525
+ function appendNewsItems(items) {
526
+ if (!items || items.length === 0) {
527
+ newsHasMore = false;
528
+ newsLoadMore.hidden = true;
529
+ return;
530
+ }
531
+ items.forEach((item) => newsList.appendChild(buildNewsItem(item, newsOffset++)));
532
+ }
533
+
534
+ async function loadMoreNews() {
535
+ if (newsLoading || !newsHasMore) return;
536
+ newsLoading = true;
537
+ newsLoadMoreBtn.classList.add("loading");
538
+ newsLoadMoreBtn.disabled = true;
539
+
540
+ // If we're on a filtered topic, "load more" just re-fetches the filtered set.
541
+ let items;
542
+ if (newsTopicQuery) {
543
+ items = await getFilteredNews(newsTopicQuery, NEWS_PAGE_SIZE);
544
+ } else {
545
+ const r = await getMoreNews(newsOffset, NEWS_PAGE_SIZE);
546
+ items = r.items;
547
+ newsHasMore = r.hasMore;
548
+ newsOffset = r.nextOffset;
549
+ }
550
+ appendNewsItems(items);
551
+ if (!newsTopicQuery) newsLoadMore.hidden = !newsHasMore;
552
+ else newsLoadMore.hidden = true; // filtered set is already complete
553
+
554
+ newsLoadMoreBtn.classList.remove("loading");
555
+ newsLoadMoreBtn.disabled = false;
556
+ newsLoading = false;
557
+ }
558
+
559
+ function setupNewsObserver() {
560
+ if (newsObserver) newsObserver.disconnect();
561
+ if (!newsSentinel || !("IntersectionObserver" in window)) return;
562
+ newsObserver = new IntersectionObserver((entries) => {
563
+ for (const entry of entries) {
564
+ if (entry.isIntersecting && newsHasMore && !newsLoading && !newsTopicQuery) {
565
+ void loadMoreNews();
566
+ }
567
+ }
568
+ }, { root: newsList, rootMargin: "120px" });
569
+ newsObserver.observe(newsSentinel);
570
+ }
571
+
572
+ // ── Topic detection & news sequence ─────────────────────────────────────
573
+ const VI_STOPWORDS = new Set([
574
+ "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "minh",
575
+ "có", "không", "được", "là", "và", "với", "của", "cho", "trong", "ngoài", "về", "tại", "từ",
576
+ "để", "khi", "nếu", "như", "đó", "này", "kia", "gì", "nào", "đâu", "ai", "sao", "thế",
577
+ "hãy", "xin", "vui", "lòng", "giúp", "biết", "tin", "tức", "bài", "viết", "nói", "chuyện",
578
+ "chủ", "đề", "muốn", "hỏi", "xem", "nghe", "à", "ừ", "ok", "okay", "ồ", "nhé", "nhỉ", "ạ",
579
+ "đây", "nè", "một", "hai", "ba", "mấy", "các", "những", "cái", "con", "chiếc", "bản",
580
+ "hôm", "nay", "tới", "mới", "nhất", "kể",
581
+ ]);
582
+
583
+ /** Decide if the user is continuing the current news thread (not switching). */
584
+ function isFollowUp(text) {
585
+ const t = (text || "").toLowerCase();
586
+ // Only truly-continuation cues force a follow-up. Topic phrases like
587
+ // "kể về X", "tin tức X", "cho tôi biết về X" are treated as new topics.
588
+ if (/(ti��p|tiếp tục|nữa|thêm tin|tin này|tin đó|tin khác|còn tin|tin hot|tin nóng|tin hôm nay|tóm tắt|tóm lại|giải thích|ý nghĩa|chi tiết hơn|kể thêm)/.test(t)) {
589
+ return true;
590
+ }
591
+ return t.trim().split(/\s+/).length <= 3;
592
+ }
593
+
594
+ /** Detect an explicit AI / công nghệ request (route to GenK AI feed). */
595
+ function isAITopic(text) {
596
+ const t = (text || "").toLowerCase();
597
+ return /(\bai\b|trí tuệ nhân tạo|a\.i|a i|robot|machine learning|deep learning|chatbot|generative|gen ai)/.test(t);
598
+ }
599
+
600
+ /** Extract a short keyword query from a user message (Vietnamese-aware). */
601
+ function extractTopicQuery(text) {
602
+ if (isFollowUp(text)) return ""; // follow-ups never trigger a topic switch
603
+ const words = (text || "")
604
+ .toLowerCase()
605
+ .replace(/[^a-zà-ỹ0-9\s]/g, " ")
606
+ .split(/\s+/)
607
+ .filter(Boolean);
608
+ // Keep meaningful words (>=2 chars, not a stopword). Compound nouns like
609
+ // "bóng đá", "kinh tế", "bóng rổ" stay intact because both parts are kept.
610
+ const keywords = words.filter((w) => w.length >= 2 && !VI_STOPWORDS.has(w));
611
+ if (!keywords.length) return "";
612
+ return keywords.slice(0, 2).join(" ");
613
+ }
614
+
615
+ /**
616
+ * Called when the user sends a message. If it's a new topic, re-filter the
617
+ * news list to that topic and reset the image sequence. AI topics pull the
618
+ * GenK "AI" feed from VNEWS.
619
+ */
620
+ async function maybeSwitchTopic(userText) {
621
+ if (isFollowUp(userText)) return false; // keep current sequence/list
622
+
623
+ // AI topic -> pull GenK AI feed from VNEWS.
624
+ if (isAITopic(userText)) {
625
+ const aiItems = await getVnewsAIFeed(NEWS_PAGE_SIZE);
626
+ if (aiItems.length) {
627
+ newsTopicQuery = "ai";
628
+ preFetchedNews = aiItems;
629
+ newsSeq = aiItems.slice();
630
+ newsSeqIdx = -1;
631
+ showNewsPanel(aiItems);
632
+ setCurrentNews(aiItems[0]);
633
+ return true;
634
+ }
635
+ // Fall back to general news filter on "ai".
636
+ const filtered = await getFilteredNews("ai", NEWS_PAGE_SIZE);
637
+ if (filtered.length) {
638
+ newsTopicQuery = "ai";
639
+ preFetchedNews = filtered;
640
+ newsSeq = filtered.slice();
641
+ newsSeqIdx = -1;
642
+ showNewsPanel(filtered);
643
+ setCurrentNews(filtered[0]);
644
+ return true;
645
+ }
646
+ return false;
647
+ }
648
+
649
+ const query = extractTopicQuery(userText);
650
+ if (!query || query === newsTopicQuery) return false;
651
+
652
+ const filtered = await getFilteredNews(query, NEWS_PAGE_SIZE);
653
+ if (!filtered.length) return false; // no news for this topic -> keep general
654
+
655
+ newsTopicQuery = query;
656
+ preFetchedNews = filtered;
657
+ newsSeq = filtered.slice();
658
+ newsSeqIdx = -1;
659
+ showNewsPanel(filtered);
660
+ setCurrentNews(filtered[0]);
661
+ return true;
662
+ }
663
+
664
+ // Current news item being discussed + which image to show next.
665
+ let curNews = null;
666
+ let curImgIdx = 0;
667
+
668
+ /** Return the next image URL for the CURRENT news item, looping through its
669
+ * images (image[0], image[1], ...). Falls back to a single image. */
670
+ function nextNewsImage() {
671
+ if (!curNews) return null;
672
+ const imgs = (curNews.images && curNews.images.length)
673
+ ? curNews.images
674
+ : (curNews.image ? [curNews.image] : []);
675
+ if (!imgs.length) return null;
676
+ const url = imgs[curImgIdx % imgs.length];
677
+ curImgIdx += 1;
678
+ return url;
679
+ }
680
+
681
+ /** Set the news item currently being discussed (resets the image cursor). */
682
+ function setCurrentNews(item) {
683
+ curNews = item || null;
684
+ curImgIdx = 0;
685
+ }
686
+
687
+ // ── Avatar list ─────────────────────────────────────────────────────────
688
+ async function fetchAvatarList() {
689
+ try {
690
+ const resp = await fetch("/api/avatars");
691
+ if (resp.ok) avatarList = (await resp.json()).avatars || [];
692
+ } catch {}
693
+ }
694
+
695
+ function populateAvatarSelects(selectedName) {
696
+ for (const sel of [chatAvatarSelect, settingsAvatarSelect]) {
697
+ sel.innerHTML = "";
698
+ const def = document.createElement("option");
699
+ def.value = "";
700
+ def.textContent = "(Default - Brunette)";
701
+ sel.appendChild(def);
702
+
703
+ for (const name of avatarList) {
704
+ const opt = document.createElement("option");
705
+ opt.value = name;
706
+ opt.textContent = name.replace(/\.glb$/i, "").replace(/_/g, " ") + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
707
+ sel.appendChild(opt);
708
+ }
709
+
710
+ if (selectedName && avatarList.includes(selectedName)) {
711
+ sel.value = selectedName;
712
+ }
713
+ }
714
+ }
715
+ updateAvatarThumbPreview();
716
+
717
+
718
+ function setAvatarFromSelect(value) {
719
+ settings.avatar = value || "";
720
+ saveSettings();
721
+ }
722
+
723
+ async function reloadAvatar() {
724
+ if (!stage.head) return;
725
+ loading.classList.remove("done");
726
+ loading.textContent = "Loading avatar...";
727
+
728
+ try {
729
+ await stage.init({
730
+ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
731
+ onprogress: (ev) => {
732
+ if (ev.lengthComputable) {
733
+ loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
734
+ }
735
+ }
736
+ });
737
+ stage.resume(); // keep the render loop running so the avatar stays visible
738
+ } catch (err) {
739
+ console.error(err);
740
+ }
741
+
742
+ loading.classList.add("done");
743
+ }
744
+
745
+ // ── Chat / captions ────────────────────────────────────────────────────
746
+ function clampRect() {
747
+ const vw = window.innerWidth, vh = window.innerHeight;
748
+ const r = textChat.getBoundingClientRect();
749
+ let l = r.left, t = r.top;
750
+ l = Math.max(10, Math.min(l, vw - r.width - 10));
751
+ t = Math.max(10, Math.min(t, vh - r.height - 10));
752
+ textChat.style.left = `${l}px`;
753
+ textChat.style.top = `${t}px`;
754
+ }
755
+
756
+ function makeDraggable() {
757
+ let dragging = false;
758
+ let startX, startY, startLeft, startTop;
759
+
760
+ function getPos(e) {
761
+ const p = e.changedTouches ? e.changedTouches[0] : e;
762
+ return { x: p.clientX, y: p.clientY };
763
+ }
764
+
765
+ function onStart(e) {
766
+ if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) {
767
+ return;
768
+ }
769
+ const p = getPos(e);
770
+ dragging = true;
771
+ const rect = textChat.getBoundingClientRect();
772
+ startX = p.x;
773
+ startY = p.y;
774
+ startLeft = rect.left;
775
+ startTop = rect.top;
776
+ textChat.classList.add("dragging");
777
+ e.preventDefault();
778
+ }
779
+
780
+ function onMove(e) {
781
+ if (!dragging) return;
782
+ const p = getPos(e);
783
+ textChat.style.left = `${startLeft + p.x - startX}px`;
784
+ textChat.style.top = `${startTop + p.y - startY}px`;
785
+ textChat.style.right = "auto";
786
+ textChat.style.bottom = "auto";
787
+ textChat.style.width = "";
788
+ textChat.style.height = "";
789
+ e.preventDefault();
790
+ }
791
+
792
+ function onEnd() {
793
+ if (!dragging) return;
794
+ dragging = false;
795
+ textChat.classList.remove("dragging");
796
+ clampRect();
797
+ }
798
+
799
+ chatHeader.addEventListener("mousedown", onStart);
800
+ document.addEventListener("mousemove", onMove);
801
+ document.addEventListener("mouseup", onEnd);
802
+ chatHeader.addEventListener("touchstart", onStart, { passive: false });
803
+ document.addEventListener("touchmove", onMove, { passive: false });
804
+ document.addEventListener("touchend", onEnd);
805
+ }
806
+
807
+ function makeResizable() {
808
+ let resizing = false;
809
+ let startX, startY, startW, startH;
810
+
811
+ function getPos(e) {
812
+ const p = e.changedTouches ? e.changedTouches[0] : e;
813
+ return { x: p.clientX, y: p.clientY };
814
+ }
815
+
816
+ function onStart(e) {
817
+ resizing = true;
818
+ const rect = textChat.getBoundingClientRect();
819
+ const p = getPos(e);
820
+ startX = p.x;
821
+ startY = p.y;
822
+ startW = rect.width;
823
+ startH = rect.height;
824
+ textChat.classList.add("resizing");
825
+ e.preventDefault();
826
+ e.stopPropagation();
827
+ }
828
+
829
+ function onMove(e) {
830
+ if (!resizing) return;
831
+ const p = getPos(e);
832
+ textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`;
833
+ textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`;
834
+ textChat.style.right = "auto";
835
+ textChat.style.bottom = "auto";
836
+ e.preventDefault();
837
+ }
838
+
839
+ function onEnd() {
840
+ if (!resizing) return;
841
+ resizing = false;
842
+ textChat.classList.remove("resizing");
843
+ }
844
+
845
+ chatResizeHandle.addEventListener("mousedown", onStart);
846
+ document.addEventListener("mousemove", onMove);
847
+ document.addEventListener("mouseup", onEnd);
848
+ chatResizeHandle.addEventListener("touchstart", onStart, { passive: false });
849
+ document.addEventListener("touchmove", onMove, { passive: false });
850
+ document.addEventListener("touchend", onEnd);
851
+ }
852
+
853
+ function setCaption(text, kind = "") {
854
+ caption.textContent = text;
855
+ caption.className = kind;
856
+ }
857
+
858
+ function showSubtitles(text) {
859
+ if (!settings.subtitles) return;
860
+ clearTimeout(subtitleTimer);
861
+ subtitles.innerHTML = text;
862
+ subtitles.classList.add("visible");
863
+
864
+ subtitles.querySelectorAll("a[href]").forEach((a) => {
865
+ a.addEventListener("click", (e) => {
866
+ e.preventDefault();
867
+ const href = a.getAttribute("href");
868
+ if (href) {
869
+ window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
870
+ }
871
+ });
872
+ });
873
+ }
874
+
875
+ function fadeSubtitles(delayMs = 2600) {
876
+ clearTimeout(subtitleTimer);
877
+ subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs);
878
+ }
879
+
880
+ function escapeRegex(str) {
881
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
882
+ }
883
+
884
+ function addChatMessage(role, text, newsItem = null) {
885
+ const msg = document.createElement("div");
886
+ msg.className = `chat-message ${role}`;
887
+ msg.dataset.text = text; // store plain text for rewrite selection
888
+ if (newsItem) {
889
+ if (newsItem.image) msg.dataset.img = newsItem.image;
890
+ if (newsItem.link) msg.dataset.src = newsItem.link;
891
+ }
892
+
893
+ let displayText = text;
894
+ if (role === "assistant" && latestNewsUrl && latestNewsSource) {
895
+ const sourceEscaped = escapeRegex(latestNewsSource);
896
+ const patterns = [
897
+ new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'),
898
+ new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'),
899
+ ];
900
+
901
+ for (const pattern of patterns) {
902
+ if (pattern.test(displayText)) {
903
+ displayText = displayText.replace(pattern, (match) =>
904
+ `<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">${match}</a>`
905
+ );
906
+ break;
907
+ }
908
+ }
909
+ }
910
+
911
+ msg.innerHTML = displayText;
912
+
913
+ // Attach a news image card (for greeting + each subsequent answer).
914
+ // Render even when the image is missing: newsImageCardHtml shows a
915
+ // gradient fallback using the source initials, so the user always sees
916
+ // a clickable source card.
917
+ if ((role === "assistant") && newsItem) {
918
+ const card = document.createElement("div");
919
+ card.innerHTML = newsImageCardHtml(newsItem).trim();
920
+ const cardEl = card.firstElementChild;
921
+ if (cardEl) {
922
+ cardEl.addEventListener("click", () => {
923
+ if (newsItem.link) window.open(newsItem.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
924
+ });
925
+ msg.appendChild(cardEl);
926
+ }
927
+ }
928
+
929
+ // Click a message to toggle multi-select (avatar + user messages) for rewrite.
930
+ msg.addEventListener("click", () => {
931
+ msg.classList.toggle("selected");
932
+ updateRewriteButton();
933
+ });
934
+
935
+ chatMessages.appendChild(msg);
936
+ chatMessages.scrollTop = chatMessages.scrollHeight;
937
+
938
+ msg.querySelectorAll("a[href]").forEach((a) => {
939
+ a.addEventListener("click", (e) => {
940
+ e.preventDefault();
941
+ const href = a.getAttribute("href");
942
+ if (href) {
943
+ window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
944
+ }
945
+ });
946
+ });
947
+
948
+ if (role === "assistant" && preFetchedNews.length > 0) {
949
+ showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
950
+ }
951
+ }
952
+
953
+ function showTextChat(show) {
954
+ textChat.hidden = !show;
955
+ if (show) chatInput.focus();
956
+ }
957
+
958
+ function sendTextViaSession(text) {
959
+ if (!client) return false;
960
+ const st = client._status;
961
+ if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") {
962
+ return false;
963
+ }
964
+ setCaption("SENDING…");
965
+ client.sendUserText(text);
966
+ client.requestResponse();
967
+ return true;
968
+ }
969
+
970
+ function sendTextMessage() {
971
+ const text = chatInput.value.trim();
972
+ if (!text) return;
973
+
974
+ addChatMessage("user", text);
975
+ chatInput.value = "";
976
+
977
+ // New topic? re-filter the news list and reset the image sequence.
978
+ if (client && sessionInProgress) {
979
+ void maybeSwitchTopic(text);
980
+ }
981
+
982
+ if (!client || !sessionInProgress) {
983
+ const url = new URL(location.href);
984
+ url.searchParams.set("fakemic", "1");
985
+ history.replaceState(null, "", url.href);
986
+ void startTextSession(text);
987
+ return;
988
+ }
989
+
990
+ if (!sendTextViaSession(text)) {
991
+ setCaption("QUEUED…");
992
+ }
993
+ }
994
+
995
+ if (newsCloseBtn) {
996
+ newsCloseBtn.addEventListener("click", (e) => {
997
+ e.stopPropagation();
998
+ newsPanel.hidden = true;
999
+ });
1000
+ }
1001
+
1002
+ if (newsToggleBtn) {
1003
+ newsToggleBtn.addEventListener("click", () => {
1004
+ newsPanel.hidden = !newsPanel.hidden;
1005
+ });
1006
+ }
1007
+
1008
+ if (newsLoadMoreBtn) {
1009
+ newsLoadMoreBtn.addEventListener("click", () => void loadMoreNews());
1010
+ }
1011
+
1012
+ if (chatCloseBtn) {
1013
+ chatCloseBtn.addEventListener("click", (e) => {
1014
+ e.stopPropagation();
1015
+ textMode = false;
1016
+ showTextChat(false);
1017
+ textModeBtn.classList.remove("active");
1018
+ });
1019
+ }
1020
+
1021
+ // ── VNEWS control panel (created dynamically, no index.html change) ─────
1022
+ let vnewsPanel, vnewsConnectBtn, vnewsStatus, rewriteBtn, vnewsCloseBtn, vnewsToggleBtn;
1023
+
1024
+ function buildVnewsPanel() {
1025
+ vnewsPanel = document.createElement("div");
1026
+ vnewsPanel.id = "vnews-panel";
1027
+ vnewsPanel.hidden = true;
1028
+
1029
+ const header = document.createElement("div");
1030
+ header.id = "vnews-header";
1031
+ const title = document.createElement("span");
1032
+ title.id = "vnews-title";
1033
+ title.textContent = "VNEWS";
1034
+ vnewsCloseBtn = document.createElement("button");
1035
+ vnewsCloseBtn.id = "vnews-close-btn";
1036
+ vnewsCloseBtn.className = "icon-btn small";
1037
+ vnewsCloseBtn.textContent = "✕";
1038
+ vnewsCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); vnewsPanel.hidden = true; });
1039
+ header.appendChild(title);
1040
+ header.appendChild(vnewsCloseBtn);
1041
+
1042
+ vnewsConnectBtn = document.createElement("button");
1043
+ vnewsConnectBtn.id = "vnews-connect-btn";
1044
+ vnewsConnectBtn.className = "vnews-btn";
1045
+ vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
1046
+ vnewsConnectBtn.addEventListener("click", () => void connectVnews());
1047
+
1048
+ vnewsStatus = document.createElement("div");
1049
+ vnewsStatus.id = "vnews-status";
1050
+ vnewsStatus.textContent = "Chưa kết nối";
1051
+
1052
+ rewriteBtn = document.createElement("button");
1053
+ rewriteBtn.id = "vnews-rewrite-btn";
1054
+ rewriteBtn.className = "vnews-btn";
1055
+ rewriteBtn.textContent = "📝 Đăng bài rewrite";
1056
+ rewriteBtn.disabled = true;
1057
+ rewriteBtn.hidden = true;
1058
+ rewriteBtn.addEventListener("click", () => void doRewriteSelected());
1059
+
1060
+ vnewsPanel.appendChild(header);
1061
+ vnewsPanel.appendChild(vnewsConnectBtn);
1062
+ vnewsPanel.appendChild(vnewsStatus);
1063
+ vnewsPanel.appendChild(rewriteBtn);
1064
+ document.body.appendChild(vnewsPanel);
1065
+
1066
+ // A small toggle button on the top bar.
1067
+ vnewsToggleBtn = document.createElement("button");
1068
+ vnewsToggleBtn.id = "vnews-toggle-btn";
1069
+ vnewsToggleBtn.className = "icon-btn";
1070
+ vnewsToggleBtn.title = "VNEWS";
1071
+ vnewsToggleBtn.textContent = "📰";
1072
+ vnewsToggleBtn.addEventListener("click", () => { vnewsPanel.hidden = !vnewsPanel.hidden; });
1073
+ const topbar = document.querySelector("#topbar");
1074
+ if (topbar) topbar.appendChild(vnewsToggleBtn);
1075
+
1076
+ makeVnewsDraggable();
1077
+ }
1078
+
1079
+ async function connectVnews() {
1080
+ vnewsConnectBtn.disabled = true;
1081
+ vnewsConnectBtn.textContent = "⏳ Đang kết nối…";
1082
+ vnewsStatus.textContent = "Đang tải dữ liệu VNEWS…";
1083
+ const articles = await getVnewsHomepage();
1084
+ if (articles.length) {
1085
+ vnewsConnected = true;
1086
+ vnewsArticles = articles;
1087
+ vnewsStatus.textContent = `✅ Đã kết nối · ${articles.length} bài`;
1088
+ vnewsConnectBtn.textContent = "✅ VNEWS đã kết nối";
1089
+ rewriteBtn.hidden = false;
1090
+ rewriteBtn.disabled = true; // enabled when a message is selected
1091
+ updateRewriteButton();
1092
+ // Refresh the avatar's instructions so it can answer from VNEWS.
1093
+ if (client) client.updateSession({ instructions: effectiveInstructions(preFetchedNews) });
1094
+ } else {
1095
+ vnewsStatus.textContent = "❌ Không thể kết nối VNEWS";
1096
+ vnewsConnectBtn.disabled = false;
1097
+ vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
1098
+ }
1099
+ }
1100
+
1101
+ function updateRewriteButton() {
1102
+ const selected = chatMessages.querySelectorAll(".chat-message.selected");
1103
+ let hasText = false;
1104
+ selected.forEach((m) => { if ((m.dataset.text || "").trim().length > 0) hasText = true; });
1105
+ // Caption hint when connected and nothing selected yet.
1106
+ if (vnewsConnected && selected.length === 0) {
1107
+ setCaption("CHỌN 1 HAY NHIỀU TIN NHẮN ĐỂ ĐĂNG VNEWS", "live");
1108
+ }
1109
+ rewriteBtn.disabled = !vnewsConnected || selected.length === 0 || !hasText;
1110
+ }
1111
+
1112
+ /** Rewrite the selected chat content into a VNEWS wall post. */
1113
+ async function doRewriteSelected() {
1114
+ const selected = [...chatMessages.querySelectorAll(".chat-message.selected")];
1115
+ if (selected.length === 0) { setCaption("CHỌN TIN NHẮN ĐỂ REWRITE", "error"); return; }
1116
+ // Combine ALL selected messages (avatar + user) into one post.
1117
+ const context = selected
1118
+ .map((m) => (m.dataset.text || "").trim())
1119
+ .filter((t) => t.length > 0)
1120
+ .join("\n\n");
1121
+ if (context.length < 20) { setCaption("TIN NHẮN QUÁ NGẮN", "error"); return; }
1122
+ // Prefer the original source URL of a news item so VNEWS can pull its image.
1123
+ let srcUrl = "";
1124
+ for (const m of selected) { if (m.dataset.src) { srcUrl = m.dataset.src; break; } }
1125
+
1126
+ rewriteBtn.disabled = true;
1127
+ rewriteBtn.textContent = "⏳ AI đang xào nấu bài viết…";
1128
+ let title = "";
1129
+ let article = context;
1130
+ try {
1131
+ const sresp = await fetch("/api/summarize", {
1132
+ method: "POST",
1133
+ headers: { "Content-Type": "application/json" },
1134
+ body: JSON.stringify({ text: context }),
1135
+ });
1136
+ if (sresp.ok) {
1137
+ const sdata = await sresp.json();
1138
+ if (sdata && sdata.body) { article = sdata.body; title = sdata.title || ""; }
1139
+ }
1140
+ } catch (e) { /* fall back to raw context */ }
1141
+
1142
+ rewriteBtn.textContent = "⏳ Đăng…";
1143
+ try {
1144
+ // Resolve a REAL hero image from the source article (VNEWS scrapes og:image).
1145
+ let heroImage = srcUrl; // fallback: the article URL itself
1146
+ if (srcUrl) {
1147
+ try {
1148
+ const artResp = await fetch(`${VNEWS_BASE}/api/article?url=${encodeURIComponent(srcUrl)}`, { headers: { "Accept": "application/json" } });
1149
+ if (artResp.ok) {
1150
+ const art = await artResp.json();
1151
+ if (art && art.og_image) heroImage = art.og_image;
1152
+ }
1153
+ } catch (_) { /* keep fallback */ }
1154
+ }
1155
+ // Publish the AI-composed article directly (not just re-split).
1156
+ const payload = { body: article };
1157
+ if (title) payload.title = title;
1158
+ if (heroImage) payload.image = heroImage;
1159
+ if (srcUrl) payload.url = srcUrl;
1160
+ const resp = await fetch(`${VNEWS_BASE}/api/publish`, {
1161
+ method: "POST",
1162
+ headers: { "Content-Type": "application/json" },
1163
+ body: JSON.stringify(payload),
1164
+ });
1165
+ const data = await resp.json();
1166
+ if (data && data.post) {
1167
+ const shown = (title ? `**${title}**\n\n` : "") + article;
1168
+ addChatMessage("assistant", `✅ Đã đăng bài AI tổng hợp (${selected.length} tin nhắn) lên VNEWS:\n${shown}`);
1169
+ setCaption("ĐÃ ĐĂNG LÊN VNEWS", "live");
1170
+ } else {
1171
+ addChatMessage("assistant", `⚠️ VNEWS: ${data.error || "không đăng được bài"}.`);
1172
+ }
1173
+ } catch (e) {
1174
+ addChatMessage("assistant", "⚠️ Lỗi khi kết nối VNEWS để đăng bài.");
1175
+ }
1176
+ rewriteBtn.textContent = "📝 Đăng bài rewrite";
1177
+ // Clear selection after posting.
1178
+ selected.forEach((m) => m.classList.remove("selected"));
1179
+ updateRewriteButton();
1180
+ }
1181
+
1182
+ function makeVnewsDraggable() {
1183
+ let dragging = false, startX, startY, startLeft, startTop;
1184
+ const header = vnewsPanel.querySelector("#vnews-header");
1185
+ function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; }
1186
+ header.addEventListener("mousedown", (e) => {
1187
+ if (e.target.closest("#vnews-close-btn")) return;
1188
+ dragging = true;
1189
+ const rect = vnewsPanel.getBoundingClientRect();
1190
+ startX = e.clientX; startY = e.clientY;
1191
+ startLeft = rect.left; startTop = rect.top;
1192
+ vnewsPanel.classList.add("dragging");
1193
+ e.preventDefault();
1194
+ });
1195
+ document.addEventListener("mousemove", (e) => {
1196
+ if (!dragging) return;
1197
+ const vw = window.innerWidth, vh = window.innerHeight;
1198
+ const w = vnewsPanel.offsetWidth, h = vnewsPanel.offsetHeight;
1199
+ const l = Math.max(10, Math.min(startLeft + e.clientX - startX, vw - w - 10));
1200
+ const t = Math.max(10, Math.min(startTop + e.clientY - startY, vh - h - 10));
1201
+ vnewsPanel.style.left = `${l}px`; vnewsPanel.style.top = `${t}px`;
1202
+ vnewsPanel.style.right = "auto"; vnewsPanel.style.bottom = "auto";
1203
+ });
1204
+ document.addEventListener("mouseup", () => { dragging = false; vnewsPanel.classList.remove("dragging"); });
1205
+ }
1206
+
1207
+ // ── Button logic ────────────────────────────────────────────────────────
1208
+ let mainAction = "start";
1209
+
1210
+ function setMainButton(action, label) {
1211
+ mainAction = action;
1212
+ mainBtnLabel.textContent = label;
1213
+ mainBtn.disabled = action === "busy";
1214
+ mainBtn.classList.toggle("live", action === "stop");
1215
+ muteBtn.hidden = action !== "stop";
1216
+ textModeBtn.hidden = false;
1217
+ }
1218
+
1219
+ const CAPTIONS = {
1220
+ idle: "TAP TO TALK",
1221
+ "creating-session": "REQUESTING A SLOT…",
1222
+ queued: "WAITING IN LINE…",
1223
+ "your-turn": "YOUR TURN, TAP TO JOIN",
1224
+ connecting: "CONNECTING…",
1225
+ connected: "GO AHEAD, I'M LISTENING",
1226
+ "user-speaking": "LISTENING",
1227
+ processing: "THINKING…",
1228
+ "ai-speaking": "SPEAKING",
1229
+ closed: "TAP TO TALK",
1230
+ error: "SOMETHING BROKE, TAP TO RETRY",
1231
+ };
1232
+
1233
+ function onStatus(status) {
1234
+ stage.setConversationState(status);
1235
+ setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live");
1236
+
1237
+ switch (status) {
1238
+ case "idle":
1239
+ case "closed":
1240
+ setMainButton("start", "Start talking");
1241
+ break;
1242
+ case "error":
1243
+ setMainButton("start", "Retry");
1244
+ break;
1245
+ case "creating-session":
1246
+ case "connecting":
1247
+ setMainButton("busy", "Connecting…");
1248
+ break;
1249
+ case "queued":
1250
+ setMainButton("stop", "Leave queue");
1251
+ break;
1252
+ case "your-turn":
1253
+ setMainButton("join", "Join now");
1254
+ break;
1255
+ default:
1256
+ setMainButton("stop", "End conversation");
1257
+ break;
1258
+ }
1259
+
1260
+ if (status === "user-speaking") {
1261
+ subtitles.classList.remove("visible");
1262
+ showTextChat(textMode);
1263
+ }
1264
+ }
1265
+
1266
+ // ── Tool runner ─────────────────────────────────────────────────────────
1267
+ function runTool(name, argsJson, callId) {
1268
+ if (!client) return;
1269
+
1270
+ let args = {};
1271
+ try { args = JSON.parse(argsJson || "{}"); } catch {}
1272
+
1273
+ if (name === "get_vnews_news") {
1274
+ const query = (args.query || "").trim();
1275
+ let articles;
1276
+ if (vnewsConnected) articles = searchVnews(query, 8);
1277
+ else articles = []; // not connected yet
1278
+ const out = vnewsLinksAsHtml(articles);
1279
+ client.sendToolOutput(callId, `Kết quả từ VNEWS${query ? ` cho "${query}"` : " (mới nhất)"}:\n${out}`);
1280
+ client.requestResponse();
1281
+ return;
1282
+ }
1283
+
1284
+ if (name === "get_current_datetime") {
1285
+ const now = new Date();
1286
+ const dateStr = formatVietnameseDate(now);
1287
+ const timeStr = formatVietnameseTime(now);
1288
+ client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`);
1289
+ client.requestResponse();
1290
+ return;
1291
+ }
1292
+
1293
+ if (name === "search_wikipedia") {
1294
+ const query = args.query || "";
1295
+ if (!query) {
1296
+ client.sendToolOutput(callId, `No query.`);
1297
+ client.requestResponse();
1298
+ return;
1299
+ }
1300
+
1301
+ fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`)
1302
+ .then(r => r.json())
1303
+ .then(data => {
1304
+ if (data.extract) {
1305
+ client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`);
1306
+ client.requestResponse();
1307
+ return;
1308
+ }
1309
+ fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`)
1310
+ .then(r => r.json())
1311
+ .then(sd => {
1312
+ if (!sd.results?.length) {
1313
+ client.sendToolOutput(callId, `No Wikipedia results for "${query}".`);
1314
+ client.requestResponse();
1315
+ return;
1316
+ }
1317
+ fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`)
1318
+ .then(r => r.json())
1319
+ .then(sum => {
1320
+ client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0, 3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`);
1321
+ client.requestResponse();
1322
+ });
1323
+ });
1324
+ })
1325
+ .catch(() => {
1326
+ client.sendToolOutput(callId, `Wikipedia search failed.`);
1327
+ client.requestResponse();
1328
+ });
1329
+ return;
1330
+ }
1331
+
1332
+ if (name === "search_web") {
1333
+ const query = args.query || "";
1334
+ if (!query) {
1335
+ client.sendToolOutput(callId, `No query.`);
1336
+ client.requestResponse();
1337
+ return;
1338
+ }
1339
+
1340
+ fetch(`/api/web/search?q=${encodeURIComponent(query)}`)
1341
+ .then(r => r.json())
1342
+ .then(data => {
1343
+ if (!data.results?.length) {
1344
+ client.sendToolOutput(callId, `No web results for "${query}".`);
1345
+ client.requestResponse();
1346
+ return;
1347
+ }
1348
+ client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0, 3).map((r, i) => `${i + 1}. ${r.title}\n ${r.snippet}`).join("\n")}`);
1349
+ client.requestResponse();
1350
+ })
1351
+ .catch(() => {
1352
+ client.sendToolOutput(callId, `Web search failed.`);
1353
+ client.requestResponse();
1354
+ });
1355
+ return;
1356
+ }
1357
+
1358
+ const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
1359
+ client.sendToolOutput(callId, result);
1360
+ client.requestResponse();
1361
+ }
1362
+
1363
+ async function connectSession(c) {
1364
+ try {
1365
+ await c.connect();
1366
+ return c;
1367
+ } catch (err) {
1368
+ const code = err?.code;
1369
+ if (code === "limit") {
1370
+ setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error");
1371
+ } else if (code === "queue-full") {
1372
+ setCaption("ALL SEATS TAKEN, TRY AGAIN", "error");
1373
+ } else if (code === "join-expired") {
1374
+ setCaption("SPOT EXPIRED, TAP TO RETRY", "error");
1375
+ } else if (code !== "aborted") {
1376
+ console.error(err);
1377
+ setCaption("COULD NOT CONNECT, TAP TO RETRY", "error");
1378
+ }
1379
+ await endSession(true);
1380
+ return null;
1381
+ }
1382
+ }
1383
+
1384
+ async function startVoiceSession() {
1385
+ if (sessionInProgress) return;
1386
+ sessionInProgress = true;
1387
+
1388
+ await stage.resume();
1389
+
1390
+ let micStream;
1391
+ if (new URLSearchParams(location.search).has("fakemic")) {
1392
+ const ctx = stage.audioCtx;
1393
+ micStream = ctx.createMediaStreamDestination().stream;
1394
+ } else {
1395
+ try {
1396
+ micStream = await navigator.mediaDevices.getUserMedia({
1397
+ audio: {
1398
+ echoCancellation: true,
1399
+ noiseSuppression: true,
1400
+ autoGainControl: true
1401
+ }
1402
+ });
1403
+ } catch {
1404
+ setCaption("MIC BLOCKED", "error");
1405
+ sessionInProgress = false;
1406
+ return;
1407
+ }
1408
+ }
1409
+
1410
+ const audioCtx = stage.audioCtx;
1411
+ const voiceSink = stage.voiceSink;
1412
+ if (!audioCtx || !voiceSink) {
1413
+ sessionInProgress = false;
1414
+ return;
1415
+ }
1416
+
1417
+ const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
1418
+ if (!preFetchedNews.length) {
1419
+ preFetchedNews = newsItems;
1420
+ }
1421
+
1422
+ // Initialize the image sequence from the current news set.
1423
+ newsSeq = preFetchedNews.slice();
1424
+ newsSeqIdx = -1;
1425
+ newsTopicQuery = "";
1426
+
1427
+ if (newsItems.length > 0) {
1428
+ latestNewsUrl = newsItems[0].link;
1429
+ setCurrentNews(newsItems[0]);
1430
+ try {
1431
+ latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
1432
+ } catch {
1433
+ latestNewsSource = "VnExpress";
1434
+ }
1435
+ }
1436
+
1437
+ const c = new S2sWsRealtimeClient({
1438
+ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
1439
+ voice: settings.voice,
1440
+ instructions: effectiveInstructions(newsItems),
1441
+ micStream,
1442
+ audioContext: audioCtx,
1443
+ outputNode: voiceSink,
1444
+ workletBaseUrl: "/worklets/",
1445
+ tools: TOOL_DEFS,
1446
+ _textOnly: false,
1447
+ });
1448
+
1449
+ client = c;
1450
+ _attachClientEvents(c);
1451
+ setCaption("REQUESTING A SLOT…");
1452
+
1453
+ const ok = await connectSession(c);
1454
+ if (!ok) return;
1455
+
1456
+ if (!autoGreetingSent) {
1457
+ c.requestResponse();
1458
+ }
1459
+ }
1460
+
1461
+ async function startTextSession(initialText) {
1462
+ if (sessionInProgress) {
1463
+ if (client && initialText) {
1464
+ client.sendUserText(initialText);
1465
+ client.requestResponse();
1466
+ }
1467
+ return;
1468
+ }
1469
+
1470
+ sessionInProgress = true;
1471
+ await stage.resume();
1472
+
1473
+ const audioCtx = stage.audioCtx;
1474
+ const voiceSink = stage.voiceSink;
1475
+ if (!audioCtx || !voiceSink) {
1476
+ sessionInProgress = false;
1477
+ return;
1478
+ }
1479
+
1480
+ const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
1481
+ if (!preFetchedNews.length) {
1482
+ preFetchedNews = newsItems;
1483
+ }
1484
+
1485
+ newsSeq = preFetchedNews.slice();
1486
+ newsSeqIdx = -1;
1487
+ newsTopicQuery = "";
1488
+
1489
+ if (newsItems.length > 0) {
1490
+ latestNewsUrl = newsItems[0].link;
1491
+ setCurrentNews(newsItems[0]);
1492
+ try {
1493
+ latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
1494
+ } catch {
1495
+ latestNewsSource = "VnExpress";
1496
+ }
1497
+ }
1498
+
1499
+ const c = new S2sWsRealtimeClient({
1500
+ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
1501
+ voice: settings.voice,
1502
+ instructions: effectiveInstructions(newsItems),
1503
+ audioContext: audioCtx,
1504
+ outputNode: voiceSink,
1505
+ workletBaseUrl: "/worklets/",
1506
+ tools: TOOL_DEFS,
1507
+ _textOnly: true,
1508
+ });
1509
+
1510
+ client = c;
1511
+ _attachClientEvents(c);
1512
+ textMode = true;
1513
+ showTextChat(true);
1514
+ textModeBtn.classList.add("active");
1515
+
1516
+ const ok = await connectSession(c);
1517
+ if (!ok) return;
1518
+
1519
+ if (!autoGreetingSent) {
1520
+ c.requestResponse();
1521
+ }
1522
+
1523
+ if (initialText) {
1524
+ c.sendUserText(initialText);
1525
+ c.requestResponse();
1526
+ }
1527
+ }
1528
+
1529
+ function _attachClientEvents(c) {
1530
+ c.addEventListener("status", (e) => {
1531
+ onStatus(e.detail.status);
1532
+ });
1533
+
1534
+ c.addEventListener("queue", (e) => {
1535
+ const { position } = e.detail;
1536
+ setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live");
1537
+ });
1538
+
1539
+ c.addEventListener("transcript", (e) => {
1540
+ const { role, text } = e.detail;
1541
+ if (role === "assistant" && text) {
1542
+ const normalized = smartNormalize(text);
1543
+ showSubtitles(normalized);
1544
+ // Attach the next image of the CURRENT news item (loops through its images).
1545
+ const img = nextNewsImage();
1546
+ const newsItem = curNews ? { ...curNews, image: img || curNews.image } : null;
1547
+ addChatMessage("assistant", normalized, newsItem);
1548
+ }
1549
+ });
1550
+
1551
+ c.addEventListener("response-finished", () => {
1552
+ autoGreetingSent = true;
1553
+ fadeSubtitles();
1554
+ if (preFetchedNews.length > 0) {
1555
+ showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
1556
+ }
1557
+ _topicTagsRendered = false;
1558
+ try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
1559
+ });
1560
+
1561
+ c.addEventListener("toolcall", (e) => {
1562
+ const { name, arguments: args, callId } = e.detail;
1563
+ runTool(name, args, callId);
1564
+ });
1565
+
1566
+ c.addEventListener("server-error", (e) => {
1567
+ console.warn("server error:", e.detail.error);
1568
+ });
1569
+
1570
+ c.addEventListener("error", () => {
1571
+ void endSession();
1572
+ });
1573
+ }
1574
+
1575
+ async function endSession(silent = false) {
1576
+ const c = client;
1577
+ client = null;
1578
+ sessionInProgress = false;
1579
+ autoGreetingSent = false;
1580
+
1581
+ if (c) {
1582
+ if (c.options.micStream) {
1583
+ for (const track of c.options.micStream?.getTracks() ?? []) {
1584
+ track.stop();
1585
+ }
1586
+ }
1587
+ await c.close().catch(() => {});
1588
+ }
1589
+
1590
+ stage.setConversationState("idle");
1591
+ subtitles.classList.remove("visible");
1592
+
1593
+ if (newsPanel) {
1594
+ newsPanel.hidden = true;
1595
+ }
1596
+
1597
+ if (!silent) {
1598
+ setCaption(CAPTIONS.idle);
1599
+ setMainButton("start", "Start talking");
1600
+ // Show today's real trending topics immediately (also re-shown after greeting).
1601
+ try { void renderTopicTags(); } catch (e) { console.warn("[topic] startWithSelection:", e); }
1602
+ }
1603
+ }
1604
+
1605
+ // ── UI events ───────────────────────────────────────────────────────────
1606
+ mainBtn.addEventListener("click", () => {
1607
+ if (mainAction === "start") {
1608
+ if (sessionInProgress) return;
1609
+ if (new URLSearchParams(location.search).has("fakemic")) {
1610
+ void startTextSession();
1611
+ } else {
1612
+ void startVoiceSession();
1613
+ }
1614
+ } else if (mainAction === "join") {
1615
+ stage.resume();
1616
+ client?.join();
1617
+ } else if (mainAction === "stop") {
1618
+ void endSession();
1619
+ }
1620
+ });
1621
+
1622
+ muteBtn.addEventListener("click", () => {
1623
+ muted = !muted;
1624
+ client?.setMuted(muted);
1625
+ muteBtn.classList.toggle("active", muted);
1626
+ muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
1627
+ });
1628
+
1629
+ textModeBtn.addEventListener("click", () => {
1630
+ textMode = !textMode;
1631
+ showTextChat(textMode);
1632
+ textModeBtn.classList.toggle("active", textMode);
1633
+ });
1634
+
1635
+ chatSendBtn.addEventListener("click", () => sendTextMessage());
1636
+
1637
+ chatInput.addEventListener("keypress", (e) => {
1638
+ if (e.key === "Enter") {
1639
+ sendTextMessage();
1640
+ }
1641
+ });
1642
+
1643
+ chatAvatarSelect.addEventListener("change", () => {
1644
+ setAvatarFromSelect(chatAvatarSelect.value);
1645
+ void reloadAvatar();
1646
+ });
1647
+
1648
+ settingsAvatarSelect.addEventListener("change", () => {
1649
+ setAvatarFromSelect(settingsAvatarSelect.value);
1650
+ chatAvatarSelect.value = settingsAvatarSelect.value;
1651
+ void reloadAvatar();
1652
+ });
1653
+
1654
+ settingsBtn.addEventListener("click", () => {
1655
+ inputVoice.value = settings.voice;
1656
+ inputInstructions.value = settings.instructions;
1657
+ inputDirectUrl.value = settings.directUrl;
1658
+ inputSubtitles.checked = settings.subtitles;
1659
+ settingsAvatarSelect.value = chatAvatarSelect.value;
1660
+ settingsDialog.showModal();
1661
+ });
1662
+
1663
+ settingsDialog.addEventListener("close", () => {
1664
+ settings = {
1665
+ voice: inputVoice.value || DEFAULT_VOICE,
1666
+ avatar: settingsAvatarSelect.value || "",
1667
+ instructions: inputInstructions.value,
1668
+ directUrl: inputDirectUrl.value.trim(),
1669
+ subtitles: inputSubtitles.checked
1670
+ };
1671
+
1672
+ if (settings.avatar !== "vuong.glb") {
1673
+ autoGreetingSent = false;
1674
+ }
1675
+
1676
+ saveSettings();
1677
+ chatAvatarSelect.value = settings.avatar;
1678
+
1679
+ if (!settings.subtitles) {
1680
+ subtitles.classList.remove("visible");
1681
+ }
1682
+
1683
+ client?.updateSession({
1684
+ voice: settings.voice,
1685
+ instructions: effectiveInstructions(preFetchedNews)
1686
+ });
1687
+ });
1688
+
1689
+ window.addEventListener("beforeunload", () => {
1690
+ client?.close();
1691
+ });
1692
+
1693
+ // ── Boot ────────────────────────────────────────────────────────────────
1694
+ async function boot() {
1695
+ // 1) Reveal the picker IMMEDIATELY — first screen, never a loader.
1696
+ showPicker();
1697
+
1698
+ // 2) Voice <select> options.
1699
+ for (const v of VOICES) {
1700
+ const o = document.createElement("option");
1701
+ o.value = v;
1702
+ o.textContent = v.replaceAll("_", " ");
1703
+ inputVoice.append(o);
1704
+ }
1705
+ inputVoice.value = settings.voice;
1706
+
1707
+ // 3) Best-effort setup; isolate each step so a failure can NEVER hide the picker.
1708
+ try {
1709
+ const resp = await fetch("api/config");
1710
+ if (resp.ok) config = { ...config, ...(await resp.json()) };
1711
+ } catch (e) { console.warn("[boot] config:", e); }
1712
+ directUrlRow.hidden = !config.allowDirect;
1713
+
1714
+ // Keep using the fake microphone (no real getUserMedia) so we never hit
1715
+ // the "MIC BLOCKED" permission prompt. Restored from the original boot().
1716
+ try {
1717
+ const url = new URL(location.href);
1718
+ url.searchParams.set("fakemic", "1");
1719
+ history.replaceState(null, "", url.href);
1720
+ } catch (e) { console.warn("[boot] fakemic:", e); }
1721
+
1722
+ try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
1723
+ try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
1724
+ try {
1725
+ makeDraggable(); makeResizable(); makeNewsDraggable(); makeNewsResizable(); buildVnewsPanel();
1726
+ } catch (e) { console.warn("[boot] ui:", e); }
1727
+
1728
+ // 4) Fill the picker (avatar cards + voice chips).
1729
+ try { buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
1730
+ try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
1731
+ try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", e); }
1732
+ }
1733
+
1734
+ // ── News box draggable + resizable (mirrors the chat box) ────────────────
1735
+ function makeNewsDraggable() {
1736
+ let dragging = false;
1737
+ let startX, startY, startLeft, startTop;
1738
+
1739
+ function getPos(e) {
1740
+ const p = e.changedTouches ? e.changedTouches[0] : e;
1741
+ return { x: p.clientX, y: p.clientY };
1742
+ }
1743
+
1744
+ function onStart(e) {
1745
+ if (e.target.closest("#news-header") === null) return; // only drag from header
1746
+ if (e.target.closest("#news-close-btn")) return;
1747
+ const p = getPos(e);
1748
+ dragging = true;
1749
+ const rect = newsPanel.getBoundingClientRect();
1750
+ startX = p.x;
1751
+ startY = p.y;
1752
+ startLeft = rect.left;
1753
+ startTop = rect.top;
1754
+ newsPanel.classList.add("dragging");
1755
+ e.preventDefault();
1756
+ }
1757
+
1758
+ function onMove(e) {
1759
+ if (!dragging) return;
1760
+ const p = getPos(e);
1761
+ const vw = window.innerWidth, vh = window.innerHeight;
1762
+ const w = newsPanel.offsetWidth, h = newsPanel.offsetHeight;
1763
+ let l = Math.max(10, Math.min(startLeft + p.x - startX, vw - w - 10));
1764
+ let t = Math.max(10, Math.min(startTop + p.y - startY, vh - h - 10));
1765
+ newsPanel.style.left = `${l}px`;
1766
+ newsPanel.style.top = `${t}px`;
1767
+ newsPanel.style.right = "auto";
1768
+ newsPanel.style.bottom = "auto";
1769
+ e.preventDefault();
1770
+ }
1771
+
1772
+ function onEnd() {
1773
+ if (!dragging) return;
1774
+ dragging = false;
1775
+ newsPanel.classList.remove("dragging");
1776
+ }
1777
+
1778
+ const header = $("#news-header");
1779
+ header.addEventListener("mousedown", onStart);
1780
+ document.addEventListener("mousemove", onMove);
1781
+ document.addEventListener("mouseup", onEnd);
1782
+ header.addEventListener("touchstart", onStart, { passive: false });
1783
+ document.addEventListener("touchmove", onMove, { passive: false });
1784
+ document.addEventListener("touchend", onEnd);
1785
+ }
1786
+
1787
+ function makeNewsResizable() {
1788
+ let resizing = false;
1789
+ let startX, startY, startW, startH;
1790
+
1791
+ function getPos(e) {
1792
+ const p = e.changedTouches ? e.changedTouches[0] : e;
1793
+ return { x: p.clientX, y: p.clientY };
1794
+ }
1795
+
1796
+ function onStart(e) {
1797
+ resizing = true;
1798
+ const rect = newsPanel.getBoundingClientRect();
1799
+ const p = getPos(e);
1800
+ startX = p.x;
1801
+ startY = p.y;
1802
+ startW = rect.width;
1803
+ startH = rect.height;
1804
+ newsPanel.classList.add("resizing");
1805
+ e.preventDefault();
1806
+ e.stopPropagation();
1807
+ }
1808
+
1809
+ function onMove(e) {
1810
+ if (!resizing) return;
1811
+ const p = getPos(e);
1812
+ newsPanel.style.width = `${Math.max(300, startW + p.x - startX)}px`;
1813
+ newsPanel.style.height = `${Math.max(180, startH + p.y - startY)}px`;
1814
+ newsPanel.style.right = "auto";
1815
+ newsPanel.style.bottom = "auto";
1816
+ e.preventDefault();
1817
+ }
1818
+
1819
+ function onEnd() {
1820
+ if (!resizing) return;
1821
+ resizing = false;
1822
+ newsPanel.classList.remove("resizing");
1823
+ }
1824
+
1825
+ newsResizeHandle.addEventListener("mousedown", onStart);
1826
+ document.addEventListener("mousemove", onMove);
1827
+ document.addEventListener("mouseup", onEnd);
1828
+ newsResizeHandle.addEventListener("touchstart", onStart, { passive: false });
1829
+ document.addEventListener("touchmove", onMove, { passive: false });
1830
+ document.addEventListener("touchend", onEnd);
1831
+ }
1832
+
1833
+ void boot();
1834
+
1835
+ // ── Capture PNG (snapshot of the 3D avatar) ──────────────
1836
+ const THUMB_KEY = "avatar.thumbs";
1837
+ const avatarThumbPreview = document.getElementById("avatar-thumb-preview");
1838
+ function loadThumbs() {
1839
+ try { return JSON.parse(localStorage.getItem(THUMB_KEY) || "{}"); }
1840
+ catch { return {}; }
1841
+ }
1842
+ function saveThumbs(map) { localStorage.setItem(THUMB_KEY, JSON.stringify(map)); }
1843
+ function serverThumbUrl(name) {
1844
+ if (!name) return "";
1845
+ return `/avatars/${name}.thumb.png`;
1846
+ }
1847
+ // Show the avatar's representative image: prefer the freshly-captured local
1848
+ // copy (instant), fall back to the server-stored thumbnail (uploaded earlier).
1849
+ function updateAvatarThumbPreview() {
1850
+ if (!avatarThumbPreview) return;
1851
+ const name = chatAvatarSelect.value || settings.avatar || "";
1852
+ const map = loadThumbs();
1853
+ const local = map[name] || "";
1854
+ if (local) {
1855
+ avatarThumbPreview.src = local;
1856
+ avatarThumbPreview.hidden = false;
1857
+ avatarThumbPreview.onerror = () => {
1858
+ const srv = serverThumbUrl(name);
1859
+ if (srv && avatarThumbPreview.src !== srv) {
1860
+ avatarThumbPreview.src = srv;
1861
+ avatarThumbPreview.onerror = () => {
1862
+ const localBackup = map[name + "._local"];
1863
+ if (localBackup && avatarThumbPreview.src !== localBackup) {
1864
+ avatarThumbPreview.src = localBackup;
1865
+ avatarThumbPreview.onerror = null;
1866
+ } else {
1867
+ avatarThumbPreview.hidden = true;
1868
+ }
1869
+ };
1870
+ } else {
1871
+ avatarThumbPreview.hidden = true;
1872
+ }
1873
+ };
1874
+ return;
1875
+ }
1876
+ const srv = serverThumbUrl(name);
1877
+ if (srv) {
1878
+ avatarThumbPreview.onerror = () => { avatarThumbPreview.hidden = true; };
1879
+ avatarThumbPreview.src = srv;
1880
+ avatarThumbPreview.hidden = false;
1881
+ return;
1882
+ }
1883
+ avatarThumbPreview.hidden = true;
1884
+ }
1885
+ if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
1886
+
1887
+ const snapBtn = document.getElementById("snap-btn");
1888
+ const snapDialog = document.getElementById("snap-dialog");
1889
+ const snapPreview = document.getElementById("snap-preview");
1890
+ const snapDownload = document.getElementById("snap-download");
1891
+ const snapSetAvatar = document.getElementById("snap-setavatar");
1892
+ let _latestSnap = null;
1893
+ if (snapBtn) {
1894
+ snapBtn.addEventListener("click", () => {
1895
+ if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
1896
+ const url = stage.capturePNG();
1897
+ if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
1898
+ _latestSnap = url;
1899
+ snapPreview.src = url;
1900
+ if (snapDialog.showModal) snapDialog.showModal();
1901
+ else snapDialog.setAttribute("open", "");
1902
+ });
1903
+ }
1904
+ if (snapDownload) {
1905
+ snapDownload.addEventListener("click", () => {
1906
+ if (!_latestSnap) return;
1907
+ const name = (settings.avatar || "avatar").replace(/\.glb$/i, "");
1908
+ const a = document.createElement("a");
1909
+ a.href = _latestSnap; a.download = name + ".png";
1910
+ document.body.appendChild(a); a.click(); a.remove();
1911
+ });
1912
+ }
1913
+ if (snapSetAvatar) {
1914
+ snapSetAvatar.addEventListener("click", async () => {
1915
+ if (!_latestSnap) return;
1916
+ const key = settings.avatar || "";
1917
+ if (!key) { setCaption("CHON 1 AVATAR TRUOC", "error"); return; }
1918
+ snapSetAvatar.disabled = true;
1919
+ snapSetAvatar.textContent = "⏳ Đang lưu…";
1920
+ try {
1921
+ const resp = await fetch("/api/avatar-thumbnail", {
1922
+ method: "POST",
1923
+ headers: { "Content-Type": "application/json" },
1924
+ body: JSON.stringify({ avatar: key, dataUrl: _latestSnap }),
1925
+ });
1926
+ const data = await resp.json().catch(() => ({}));
1927
+ if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
1928
+ // Cache locally too (works offline + instant preview)
1929
+ const map = loadThumbs();
1930
+ map[key] = _latestSnap;
1931
+ saveThumbs(map);
1932
+ // Use the server URL for the preview now that it is persisted.
1933
+ // Keep the local data URL in the map as a fallback if server URL fails.
1934
+ if (data.url) {
1935
+ avatarThumbPreview.src = data.url;
1936
+ avatarThumbPreview.hidden = false;
1937
+ avatarThumbPreview.onerror = () => {
1938
+ // Fallback to local data URL if server URL fails.
1939
+ avatarThumbPreview.src = _latestSnap;
1940
+ avatarThumbPreview.onerror = null;
1941
+ };
1942
+ // Store server URL but keep local as fallback in a separate key.
1943
+ map[key] = data.url;
1944
+ map[key + "._local"] = _latestSnap;
1945
+ saveThumbs(map);
1946
+ } else {
1947
+ avatarThumbPreview.src = _latestSnap;
1948
+ avatarThumbPreview.hidden = false;
1949
+ }
1950
+ // Force refresh all avatar previews (picker + chat select + settings select)
1951
+ try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
1952
+ try { updateAvatarThumbPreview(); } catch (e) { console.warn("[snap] updateThumb:", e); }
1953
+ setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
1954
+ } catch (e) {
1955
+ console.warn(e);
1956
+ setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
1957
+ } finally {
1958
+ snapSetAvatar.disabled = false;
1959
+ snapSetAvatar.textContent = "🖼️ Đặt làm ảnh đại diện";
1960
+ }
1961
+ });
1962
+ }
1963
+
1964
+ // ── Onboarding picker (choose character + voice BEFORE loading avatar) ──
1965
+ function buildPicker() {
1966
+ const grid = document.getElementById("picker-avatars");
1967
+ const voices = document.getElementById("picker-voices");
1968
+ if (!grid || !voices) return;
1969
+ grid.innerHTML = "";
1970
+ voices.innerHTML = "";
1971
+ const selAvatar = settings.avatar || "";
1972
+ const list = avatarList.length ? avatarList : ["vuong.glb"];
1973
+ for (const name of list) {
1974
+ const card = document.createElement("button");
1975
+ card.type = "button";
1976
+ card.className = "avatar-card" + (name === selAvatar ? " selected" : "");
1977
+ card.dataset.avatar = name;
1978
+ const label = name.replace(/\.glb$/i, "").replace(/_/g, " ");
1979
+ const thumb = document.createElement("div");
1980
+ thumb.className = "avatar-card-thumb";
1981
+ const _map = loadThumbs();
1982
+ const _img = document.createElement("img");
1983
+ _img.alt = label;
1984
+ _img.loading = "lazy";
1985
+ _img.addEventListener("error", () => {
1986
+ _img.remove();
1987
+ thumb.textContent = label.slice(0, 1).toUpperCase();
1988
+ });
1989
+ const _srv = serverThumbUrl(name);
1990
+ // Prefer local cached data URL if available (instant), otherwise server URL.
1991
+ // The onerror handler below will fall back to the server URL if local fails,
1992
+ // and to a local backup if server URL also fails.
1993
+ _img.src = _map[name] || _srv;
1994
+ _img.addEventListener("error", () => {
1995
+ // If local failed and we haven't tried server yet, try server.
1996
+ if (_map[name] && _srv && _img.src !== _srv) {
1997
+ _img.src = _srv;
1998
+ } else if (_map[name + "._local"] && _img.src !== _map[name + "._local"]) {
1999
+ // Server URL failed, try local backup.
2000
+ _img.src = _map[name + "._local"];
2001
+ } else {
2002
+ _img.remove();
2003
+ thumb.textContent = label.slice(0, 1).toUpperCase();
2004
+ }
2005
+ });
2006
+ thumb.appendChild(_img);
2007
+ const span = document.createElement("span");
2008
+ span.className = "avatar-card-name";
2009
+ span.textContent = label + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
2010
+ card.appendChild(thumb);
2011
+ card.appendChild(span);
2012
+ card.addEventListener("click", () => {
2013
+ grid.querySelectorAll(".avatar-card").forEach((c) => c.classList.remove("selected"));
2014
+ card.classList.add("selected");
2015
+ settings.avatar = name;
2016
+ saveSettings();
2017
+ });
2018
+ grid.appendChild(card);
2019
+ }
2020
+ for (const v of VOICES) {
2021
+ const chip = document.createElement("button");
2022
+ chip.type = "button";
2023
+ chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
2024
+ chip.dataset.voice = v;
2025
+ chip.textContent = v.replaceAll("_", " ");
2026
+ chip.addEventListener("click", () => {
2027
+ voices.querySelectorAll(".voice-chip").forEach((c) => c.classList.remove("selected"));
2028
+ chip.classList.add("selected");
2029
+ settings.voice = v;
2030
+ saveSettings();
2031
+ });
2032
+ voices.appendChild(chip);
2033
+ }
2034
+ }
2035
+ function showPicker() {
2036
+ const p = document.getElementById("picker");
2037
+ if (p) p.hidden = false;
2038
+ }
2039
+ function hidePicker() {
2040
+ const p = document.getElementById("picker");
2041
+ if (p) p.hidden = true;
2042
+ }
2043
+ async function startWithSelection() {
2044
+ hidePicker();
2045
+ setCaption("Chờ tôi một lát! Tôi sẽ đến với bạn ngay!");
2046
+ setMainButton("busy", "Loading…");
2047
+ loading.classList.remove("done");
2048
+ loading.classList.add("active");
2049
+ loading.textContent = "Loading avatar...";
2050
+
2051
+ // Show the start progress bar with the friendly message.
2052
+ const startProgress = document.getElementById("start-progress");
2053
+ const startProgressFill = startProgress ? startProgress.querySelector(".start-progress__fill") : null;
2054
+ if (startProgress) startProgress.hidden = false;
2055
+ if (startProgressFill) startProgressFill.style.width = "0%";
2056
+
2057
+ let progress = 0;
2058
+ const progressInterval = setInterval(() => {
2059
+ progress = Math.min(progress + 5, 95);
2060
+ if (startProgressFill) startProgressFill.style.width = progress + "%";
2061
+ }, 150);
2062
+
2063
+ const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
2064
+ let ok = false;
2065
+ try {
2066
+ const initPromise = stage.init({
2067
+ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
2068
+ onprogress: (ev) => {
2069
+ if (ev.lengthComputable) {
2070
+ const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
2071
+ loading.textContent = `Loading avatar ${pct}%`;
2072
+ if (startProgressFill) startProgressFill.style.width = pct + "%";
2073
+ progress = pct;
2074
+ }
2075
+ }
2076
+ });
2077
+ const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
2078
+ await Promise.race([initPromise, timeout]);
2079
+ stage.resume(); // start render loop so the avatar is actually visible (not a single black frame)
2080
+ ok = true;
2081
+ } catch (err) {
2082
+ console.error("[startWithSelection] init failed/timeout:", err);
2083
+ } finally {
2084
+ clearInterval(progressInterval);
2085
+ if (startProgressFill) startProgressFill.style.width = "100%";
2086
+ // Hide the start progress bar after a brief moment showing 100%
2087
+ setTimeout(() => {
2088
+ if (startProgress) startProgress.hidden = true;
2089
+ if (startProgressFill) startProgressFill.style.width = "0%";
2090
+ }, 500);
2091
+ try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; }
2092
+ newsSeq = preFetchedNews.slice();
2093
+ newsSeqIdx = -1;
2094
+ newsTopicQuery = "";
2095
+ loading.classList.remove("active");
2096
+ if (ok) setCaption(CAPTIONS.idle);
2097
+ else setCaption("AVATAR KHÔNG HIỂN THỊ — THỬ CHỌN AVATAR KHÁC", "error");
2098
+ setMainButton("start", "Start talking");
2099
+ }
2100
+ }
2101
+ const pickerStartBtn = document.getElementById("picker-start");
2102
+ if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
avatars ADDED
File without changes
index.html CHANGED
@@ -11,19 +11,6 @@
11
  rel="stylesheet"
12
  />
13
  <link rel="stylesheet" href="./src/style.css" />
14
- <!--
15
- CRITICAL: Import map for bare module specifiers used in src/avatar.js and src/app.js.
16
- Without this, browser fails to resolve "three", "@met4citizen/talkinghead", etc.
17
- -->
18
- <script type="importmap">
19
- {
20
- "imports": {
21
- "three": "https://esm.sh/three@0.173.0",
22
- "three/": "https://esm.sh/three@0.173.0/",
23
- "@met4citizen/talkinghead": "https://esm.sh/@met4citizen/talkinghead@0.6.26"
24
- }
25
- }
26
- </script>
27
  </head>
28
  <body>
29
  <main id="app">
@@ -68,14 +55,6 @@
68
  <div id="chat-messages"></div>
69
  <div id="chat-input-container">
70
  <input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
71
- <button id="chat-mic-btn" class="icon-btn" aria-label="Switch to voice chat" title="Chuyển sang chat giọng nói" hidden>
72
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
73
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
74
- <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
75
- <line x1="12" y1="19" x2="12" y2="23"></line>
76
- <line x1="8" y1="23" x2="16" y2="23"></line>
77
- </svg>
78
- </button>
79
  <button id="chat-send-btn" class="icon-btn" aria-label="Send message">
80
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
81
  <line x1="22" y1="2" x2="11" y2="13"></line>
@@ -175,4 +154,4 @@
175
  </main>
176
  <script type="module" src="./src/app.js"></script>
177
  </body>
178
- </html>
 
11
  rel="stylesheet"
12
  />
13
  <link rel="stylesheet" href="./src/style.css" />
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </head>
15
  <body>
16
  <main id="app">
 
55
  <div id="chat-messages"></div>
56
  <div id="chat-input-container">
57
  <input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
 
 
 
 
 
 
 
 
58
  <button id="chat-send-btn" class="icon-btn" aria-label="Send message">
59
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
60
  <line x1="22" y1="2" x2="11" y2="13"></line>
 
154
  </main>
155
  <script type="module" src="./src/app.js"></script>
156
  </body>
157
+ </html>
index.ts CHANGED
@@ -21,19 +21,6 @@ async function proxy(path, req, init = {}) {
21
  const cookie = req.headers.get("cookie");
22
  if (cookie) headers.set("Cookie", cookie);
23
  const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers });
24
-
25
- // Handle non-JSON errors (429, 502, etc) from upstream
26
- if (!resp.ok) {
27
- const ct = resp.headers.get("content-type") || "";
28
- if (ct.includes("text/html")) {
29
- const body = await resp.text().catch(() => "");
30
- if (resp.status === 429) {
31
- return Response.json({ error: "Dịch vụ giọng nói đang quá tải, thử lại sau", state: "rate_limited" }, { status: 429 });
32
- }
33
- return Response.json({ error: "Speech service error", state: "unavailable" }, { status: resp.status });
34
- }
35
- }
36
-
37
  const body = await resp.text();
38
  const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
39
  const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
@@ -42,6 +29,8 @@ async function proxy(path, req, init = {}) {
42
  }
43
 
44
  function staticFile(dir, name) {
 
 
45
  const filePath = join(process.cwd(), "public", dir, name);
46
  const file = Bun.file(filePath);
47
  return new Response(file);
@@ -121,15 +110,19 @@ function sourceNameFromUrl(feedUrl) {
121
 
122
  /** Extract the first usable image URL from an RSS <item> block. */
123
  function extractImage(itemXml) {
 
124
  const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/i);
125
  if (media) return media[1];
 
126
  const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/i);
127
  if (enc) {
128
  const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i);
129
  if (urlMatch) return urlMatch[1];
130
  }
 
131
  const imgTag = itemXml.match(/<image>\s*<url>([\s\S]*?)<\/url>/i);
132
  if (imgTag) return imgTag[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
 
133
  const imgSrc = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/i);
134
  if (imgSrc) return imgSrc[1];
135
  return "";
@@ -158,6 +151,7 @@ function extractDescription(itemXml) {
158
  }
159
 
160
  const FEEDS = [
 
161
  "https://vnexpress.net/rss/tin-moi-nhat.rss",
162
  "https://dantri.com.vn/trangchu.rss",
163
  "https://tuoitre.vn/rss/tin-moi-nhat.rss",
@@ -174,51 +168,70 @@ const FEEDS = [
174
  "https://www.nguoiduatin.vn/rss/home.rss",
175
  "https://tienphong.vn/rss/home.rss",
176
  "https://vov.vn/rss/home.rss",
 
 
177
  "https://vietnamnet.vn/rss/the-thao.rss",
178
  "https://vtc.vn/rss/the-thao.rss",
179
  "https://thanhnien.vn/rss/the-thao.rss",
180
  "https://tuoitre.vn/rss/the-thao.rss",
181
  "https://dantri.com.vn/the-thao.rss",
 
182
  "https://www.bongda.com.vn/rss-bong-da.html",
183
  "https://bongdoanhnghia.vn/feed",
 
184
  "https://vietnamnet.vn/rss/kinh-te.rss",
185
  "https://cafef.vn/rss/thi-truong.rss",
186
  "https://vtc.vn/rss/kinh-te.rss",
187
  "https://thanhnien.vn/rss/kinh-te.rss",
188
  "https://laodong.vn/kinh-te-doanh-nghiep.rss",
 
189
  "https://vietnamnet.vn/rss/cong-nghe.rss",
190
  "https://genk.vn/rss/cong-nghe.rss",
191
  "https://cafef.vn/rss/cong-nghe.rss",
192
  "https://vtc.vn/rss/cong-nghe.rss",
193
  "https://thanhnien.vn/rss/cong-nghe.rss",
 
194
  "https://vietnamnet.vn/rss/thoi-su.rss",
195
  "https://vtc.vn/rss/thoi-su.rss",
196
  "https://thanhnien.vn/rss/thoi-su.rss",
197
  "https://tuoitre.vn/rss/thoi-su.rss",
 
198
  "https://vietnamnet.vn/rss/giao-duc.rss",
199
  "https://vtc.vn/rss/giao-duc.rss",
200
  "https://thanhnien.vn/rss/giao-duc.rss",
201
  "https://dantri.com.vn/giao-duc.rss",
 
202
  "https://vietnamnet.vn/rss/suc-khoe.rss",
203
  "https://vtc.vn/rss/suc-khoe.rss",
204
  "https://thanhnien.vn/rss/suc-khoe.rss",
 
205
  "https://vietnamnet.vn/rss/du-lich.rss",
206
  "https://vtc.vn/rss/du-lich.rss",
207
  "https://thanhnien.vn/rss/du-lich.rss",
 
208
  "https://vietnamnet.vn/rss/o-to-xe-xem.rss",
209
  "https://vtc.vn/rss/o-to.rss",
 
210
  "https://vietnamnet.vn/rss/giai-tri.rss",
211
  "https://vtc.vn/rss/giai-tri.rss",
212
  "https://thanhnien.vn/rss/giai-tri.rss",
 
213
  "https://vietnamnet.vn/rss/phap-luat.rss",
214
  "https://vtc.vn/rss/phap-luat.rss",
215
  "https://thanhnien.vn/rss/phap-luat.rss",
216
  ];
217
 
 
 
 
 
218
  function decodeHtmlEntities(str) {
219
  if (!str) return "";
 
220
  let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
 
221
  decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
 
222
  const namedEntities = {
223
  "&Ocirc;": "Ô", "&ograve;": "ò", "&Ograve;": "Ò", "&ocirc;": "ô",
224
  "&agrave;": "à", "&Agrave;": "À", "&eacute;": "é", "&Eacute;": "É",
@@ -240,9 +253,19 @@ function decodeHtmlEntities(str) {
240
  return decoded;
241
  }
242
 
 
 
 
 
 
 
 
 
243
  async function fetchHotNews() {
244
- const parsed = [];
245
  const seenLinks = new Set();
 
 
246
  await Promise.all(
247
  FEEDS.map(async (feedUrl) => {
248
  try {
@@ -258,7 +281,7 @@ async function fetchHotNews() {
258
  const tm = item.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
259
  const lm = item.match(/<link[^>]*>([\s\S]*?)<\/link>/i);
260
  if (!tm || !tm[1]) continue;
261
- const cleanTitle = decodeHtmlEntities(tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim());
262
  const cleanLink = lm ? lm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
263
  if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) {
264
  seenLinks.add(cleanLink);
@@ -273,10 +296,13 @@ async function fetchHotNews() {
273
  }
274
  })
275
  );
 
 
 
276
  const queue = parsed.map((p) => ({ source: p.source, items: p.items.slice() }));
277
- const items = [];
278
  let added = true;
279
- const MAX = 150;
280
  while (added && items.length < MAX) {
281
  added = false;
282
  for (const q of queue) {
@@ -288,10 +314,13 @@ async function fetchHotNews() {
288
  }
289
  }
290
  }
 
 
291
  const shuffled = items.sort(() => Math.random() - 0.5);
292
  return shuffled;
293
  }
294
 
 
295
  let newsPool = [];
296
  let newsPoolLoadedAt = 0;
297
 
@@ -302,32 +331,46 @@ async function refreshNewsPool() {
302
  }
303
 
304
  async function getNewsPool() {
 
305
  if (newsPool.length === 0 || Date.now() - newsPoolLoadedAt > 5 * 60 * 1000) {
306
  await refreshNewsPool();
307
  }
308
  return newsPool;
309
  }
310
 
 
311
  const ALL_TOPICS = [
 
312
  { slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] },
313
  { slug: "aseancup", label: "ASEAN Cup", keywords: ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"] },
314
  { slug: "bongda", label: "Bóng đá", keywords: ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"] },
315
  { slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] },
316
- { slug: "ai", label: "AI", keywords: ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi k3", "kimi"] },
 
317
  { slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] },
 
318
  { slug: "kinhte", label: "Kinh tế", keywords: ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"] },
 
319
  { slug: "thoisu", label: "Thời sự", keywords: ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"] },
 
320
  { slug: "giao-duc", label: "Giáo dục", keywords: ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"] },
 
321
  { slug: "suc-khoe", label: "Sức khỏe", keywords: ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"] },
 
322
  { slug: "du-lich", label: "Du lịch", keywords: ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"] },
 
323
  { slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] },
 
324
  { slug: "thegioi", label: "Thế giới", keywords: ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"] },
 
325
  { slug: "doisong", label: "Đời sống", keywords: ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"] },
326
  { slug: "giai-tri", label: "Giải trí", keywords: ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"] },
327
  { slug: "am-nhac", label: "Âm nhạc", keywords: ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"] },
 
328
  { slug: "phap-luat", label: "Pháp luật", keywords: ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"] },
329
  ];
330
 
 
331
  const VI_STOPWORDS = new Set([
332
  "và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia",
333
  "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi",
@@ -337,21 +380,35 @@ const VI_STOPWORDS = new Set([
337
  "vừa", "cũng", "chỉ", "đúng", "tất", "cả", "mỗi", "khác", "mới", "cũ", "lớn", "nhỏ", "cao", "thấp",
338
  "vừa", "rồi", "thì", "mới", "đã", "sẽ", "đang", "được", "để", "về", "từ", "trên", "dưới", "trong",
339
  "ngày", "giờ", "phút", "giây", "năm", "tháng", "tuần", "giờ", "phút", "giây",
 
340
  "ng", "nh", "nc", "nd", "nt", "nl", "nm", "np", "nk", "nj", "ni", "no", "nr", "ns", "nv", "nz",
341
  "ch", "tr", "ph", "th", "kh", "gh", "nh", "ng", "cn", "cv", "đc", "đk", "đt", "đv",
342
  ]);
343
 
 
 
 
 
 
344
  async function getTrendingTopics() {
345
  const pool = await getNewsPool();
 
 
 
346
  const allText = pool
347
  .map((i) => ((i.title || "") + " " + (i.description || "")).replace(/&[a-z]+;/gi, " "))
348
  .join(" ")
349
  .toLowerCase();
 
 
 
350
  const tokens = allText
351
- .replace(/[0-9]+/g, " ")
352
- .replace(/[^\p{L}\s]/gu, " ")
353
  .split(/\s+/)
354
  .filter((t) => t.length >= 2 && !VI_STOPWORDS.has(t));
 
 
355
  const bigramFreq = new Map();
356
  for (let i = 0; i < tokens.length - 1; i++) {
357
  const bigram = tokens[i] + " " + tokens[i + 1];
@@ -359,6 +416,8 @@ async function getTrendingTopics() {
359
  bigramFreq.set(bigram, (bigramFreq.get(bigram) || 0) + 1);
360
  }
361
  }
 
 
362
  const trigramFreq = new Map();
363
  for (let i = 0; i < tokens.length - 2; i++) {
364
  const trigram = tokens[i] + " " + tokens[i + 1] + " " + tokens[i + 2];
@@ -366,6 +425,8 @@ async function getTrendingTopics() {
366
  trigramFreq.set(trigram, (trigramFreq.get(trigram) || 0) + 1);
367
  }
368
  }
 
 
369
  const topicScores = [];
370
  for (const topic of ALL_TOPICS) {
371
  let count = 0;
@@ -373,32 +434,64 @@ async function getTrendingTopics() {
373
  const kwLower = kw.toLowerCase();
374
  count += (allText.split(kwLower).length - 1);
375
  }
376
- if (count > 0) topicScores.push({ slug: topic.slug, label: topic.label, count, type: "fixed" });
 
 
377
  }
 
 
378
  const knownKeywords = new Set();
379
- for (const t of ALL_TOPICS) { for (const kw of t.keywords) knownKeywords.add(kw.toLowerCase()); }
 
 
 
 
380
  const topTrigrams = [...trigramFreq.entries()]
381
- .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 6) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
382
- .sort((a, b) => b[1] - a[1]).slice(0, 10);
 
 
 
 
 
 
 
383
  const topBigrams = [...bigramFreq.entries()]
384
- .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 4) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
385
- .sort((a, b) => b[1] - a[1]).slice(0, 20);
 
 
 
 
 
 
 
386
  topicScores.sort((a, b) => b.count - a.count);
 
 
387
  const trending = [...topicScores.slice(0, 10)];
388
- const rawKeywords = [...topTrigrams, ...topBigrams].filter(([phrase, freq]) => freq >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10);
 
 
 
 
 
 
389
  for (const [phrase, freq] of rawKeywords) {
390
  const slug = phrase.replace(/\s+/g, "-").replace(/[^\p{L}\p{N}-]/gu, "");
391
  if (slug && slug.length > 2 && /^[a-zà-ỹ\-]+$/.test(slug) && !trending.find((t) => t.slug === slug)) {
392
- const label = phrase.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
 
 
 
393
  trending.push({ slug, label, count: freq, type: "dynamic" });
394
  }
395
  }
396
- // Always include AI topic regardless of score (it has its own VNEWS AI feed)
397
- if (!trending.find((f) => f.slug === "ai")) {
398
- trending.push({ slug: "ai", label: "AI", count: 1, type: "fixed" });
399
- }
400
  if (trending.length < 10) {
401
  const defaults = [
 
402
  { slug: "congngang", label: "Công nghệ", keywords: [] },
403
  { slug: "bongda", label: "Bóng đá", keywords: [] },
404
  { slug: "kinhte", label: "Kinh tế", keywords: [] },
@@ -410,13 +503,19 @@ async function getTrendingTopics() {
410
  { slug: "giai-tri", label: "Giải trí", keywords: [] },
411
  ];
412
  for (const d of defaults) {
413
- if (!trending.find((f) => f.slug === d.slug)) trending.push({ ...d, count: 1, type: "fixed" });
 
 
414
  }
415
  }
 
 
416
  const now = new Date();
417
  const start = new Date(now.getFullYear(), 0, 0);
418
  const dayOfYear = Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
419
  const seed = dayOfYear;
 
 
420
  const shuffled = [...trending];
421
  let r = seed;
422
  for (let i = shuffled.length - 1; i > 0; i--) {
@@ -424,19 +523,25 @@ async function getTrendingTopics() {
424
  const j = r % (i + 1);
425
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
426
  }
 
 
427
  return shuffled.slice(0, 20).map(({ slug, label }) => ({ slug, label }));
428
  }
429
 
 
430
  async function filterNews(query, limit = 15) {
431
  const pool = await getNewsPool();
432
  const q = (query || "").trim().toLowerCase();
433
  if (!q) return pool.slice(0, limit);
 
 
 
434
  const SLUG_MAP = {
435
  "worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"],
436
  "aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"],
437
  "bongda": ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"],
438
  "thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"],
439
- "ai": ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi"],
440
  "congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"],
441
  "kinhte": ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"],
442
  "thoisu": ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"],
@@ -450,11 +555,18 @@ async function filterNews(query, limit = 15) {
450
  "am-nhac": ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"],
451
  "phap-luat": ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"],
452
  };
 
 
 
453
  const LABEL_TO_SLUG = {};
454
  for (const [slug, terms] of Object.entries(SLUG_MAP)) {
 
455
  const labelKey = terms[0].replace(/\s+/g, "").toLowerCase();
456
  if (labelKey) LABEL_TO_SLUG[labelKey] = slug;
457
  }
 
 
 
458
  let terms = SLUG_MAP[q];
459
  if (!terms) {
460
  const qNoSpace = q.replace(/\s+/g, "").toLowerCase();
@@ -462,30 +574,28 @@ async function filterNews(query, limit = 15) {
462
  if (slug) terms = SLUG_MAP[slug];
463
  }
464
  if (!terms) {
465
- if (q.includes("-")) { terms = q.split("-").filter((t) => t.length > 1); }
466
- else { terms = [q]; }
 
 
 
 
 
467
  }
 
 
468
  if (!terms || terms.length === 0) return [];
469
- const matched = pool.filter((it) => {
470
- const titleLower = it.title.toLowerCase();
471
- const descLower = (it.description || "").toLowerCase();
472
- const text = titleLower + " " + descLower;
473
- return terms.some((term) => {
474
- // Terms with leading/trailing spaces use word-boundary regex
475
- // that handles Vietnamese diacritics (NOT just \w/\b which
476
- // breaks on non-ASCII letters)
477
- if (term.startsWith(" ") || term.endsWith(" ")) {
478
- const word = term.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
479
- // Match only at whitespace/punctuation/string boundaries
480
- const re = new RegExp(`(^|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\])${word}($|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\-])`, 'i');
481
- return re.test(text);
482
- }
483
- return titleLower.includes(term) || descLower.includes(term);
484
- });
485
- });
486
  return matched.slice(0, limit);
487
  }
488
 
 
489
  async function fetchArticleContent(url) {
490
  try {
491
  const resp = await fetch(url, {
@@ -493,6 +603,8 @@ async function fetchArticleContent(url) {
493
  signal: AbortSignal.timeout(8000),
494
  });
495
  const html = await resp.text();
 
 
496
  let text = html
497
  .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
498
  .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
@@ -501,56 +613,156 @@ async function fetchArticleContent(url) {
501
  .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
502
  .replace(/<[^>]+>/g, " ")
503
  .replace(/&[a-z]+;/g, " ")
504
- .replace(/\s+/g, " ").trim();
505
- return text.slice(0, 4000);
 
 
506
  } catch (e) {
507
  console.warn(`[Article] Failed to fetch ${url}: ${e}`);
508
  return null;
509
  }
510
  }
511
 
 
512
  function formatVietnameseTime(now) {
513
  let h = now.getHours();
514
  const m = now.getMinutes();
515
  let part;
516
  if (h >= 0 && h < 5) part = "đêm";
517
  else if (h < 10) part = "sáng";
518
- else if (h < 13) part = "trưa";
519
  else if (h < 18) part = "chiều";
520
- else part = "tối";
521
- const hh = h === 0 ? 12 : h;
522
  if (m === 0) return `${hh}h ${part}`;
523
  return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
524
  }
525
 
526
- // Upload thumbnails to sidecar service (no Space rebuild)
527
- async function uploadToSidecar(path: string, buf: Uint8Array) {
528
- const sidecarUrl = Bun.env.SIDECAR_URL || "http://localhost:7861";
529
- const base64 = Buffer.from(buf).toString("base64");
530
-
531
- try {
532
- const resp = await fetch(`${sidecarUrl}/upload`, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
  method: "POST",
534
- headers: { "Content-Type": "application/json" },
535
- body: JSON.stringify({ path, content: base64 }),
536
  });
537
- if (!resp.ok) {
538
- const txt = await resp.text();
539
- console.warn(`[sidecar] upload failed (${resp.status}): ${txt}`);
540
- return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  }
542
- const data = await resp.json();
543
- console.log(`[sidecar] queued ${path} for dataset upload`);
544
- return true;
545
- } catch (err) {
546
- console.warn(`[sidecar] error: ${err}`);
547
- return false;
548
  }
549
  }
550
 
551
- async function uploadToHub(path: string, buf: Uint8Array) {
552
- // Use sidecar service for dataset upload (no Space rebuild)
553
- return uploadToSidecar(path, buf);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  }
555
 
556
  const server = Bun.serve({
@@ -564,6 +776,8 @@ const server = Bun.serve({
564
  return Response.json({ items: pool.slice(0, 15) });
565
  },
566
  },
 
 
567
  "/api/news/more": {
568
  GET: async (req) => {
569
  const url = new URL(req.url);
@@ -580,6 +794,7 @@ const server = Bun.serve({
580
  });
581
  },
582
  },
 
583
  "/api/news/filter": {
584
  GET: async (req) => {
585
  const url = new URL(req.url);
@@ -594,11 +809,14 @@ const server = Bun.serve({
594
  const url = new URL(req.url);
595
  const articleUrl = url.searchParams.get("url");
596
  if (!articleUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
 
597
  const content = await fetchArticleContent(articleUrl);
598
  if (!content) return Response.json({ error: "Could not fetch article" }, { status: 502 });
 
599
  return Response.json({ content, url: articleUrl });
600
  },
601
  },
 
602
  "/api/topics/trending": {
603
  GET: async () => {
604
  const topics = await getTrendingTopics();
@@ -616,33 +834,70 @@ const server = Bun.serve({
616
  const bullets = text.split(/\n\n+/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
617
  return Response.json({ title: text.slice(0, 80), body: bullets.map((b) => `• ${b}`).join("\n"), fallback: true });
618
  }
 
619
  const cleanText = text
620
  .split(/\n+/)
621
  .map((line) => line.replace(/^\s*(người dùng|user|avatar|bạn|trợ lí|trợ lý|assistant)\s*[:\-]\s*/i, "").trim())
622
- .filter(Boolean).join("\n");
623
- const SYSTEM_PROMPT = "Bạn là một BIÊN TẬP VIÊN báo chí tiếng Việt. VIẾT LẠI nội dung thành MỘT BÀI HOÀN CHỈNH bằng NGÔN TỪ CỦA BẠN.\n" +
624
- "QUY TẮC:\n1. KHÔNG sao chép nguyên văn. Diễn đạt lại (paraphrase), tóm gọn, tự nhiên.\n" +
625
- "2. KHÔNG giữ định dạng chat 'Người dùng:'/'Avatar:'.\n3. Dòng đầu là TIÊU ĐỀ (dưới 90 ký tự).\n" +
626
- "4. Tiếp theo: 1 mở đầu, 3-4 đoạn thân bài, 1 kết luận.\n5. Tiếng Việt chuẩn, KHÔNG liên kết, KHÔNG đánh số đầu dòng.\n6. Định dạng: TIÊU ĐỀ: <tiêu đề>\n\n<nội dung>";
 
 
 
 
 
 
 
627
  const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`;
628
  const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || "";
629
  const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct";
630
  let out = "";
 
631
  if (SUMMARY_ENDPOINT) {
632
  try {
633
- const sresp = await fetch(SUMMARY_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(90000) });
634
- if (sresp.ok) { const sdata = await sresp.json(); if (sdata && sdata.body) out = (sdata.body || "").trim(); }
 
 
 
 
 
 
 
 
635
  } catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); }
636
  }
 
637
  if (!out) {
638
  try {
639
- const rresp = await fetch("https://router.huggingface.co/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: MODEL, messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: USER_PROMPT }], max_tokens: 800, temperature: 0.8, top_p: 0.9 }), signal: AbortSignal.timeout(45000) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); }
641
  } catch (e) { console.warn(`[summarize] router failed: ${e}`); }
642
  }
643
  if (!out && Bun.env.SUMMARY_MODEL_LEGACY) {
644
  try {
645
- const lresp = await fetch(`https://api-inference.huggingface.co/models/${Bun.env.SUMMARY_MODEL_LEGACY}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ inputs: `${SYSTEM_PROMPT}\n\n${USER_PROMPT}`, parameters: { max_new_tokens: 700, return_full_text: false, temperature: 0.8, do_sample: true } }), signal: AbortSignal.timeout(45000) });
 
 
 
 
 
646
  if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); }
647
  } catch (e) { console.warn(`[summarize] legacy failed: ${e}`); }
648
  }
@@ -653,9 +908,16 @@ const server = Bun.serve({
653
  if (!title) title = bodyText.slice(0, 80);
654
  return Response.json({ title, body: bodyText || out });
655
  }
 
 
 
656
  const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean);
657
  const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện";
658
- const bodyFb = lines.map((l) => l.charAt(0).toUpperCase() + l.slice(1)).join(" ").replace(/\s+/g, " ").trim();
 
 
 
 
659
  return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true });
660
  } catch (e) {
661
  console.warn(`[summarize] ${e}`);
@@ -668,9 +930,19 @@ const server = Bun.serve({
668
  const files = await listAllAvatarFiles();
669
  const avatarsDir = join(process.cwd(), "public/avatars");
670
  const avatars = await listAvatars();
 
671
  const thumbStatus = {};
672
- for (const name of avatars) { thumbStatus[name] = await avatarHasThumbnail(name); }
673
- return Response.json({ cwd: process.cwd(), avatarsDir, files, count: files.length, avatars, thumbStatus });
 
 
 
 
 
 
 
 
 
674
  },
675
  },
676
  "/api/debug/thumbnail/:name": {
@@ -681,7 +953,14 @@ const server = Bun.serve({
681
  const file = Bun.file(thumbPath);
682
  const exists = await file.exists();
683
  const size = exists ? file.size : 0;
684
- return Response.json({ name, thumbName, thumbPath, exists, size, url: `/avatars/${thumbName}` });
 
 
 
 
 
 
 
685
  },
686
  },
687
  "/api/avatars": {
@@ -696,13 +975,52 @@ const server = Bun.serve({
696
  return Response.json({ avatars });
697
  },
698
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  "/api/avatars/debug": {
700
  GET: async () => {
701
  const allFiles = await listAllAvatarFiles();
702
  const glbFiles = allFiles.filter((f) => f.endsWith(".glb"));
703
  const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png"));
704
  const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png"));
705
- return Response.json({ allFiles, glbFiles, thumbFiles, otherFiles, avatarsDir: join(process.cwd(), "public/avatars") });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  },
707
  },
708
  "/api/wiki/search": {
@@ -711,9 +1029,14 @@ const server = Bun.serve({
711
  const q = url.searchParams.get("q");
712
  if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
713
  try {
714
- const resp = await fetch(`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`);
 
 
715
  const data = await resp.json();
716
- const results = (data.query?.search ?? []).map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") }));
 
 
 
717
  return Response.json({ results });
718
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
719
  },
@@ -724,9 +1047,15 @@ const server = Bun.serve({
724
  const title = url.searchParams.get("title");
725
  if (!title) return Response.json({ error: "Missing ?title=" }, { status: 400 });
726
  try {
727
- const resp = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`);
 
 
728
  const data = await resp.json();
729
- return Response.json({ title: data.title, extract: data.extract, url: data.content_urls?.desktop?.page });
 
 
 
 
730
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
731
  },
732
  },
@@ -736,7 +1065,10 @@ const server = Bun.serve({
736
  const q = url.searchParams.get("q");
737
  if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
738
  try {
739
- const resp = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, { headers: { "User-Agent": "Mozilla/5.0" } });
 
 
 
740
  const html = await resp.text();
741
  const results = [];
742
  const linkRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
@@ -761,12 +1093,26 @@ const server = Bun.serve({
761
  const targetUrl = url.searchParams.get("url");
762
  if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
763
  try {
764
- const resp = await fetch(targetUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, signal: AbortSignal.timeout(8000) });
 
 
 
765
  const html = await resp.text();
766
- let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "").replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "").replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim();
 
 
 
 
 
 
 
 
 
767
  const content = text.slice(0, 3000);
768
  return Response.json({ content, url: targetUrl });
769
- } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); }
 
 
770
  },
771
  },
772
  "/api/session": {
@@ -790,80 +1136,104 @@ const server = Bun.serve({
790
  },
791
  "/worklets/:name": (req) => staticFile("worklets", req.params.name),
792
  "/vendor/:name": (req) => staticFile("vendor", req.params.name),
793
- "/src/:name": async (req) => {
794
- const filePath = join(process.cwd(), "src", req.params.name);
795
- const file = Bun.file(filePath);
796
- const exists = await file.exists();
797
- if (!exists) return new Response("Not Found", { status: 404 });
798
- return new Response(file);
799
- },
800
  "/api/avatar-thumbnail": {
801
  POST: async (req) => {
802
  try {
803
  const body = await req.json();
804
  const avatar = (body.avatar || "").toString();
805
  const dataUrl = (body.dataUrl || "").toString();
 
 
806
  if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) {
 
807
  return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 });
808
  }
809
- const m = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/);
810
- if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
 
 
 
811
  let buf;
812
- try { buf = Buffer.from(m[2], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); }
813
- if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
814
- if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
815
- // Accept PNG (89 50 4E 47) or JPEG (FF D8 FF)
816
- const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
817
- const isJpeg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
818
- if (!isPng && !isJpeg) {
819
- return Response.json({ ok: false, error: "Not a PNG or JPEG" }, { status: 400 });
 
 
 
 
 
 
 
820
  }
821
  const fileName = `${avatar}.thumb.png`;
822
  const avatarsDir = join(process.cwd(), "public/avatars");
823
  const localPath = join(avatarsDir, fileName);
824
- const tmpPath = `/tmp/${fileName}`; // Use /tmp which is always writable
825
  const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
 
826
 
827
- // ── 1. Write to /tmp (always writable) for immediate serving ──
828
  try {
829
- await writeFile(tmpPath, uint8Buf);
830
- console.log(`[thumbnail] saved to tmp: ${tmpPath} (${buf.length} bytes)`);
831
- } catch (writeErr) {
832
- console.warn(`[thumbnail] tmp write failed: ${writeErr}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833
  }
834
 
835
- // ── 2. Upload to Hub for persistence ──
836
- uploadToHub(`public/avatars/${fileName}`, buf).catch((err) => {
837
- console.warn(`[thumbnail] Hub upload failed: ${err}. Thumbnail in /tmp only.`);
838
- });
 
 
 
839
 
840
- // ── 3. Serve from /tmp via fallback handler ──
841
  const thumbUrl = `/avatars/${fileName}`;
842
-
843
- return Response.json({ ok: true, url: thumbUrl, size: buf.length });
 
 
 
 
 
 
844
  } catch (e) {
845
- console.error(`[thumbnail] error: ${e}`);
846
- return Response.json({ ok: false, error: "Internal server error" }, { status: 500 });
847
  }
848
  },
849
  },
850
- },
851
- // fallback: serve static files from public/ and /tmp/avatars
852
- async fetch(req) {
853
- const url = new URL(req.url);
854
- // Serve avatar thumbnails from /tmp first (where they are written)
855
- if (url.pathname.startsWith("/avatars/")) {
856
- const tmpPath = join("/tmp", url.pathname.split("/").pop() || "");
857
- const tmpFile = Bun.file(tmpPath);
858
- if (await tmpFile.exists()) return new Response(tmpFile);
859
- }
860
- const filePath = join(process.cwd(), "public", url.pathname);
861
- const file = Bun.file(filePath);
862
- const exists = await file.exists();
863
- if (exists) return new Response(file);
864
- return new Response("Not Found", { status: 404 });
865
  },
866
  });
867
 
868
- console.log(`server running on port ${PORT}`);// force rebuild Sat Jul 25 04:14:36 UTC 2026
869
- // flush 60s
 
 
 
 
 
 
21
  const cookie = req.headers.get("cookie");
22
  if (cookie) headers.set("Cookie", cookie);
23
  const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers });
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  const body = await resp.text();
25
  const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
26
  const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
 
29
  }
30
 
31
  function staticFile(dir, name) {
32
+ // Use process.cwd() for reliability in Space environments where
33
+ // import.meta.dir may not point to the expected directory.
34
  const filePath = join(process.cwd(), "public", dir, name);
35
  const file = Bun.file(filePath);
36
  return new Response(file);
 
110
 
111
  /** Extract the first usable image URL from an RSS <item> block. */
112
  function extractImage(itemXml) {
113
+ // 1) media:thumbnail / media:content with url=
114
  const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/i);
115
  if (media) return media[1];
116
+ // 2) <enclosure type="image/..." url="...">
117
  const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/i);
118
  if (enc) {
119
  const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i);
120
  if (urlMatch) return urlMatch[1];
121
  }
122
+ // 3) <image><url>...</url></image>
123
  const imgTag = itemXml.match(/<image>\s*<url>([\s\S]*?)<\/url>/i);
124
  if (imgTag) return imgTag[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
125
+ // 4) any <img ... src="..."> inside description
126
  const imgSrc = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/i);
127
  if (imgSrc) return imgSrc[1];
128
  return "";
 
151
  }
152
 
153
  const FEEDS = [
154
+ // ── General Vietnamese news (existing) ──
155
  "https://vnexpress.net/rss/tin-moi-nhat.rss",
156
  "https://dantri.com.vn/trangchu.rss",
157
  "https://tuoitre.vn/rss/tin-moi-nhat.rss",
 
168
  "https://www.nguoiduatin.vn/rss/home.rss",
169
  "https://tienphong.vn/rss/home.rss",
170
  "https://vov.vn/rss/home.rss",
171
+ // ── Expanded sources per topic ──
172
+ // Thể thao / World Cup / Asian Cup
173
  "https://vietnamnet.vn/rss/the-thao.rss",
174
  "https://vtc.vn/rss/the-thao.rss",
175
  "https://thanhnien.vn/rss/the-thao.rss",
176
  "https://tuoitre.vn/rss/the-thao.rss",
177
  "https://dantri.com.vn/the-thao.rss",
178
+ // Bóng đá chuyên sâu
179
  "https://www.bongda.com.vn/rss-bong-da.html",
180
  "https://bongdoanhnghia.vn/feed",
181
+ // Kinh tế / Tài chính
182
  "https://vietnamnet.vn/rss/kinh-te.rss",
183
  "https://cafef.vn/rss/thi-truong.rss",
184
  "https://vtc.vn/rss/kinh-te.rss",
185
  "https://thanhnien.vn/rss/kinh-te.rss",
186
  "https://laodong.vn/kinh-te-doanh-nghiep.rss",
187
+ // Công nghệ / AI
188
  "https://vietnamnet.vn/rss/cong-nghe.rss",
189
  "https://genk.vn/rss/cong-nghe.rss",
190
  "https://cafef.vn/rss/cong-nghe.rss",
191
  "https://vtc.vn/rss/cong-nghe.rss",
192
  "https://thanhnien.vn/rss/cong-nghe.rss",
193
+ // Thời sự / Chính trị
194
  "https://vietnamnet.vn/rss/thoi-su.rss",
195
  "https://vtc.vn/rss/thoi-su.rss",
196
  "https://thanhnien.vn/rss/thoi-su.rss",
197
  "https://tuoitre.vn/rss/thoi-su.rss",
198
+ // Giáo dục
199
  "https://vietnamnet.vn/rss/giao-duc.rss",
200
  "https://vtc.vn/rss/giao-duc.rss",
201
  "https://thanhnien.vn/rss/giao-duc.rss",
202
  "https://dantri.com.vn/giao-duc.rss",
203
+ // Sức khỏe
204
  "https://vietnamnet.vn/rss/suc-khoe.rss",
205
  "https://vtc.vn/rss/suc-khoe.rss",
206
  "https://thanhnien.vn/rss/suc-khoe.rss",
207
+ // Du lịch
208
  "https://vietnamnet.vn/rss/du-lich.rss",
209
  "https://vtc.vn/rss/du-lich.rss",
210
  "https://thanhnien.vn/rss/du-lich.rss",
211
+ // Ô tô
212
  "https://vietnamnet.vn/rss/o-to-xe-xem.rss",
213
  "https://vtc.vn/rss/o-to.rss",
214
+ // Giải trí / Âm nhạc
215
  "https://vietnamnet.vn/rss/giai-tri.rss",
216
  "https://vtc.vn/rss/giai-tri.rss",
217
  "https://thanhnien.vn/rss/giai-tri.rss",
218
+ // Pháp luật
219
  "https://vietnamnet.vn/rss/phap-luat.rss",
220
  "https://vtc.vn/rss/phap-luat.rss",
221
  "https://thanhnien.vn/rss/phap-luat.rss",
222
  ];
223
 
224
+ /**
225
+ * Decode common HTML entities in a string to proper Unicode characters.
226
+ * Handles numeric entities (&#039;, &#244;) and named entities (&Ocirc;, &agrave;, &ocirc;).
227
+ */
228
  function decodeHtmlEntities(str) {
229
  if (!str) return "";
230
+ // Handle numeric entities: &#039; -> ', &#244; -> ô, etc.
231
  let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
232
+ // Handle hex entities: &#x27; -> ', etc.
233
  decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
234
+ // Handle common named entities
235
  const namedEntities = {
236
  "&Ocirc;": "Ô", "&ograve;": "ò", "&Ograve;": "Ò", "&ocirc;": "ô",
237
  "&agrave;": "à", "&Agrave;": "À", "&eacute;": "é", "&Eacute;": "É",
 
253
  return decoded;
254
  }
255
 
256
+ /**
257
+ * Fetch hot Vietnamese news from many RSS feeds.
258
+ *
259
+ * Uses ROUND-ROBIN selection: we pull an equal number of items from each
260
+ * source before moving on, so no single source (e.g. VnExpress) can dominate
261
+ * the list. The combined result is then shuffled for extra variety. Every
262
+ * item carries a thumbnail image when the feed provides one.
263
+ */
264
  async function fetchHotNews() {
265
+ const parsed = []; // [{source, items:[{title,link,image}]}]
266
  const seenLinks = new Set();
267
+
268
+ // Fetch all feeds in parallel for speed.
269
  await Promise.all(
270
  FEEDS.map(async (feedUrl) => {
271
  try {
 
281
  const tm = item.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
282
  const lm = item.match(/<link[^>]*>([\s\S]*?)<\/link>/i);
283
  if (!tm || !tm[1]) continue;
284
+ const cleanTitle = decodeHtmlEntities(tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim());
285
  const cleanLink = lm ? lm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
286
  if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) {
287
  seenLinks.add(cleanLink);
 
296
  }
297
  })
298
  );
299
+
300
+ // Round-robin: take 1 item from each source in turn, repeat until we have
301
+ // enough (or sources are exhausted). This guarantees source diversity.
302
  const queue = parsed.map((p) => ({ source: p.source, items: p.items.slice() }));
303
+ const items = []; // {title, link, source, image}
304
  let added = true;
305
+ const MAX = 150; // Increased to accommodate expanded feed sources
306
  while (added && items.length < MAX) {
307
  added = false;
308
  for (const q of queue) {
 
314
  }
315
  }
316
  }
317
+
318
+ // Final shuffle so the order is not source-clustered.
319
  const shuffled = items.sort(() => Math.random() - 0.5);
320
  return shuffled;
321
  }
322
 
323
+ // ── In-memory news pool (refreshed periodically) for stable pagination ──
324
  let newsPool = [];
325
  let newsPoolLoadedAt = 0;
326
 
 
331
  }
332
 
333
  async function getNewsPool() {
334
+ // Refresh if empty or older than 5 minutes.
335
  if (newsPool.length === 0 || Date.now() - newsPoolLoadedAt > 5 * 60 * 1000) {
336
  await refreshNewsPool();
337
  }
338
  return newsPool;
339
  }
340
 
341
+ /** All known topics with their keyword mappings. */
342
  const ALL_TOPICS = [
343
+ // ── Sports (dynamic: World Cup, Asian Cup, etc.) ──
344
  { slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] },
345
  { slug: "aseancup", label: "ASEAN Cup", keywords: ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"] },
346
  { slug: "bongda", label: "Bóng đá", keywords: ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"] },
347
  { slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] },
348
+ // ── Technology & AI ──
349
+ { slug: "ai", label: "AI", keywords: ["trí tuệ nhân tạo", "ai ", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "kimik3", "kimi k3", "kimi"] },
350
  { slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] },
351
+ // ── Economy & Finance ──
352
  { slug: "kinhte", label: "Kinh tế", keywords: ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"] },
353
+ // ── News & Politics ──
354
  { slug: "thoisu", label: "Thời sự", keywords: ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"] },
355
+ // ── Education ──
356
  { slug: "giao-duc", label: "Giáo dục", keywords: ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"] },
357
+ // ── Health ──
358
  { slug: "suc-khoe", label: "Sức khỏe", keywords: ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"] },
359
+ // ── Travel ──
360
  { slug: "du-lich", label: "Du lịch", keywords: ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"] },
361
+ // ── Automotive ──
362
  { slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] },
363
+ // ── World ──
364
  { slug: "thegioi", label: "Thế giới", keywords: ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"] },
365
+ // ── Life & Entertainment ──
366
  { slug: "doisong", label: "Đời sống", keywords: ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"] },
367
  { slug: "giai-tri", label: "Giải trí", keywords: ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"] },
368
  { slug: "am-nhac", label: "Âm nhạc", keywords: ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"] },
369
+ // ── Law ──
370
  { slug: "phap-luat", label: "Pháp luật", keywords: ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"] },
371
  ];
372
 
373
+ /** Vietnamese stopwords to exclude from keyword extraction. */
374
  const VI_STOPWORDS = new Set([
375
  "và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia",
376
  "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi",
 
380
  "vừa", "cũng", "chỉ", "đúng", "tất", "cả", "mỗi", "khác", "mới", "cũ", "lớn", "nhỏ", "cao", "thấp",
381
  "vừa", "rồi", "thì", "mới", "đã", "sẽ", "đang", "được", "để", "về", "từ", "trên", "dưới", "trong",
382
  "ngày", "giờ", "phút", "giây", "năm", "tháng", "tuần", "giờ", "phút", "giây",
383
+ // Single/double letter artifacts from HTML entity stripping
384
  "ng", "nh", "nc", "nd", "nt", "nl", "nm", "np", "nk", "nj", "ni", "no", "nr", "ns", "nv", "nz",
385
  "ch", "tr", "ph", "th", "kh", "gh", "nh", "ng", "cn", "cv", "đc", "đk", "đt", "đv",
386
  ]);
387
 
388
+ /**
389
+ * Extract trending topics from news pool by analyzing word frequency.
390
+ * Combines fixed ALL_TOPICS with real HOT keywords extracted from today's news.
391
+ * Returns 20 topics (10 fixed + 10 dynamic) that change every day.
392
+ */
393
  async function getTrendingTopics() {
394
  const pool = await getNewsPool();
395
+
396
+ // Combine all titles and descriptions into one text.
397
+ // Strip HTML entities first (&amp; -> &, &apos; -> ', etc.)
398
  const allText = pool
399
  .map((i) => ((i.title || "") + " " + (i.description || "")).replace(/&[a-z]+;/gi, " "))
400
  .join(" ")
401
  .toLowerCase();
402
+
403
+ // ── Extract candidate keywords (2-3 word phrases) from news text ──
404
+ // Split on non-alphanumeric, filter stopwords and short tokens.
405
  const tokens = allText
406
+ .replace(/[0-9]+/g, " ") // remove pure numbers
407
+ .replace(/[^\p{L}\s]/gu, " ") // keep only letters (Vietnamese + Latin)
408
  .split(/\s+/)
409
  .filter((t) => t.length >= 2 && !VI_STOPWORDS.has(t));
410
+
411
+ // ── Extract 2-word phrases (bigrams) for compound topics ──
412
  const bigramFreq = new Map();
413
  for (let i = 0; i < tokens.length - 1; i++) {
414
  const bigram = tokens[i] + " " + tokens[i + 1];
 
416
  bigramFreq.set(bigram, (bigramFreq.get(bigram) || 0) + 1);
417
  }
418
  }
419
+
420
+ // ── Extract 3-word phrases (trigrams) for longer topics ──
421
  const trigramFreq = new Map();
422
  for (let i = 0; i < tokens.length - 2; i++) {
423
  const trigram = tokens[i] + " " + tokens[i + 1] + " " + tokens[i + 2];
 
425
  trigramFreq.set(trigram, (trigramFreq.get(trigram) || 0) + 1);
426
  }
427
  }
428
+
429
+ // ── Score fixed ALL_TOPICS by keyword frequency in today's news ──
430
  const topicScores = [];
431
  for (const topic of ALL_TOPICS) {
432
  let count = 0;
 
434
  const kwLower = kw.toLowerCase();
435
  count += (allText.split(kwLower).length - 1);
436
  }
437
+ if (count > 0) {
438
+ topicScores.push({ slug: topic.slug, label: topic.label, count, type: "fixed" });
439
+ }
440
  }
441
+
442
+ // ── Extract raw hot keywords not in ALL_TOPICS ──
443
  const knownKeywords = new Set();
444
+ for (const t of ALL_TOPICS) {
445
+ for (const kw of t.keywords) knownKeywords.add(kw.toLowerCase());
446
+ }
447
+
448
+ // Get top trigrams not in known list
449
  const topTrigrams = [...trigramFreq.entries()]
450
+ .filter(([phrase]) => {
451
+ if (knownKeywords.has(phrase) || phrase.length <= 6) return false;
452
+ const words = phrase.split(" ");
453
+ return !words.some((w) => VI_STOPWORDS.has(w));
454
+ })
455
+ .sort((a, b) => b[1] - a[1])
456
+ .slice(0, 10);
457
+
458
+ // Get top bigrams not in known list
459
  const topBigrams = [...bigramFreq.entries()]
460
+ .filter(([phrase]) => {
461
+ if (knownKeywords.has(phrase) || phrase.length <= 4) return false;
462
+ const words = phrase.split(" ");
463
+ return !words.some((w) => VI_STOPWORDS.has(w));
464
+ })
465
+ .sort((a, b) => b[1] - a[1])
466
+ .slice(0, 20);
467
+
468
+ // ── Combine fixed topics + raw hot keywords ──
469
  topicScores.sort((a, b) => b.count - a.count);
470
+
471
+ // Take top 10 fixed topics (those with count > 0)
472
  const trending = [...topicScores.slice(0, 10)];
473
+
474
+ // Add raw hot keywords as additional topics (up to 10)
475
+ const rawKeywords = [...topTrigrams, ...topBigrams]
476
+ .filter(([phrase, freq]) => freq >= 2)
477
+ .sort((a, b) => b[1] - a[1])
478
+ .slice(0, 10);
479
+
480
  for (const [phrase, freq] of rawKeywords) {
481
  const slug = phrase.replace(/\s+/g, "-").replace(/[^\p{L}\p{N}-]/gu, "");
482
  if (slug && slug.length > 2 && /^[a-zà-ỹ\-]+$/.test(slug) && !trending.find((t) => t.slug === slug)) {
483
+ const label = phrase
484
+ .split(" ")
485
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
486
+ .join(" ");
487
  trending.push({ slug, label, count: freq, type: "dynamic" });
488
  }
489
  }
490
+
491
+ // ── If not enough trending, add some defaults ──
 
 
492
  if (trending.length < 10) {
493
  const defaults = [
494
+ { slug: "ai", label: "AI", keywords: [] },
495
  { slug: "congngang", label: "Công nghệ", keywords: [] },
496
  { slug: "bongda", label: "Bóng đá", keywords: [] },
497
  { slug: "kinhte", label: "Kinh tế", keywords: [] },
 
503
  { slug: "giai-tri", label: "Giải trí", keywords: [] },
504
  ];
505
  for (const d of defaults) {
506
+ if (!trending.find((f) => f.slug === d.slug)) {
507
+ trending.push({ ...d, count: 1, type: "fixed" });
508
+ }
509
  }
510
  }
511
+
512
+ // ── Daily rotation: pick 20 topics that change every day ──
513
  const now = new Date();
514
  const start = new Date(now.getFullYear(), 0, 0);
515
  const dayOfYear = Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
516
  const seed = dayOfYear;
517
+
518
+ // Simple seeded shuffle (LCG) for deterministic daily rotation.
519
  const shuffled = [...trending];
520
  let r = seed;
521
  for (let i = shuffled.length - 1; i > 0; i--) {
 
523
  const j = r % (i + 1);
524
  [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
525
  }
526
+
527
+ // Take top 20 (or all if fewer).
528
  return shuffled.slice(0, 20).map(({ slug, label }) => ({ slug, label }));
529
  }
530
 
531
+ /** Filter the cached pool by a keyword (case-insensitive, in title). */
532
  async function filterNews(query, limit = 15) {
533
  const pool = await getNewsPool();
534
  const q = (query || "").trim().toLowerCase();
535
  if (!q) return pool.slice(0, limit);
536
+
537
+ // Map slug → expanded search terms for better matching.
538
+ // Also map label (e.g. "Bóng đá") → slug so the frontend can pass either.
539
  const SLUG_MAP = {
540
  "worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"],
541
  "aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"],
542
  "bongda": ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"],
543
  "thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"],
544
+ "ai": ["trí tuệ nhân tạo", "ai ", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "kimik3", "kimi"],
545
  "congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"],
546
  "kinhte": ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"],
547
  "thoisu": ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"],
 
555
  "am-nhac": ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"],
556
  "phap-luat": ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"],
557
  };
558
+
559
+ // Build a reverse map: label (lowercase, no spaces) → slug
560
+ // e.g. "bóngđá" → "bongda", "kinh tế" → "kinhte"
561
  const LABEL_TO_SLUG = {};
562
  for (const [slug, terms] of Object.entries(SLUG_MAP)) {
563
+ // Use the first term (which is usually the Vietnamese label) as the label key
564
  const labelKey = terms[0].replace(/\s+/g, "").toLowerCase();
565
  if (labelKey) LABEL_TO_SLUG[labelKey] = slug;
566
  }
567
+
568
+ // Resolve query to expanded search terms.
569
+ // Try: exact slug match → label match → raw query as fallback.
570
  let terms = SLUG_MAP[q];
571
  if (!terms) {
572
  const qNoSpace = q.replace(/\s+/g, "").toLowerCase();
 
574
  if (slug) terms = SLUG_MAP[slug];
575
  }
576
  if (!terms) {
577
+ // For dynamic slugs (e.g. "việt-nam", "công-an"), convert to search terms
578
+ // by splitting on hyphens and using each part.
579
+ if (q.includes("-")) {
580
+ terms = q.split("-").filter((t) => t.length > 1);
581
+ } else {
582
+ terms = [q];
583
+ }
584
  }
585
+
586
+ // If no terms after processing, return empty (no results)
587
  if (!terms || terms.length === 0) return [];
588
+
589
+ const matched = pool.filter((it) => {
590
+ const titleLower = it.title.toLowerCase();
591
+ const descLower = (it.description || "").toLowerCase();
592
+ return terms.some((term) => titleLower.includes(term) || descLower.includes(term));
593
+ });
594
+
 
 
 
 
 
 
 
 
 
 
595
  return matched.slice(0, limit);
596
  }
597
 
598
+ /** Fetch and extract article content for summarization */
599
  async function fetchArticleContent(url) {
600
  try {
601
  const resp = await fetch(url, {
 
603
  signal: AbortSignal.timeout(8000),
604
  });
605
  const html = await resp.text();
606
+
607
+ // Extract text content (simple extraction)
608
  let text = html
609
  .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
610
  .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
 
613
  .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
614
  .replace(/<[^>]+>/g, " ")
615
  .replace(/&[a-z]+;/g, " ")
616
+ .replace(/\s+/g, " ")
617
+ .trim();
618
+
619
+ return text.slice(0, 4000); // Return first 4000 chars
620
  } catch (e) {
621
  console.warn(`[Article] Failed to fetch ${url}: ${e}`);
622
  return null;
623
  }
624
  }
625
 
626
+ /** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */
627
  function formatVietnameseTime(now) {
628
  let h = now.getHours();
629
  const m = now.getMinutes();
630
  let part;
631
  if (h >= 0 && h < 5) part = "đêm";
632
  else if (h < 10) part = "sáng";
633
+ else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa
634
  else if (h < 18) part = "chiều";
635
+ else part = "tối"; // 18h trở đi -> tối
636
+ const hh = h === 0 ? 12 : h; // midnight shown as 12h đ��m
637
  if (m === 0) return `${hh}h ${part}`;
638
  return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
639
  }
640
 
641
+
642
+ // Upload a small file to this Space's Hub repo (persists across restarts).
643
+ // Uses the HuggingFace Hub API: create_commit with base64-encoded file.
644
+ // Falls back to Python huggingface_hub library if raw API fails.
645
+ async function uploadToHub(path, buf) {
646
+ const token = Bun.env.HF_TOKEN;
647
+ if (!token) throw new Error("no HF_TOKEN");
648
+ const base = `https://huggingface.co/api/spaces/${REPO_ID}`;
649
+
650
+ // Encode the buffer as base64 for the commit API.
651
+ // Use Bun's built-in base64 encoding for reliability.
652
+ const b64 = Buffer.from(buf).toString("base64");
653
+
654
+ const body = JSON.stringify({
655
+ commit_message: `avatar thumbnail ${path}`,
656
+ summary: `avatar thumbnail ${path}`,
657
+ operations: [
658
+ {
659
+ operation: "create",
660
+ path,
661
+ ...(b64.length < 10 * 1024 * 1024
662
+ ? { base64: b64 }
663
+ : { lfs: { endpoint: "", path, sha256: "", size: buf.length } }),
664
+ },
665
+ ],
666
+ });
667
+
668
+ // Try the commit API
669
+ let resp = await fetch(`${base}/commit/main`, {
670
+ method: "POST",
671
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
672
+ body,
673
+ });
674
+
675
+ // If /commit/main fails, try /commit (some API versions differ)
676
+ if (!resp.ok && resp.status === 404) {
677
+ resp = await fetch(`${base}/commit`, {
678
  method: "POST",
679
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
680
+ body,
681
  });
682
+ }
683
+
684
+ if (!resp.ok) {
685
+ const txt = await resp.text();
686
+ console.warn(`[uploadToHub] raw API failed (${resp.status}): ${txt}, trying Python fallback`);
687
+ // Fall back to Python huggingface_hub library
688
+ return await uploadViaPython(path, buf);
689
+ }
690
+
691
+ // Verify the file was actually created by checking the response
692
+ const result = await resp.json();
693
+ if (result.success === false || result.error) {
694
+ console.warn(`[uploadToHub] API returned error: ${JSON.stringify(result)}, trying Python fallback`);
695
+ return await uploadViaPython(path, buf);
696
+ }
697
+
698
+ // Verify the file was actually persisted by fetching it back.
699
+ console.log(`[uploadToHub] raw API returned success for ${path}, verifying file exists...`);
700
+ try {
701
+ const verifyResp = await fetch(`${base}/resolve/main/${path}`, {
702
+ headers: { Authorization: `Bearer ${token}` },
703
+ });
704
+ if (verifyResp.ok) {
705
+ console.log(`[uploadToHub] verified ${path} exists on Hub`);
706
+ return true;
707
+ } else {
708
+ console.warn(`[uploadToHub] verification failed (${verifyResp.status}), trying Python fallback`);
709
+ return await uploadViaPython(path, buf);
710
  }
711
+ } catch (e) {
712
+ console.warn(`[uploadToHub] verification error: ${e}, trying Python fallback`);
713
+ return await uploadViaPython(path, buf);
 
 
 
714
  }
715
  }
716
 
717
+ // Upload via Python huggingface_hub library (more reliable than raw API)
718
+ async function uploadViaPython(path, buf) {
719
+ const token = Bun.env.HF_TOKEN;
720
+ if (!token) throw new Error("no HF_TOKEN");
721
+ const repoId = REPO_ID;
722
+
723
+ try {
724
+ // Write buffer to temp file - convert to Uint8Array for Bun.write compatibility
725
+ const tmpPath = `/tmp/thumb_${Date.now()}_${path.replace(/\//g, "_")}`;
726
+ const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
727
+ await Bun.write(tmpPath, uint8Buf);
728
+
729
+ // Use Python huggingface_hub library
730
+ const proc = Bun.spawn(
731
+ ["python3", "-c", `
732
+ import os, sys
733
+ from huggingface_hub import HfApi
734
+ api = HfApi(token=os.environ.get("HF_TOKEN", ""))
735
+ api.upload_file(
736
+ path_or_fileobj="${tmpPath}",
737
+ repo_id="${repoId}",
738
+ repo_type="space",
739
+ path_in_repo="${path}",
740
+ commit_message="avatar thumbnail upload",
741
+ )
742
+ print("OK")
743
+ `],
744
+ {
745
+ env: { ...Bun.env, HF_TOKEN: token },
746
+ stdout: "pipe",
747
+ stderr: "pipe",
748
+ }
749
+ );
750
+
751
+ const stdout = await new Response(proc.stdout).text();
752
+ const stderr = await new Response(proc.stderr).text();
753
+ const exitCode = await proc.exited;
754
+
755
+ if (exitCode === 0 && stdout.includes("OK")) {
756
+ console.log(`[uploadViaPython] successfully uploaded ${path}`);
757
+ return true;
758
+ } else {
759
+ console.warn(`[uploadViaPython] failed (exit ${exitCode}): ${stderr}`);
760
+ throw new Error(`Python upload failed: ${stderr}`);
761
+ }
762
+ } catch (e) {
763
+ console.warn(`[uploadViaPython] error: ${e}`);
764
+ throw e;
765
+ }
766
  }
767
 
768
  const server = Bun.serve({
 
776
  return Response.json({ items: pool.slice(0, 15) });
777
  },
778
  },
779
+ // Lazy-load endpoint: returns the next page of cached news.
780
+ // ?offset=10&limit=10 → items 10..19 from the cached pool.
781
  "/api/news/more": {
782
  GET: async (req) => {
783
  const url = new URL(req.url);
 
794
  });
795
  },
796
  },
797
+ // Filter the cached news by a keyword (newest/top matches first).
798
  "/api/news/filter": {
799
  GET: async (req) => {
800
  const url = new URL(req.url);
 
809
  const url = new URL(req.url);
810
  const articleUrl = url.searchParams.get("url");
811
  if (!articleUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
812
+
813
  const content = await fetchArticleContent(articleUrl);
814
  if (!content) return Response.json({ error: "Could not fetch article" }, { status: 502 });
815
+
816
  return Response.json({ content, url: articleUrl });
817
  },
818
  },
819
+ // Realtime trending topics extracted from today's news
820
  "/api/topics/trending": {
821
  GET: async () => {
822
  const topics = await getTrendingTopics();
 
834
  const bullets = text.split(/\n\n+/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
835
  return Response.json({ title: text.slice(0, 80), body: bullets.map((b) => `• ${b}`).join("\n"), fallback: true });
836
  }
837
+ // ── Step 1: clean the chat transcript ──────────────────────────────
838
  const cleanText = text
839
  .split(/\n+/)
840
  .map((line) => line.replace(/^\s*(người dùng|user|avatar|bạn|trợ lí|trợ lý|assistant)\s*[:\-]\s*/i, "").trim())
841
+ .filter(Boolean)
842
+ .join("\n");
843
+
844
+ const SYSTEM_PROMPT =
845
+ "Bạn một BIÊN TẬP VIÊN báo chí tiếng Việt. VIẾT LẠI nội dung thành MỘT BÀI HOÀN CHỈNH bằng NGÔN TỪ CỦA BẠN.\n" +
846
+ "QUY TẮC:\n" +
847
+ "1. KHÔNG sao chép nguyên văn. Diễn đạt lại (paraphrase), tóm gọn, tự nhiên.\n" +
848
+ "2. KHÔNG giữ định dạng chat 'Người dùng:'/'Avatar:'.\n" +
849
+ "3. Dòng đầu là TIÊU ĐỀ (dưới 90 ký tự).\n" +
850
+ "4. Tiếp theo: 1 mở đầu, 3-4 đoạn thân bài, 1 kết luận.\n" +
851
+ "5. Tiếng Việt chuẩn, KHÔNG liên kết, KHÔNG đánh số đầu dòng.\n" +
852
+ "6. Định dạng: TIÊU ĐỀ: <tiêu đề>\n\n<nội dung>";
853
  const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`;
854
  const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || "";
855
  const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct";
856
  let out = "";
857
+ // ── Prefer the self-hosted summarizer Space (reliable, no geo-block) ──
858
  if (SUMMARY_ENDPOINT) {
859
  try {
860
+ const sresp = await fetch(SUMMARY_ENDPOINT, {
861
+ method: "POST",
862
+ headers: { "Content-Type": "application/json" },
863
+ body: JSON.stringify({ text }),
864
+ signal: AbortSignal.timeout(90000),
865
+ });
866
+ if (sresp.ok) {
867
+ const sdata = await sresp.json();
868
+ if (sdata && sdata.body) out = (sdata.body || "").trim();
869
+ }
870
  } catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); }
871
  }
872
+ // ── Fall back to the HF router (OpenAI-compatible) ──
873
  if (!out) {
874
  try {
875
+ const rresp = await fetch("https://router.huggingface.co/v1/chat/completions", {
876
+ method: "POST",
877
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
878
+ body: JSON.stringify({
879
+ model: MODEL,
880
+ messages: [
881
+ { role: "system", content: SYSTEM_PROMPT },
882
+ { role: "user", content: USER_PROMPT },
883
+ ],
884
+ max_tokens: 800,
885
+ temperature: 0.8,
886
+ top_p: 0.9,
887
+ }),
888
+ signal: AbortSignal.timeout(45000),
889
+ });
890
  if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); }
891
  } catch (e) { console.warn(`[summarize] router failed: ${e}`); }
892
  }
893
  if (!out && Bun.env.SUMMARY_MODEL_LEGACY) {
894
  try {
895
+ const lresp = await fetch(`https://api-inference.huggingface.co/models/${Bun.env.SUMMARY_MODEL_LEGACY}`, {
896
+ method: "POST",
897
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
898
+ body: JSON.stringify({ inputs: `${SYSTEM_PROMPT}\n\n${USER_PROMPT}`, parameters: { max_new_tokens: 700, return_full_text: false, temperature: 0.8, do_sample: true } }),
899
+ signal: AbortSignal.timeout(45000),
900
+ });
901
  if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); }
902
  } catch (e) { console.warn(`[summarize] legacy failed: ${e}`); }
903
  }
 
908
  if (!title) title = bodyText.slice(0, 80);
909
  return Response.json({ title, body: bodyText || out });
910
  }
911
+ // ── No-model fallback: light paraphrase (NEVER verbatim copy) ──
912
+ // Re-flow the chat lines into a short prose summary with a title,
913
+ // instead of echoing the raw transcript.
914
  const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean);
915
  const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện";
916
+ const bodyFb = lines
917
+ .map((l) => l.charAt(0).toUpperCase() + l.slice(1))
918
+ .join(" ")
919
+ .replace(/\s+/g, " ")
920
+ .trim();
921
  return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true });
922
  } catch (e) {
923
  console.warn(`[summarize] ${e}`);
 
930
  const files = await listAllAvatarFiles();
931
  const avatarsDir = join(process.cwd(), "public/avatars");
932
  const avatars = await listAvatars();
933
+ // Check thumbnails for each avatar
934
  const thumbStatus = {};
935
+ for (const name of avatars) {
936
+ thumbStatus[name] = await avatarHasThumbnail(name);
937
+ }
938
+ return Response.json({
939
+ cwd: process.cwd(),
940
+ avatarsDir,
941
+ files,
942
+ count: files.length,
943
+ avatars,
944
+ thumbStatus
945
+ });
946
  },
947
  },
948
  "/api/debug/thumbnail/:name": {
 
953
  const file = Bun.file(thumbPath);
954
  const exists = await file.exists();
955
  const size = exists ? file.size : 0;
956
+ return Response.json({
957
+ name,
958
+ thumbName,
959
+ thumbPath,
960
+ exists,
961
+ size,
962
+ url: `/avatars/${thumbName}`
963
+ });
964
  },
965
  },
966
  "/api/avatars": {
 
975
  return Response.json({ avatars });
976
  },
977
  },
978
+ "/api/avatars/debug": {
979
+ GET: async () => {
980
+ const allFiles = await listAllAvatarFiles();
981
+ const avatars = await Promise.all(
982
+ (await listAvatars()).map(async (name) => ({
983
+ name,
984
+ thumbnail: await avatarHasThumbnail(name) ? `/avatars/${name}.thumb.png` : null,
985
+ }))
986
+ );
987
+ return Response.json({ avatars, allFiles, avatarsDir: join(process.cwd(), "public/avatars") });
988
+ },
989
+ },
990
+ "/api/avatars/debug": {
991
+ GET: async () => {
992
+ const allFiles = await listAllAvatarFiles();
993
+ const avatarsDir = join(process.cwd(), "public/avatars");
994
+ return Response.json({
995
+ avatarsDir,
996
+ allFiles,
997
+ glbFiles: allFiles.filter(f => f.endsWith(".glb")),
998
+ thumbFiles: allFiles.filter(f => f.endsWith(".thumb.png")),
999
+ });
1000
+ },
1001
+ },
1002
  "/api/avatars/debug": {
1003
  GET: async () => {
1004
  const allFiles = await listAllAvatarFiles();
1005
  const glbFiles = allFiles.filter((f) => f.endsWith(".glb"));
1006
  const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png"));
1007
  const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png"));
1008
+ return Response.json({ allFiles, glbFiles, thumbFiles, otherFiles });
1009
+ },
1010
+ },
1011
+ "/api/avatars/debug": {
1012
+ GET: async () => {
1013
+ const allFiles = await listAllAvatarFiles();
1014
+ const glbFiles = allFiles.filter((f) => f.endsWith(".glb"));
1015
+ const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png"));
1016
+ const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png"));
1017
+ return Response.json({
1018
+ allFiles,
1019
+ glbFiles,
1020
+ thumbFiles,
1021
+ otherFiles,
1022
+ avatarsDir: join(process.cwd(), "public/avatars"),
1023
+ });
1024
  },
1025
  },
1026
  "/api/wiki/search": {
 
1029
  const q = url.searchParams.get("q");
1030
  if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
1031
  try {
1032
+ const resp = await fetch(
1033
+ `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`
1034
+ );
1035
  const data = await resp.json();
1036
+ const results = (data.query?.search ?? []).map((r) => ({
1037
+ title: r.title,
1038
+ snippet: r.snippet.replace(/<[^>]+>/g, ""),
1039
+ }));
1040
  return Response.json({ results });
1041
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
1042
  },
 
1047
  const title = url.searchParams.get("title");
1048
  if (!title) return Response.json({ error: "Missing ?title=" }, { status: 400 });
1049
  try {
1050
+ const resp = await fetch(
1051
+ `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`
1052
+ );
1053
  const data = await resp.json();
1054
+ return Response.json({
1055
+ title: data.title,
1056
+ extract: data.extract,
1057
+ url: data.content_urls?.desktop?.page,
1058
+ });
1059
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
1060
  },
1061
  },
 
1065
  const q = url.searchParams.get("q");
1066
  if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
1067
  try {
1068
+ const resp = await fetch(
1069
+ `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`,
1070
+ { headers: { "User-Agent": "Mozilla/5.0" } }
1071
+ );
1072
  const html = await resp.text();
1073
  const results = [];
1074
  const linkRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
 
1093
  const targetUrl = url.searchParams.get("url");
1094
  if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
1095
  try {
1096
+ const resp = await fetch(targetUrl, {
1097
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" },
1098
+ signal: AbortSignal.timeout(8000),
1099
+ });
1100
  const html = await resp.text();
1101
+ let text = html
1102
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
1103
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
1104
+ .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
1105
+ .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
1106
+ .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
1107
+ .replace(/<[^>]+>/g, " ")
1108
+ .replace(/&[a-z]+;/g, " ")
1109
+ .replace(/\s+/g, " ")
1110
+ .trim();
1111
  const content = text.slice(0, 3000);
1112
  return Response.json({ content, url: targetUrl });
1113
+ } catch {
1114
+ return Response.json({ error: "Could not fetch page." }, { status: 502 });
1115
+ }
1116
  },
1117
  },
1118
  "/api/session": {
 
1136
  },
1137
  "/worklets/:name": (req) => staticFile("worklets", req.params.name),
1138
  "/vendor/:name": (req) => staticFile("vendor", req.params.name),
 
 
 
 
 
 
 
1139
  "/api/avatar-thumbnail": {
1140
  POST: async (req) => {
1141
  try {
1142
  const body = await req.json();
1143
  const avatar = (body.avatar || "").toString();
1144
  const dataUrl = (body.dataUrl || "").toString();
1145
+ console.log(`[thumb] POST received: avatar=${avatar}, dataUrl length=${dataUrl?.length || 0}`);
1146
+ // Only allow a known .glb name + a PNG data URL.
1147
  if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) {
1148
+ console.log(`[thumb] Invalid avatar name: ${avatar}`);
1149
  return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 });
1150
  }
1151
+ const m = dataUrl.match(/^data:image\/png;base64,(.+)$/);
1152
+ if (!m) {
1153
+ console.log(`[thumb] Invalid data URL format`);
1154
+ return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
1155
+ }
1156
  let buf;
1157
+ try { buf = Buffer.from(m[1], "base64"); } catch (e) {
1158
+ console.log(`[thumb] Base64 decode failed: ${e}`);
1159
+ return Response.json({ ok: false, error: "Bad base64" }, { status: 400 });
1160
+ }
1161
+ if (!buf || !buf.length) {
1162
+ console.log(`[thumb] Empty buffer after decode`);
1163
+ return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
1164
+ }
1165
+ if (buf.length > 5 * 1024 * 1024) {
1166
+ console.log(`[thumb] Image too large: ${buf.length} bytes`);
1167
+ return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
1168
+ }
1169
+ if (!(buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)) {
1170
+ console.log(`[thumb] Not a PNG: first bytes=${buf[0]},${buf[1]},${buf[2]},${buf[3]}`);
1171
+ return Response.json({ ok: false, error: "Not a PNG" }, { status: 400 });
1172
  }
1173
  const fileName = `${avatar}.thumb.png`;
1174
  const avatarsDir = join(process.cwd(), "public/avatars");
1175
  const localPath = join(avatarsDir, fileName);
 
1176
  const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
1177
+ console.log(`[thumb] About to write: localPath=${localPath}, buf size=${buf.length}, uint8 size=${uint8Buf.length}`);
1178
 
1179
+ // ── 1. Write to local Space filesystem (serves immediately this session) ──
1180
  try {
1181
+ // Ensure the directory exists (mkdir -p equivalent)
1182
+ await Bun.spawn(["mkdir", "-p", avatarsDir]).exited;
1183
+ await writeFile(localPath, uint8Buf);
1184
+ console.log(`[thumb] saved locally: ${localPath}`);
1185
+ // Verify the file was written
1186
+ const verifyFile = Bun.file(localPath);
1187
+ const fileExists = await verifyFile.exists();
1188
+ console.log(`[thumb] local file exists: ${fileExists}, size: ${verifyFile.size}`);
1189
+ if (!fileExists) {
1190
+ console.error(`[thumb] CRITICAL: File was not written! Path: ${localPath}`);
1191
+ // Try alternative write method
1192
+ try {
1193
+ await Bun.write(localPath, uint8Buf);
1194
+ const verifyFile2 = Bun.file(localPath);
1195
+ const fileExists2 = await verifyFile2.exists();
1196
+ console.log(`[thumb] Alternative write: fileExists=${fileExists2}, size=${verifyFile2.size}`);
1197
+ } catch (e2) {
1198
+ console.error(`[thumb] Alternative write also failed: ${e2}`);
1199
+ }
1200
+ }
1201
+ } catch (e) {
1202
+ console.warn("[thumb] local write failed:", e);
1203
  }
1204
 
1205
+ // ── 2. Persist to the Hub repo so it survives Space restarts ──
1206
+ try {
1207
+ await uploadToHub(`public/avatars/${fileName}`, buf);
1208
+ console.log(`[thumb] persisted to Hub: public/avatars/${fileName}`);
1209
+ } catch (e) {
1210
+ console.warn("[thumb] hub upload failed:", e);
1211
+ }
1212
 
1213
+ // ── 3. Verify the file is accessible via the Space's static file route ──
1214
  const thumbUrl = `/avatars/${fileName}`;
1215
+ // Also verify via staticFile route
1216
+ try {
1217
+ const staticResp = await fetch(`http://localhost:${PORT}${thumbUrl}`);
1218
+ console.log(`[thumb] static file check: status=${staticResp.status}, content-type=${staticResp.headers.get("content-type")}`);
1219
+ } catch (e) {
1220
+ console.warn("[thumb] static file check failed:", e);
1221
+ }
1222
+ return Response.json({ ok: true, url: thumbUrl, avatar, file: fileName });
1223
  } catch (e) {
1224
+ console.warn(`[thumb] ${e}`);
1225
+ return Response.json({ ok: false, error: "save failed" }, { status: 500 });
1226
  }
1227
  },
1228
  },
1229
+ "/avatars/:name": (req) => staticFile("avatars", req.params.name),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
  },
1231
  });
1232
 
1233
+ // Warm the news pool on boot so the first request is instant.
1234
+ void refreshNewsPool();
1235
+
1236
+ console.log(`gemma-avatar listening on ${server.url}`);
1237
+ console.log(UPSTREAM ? `session backend: ${LOAD_BALANCER_URL ? "load balancer" : "session proxy"} (${UPSTREAM})` : "session backend: none");
1238
+
1239
+ /* rebuild trigger */
public/avatars/lisamy1.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f
3
- size 8320540
 
 
 
 
public/avatars/scene.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:c88c7c12ee1e978770ed95f1e0ab7e884e2af8c5d368d0ce7c57cbef5a42a47b
3
- size 7490660
 
 
 
 
public/avatars/viverse_avatar_model_209370.vrm DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:75ff29129461144ed2f1d8812d61cc599d1e7694c83efbfee328f7ca2cde8cce
3
- size 8996552
 
 
 
 
public/avatars/vuong.glb.thumb.png ADDED
public/avatars/vuong1.glb DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a
3
- size 6964484
 
 
 
 
public/worklets/audio-playback.js CHANGED
@@ -168,4 +168,4 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
168
  }
169
  }
170
 
171
- registerProcessor("audio-playback", AudioPlaybackProcessor);
 
168
  }
169
  }
170
 
171
+ registerProcessor("audio-playback", AudioPlaybackProcessor);
public/worklets/mic-capture.js CHANGED
@@ -156,4 +156,4 @@ class MicCaptureProcessor extends AudioWorkletProcessor {
156
  }
157
  }
158
 
159
- registerProcessor("mic-capture", MicCaptureProcessor);
 
156
  }
157
  }
158
 
159
+ registerProcessor("mic-capture", MicCaptureProcessor);
requirements.txt DELETED
@@ -1 +0,0 @@
1
- huggingface_hub[hf_transfer]>=0.25.0
 
 
src/aiTopicPatch.js DELETED
@@ -1,118 +0,0 @@
1
- const AI_TOPIC_PATCH_VERSION = "1.0.0";
2
-
3
- function normalizeAiText(text) {
4
- return (text || "")
5
- .toLowerCase()
6
- .replace(/\bai\b/g, " ai ")
7
- .replace(/\ba\.i\b/g, " ai ")
8
- .replace(/a i/g, " ai ")
9
- .replace(/\bllm\b/g, " llm ")
10
- .replace(/\bllms\b/g, " llms ")
11
- .replace(/\bml\b/g, " ml ")
12
- .replace(/\bdl\b/g, " dl ")
13
- .replace(/\bgpt\b/g, " gpt ")
14
- .replace(/\bgemini\b/g, " gemini ")
15
- .replace(/\bqwen\b/g, " qwen ")
16
- .replace(/\bclaude\b/g, " claude ")
17
- .replace(/\bopenai\b/g, " openai ")
18
- .replace(/\bchatgpt\b/g, " chatgpt ")
19
- .replace(/\bdeep learning\b/g, " deep learning ")
20
- .replace(/\bmachine learning\b/g, " machine learning ")
21
- .replace(/\bgenerative ai\b/g, " generative ai ")
22
- .replace(/\bgen ai\b/g, " gen ai ")
23
- .replace(/\bcomputer vision\b/g, " computer vision ")
24
- .replace(/\bnatural language processing\b/g, " natural language processing ")
25
- .replace(/\bnlp\b/g, " nlp ")
26
- .replace(/\brobotics\b/g, " robotics ")
27
- .replace(/\brobot\b/g, " robot ")
28
- .replace(/\bchatbot\b/g, " chatbot ")
29
- .replace(/\bchat bot\b/g, " chatbot ")
30
- .replace(/\btrí tuệ nhân tạo\b/g, " trí tuệ nhân tạo ")
31
- .replace(/\bkhí cụ nhân tạo\b/g, " trí tuệ nhân tạo ")
32
- .replace(/\bthông minh nhân tạo\b/g, " trí tuệ nhân tạo ")
33
- .replace(/\bứng dụng ai\b/g, " ứng dụng ai ")
34
- .replace(/\bdạy học ai\b/g, " dạy học ai ")
35
- .replace(/\btạo ảnh bằng ai\b/g, " tạo ảnh bằng ai ")
36
- .replace(/\btạo video bằng ai\b/g, " tạo video bằng ai ")
37
- .replace(/\bchatbot ai\b/g, " chatbot ai ")
38
- .replace(/\bAI\b/g, " ai ")
39
- .replace(/\bLLM\b/g, " llm ")
40
- .replace(/\bGPT\b/g, " gpt ")
41
- .replace(/\bQwen\b/g, " qwen ")
42
- .replace(/\bClaude\b/g, " claude ")
43
- .replace(/\bOpenAI\b/g, " openai ")
44
- .replace(/\bChatGPT\b/g, " chatgpt ")
45
- .replace(/\bGemini\b/g, " gemini ")
46
- .replace(/\bGenK\b/g, " genk ")
47
- .replace(/\bKimi\b/g, " kimi ")
48
- .replace(/\bKimi K3\b/g, " kimi k3 ")
49
- .replace(/\bKimi k3\b/g, " kimi k3 ");
50
- }
51
-
52
- function detectAiTopic(text) {
53
- const t = normalizeAiText(text);
54
- const aiTerms = [
55
- " trí tuệ nhân tạo ", " ai ", " artificial intelligence ", " machine learning ", " deep learning ",
56
- " generative ai ", " gen ai ", " llm ", " llms ", " chatbot ", " chatgpt ", " openai ", " gpt ",
57
- " gemini ", " qwen ", " claude ", " kimi ", " kimi k3 ", " nlp ", " natural language processing ",
58
- " computer vision ", " robotics ", " robot ", " chatbot ai ", " ứng dụng ai ", " dạy học ai ",
59
- " tạo ảnh bằng ai ", " tạo video bằng ai "
60
- ];
61
- return aiTerms.some((term) => t.includes(term));
62
- }
63
-
64
- function escapeHtml(text) {
65
- return String(text || "")
66
- .replace(/&/g, "&amp;")
67
- .replace(/</g, "&lt;")
68
- .replace(/>/g, "&gt;")
69
- .replace(/"/g, "&quot;")
70
- .replace(/'/g, "&#039;");
71
- }
72
-
73
- function renderAiTopicChip() {
74
- if (document.getElementById("ai-topic-inline")) return;
75
- const chatMessages = document.getElementById("chat-messages");
76
- if (!chatMessages) return;
77
-
78
- const wrap = document.createElement("div");
79
- wrap.className = "topic-tags";
80
- wrap.id = "ai-topic-inline";
81
-
82
- const chip = document.createElement("button");
83
- chip.type = "button";
84
- chip.className = "topic-chip ai-topic-chip";
85
- chip.textContent = "# AI";
86
- chip.title = "Mở tin tức AI / trí tuệ nhân tạo";
87
- chip.addEventListener("click", () => {
88
- const chatInput = document.getElementById("chat-input");
89
- if (chatInput) chatInput.value = "Tin tức AI, trí tuệ nhân tạo mới nhất";
90
- const btn = document.getElementById("chat-send-btn");
91
- if (btn) btn.click();
92
- });
93
-
94
- wrap.appendChild(chip);
95
- chatMessages.appendChild(wrap);
96
- chatMessages.scrollTop = chatMessages.scrollHeight;
97
- }
98
-
99
- function injectAiTopicIntoSearchContext(text) {
100
- const original = text || "";
101
- if (!detectAiTopic(original)) return original;
102
-
103
- const aiText = `Chủ đề AI cần ưu tiên: tin tức AI, trí tuệ nhân tạo, machine learning, deep learning, chatbot, generative AI, LLM, Kimi, Qwen, Gemini, GPT, OpenAI, Claude, ứng dụng AI, tạo ảnh bằng AI, tạo video bằng AI.`;
104
- if (original.includes(aiText)) return original;
105
-
106
- return `${original}\n\n${aiText}`;
107
- }
108
-
109
- window.AiTopicPatch = {
110
- version: AI_TOPIC_PATCH_VERSION,
111
- detectAiTopic,
112
- injectAiTopicIntoSearchContext,
113
- renderAiTopicChip,
114
- };
115
-
116
- document.addEventListener("DOMContentLoaded", () => {
117
- window.AiTopicPatch.renderAiTopicChip();
118
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app.js CHANGED
@@ -1,2326 +1,48 @@
1
- import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
2
- import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
3
- import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
4
- import { VoiceTyper } from "./voiceTyper.js";
5
 
6
- const VOICES = [
7
- "Aiden", "Ryan", "Dylan", "Eric",
8
- "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian",
9
- ];
10
- const DEFAULT_VOICE = "Sohee";
11
-
12
- // ── VNEWS integration ───────────────────────────────────────────────────
13
- // The VNEWS space (bep40/vnews) is reachable at this subdomain. It exposes a
14
- // clean JSON API (no key required) that we use to pull AI/tech news, answer
15
- // from VNEWS data, and rewrite selected chat content into VNEWS "wall" posts.
16
- const VNEWS_BASE = "https://bep40-vnews.hf.space";
17
-
18
- // ── News fetching ───────────────────────────────────────────────────────
19
- const NEWS_PAGE_SIZE = 10;
20
-
21
- async function getHotNews() {
22
- let items = [];
23
- try {
24
- const resp = await fetch("/api/news/hot");
25
- if (resp.ok) {
26
- const data = await resp.json();
27
- if (data.items && data.items.length > 0) {
28
- items = data.items.slice(0, NEWS_PAGE_SIZE);
29
- }
30
- }
31
- } catch (e) { console.warn("News API failed:", e); }
32
- return items;
33
- }
34
-
35
- async function getMoreNews(offset, limit = NEWS_PAGE_SIZE) {
36
- try {
37
- const resp = await fetch(`/api/news/more?offset=${offset}&limit=${limit}`);
38
- if (resp.ok) {
39
- const data = await resp.json();
40
- return {
41
- items: Array.isArray(data.items) ? data.items : [],
42
- nextOffset: data.offset ?? offset + (data.items?.length ?? 0),
43
- hasMore: Boolean(data.hasMore),
44
- };
45
- }
46
- } catch (e) { console.warn("News more API failed:", e); }
47
- return { items: [], nextOffset: offset, hasMore: false };
48
- }
49
-
50
- async function getFilteredNews(query, limit = NEWS_PAGE_SIZE) {
51
- try {
52
- const resp = await fetch(`/api/news/filter?q=${encodeURIComponent(query)}&limit=${limit}`);
53
- if (resp.ok) {
54
- const data = await resp.json();
55
- return Array.isArray(data.items) ? data.items : [];
56
- }
57
- } catch (e) { console.warn("News filter API failed:", e); }
58
- return [];
59
- }
60
-
61
- // ── VNEWS fetch helpers ─────────────────────────────────────────────────
62
- let vnewsConnected = false;
63
- let vnewsArticles = []; // flattened articles from VNEWS homepage
64
- let vnewsStatusTimer = 0;
65
-
66
- async function vnewsFetch(path) {
67
- try {
68
- const resp = await fetch(`${VNEWS_BASE}${path}`, { headers: { "Accept": "application/json" } });
69
- if (!resp.ok) return null;
70
- return await resp.json();
71
- } catch (e) {
72
- console.warn("VNEWS fetch failed:", path, e);
73
- return null;
74
- }
75
- }
76
-
77
- /** Pull the GenK "AI" feed (source = genk) — used for the "tin tức về AI" topic. */
78
- async function getVnewsAIFeed(limit = NEWS_PAGE_SIZE) {
79
- const data = await vnewsFetch(`/api/category/cong-nghe`);
80
- if (!Array.isArray(data)) return [];
81
- return data
82
- .filter((a) => a && a.title && a.link)
83
- .map((a) => ({
84
- title: a.title,
85
- link: a.link,
86
- image: a.img || "",
87
- source: a.source === "genk" ? "GenK AI" : (a.source || "VNEWS"),
88
- vnews: true,
89
- }))
90
- .slice(0, limit);
91
- }
92
-
93
- /** Pull the VNEWS homepage (all categories) and flatten into a searchable list. */
94
-
95
- // ── Topic tags (chủ đề HOT thời gian thực) — click mở modal tin theo chủ đề ──
96
- // Fetch realtime trending topics from the backend API (extracted from today's news).
97
- async function getTrendingTopics() {
98
- try {
99
- const resp = await fetch("/api/topics/trending");
100
- if (resp.ok) {
101
- const data = await resp.json();
102
- if (Array.isArray(data.topics) && data.topics.length > 0) {
103
- return data.topics;
104
- }
105
- }
106
- } catch (e) { console.warn("[topic] API failed:", e); }
107
- return [];
108
- }
109
-
110
- async function openTopic(slug, label) {
111
- try {
112
- setCaption("ĐANG TẢI TIN: " + label, "live");
113
- let items;
114
- // AI topic -> use VNEWS AI feed for better results
115
- if (slug === "ai") {
116
- items = await getVnewsAIFeed(NEWS_PAGE_SIZE);
117
- } else {
118
- const raw = await getFilteredNews(slug);
119
- items = (Array.isArray(raw) ? raw : []).map((n) => ({
120
- title: n.title, link: n.link, image: n.image || "",
121
- source: n.source || "Tin tức", vnews: Boolean(n.vnews),
122
- }));
123
- }
124
- showNewsPanel(items);
125
- const nt = document.getElementById("news-title");
126
- if (nt) nt.textContent = "📰 Tin: " + label;
127
- document.querySelectorAll(".topic-chip").forEach((c) => c.classList.toggle("active", c.dataset.slug === slug));
128
- if (!items.length) setCaption("Không có tin cho: " + label, "error");
129
- } catch (e) {
130
- console.warn("[topic] openTopic:", e);
131
- setCaption("LỖI TẢI TIN: " + label, "error");
132
- }
133
- }
134
-
135
- // Render today's real trending topics INSIDE the chat, right under the avatar's
136
- // greeting, so they are always visible. Clicking a chip opens that topic's news.
137
- let _topicTagsRendered = false;
138
- async function renderTopicTags() {
139
- if (_topicTagsRendered) {
140
- // Re-render existing tags (update content, re-add AI if missing)
141
- const oldEl = chatMessages.querySelector("#topic-tags-inline");
142
- if (oldEl) oldEl.remove();
143
- _topicTagsRendered = false;
144
- }
145
- let topics = [];
146
- try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); }
147
- if (!topics.length) return;
148
- const wrap = document.createElement("div");
149
- wrap.className = "topic-tags";
150
- wrap.id = "topic-tags-inline";
151
- for (const c of topics) {
152
- const chip = document.createElement("button");
153
- chip.type = "button";
154
- chip.className = "topic-chip";
155
- chip.dataset.slug = c.slug;
156
- chip.textContent = "#" + c.label.replace(/\s+/g, "_");
157
- chip.addEventListener("click", () => void openTopic(c.slug, c.label));
158
- wrap.appendChild(chip);
159
- }
160
- const oldEl = chatMessages.querySelector("#topic-tags-inline");
161
- if (oldEl) oldEl.remove();
162
- chatMessages.appendChild(wrap);
163
- chatMessages.scrollTop = chatMessages.scrollHeight;
164
- _topicTagsRendered = true;
165
- }
166
-
167
- async function getVnewsHomepage() {
168
- const data = await vnewsFetch(`/api/homepage`);
169
- if (!Array.isArray(data)) return [];
170
- return data
171
- .filter((a) => a && a.title && a.link)
172
- .map((a) => ({
173
- title: a.title,
174
- link: a.link,
175
- image: a.img || "",
176
- source: a.group || a.source || "VNEWS",
177
- vnews: true,
178
- }));
179
- }
180
-
181
- /** Search VNEWS articles by a free-text query (case-insensitive, in title). */
182
- function searchVnews(query, limit = 8) {
183
- const q = (query || "").toLowerCase().trim();
184
- if (!q) return vnewsArticles.slice(0, limit);
185
- return vnewsArticles
186
- .filter((a) => a.title.toLowerCase().includes(q))
187
- .slice(0, limit);
188
- }
189
-
190
- /** Format VNEWS articles as clickable link cards for the avatar to cite. */
191
- function vnewsLinksAsHtml(articles) {
192
- if (!articles.length) return "(không có bài viết VNEWS phù hợp)";
193
- return articles
194
- .map((a, i) => {
195
- const short = `${VNEWS_BASE}/?p=${encodeURIComponent(a.link)}`;
196
- return `${i + 1}. <a href="${a.link}" target="_blank" rel="noopener">${a.title}</a> — nguồn ${a.source} (link rút gọn: ${short})`;
197
- })
198
- .join("<br>");
199
- }
200
-
201
- const DEFAULT_INSTRUCTIONS = [
202
- "You are a friendly voice assistant with a visible, human-like 3D avatar.",
203
- "CRITICAL: Always respond in the SAME LANGUAGE that the user writes or speaks.",
204
- "If Vietnamese, respond in Vietnamese. If English, respond in English.",
205
- "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),",
206
- "CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35'). This ensures the TTS voice reads them correctly.",
207
- "Keep replies short, natural, warm. Never list-like.",
208
- "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression. Use them naturally.",
209
- "NEVER guess. Use search_web, search_wikipedia, or get_vnews_news for ANY factual question.",
210
- "When you cite a VNEWS article, ALWAYS include the direct link as a clickable <a href> tag AND mention the short link form.",
211
- "Never mention tools or that you control an avatar.",
212
- "Source links should be formatted as popup links with <a href> tags.",
213
- ].join(" ");
214
-
215
- const STORAGE_KEYS = {
216
- voice: "avatar.voice", avatar: "avatar.model", instructions: "avatar.instructions",
217
- directUrl: "avatar.directUrl", subtitles: "avatar.subtitles",
218
- };
219
-
220
- const TOOL_DEFS = [
221
- { type: "function", name: "set_mood", description: "Change your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS } }, required: ["mood"] } },
222
- { type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES } }, required: ["gesture"] } },
223
- { type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string" } }, required: ["emoji"] } },
224
- { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {} } },
225
- { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
226
- { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
227
- { type: "function", name: "get_vnews_news", description: "Search the connected VNEWS site for Vietnamese news articles (AI, tech, sports, world...). Returns article titles with direct links and short links. Use this when the user asks about news, especially AI/technology, or after VNEWS is connected.", parameters: { type: "object", properties: { query: { type: "string", description: "Keyword to search, e.g. 'AI', 'bóng đá', 'thời sự'. Empty = latest." } }, required: ["query"] } },
228
- ];
229
-
230
- // ── DOM elements ─────────────────────────────────────────────────────────
231
- const $ = (sel) => document.querySelector(sel);
232
- const stageNode = $("#stage");
233
- const mainBtn = $("#main-btn");
234
- const mainBtnLabel = $("#main-btn-label");
235
- const muteBtn = $("#mute-btn");
236
- const textModeBtn = $("#text-mode-btn");
237
- const caption = $("#caption");
238
- const subtitles = $("#subtitles");
239
- const loading = $("#loading");
240
- const settingsBtn = $("#settings-btn");
241
- const settingsDialog = $("#settings");
242
- const inputVoice = $("#voice");
243
- const inputInstructions = $("#instructions");
244
- const inputDirectUrl = $("#direct-url");
245
- const inputSubtitles = $("#subtitles-toggle");
246
- const directUrlRow = $("#direct-url-row");
247
- const textChat = $("#text-chat");
248
- const chatHeader = $("#chat-header");
249
- const chatMessages = $("#chat-messages");
250
- const chatInput = $("#chat-input");
251
- const chatSendBtn = $("#chat-send-btn");
252
- const chatMicBtn = $("#chat-mic-btn");
253
- const chatCloseBtn = $("#chat-close-btn");
254
- const chatResizeHandle = $("#chat-resize-handle");
255
- const chatAvatarSelect = $("#chat-avatar-select");
256
- const settingsAvatarSelect = $("#settings-avatar-select");
257
- const newsPanel = $("#news-panel");
258
- const newsList = $("#news-list");
259
- const newsCloseBtn = $("#news-close-btn");
260
- const newsToggleBtn = $("#news-toggle-btn");
261
- const newsLoadMore = $("#news-loadmore");
262
- const newsLoadMoreBtn = $("#news-loadmore-btn");
263
- const newsSentinel = $("#news-sentinel");
264
- const newsResizeHandle = $("#news-resize-handle");
265
-
266
- // ── State ───────────────────────────────────────────────────────────────
267
- const stage = new AvatarStage(stageNode);
268
- let client = null;
269
- let muted = false;
270
- let subtitleTimer = 0;
271
- let textMode = false;
272
- let voiceTyper = null;
273
- let config = { lb: false, allowDirect: true };
274
- let sessionInProgress = false;
275
- let _lastVoiceText = "";
276
- let _lastVoiceSendMs = 0;
277
- let autoGreetingSent = false;
278
- let preFetchedNews = [];
279
- let latestNewsUrl = null;
280
- let latestNewsSource = null;
281
-
282
- // ── News box state ──────────────────────────────────────────────────────
283
- let newsOffset = 0; // how many items already rendered
284
- let newsLoading = false; // guard against concurrent loads
285
- let newsHasMore = true;
286
- let newsObserver = null;
287
-
288
- // ── News sequence (for walking images through the conversation) ──────────
289
- let newsSeq = []; // ordered list of news items to surface images from
290
- let newsSeqIdx = -1; // index of the NEXT image to show
291
- let newsTopicQuery = ""; // current topic keyword (empty = general hot news)
292
-
293
- function loadSettings() {
294
- return {
295
- voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE,
296
- avatar: localStorage.getItem(STORAGE_KEYS.avatar) || "vuong.glb",
297
- instructions: localStorage.getItem(STORAGE_KEYS.instructions) || "",
298
- directUrl: localStorage.getItem(STORAGE_KEYS.directUrl) || "",
299
- subtitles: localStorage.getItem(STORAGE_KEYS.subtitles) === "1",
300
- };
301
- }
302
- let settings = loadSettings();
303
-
304
- function saveSettings() {
305
- localStorage.setItem(STORAGE_KEYS.voice, settings.voice);
306
- localStorage.setItem(STORAGE_KEYS.avatar, settings.avatar);
307
- localStorage.setItem(STORAGE_KEYS.instructions, settings.instructions);
308
- localStorage.setItem(STORAGE_KEYS.directUrl, settings.directUrl);
309
- localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0");
310
- }
311
-
312
- function getSourceName(hostname) {
313
- const name = hostname.replace(/^www\./, "").toLowerCase();
314
- const known = {
315
- "vnexpress.net": "VnExpress",
316
- "dantri.com.vn": "Dân trí",
317
- "tuoitre.vn": "Tuổi Trẻ",
318
- "thanhnien.vn": "Thanh Niên",
319
- "vietnamnet.vn": "VietNamNet",
320
- "zingnews.vn": "ZingNews",
321
- "cafef.vn": "CafeF",
322
- "genk.vn": "GenK",
323
- "kenh14.vn": "Kênh 14",
324
- "afamily.vn": "Afamily",
325
- "plo.vn": "PLO",
326
- "vtc.vn": "VTC",
327
- };
328
- return known[name] || name;
329
- }
330
-
331
- /** Short label/initial used for the avatar bubble when no image is available. */
332
- function sourceInitial(source) {
333
- const map = {
334
- "VnExpress": "Vn", "Dân trí": "DT", "Tuổi Trẻ": "TT", "Thanh Niên": "TN",
335
- "VietNamNet": "VN", "ZingNews": "Z", "Kênh 14": "K14", "CafeF": "CF",
336
- "GenK": "GK", "Afamily": "AF", "PLO": "PLO", "VTC": "VTC", "GenK AI": "AI",
337
- };
338
- return map[source] || (source ? source.slice(0, 2).toUpperCase() : "?");
339
- }
340
-
341
- /** Vietnamese date: "thứ Sáu, ngày 17 tháng 7 năm 2026". */
342
- function formatVietnameseDate(now) {
343
- const weekdays = ["Chủ nhật", "thứ Hai", "thứ Ba", "thứ Tư", "thứ Năm", "thứ Sáu", "thứ Bảy"];
344
- const wd = weekdays[now.getDay()];
345
- return `${wd}, ngày ${now.getDate()} tháng ${now.getMonth() + 1} năm ${now.getFullYear()}`;
346
- }
347
-
348
- /** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */
349
- function formatVietnameseTime(now) {
350
- let h = now.getHours();
351
- const m = now.getMinutes();
352
- let part;
353
- if (h >= 0 && h < 5) part = "đêm";
354
- else if (h < 10) part = "sáng";
355
- else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa
356
- else if (h < 18) part = "chiều";
357
- else part = "tối"; // 18h trở đi -> tối
358
- const hh = h === 0 ? 12 : h;
359
- if (m === 0) return `${hh}h ${part}`;
360
- return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
361
- }
362
-
363
- function effectiveInstructions(newsItems) {
364
- const now = new Date();
365
- const dateStr = formatVietnameseDate(now);
366
- const timeStr = formatVietnameseTime(now);
367
- const dateLine = `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`;
368
- const extra = settings.instructions.trim();
369
- const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
370
- // Trending topics are now fetched from the backend API /api/topics/trending
371
- // which extracts real topics from today's news. The frontend getTrendingTopics()
372
- // calls this API and renders the topic chips.
373
-
374
- let newsInst = "";
375
- if (newsItems && newsItems.length > 0) {
376
- const topNews = newsItems[0];
377
- const sourceName = getSourceName(new URL(topNews.link).hostname);
378
- newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}.\nTin HOT nhất hôm nay: ${topNews.title} (nguồn ${sourceName})"\nInclude the full title and source name in parentheses. After greeting, offer to help with questions.`;
379
- } else {
380
- newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}."\nThen offer to help with questions.`;
381
- }
382
-
383
-
384
-
385
-
386
- let vnewsInst = "";
387
- if (vnewsConnected && vnewsArticles.length > 0) {
388
- const sample = vnewsArticles.slice(0, 6).map((a) => `• ${a.title} (${a.source})`).join("\n");
389
- vnewsInst = `\n\nVNEWS is connected. You can answer using the latest VNEWS headlines and MUST cite them with direct <a href> links. Latest VNEWS headlines:\n${sample}`;
390
- }
391
-
392
- const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}${vnewsInst}`;
393
- return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base;
394
- }
395
-
396
- // ── News image card (shown inside chat) ─────────────────────────────────
397
- function newsImageCardHtml(item) {
398
- const imgUrl = item.image || (item.images && item.images[0]) || "";
399
- const src = imgUrl
400
- ? `<img class="news-card-img" src="${imgUrl}" alt="" loading="lazy" onerror="this.style.display='none'" />`
401
- : `<div class="news-card-img news-card-img--fallback">${sourceInitial(item.source)}</div>`;
402
- const link = item.link
403
- ? `data-link="${item.link.replace(/"/g, "&quot;")}"`
404
- : "";
405
- return `<div class="news-card" ${link}>${src}<div class="news-card-meta"><span class="news-card-source">${item.source || "Tin tức"}</span><span class="news-card-title">${item.title}</span></div></div>`;
406
- }
407
-
408
- // ── News Panel ─────────────────────────────────────────────────────────
409
- function buildNewsItem(item, idx) {
410
- const el = document.createElement("div");
411
- el.className = "news-item";
412
-
413
- const thumb = document.createElement("div");
414
- thumb.className = "news-thumb";
415
- if (item.image) {
416
- const img = document.createElement("img");
417
- img.loading = "lazy";
418
- img.alt = "";
419
- img.src = item.image;
420
- img.addEventListener("error", () => {
421
- thumb.classList.add("news-thumb--fallback");
422
- thumb.textContent = sourceInitial(item.source);
423
- img.remove();
424
- });
425
- thumb.appendChild(img);
426
- } else {
427
- thumb.classList.add("news-thumb--fallback");
428
- thumb.textContent = sourceInitial(item.source);
429
- }
430
-
431
- const body = document.createElement("div");
432
- body.className = "news-body";
433
-
434
- const meta = document.createElement("div");
435
- meta.className = "news-meta";
436
- meta.innerHTML = `<span class="news-source">${item.source || "Tin tức"}</span>` +
437
- (typeof idx === "number" ? `<span class="news-index">#${idx + 1}</span>` : "");
438
-
439
- const title = document.createElement("div");
440
- title.className = "news-title";
441
- title.textContent = item.title;
442
-
443
- if (item.description) {
444
- const desc = document.createElement("div");
445
- desc.className = "news-desc";
446
- desc.textContent = item.description;
447
- body.appendChild(desc);
448
- }
449
-
450
- body.appendChild(meta);
451
- body.appendChild(title);
452
-
453
- el.appendChild(thumb);
454
- el.appendChild(body);
455
-
456
- // Action row: open source + ask in chat
457
- const actions = document.createElement("div");
458
- actions.className = "news-actions";
459
-
460
- const srcBtn = document.createElement("button");
461
- srcBtn.className = "news-act-btn";
462
- srcBtn.type = "button";
463
- srcBtn.textContent = "🔗 Nguồn";
464
- srcBtn.addEventListener("click", (e) => {
465
- e.stopPropagation();
466
- // VNEWS items open the short link so highlights/videos play on VNEWS.
467
- const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
468
- if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
469
- });
470
-
471
- const askBtn = document.createElement("button");
472
- askBtn.className = "news-act-btn news-act-btn--ask";
473
- askBtn.type = "button";
474
- askBtn.textContent = "💬 Hỏi";
475
- askBtn.addEventListener("click", (e) => {
476
- e.stopPropagation();
477
- askAboutNews(item);
478
- });
479
-
480
- actions.appendChild(srcBtn);
481
- actions.appendChild(askBtn);
482
- el.appendChild(actions);
483
-
484
- // Clicking the card body opens the source (VNEWS short link for VNEWS items).
485
- el.addEventListener("click", () => {
486
- const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link;
487
- if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
488
- });
489
- return el;
490
- }
491
-
492
- /** Fill the chat input with a question about a news item and focus/start chat. */
493
- function askAboutNews(item) {
494
- const q = `Kể chi tiết về tin này: ${item.title}`;
495
- chatInput.value = q;
496
- showTextChat(true);
497
- textModeBtn.classList.add("active");
498
- textMode = true;
499
- chatInput.focus();
500
- if (!client || !sessionInProgress) {
501
- void startTextSession(q);
502
- } else {
503
- addChatMessage("user", q);
504
- void maybeSwitchTopic(q);
505
- if (!sendTextViaSession(q)) setCaption("QUEUED…");
506
- }
507
- }
508
-
509
- function showNewsPanel(items) {
510
- if (!newsPanel || !newsList) return;
511
-
512
- newsList.innerHTML = "";
513
- newsOffset = 0;
514
- newsHasMore = true;
515
-
516
- if (!items || items.length === 0) {
517
- const empty = document.createElement("div");
518
- empty.className = "news-empty";
519
- empty.textContent = "Không có tin tức cho chủ đề này.";
520
- newsList.appendChild(empty);
521
- newsLoadMore.hidden = true;
522
- newsPanel.hidden = false;
523
- return;
524
- }
525
-
526
- items.forEach((item, i) => newsList.appendChild(buildNewsItem(item, i)));
527
- newsOffset = items.length;
528
- newsHasMore = items.length >= NEWS_PAGE_SIZE;
529
- newsLoadMore.hidden = !newsHasMore;
530
-
531
- newsPanel.hidden = false;
532
- setupNewsObserver();
533
- }
534
-
535
- function appendNewsItems(items) {
536
- if (!items || items.length === 0) {
537
- newsHasMore = false;
538
- newsLoadMore.hidden = true;
539
- return;
540
- }
541
- items.forEach((item) => newsList.appendChild(buildNewsItem(item, newsOffset++)));
542
- }
543
-
544
- async function loadMoreNews() {
545
- if (newsLoading || !newsHasMore) return;
546
- newsLoading = true;
547
- newsLoadMoreBtn.classList.add("loading");
548
- newsLoadMoreBtn.disabled = true;
549
-
550
- // If we're on a filtered topic, "load more" just re-fetches the filtered set.
551
- let items;
552
- if (newsTopicQuery) {
553
- items = await getFilteredNews(newsTopicQuery, NEWS_PAGE_SIZE);
554
- } else {
555
- const r = await getMoreNews(newsOffset, NEWS_PAGE_SIZE);
556
- items = r.items;
557
- newsHasMore = r.hasMore;
558
- newsOffset = r.nextOffset;
559
- }
560
- appendNewsItems(items);
561
- if (!newsTopicQuery) newsLoadMore.hidden = !newsHasMore;
562
- else newsLoadMore.hidden = true; // filtered set is already complete
563
-
564
- newsLoadMoreBtn.classList.remove("loading");
565
- newsLoadMoreBtn.disabled = false;
566
- newsLoading = false;
567
- }
568
-
569
- function setupNewsObserver() {
570
- if (newsObserver) newsObserver.disconnect();
571
- if (!newsSentinel || !("IntersectionObserver" in window)) return;
572
- newsObserver = new IntersectionObserver((entries) => {
573
- for (const entry of entries) {
574
- if (entry.isIntersecting && newsHasMore && !newsLoading && !newsTopicQuery) {
575
- void loadMoreNews();
576
- }
577
- }
578
- }, { root: newsList, rootMargin: "120px" });
579
- newsObserver.observe(newsSentinel);
580
- }
581
-
582
- // ── Topic detection & news sequence ─────────────────────────────────────
583
- const VI_STOPWORDS = new Set([
584
- "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "minh",
585
- "có", "không", "được", "là", "và", "với", "của", "cho", "trong", "ngoài", "về", "tại", "từ",
586
- "để", "khi", "nếu", "như", "đó", "này", "kia", "gì", "nào", "đâu", "ai", "sao", "thế",
587
- "hãy", "xin", "vui", "lòng", "giúp", "biết", "tin", "tức", "bài", "viết", "nói", "chuyện",
588
- "chủ", "đề", "muốn", "hỏi", "xem", "nghe", "à", "ừ", "ok", "okay", "ồ", "nhé", "nhỉ", "ạ",
589
- "đây", "nè", "một", "hai", "ba", "mấy", "các", "những", "cái", "con", "chiếc", "bản",
590
- "hôm", "nay", "tới", "mới", "nhất", "kể",
591
- ]);
592
-
593
- /** Decide if the user is continuing the current news thread (not switching). */
594
- function isFollowUp(text) {
595
- const t = (text || "").toLowerCase();
596
- // Only truly-continuation cues force a follow-up. Topic phrases like
597
- // "kể về X", "tin tức X", "cho tôi biết về X" are treated as new topics.
598
- if (/(tiếp|tiếp tục|nữa|thêm tin|tin này|tin đó|tin khác|còn tin|tin hot|tin nóng|tin hôm nay|tóm tắt|tóm lại|giải thích|ý nghĩa|chi tiết hơn|kể thêm)/.test(t)) {
599
- return true;
600
- }
601
- return t.trim().split(/\s+/).length <= 3;
602
- }
603
-
604
- /** Detect an explicit AI / công nghệ request (route to GenK AI feed). */
605
- function isAITopic(text) {
606
- const t = (text || "").toLowerCase();
607
- return /(\bai\b|trí tuệ nhân tạo|a\.i|a i|robot|machine learning|deep learning|chatbot|generative|gen ai|llm|gpt|qwen|kimi|chatgpt|gemini|openai|claude|tạo ảnh|tạo video|ứng dụng ai)/.test(t);
608
- }
609
-
610
- /** Extract a short keyword query from a user message (Vietnamese-aware). */
611
- function extractTopicQuery(text) {
612
- if (isFollowUp(text)) return ""; // follow-ups never trigger a topic switch
613
- const words = (text || "")
614
- .toLowerCase()
615
- .replace(/[^a-zà-ỹ0-9\s]/g, " ")
616
- .split(/\s+/)
617
- .filter(Boolean);
618
- // Keep meaningful words (>=2 chars, not a stopword). Compound nouns like
619
- // "bóng đá", "kinh tế", "bóng rổ" stay intact because both parts are kept.
620
- const keywords = words.filter((w) => w.length >= 2 && !VI_STOPWORDS.has(w));
621
- if (!keywords.length) return "";
622
- return keywords.slice(0, 2).join(" ");
623
- }
624
-
625
- /**
626
- * Called when the user sends a message. If it's a new topic, re-filter the
627
- * news list to that topic and reset the image sequence. AI topics pull the
628
- * GenK "AI" feed from VNEWS.
629
- */
630
- async function maybeSwitchTopic(userText) {
631
- if (isFollowUp(userText)) return false; // keep current sequence/list
632
-
633
- // AI topic -> pull GenK AI feed from VNEWS.
634
- if (isAITopic(userText)) {
635
- const aiItems = await getVnewsAIFeed(NEWS_PAGE_SIZE);
636
- if (aiItems.length) {
637
- newsTopicQuery = "ai";
638
- preFetchedNews = aiItems;
639
- newsSeq = aiItems.slice();
640
- newsSeqIdx = -1;
641
- showNewsPanel(aiItems);
642
- setCurrentNews(aiItems[0]);
643
- return true;
644
- }
645
- // Fall back to general news filter on "ai".
646
- const filtered = await getFilteredNews("ai", NEWS_PAGE_SIZE);
647
- if (filtered.length) {
648
- newsTopicQuery = "ai";
649
- preFetchedNews = filtered;
650
- newsSeq = filtered.slice();
651
- newsSeqIdx = -1;
652
- showNewsPanel(filtered);
653
- setCurrentNews(filtered[0]);
654
- return true;
655
- }
656
- return false;
657
- }
658
-
659
- const query = extractTopicQuery(userText);
660
- if (!query || query === newsTopicQuery) return false;
661
-
662
- const filtered = await getFilteredNews(query, NEWS_PAGE_SIZE);
663
- if (!filtered.length) return false; // no news for this topic -> keep general
664
-
665
- newsTopicQuery = query;
666
- preFetchedNews = filtered;
667
- newsSeq = filtered.slice();
668
- newsSeqIdx = -1;
669
- showNewsPanel(filtered);
670
- setCurrentNews(filtered[0]);
671
- return true;
672
- }
673
-
674
- // Current news item being discussed + which image to show next.
675
- let curNews = null;
676
- let curImgIdx = 0;
677
-
678
- /** Return the next image URL for the CURRENT news item, looping through its
679
- * images (image[0], image[1], ...). Falls back to a single image. */
680
- function nextNewsImage() {
681
- if (!curNews) return null;
682
- const imgs = (curNews.images && curNews.images.length)
683
- ? curNews.images
684
- : (curNews.image ? [curNews.image] : []);
685
- if (!imgs.length) return null;
686
- const url = imgs[curImgIdx % imgs.length];
687
- curImgIdx += 1;
688
- return url;
689
- }
690
-
691
- /** Set the news item currently being discussed (resets the image cursor). */
692
- function setCurrentNews(item) {
693
- curNews = item || null;
694
- curImgIdx = 0;
695
- }
696
-
697
- // ── Avatar list ─────────────────────────────────────────────────────────
698
- let avatarList = [];
699
- let avatarThumbs = {}; // name -> thumbnail URL (persisted on Space)
700
-
701
- async function fetchAvatarList() {
702
- try {
703
- const resp = await fetch("/api/avatars");
704
- if (resp.ok) {
705
- const data = await resp.json();
706
- avatarList = (data.avatars || []).map((a) => (typeof a === "string" ? a : a.name));
707
- // Store thumbnail URLs from the API response
708
- avatarThumbs = {};
709
- for (const a of (data.avatars || [])) {
710
- if (typeof a === "object" && a.name && a.thumbnail) {
711
- avatarThumbs[a.name] = a.thumbnail;
712
- }
713
- }
714
- }
715
- } catch {}
716
- }
717
-
718
- function populateAvatarSelects(selectedName) {
719
- for (const sel of [chatAvatarSelect, settingsAvatarSelect]) {
720
- sel.innerHTML = "";
721
- const def = document.createElement("option");
722
- def.value = "";
723
- def.textContent = "(Default - Brunette)";
724
- sel.appendChild(def);
725
-
726
- for (const name of avatarList) {
727
- const opt = document.createElement("option");
728
- opt.value = name;
729
- opt.textContent = name.replace(/\.glb$/i, "").replace(/_/g, " ") + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
730
- sel.appendChild(opt);
731
- }
732
-
733
- if (selectedName && avatarList.includes(selectedName)) {
734
- sel.value = selectedName;
735
- }
736
- }
737
- }
738
- updateAvatarThumbPreview();
739
-
740
-
741
- function setAvatarFromSelect(value) {
742
- settings.avatar = value || "";
743
- saveSettings();
744
- }
745
-
746
- async function reloadAvatar() {
747
- if (!stage.head) return;
748
- loading.classList.remove("done");
749
- loading.textContent = "Loading avatar...";
750
-
751
- try {
752
- await stage.init({
753
- avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
754
- onprogress: (ev) => {
755
- if (ev.lengthComputable) {
756
- loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
757
- }
758
- }
759
- });
760
- stage.resume(); // keep the render loop running so the avatar stays visible
761
- } catch (err) {
762
- console.error(err);
763
- }
764
-
765
- loading.classList.add("done");
766
- }
767
-
768
- // ── Chat / captions ────────────────────────────────────────────────────
769
- function clampRect() {
770
- const vw = window.innerWidth, vh = window.innerHeight;
771
- const r = textChat.getBoundingClientRect();
772
- let l = r.left, t = r.top;
773
- l = Math.max(10, Math.min(l, vw - r.width - 10));
774
- t = Math.max(10, Math.min(t, vh - r.height - 10));
775
- textChat.style.left = `${l}px`;
776
- textChat.style.top = `${t}px`;
777
- }
778
-
779
- function makeDraggable() {
780
- let dragging = false;
781
- let startX, startY, startLeft, startTop;
782
-
783
- function getPos(e) {
784
- const p = e.changedTouches ? e.changedTouches[0] : e;
785
- return { x: p.clientX, y: p.clientY };
786
- }
787
-
788
- function onStart(e) {
789
- if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) {
790
- return;
791
- }
792
- const p = getPos(e);
793
- dragging = true;
794
- const rect = textChat.getBoundingClientRect();
795
- startX = p.x;
796
- startY = p.y;
797
- startLeft = rect.left;
798
- startTop = rect.top;
799
- textChat.classList.add("dragging");
800
- e.preventDefault();
801
- }
802
-
803
- function onMove(e) {
804
- if (!dragging) return;
805
- const p = getPos(e);
806
- textChat.style.left = `${startLeft + p.x - startX}px`;
807
- textChat.style.top = `${startTop + p.y - startY}px`;
808
- textChat.style.right = "auto";
809
- textChat.style.bottom = "auto";
810
- textChat.style.width = "";
811
- textChat.style.height = "";
812
- e.preventDefault();
813
- }
814
-
815
- function onEnd() {
816
- if (!dragging) return;
817
- dragging = false;
818
- textChat.classList.remove("dragging");
819
- clampRect();
820
- }
821
-
822
- chatHeader.addEventListener("mousedown", onStart);
823
- document.addEventListener("mousemove", onMove);
824
- document.addEventListener("mouseup", onEnd);
825
- chatHeader.addEventListener("touchstart", onStart, { passive: false });
826
- document.addEventListener("touchmove", onMove, { passive: false });
827
- document.addEventListener("touchend", onEnd);
828
- }
829
-
830
- function makeResizable() {
831
- let resizing = false;
832
- let startX, startY, startW, startH;
833
-
834
- function getPos(e) {
835
- const p = e.changedTouches ? e.changedTouches[0] : e;
836
- return { x: p.clientX, y: p.clientY };
837
- }
838
-
839
- function onStart(e) {
840
- resizing = true;
841
- const rect = textChat.getBoundingClientRect();
842
- const p = getPos(e);
843
- startX = p.x;
844
- startY = p.y;
845
- startW = rect.width;
846
- startH = rect.height;
847
- textChat.classList.add("resizing");
848
- e.preventDefault();
849
- e.stopPropagation();
850
- }
851
-
852
- function onMove(e) {
853
- if (!resizing) return;
854
- const p = getPos(e);
855
- textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`;
856
- textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`;
857
- textChat.style.right = "auto";
858
- textChat.style.bottom = "auto";
859
- e.preventDefault();
860
- }
861
-
862
- function onEnd() {
863
- if (!resizing) return;
864
- resizing = false;
865
- textChat.classList.remove("resizing");
866
- }
867
-
868
- chatResizeHandle.addEventListener("mousedown", onStart);
869
- document.addEventListener("mousemove", onMove);
870
- document.addEventListener("mouseup", onEnd);
871
- chatResizeHandle.addEventListener("touchstart", onStart, { passive: false });
872
- document.addEventListener("touchmove", onMove, { passive: false });
873
- document.addEventListener("touchend", onEnd);
874
- }
875
-
876
- function setCaption(text, kind = "") {
877
- caption.textContent = text;
878
- caption.className = kind;
879
- }
880
-
881
- function showSubtitles(text) {
882
- if (!settings.subtitles) return;
883
- clearTimeout(subtitleTimer);
884
- subtitles.innerHTML = text;
885
- subtitles.classList.add("visible");
886
-
887
- subtitles.querySelectorAll("a[href]").forEach((a) => {
888
- a.addEventListener("click", (e) => {
889
- e.preventDefault();
890
- const href = a.getAttribute("href");
891
- if (href) {
892
- window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
893
- }
894
- });
895
- });
896
- }
897
-
898
- function fadeSubtitles(delayMs = 2600) {
899
- clearTimeout(subtitleTimer);
900
- subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs);
901
- }
902
-
903
- function escapeRegex(str) {
904
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
905
- }
906
-
907
- function addChatMessage(role, text, newsItem = null) {
908
- const msg = document.createElement("div");
909
- msg.className = `chat-message ${role}`;
910
- msg.dataset.text = text; // store plain text for rewrite selection
911
- if (newsItem) {
912
- if (newsItem.image) msg.dataset.img = newsItem.image;
913
- if (newsItem.link) msg.dataset.src = newsItem.link;
914
- }
915
-
916
- // Detect AI keywords in assistant messages for tag display
917
- let detectedAIKeywords = [];
918
- if (role === "assistant" && text) {
919
- const t = text.toLowerCase();
920
- const AI_PATTERNS = [
921
- "AI", "trí tuệ nhân tạo", "LLM", "GPT", "Qwen", "Kimi", "ChatGPT",
922
- "Gemini", "OpenAI", "Claude", "machine learning", "deep learning",
923
- "chatbot", "ứng dụng AI", "tạo ảnh", "tạo video", "AI tạo sinh",
924
- "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy",
925
- "neural network", "mạng nơ-ron"
926
- ];
927
- for (const kw of AI_PATTERNS) {
928
- if (t.includes(kw.toLowerCase())) {
929
- detectedAIKeywords.push(kw);
930
- }
931
- }
932
- }
933
-
934
- let displayText = text;
935
- if (role === "assistant" && latestNewsUrl && latestNewsSource) {
936
- const sourceEscaped = escapeRegex(latestNewsSource);
937
- const patterns = [
938
- new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'),
939
- new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'),
940
- ];
941
-
942
- for (const pattern of patterns) {
943
- if (pattern.test(displayText)) {
944
- displayText = displayText.replace(pattern, (match) =>
945
- `<a href="${latestNewsUrl}" target="_blank" rel="noopener" style="color:#22d3ee;text-decoration:none;">${match}</a>`
946
- );
947
- break;
948
- }
949
- }
950
- }
951
-
952
- msg.innerHTML = displayText;
953
-
954
- // Attach a news image card (for greeting + each subsequent answer).
955
- // Render even when the image is missing: newsImageCardHtml shows a
956
- // gradient fallback using the source initials, so the user always sees
957
- // a clickable source card.
958
- if ((role === "assistant") && newsItem) {
959
- const card = document.createElement("div");
960
- card.innerHTML = newsImageCardHtml(newsItem).trim();
961
- const cardEl = card.firstElementChild;
962
- if (cardEl) {
963
- cardEl.addEventListener("click", () => {
964
- if (newsItem.link) window.open(newsItem.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
965
- });
966
- msg.appendChild(cardEl);
967
- }
968
- }
969
-
970
- // Render AI keyword tags under assistant messages
971
- if (role === "assistant" && detectedAIKeywords.length > 0) {
972
- const tagRow = document.createElement("div");
973
- tagRow.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;";
974
- for (const kw of detectedAIKeywords) {
975
- const tag = document.createElement("span");
976
- tag.textContent = "\ud83e\udd16 " + kw;
977
- tag.style.cssText = "font-size:11px;background:rgba(59,130,246,0.15);color:#60a5fa;padding:2px 8px;border-radius:10px;border:1px solid rgba(59,130,246,0.3);";
978
- tagRow.appendChild(tag);
979
- }
980
- msg.appendChild(tagRow);
981
- }
982
-
983
- // Click a message to toggle multi-select (avatar + user messages) for rewrite.
984
- msg.addEventListener("click", () => {
985
- msg.classList.toggle("selected");
986
- updateRewriteButton();
987
- });
988
-
989
- chatMessages.appendChild(msg);
990
- chatMessages.scrollTop = chatMessages.scrollHeight;
991
-
992
- msg.querySelectorAll("a[href]").forEach((a) => {
993
- a.addEventListener("click", (e) => {
994
- e.preventDefault();
995
- const href = a.getAttribute("href");
996
- if (href) {
997
- window.open(href, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes");
998
- }
999
- });
1000
- });
1001
-
1002
- if (role === "assistant" && preFetchedNews.length > 0) {
1003
- showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
1004
- }
1005
- }
1006
-
1007
- function showTextChat(show) {
1008
- textChat.hidden = !show;
1009
- if (chatMicBtn) chatMicBtn.hidden = !show;
1010
- // Remove fakemic flag so mic button can request real mic later
1011
- if (show) {
1012
- const url = new URL(location.href);
1013
- url.searchParams.delete("fakemic");
1014
- history.replaceState(null, "", url.href);
1015
- }
1016
- }
1017
-
1018
- /** Handle Vietnamese voice recognition result: fill chat input, auto-send on "Gửi". */
1019
- function onVoiceResult(e) {
1020
- if (!e || !e.transcript) return;
1021
- if (!chatInput) return;
1022
- // Ignore results when AI is speaking or session not in user-turn
1023
- if (!sessionInProgress) return;
1024
- const text = e.transcript.trim();
1025
- chatInput.value = text;
1026
- if (e.isFinal && sessionInProgress) {
1027
- // Auto-send trigger phrases (Vietnamese + English)
1028
- const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i;
1029
- if (triggerRe.test(text)) {
1030
- setCaption("Đang gửi…", "live");
1031
- sendTextMessage();
1032
- // Stop voice typing after auto-send — user must click mic again to speak
1033
- if (voiceTyper && voiceTyper.active) {
1034
- voiceTyper.stop();
1035
- }
1036
- } else {
1037
- setCaption("Nhấp Send để gửi", "live");
1038
- }
1039
- }
1040
- }
1041
-
1042
- /** Start (or restart) Vietnamese voice recognition in text chat mode. */
1043
- function startVoiceTyping() {
1044
- if (!sessionInProgress || !chatInput) return false;
1045
- // Don't restart if already listening
1046
- if (voiceTyper && voiceTyper.active) return true;
1047
- // FIX: stop any previous VoiceTyper first — orphaned instances steal the mic
1048
- if (voiceTyper) {
1049
- voiceTyper.stop();
1050
- voiceTyper = null;
1051
- }
1052
- voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false });
1053
- if (!voiceTyper.init()) {
1054
- // Browser may not support Vietnamese locale — try English as fallback
1055
- voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false });
1056
- if (!voiceTyper.init()) {
1057
- setCaption("VOICE NOT SUPPORTED", "error");
1058
- voiceTyper = null;
1059
- return false;
1060
- }
1061
- }
1062
- voiceTyper.onResult = onVoiceResult;
1063
- voiceTyper.onError = (e) => console.warn("[voiceTyper]", e);
1064
- voiceTyper.onSoundStart = () => setCaption("🎤 Listening…", "live");
1065
- voiceTyper.onSoundEnd = () => {
1066
- // Keep listening — only stop when AI speaks or user clicks mic again
1067
- if (voiceTyper && voiceTyper.active) {
1068
- // Do nothing; continuous listening is intentional
1069
- }
1070
- };
1071
- voiceTyper.start();
1072
- setCaption("🎤 Listening…", "live");
1073
- return true;
1074
- }
1075
-
1076
- /** Stop Vietnamese voice recognition and return to text input. */
1077
- function stopVoiceTyping() {
1078
- if (voiceTyper && voiceTyper.active) {
1079
- voiceTyper.stop();
1080
- voiceTyper = null;
1081
- setCaption("Voice stopped", "live");
1082
- }
1083
- }
1084
-
1085
- function sendTextViaSession(text) {
1086
- if (!client) return false;
1087
- const st = client._status;
1088
- if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") {
1089
- return false;
1090
- }
1091
- setCaption("SENDING…");
1092
- client.sendUserText(text);
1093
- client.requestResponse();
1094
- return true;
1095
- }
1096
-
1097
- function sendTextMessage() {
1098
- const text = chatInput.value.trim();
1099
- if (!text) return;
1100
-
1101
- // Dedup: ignore if this exact message was already sent recently
1102
- if (text === _lastVoiceText && Date.now() - _lastVoiceSendMs < 3000) return;
1103
- _lastVoiceText = text;
1104
- _lastVoiceSendMs = Date.now();
1105
-
1106
- addChatMessage("user", text);
1107
-
1108
- // New topic? re-filter the news list and reset the image sequence.
1109
- if (client && sessionInProgress) {
1110
- void maybeSwitchTopic(text);
1111
- }
1112
-
1113
- if (!client || !sessionInProgress) {
1114
- void startTextSession(text);
1115
- return;
1116
- }
1117
-
1118
- if (!sendTextViaSession(text)) {
1119
- setCaption("QUEUED…");
1120
- }
1121
- // Voice typing stays active for continuous conversation;
1122
- // it will auto-stop when the AI starts speaking.
1123
- }
1124
-
1125
- if (newsCloseBtn) {
1126
- newsCloseBtn.addEventListener("click", (e) => {
1127
- e.stopPropagation();
1128
- newsPanel.hidden = true;
1129
- });
1130
- }
1131
-
1132
- if (newsToggleBtn) {
1133
- newsToggleBtn.addEventListener("click", () => {
1134
- newsPanel.hidden = !newsPanel.hidden;
1135
- });
1136
- }
1137
-
1138
- if (newsLoadMoreBtn) {
1139
- newsLoadMoreBtn.addEventListener("click", () => void loadMoreNews());
1140
- }
1141
-
1142
- if (chatCloseBtn) {
1143
- chatCloseBtn.addEventListener("click", (e) => {
1144
- e.stopPropagation();
1145
- textMode = false;
1146
- if (voiceTyper) {
1147
- voiceTyper.stop();
1148
- voiceTyper = null;
1149
- }
1150
- showTextChat(false);
1151
- textModeBtn.classList.remove("active");
1152
- });
1153
- }
1154
-
1155
- // ── VNEWS control panel (created dynamically, no index.html change) ─────
1156
- let vnewsPanel, vnewsConnectBtn, vnewsStatus, rewriteBtn, vnewsCloseBtn, vnewsToggleBtn;
1157
-
1158
- function buildVnewsPanel() {
1159
- vnewsPanel = document.createElement("div");
1160
- vnewsPanel.id = "vnews-panel";
1161
- vnewsPanel.hidden = true;
1162
-
1163
- const header = document.createElement("div");
1164
- header.id = "vnews-header";
1165
- const title = document.createElement("span");
1166
- title.id = "vnews-title";
1167
- title.textContent = "VNEWS";
1168
- vnewsCloseBtn = document.createElement("button");
1169
- vnewsCloseBtn.id = "vnews-close-btn";
1170
- vnewsCloseBtn.className = "icon-btn small";
1171
- vnewsCloseBtn.textContent = "✕";
1172
- vnewsCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); vnewsPanel.hidden = true; });
1173
- header.appendChild(title);
1174
- header.appendChild(vnewsCloseBtn);
1175
-
1176
- vnewsConnectBtn = document.createElement("button");
1177
- vnewsConnectBtn.id = "vnews-connect-btn";
1178
- vnewsConnectBtn.className = "vnews-btn";
1179
- vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
1180
- vnewsConnectBtn.addEventListener("click", () => void connectVnews());
1181
-
1182
- vnewsStatus = document.createElement("div");
1183
- vnewsStatus.id = "vnews-status";
1184
- vnewsStatus.textContent = "Chưa kết nối";
1185
-
1186
- rewriteBtn = document.createElement("button");
1187
- rewriteBtn.id = "vnews-rewrite-btn";
1188
- rewriteBtn.className = "vnews-btn";
1189
- rewriteBtn.textContent = "📝 Đăng bài rewrite";
1190
- rewriteBtn.disabled = true;
1191
- rewriteBtn.hidden = true;
1192
- rewriteBtn.addEventListener("click", () => void doRewriteSelected());
1193
-
1194
- vnewsPanel.appendChild(header);
1195
- vnewsPanel.appendChild(vnewsConnectBtn);
1196
- vnewsPanel.appendChild(vnewsStatus);
1197
- vnewsPanel.appendChild(rewriteBtn);
1198
- document.body.appendChild(vnewsPanel);
1199
-
1200
- // A small toggle button on the top bar.
1201
- vnewsToggleBtn = document.createElement("button");
1202
- vnewsToggleBtn.id = "vnews-toggle-btn";
1203
- vnewsToggleBtn.className = "icon-btn";
1204
- vnewsToggleBtn.title = "VNEWS";
1205
- vnewsToggleBtn.textContent = "📰";
1206
- vnewsToggleBtn.addEventListener("click", () => { vnewsPanel.hidden = !vnewsPanel.hidden; });
1207
- const topbar = document.querySelector("#topbar");
1208
- if (topbar) topbar.appendChild(vnewsToggleBtn);
1209
-
1210
- makeVnewsDraggable();
1211
- }
1212
-
1213
- async function connectVnews() {
1214
- vnewsConnectBtn.disabled = true;
1215
- vnewsConnectBtn.textContent = "⏳ Đang kết nối…";
1216
- vnewsStatus.textContent = "Đang tải dữ liệu VNEWS…";
1217
- const articles = await getVnewsHomepage();
1218
- if (articles.length) {
1219
- vnewsConnected = true;
1220
- vnewsArticles = articles;
1221
- vnewsStatus.textContent = `✅ Đã kết nối · ${articles.length} bài`;
1222
- vnewsConnectBtn.textContent = "✅ VNEWS đã kết nối";
1223
- rewriteBtn.hidden = false;
1224
- rewriteBtn.disabled = true; // enabled when a message is selected
1225
- updateRewriteButton();
1226
- // Refresh the avatar's instructions so it can answer from VNEWS.
1227
- if (client) client.updateSession({ instructions: effectiveInstructions(preFetchedNews) });
1228
- } else {
1229
- vnewsStatus.textContent = "❌ Không thể kết nối VNEWS";
1230
- vnewsConnectBtn.disabled = false;
1231
- vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS";
1232
- }
1233
- }
1234
-
1235
- function updateRewriteButton() {
1236
- const selected = chatMessages.querySelectorAll(".chat-message.selected");
1237
- let hasText = false;
1238
- selected.forEach((m) => { if ((m.dataset.text || "").trim().length > 0) hasText = true; });
1239
- // Caption hint when connected and nothing selected yet.
1240
- if (vnewsConnected && selected.length === 0) {
1241
- setCaption("CHỌN 1 HAY NHIỀU TIN NHẮN ĐỂ ĐĂNG VNEWS", "live");
1242
- }
1243
- rewriteBtn.disabled = !vnewsConnected || selected.length === 0 || !hasText;
1244
- }
1245
-
1246
- /** Rewrite the selected chat content into a VNEWS wall post. */
1247
- async function doRewriteSelected() {
1248
- const selected = [...chatMessages.querySelectorAll(".chat-message.selected")];
1249
- if (selected.length === 0) { setCaption("CHỌN TIN NHẮN ĐỂ REWRITE", "error"); return; }
1250
- // Combine ALL selected messages (avatar + user) into one post.
1251
- const context = selected
1252
- .map((m) => (m.dataset.text || "").trim())
1253
- .filter((t) => t.length > 0)
1254
- .join("\n\n");
1255
- if (context.length < 20) { setCaption("TIN NHẮN QUÁ NGẮN", "error"); return; }
1256
- // Prefer the original source URL of a news item so VNEWS can pull its image.
1257
- let srcUrl = "";
1258
- for (const m of selected) { if (m.dataset.src) { srcUrl = m.dataset.src; break; } }
1259
-
1260
- rewriteBtn.disabled = true;
1261
- rewriteBtn.textContent = "⏳ AI đang xào nấu bài viết…";
1262
- let title = "";
1263
- let article = context;
1264
- try {
1265
- const sresp = await fetch("/api/summarize", {
1266
- method: "POST",
1267
- headers: { "Content-Type": "application/json" },
1268
- body: JSON.stringify({ text: context }),
1269
- });
1270
- if (sresp.ok) {
1271
- const sdata = await sresp.json();
1272
- if (sdata && sdata.body) { article = sdata.body; title = sdata.title || ""; }
1273
- }
1274
- } catch (e) { /* fall back to raw context */ }
1275
-
1276
- rewriteBtn.textContent = "⏳ Đăng…";
1277
- try {
1278
- // Resolve a REAL hero image from the source article (VNEWS scrapes og:image).
1279
- let heroImage = srcUrl; // fallback: the article URL itself
1280
- if (srcUrl) {
1281
- try {
1282
- const artResp = await fetch(`${VNEWS_BASE}/api/article?url=${encodeURIComponent(srcUrl)}`, { headers: { "Accept": "application/json" } });
1283
- if (artResp.ok) {
1284
- const art = await artResp.json();
1285
- if (art && art.og_image) heroImage = art.og_image;
1286
- }
1287
- } catch (_) { /* keep fallback */ }
1288
- }
1289
- // Publish the AI-composed article directly (not just re-split).
1290
- const payload = { body: article };
1291
- if (title) payload.title = title;
1292
- if (heroImage) payload.image = heroImage;
1293
- if (srcUrl) payload.url = srcUrl;
1294
- const resp = await fetch(`${VNEWS_BASE}/api/publish`, {
1295
- method: "POST",
1296
- headers: { "Content-Type": "application/json" },
1297
- body: JSON.stringify(payload),
1298
- });
1299
- const data = await resp.json();
1300
- if (data && data.post) {
1301
- const shown = (title ? `**${title}**\n\n` : "") + article;
1302
- addChatMessage("assistant", `✅ Đã đăng bài AI tổng hợp (${selected.length} tin nhắn) lên VNEWS:\n${shown}`);
1303
- setCaption("ĐÃ ĐĂNG LÊN VNEWS", "live");
1304
- } else {
1305
- addChatMessage("assistant", `⚠️ VNEWS: ${data.error || "không đăng được bài"}.`);
1306
- }
1307
- } catch (e) {
1308
- addChatMessage("assistant", "⚠️ Lỗi khi kết nối VNEWS để đăng bài.");
1309
- }
1310
- rewriteBtn.textContent = "📝 Đăng bài rewrite";
1311
- // Clear selection after posting.
1312
- selected.forEach((m) => m.classList.remove("selected"));
1313
- updateRewriteButton();
1314
- }
1315
-
1316
- function makeVnewsDraggable() {
1317
- let dragging = false, startX, startY, startLeft, startTop;
1318
- const header = vnewsPanel.querySelector("#vnews-header");
1319
- function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; }
1320
- header.addEventListener("mousedown", (e) => {
1321
- if (e.target.closest("#vnews-close-btn")) return;
1322
- dragging = true;
1323
- const rect = vnewsPanel.getBoundingClientRect();
1324
- startX = e.clientX; startY = e.clientY;
1325
- startLeft = rect.left; startTop = rect.top;
1326
- vnewsPanel.classList.add("dragging");
1327
- e.preventDefault();
1328
- });
1329
- document.addEventListener("mousemove", (e) => {
1330
- if (!dragging) return;
1331
- const vw = window.innerWidth, vh = window.innerHeight;
1332
- const w = vnewsPanel.offsetWidth, h = vnewsPanel.offsetHeight;
1333
- const l = Math.max(10, Math.min(startLeft + e.clientX - startX, vw - w - 10));
1334
- const t = Math.max(10, Math.min(startTop + e.clientY - startY, vh - h - 10));
1335
- vnewsPanel.style.left = `${l}px`; vnewsPanel.style.top = `${t}px`;
1336
- vnewsPanel.style.right = "auto"; vnewsPanel.style.bottom = "auto";
1337
- });
1338
- document.addEventListener("mouseup", () => { dragging = false; vnewsPanel.classList.remove("dragging"); });
1339
- }
1340
-
1341
- // ── Button logic ────────────────────────────────────────────────────────
1342
- let mainAction = "start";
1343
-
1344
- function setMainButton(action, label) {
1345
- mainAction = action;
1346
- mainBtnLabel.textContent = label;
1347
- mainBtn.disabled = action === "busy";
1348
- mainBtn.classList.toggle("live", action === "stop");
1349
- muteBtn.hidden = action !== "stop";
1350
- textModeBtn.hidden = false;
1351
- }
1352
-
1353
- const CAPTIONS = {
1354
- idle: "TAP TO TALK",
1355
- "creating-session": "REQUESTING A SLOT…",
1356
- queued: "WAITING IN LINE…",
1357
- "your-turn": "YOUR TURN, TAP TO JOIN",
1358
- connecting: "CONNECTING…",
1359
- connected: "GO AHEAD, I'M LISTENING",
1360
- "user-speaking": "LISTENING",
1361
- processing: "THINKING…",
1362
- "ai-speaking": "SPEAKING",
1363
- closed: "TAP TO TALK",
1364
- error: "SOMETHING BROKE, TAP TO RETRY",
1365
- };
1366
-
1367
- function onStatus(status) {
1368
- stage.setConversationState(status);
1369
- setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live");
1370
-
1371
- switch (status) {
1372
- case "idle":
1373
- case "closed":
1374
- setMainButton("start", "Start talking");
1375
- break;
1376
- case "error":
1377
- setMainButton("start", "Retry");
1378
- break;
1379
- case "creating-session":
1380
- case "connecting":
1381
- setMainButton("busy", "Connecting…");
1382
- break;
1383
- case "queued":
1384
- setMainButton("stop", "Leave queue");
1385
- break;
1386
- case "your-turn":
1387
- setMainButton("join", "Join now");
1388
- break;
1389
- default:
1390
- setMainButton("stop", "End conversation");
1391
- break;
1392
- }
1393
-
1394
- if (status === "ai-speaking") {
1395
- // AI is speaking — stop voice typing so mic doesn't pick up audio
1396
- if (voiceTyper && voiceTyper.active) {
1397
- voiceTyper.stop();
1398
- }
1399
- subtitles.classList.add("visible");
1400
- }
1401
-
1402
- if (status === "user-speaking") {
1403
- // User's turn to speak — auto-start voice typing so user can speak
1404
- // immediately without clicking the mic button
1405
- setTimeout(() => startVoiceTyping(), 400);
1406
- subtitles.classList.remove("visible");
1407
- showTextChat(textMode);
1408
- }
1409
- }
1410
-
1411
- // ── Tool runner ─────────────────────────────────────────────────────────
1412
- function runTool(name, argsJson, callId) {
1413
- if (!client) return;
1414
-
1415
- let args = {};
1416
- try { args = JSON.parse(argsJson || "{}"); } catch {}
1417
-
1418
- if (name === "get_vnews_news") {
1419
- const query = (args.query || "").trim();
1420
- let articles;
1421
- if (vnewsConnected) articles = searchVnews(query, 8);
1422
- else articles = []; // not connected yet
1423
- const out = vnewsLinksAsHtml(articles);
1424
- client.sendToolOutput(callId, `Kết quả từ VNEWS${query ? ` cho "${query}"` : " (mới nhất)"}:\n${out}`);
1425
- client.requestResponse();
1426
- return;
1427
- }
1428
-
1429
- if (name === "get_current_datetime") {
1430
- const now = new Date();
1431
- const dateStr = formatVietnameseDate(now);
1432
- const timeStr = formatVietnameseTime(now);
1433
- client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`);
1434
- client.requestResponse();
1435
- return;
1436
- }
1437
-
1438
- if (name === "search_wikipedia") {
1439
- const query = args.query || "";
1440
- if (!query) {
1441
- client.sendToolOutput(callId, `No query.`);
1442
- client.requestResponse();
1443
- return;
1444
- }
1445
-
1446
- fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`)
1447
- .then(r => r.json())
1448
- .then(data => {
1449
- if (data.extract) {
1450
- client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`);
1451
- client.requestResponse();
1452
- return;
1453
- }
1454
- fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`)
1455
- .then(r => r.json())
1456
- .then(sd => {
1457
- if (!sd.results?.length) {
1458
- client.sendToolOutput(callId, `No Wikipedia results for "${query}".`);
1459
- client.requestResponse();
1460
- return;
1461
- }
1462
- fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`)
1463
- .then(r => r.json())
1464
- .then(sum => {
1465
- client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0, 3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`);
1466
- client.requestResponse();
1467
- });
1468
- });
1469
- })
1470
- .catch(() => {
1471
- client.sendToolOutput(callId, `Wikipedia search failed.`);
1472
- client.requestResponse();
1473
- });
1474
- return;
1475
- }
1476
-
1477
- if (name === "search_web") {
1478
- const query = args.query || "";
1479
- if (!query) {
1480
- client.sendToolOutput(callId, `No query.`);
1481
- client.requestResponse();
1482
- return;
1483
- }
1484
-
1485
- fetch(`/api/web/search?q=${encodeURIComponent(query)}`)
1486
- .then(r => r.json())
1487
- .then(data => {
1488
- if (!data.results?.length) {
1489
- client.sendToolOutput(callId, `No web results for "${query}".`);
1490
- client.requestResponse();
1491
- return;
1492
- }
1493
- client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0, 3).map((r, i) => `${i + 1}. ${r.title}\n ${r.snippet}`).join("\n")}`);
1494
- client.requestResponse();
1495
- })
1496
- .catch(() => {
1497
- client.sendToolOutput(callId, `Web search failed.`);
1498
- client.requestResponse();
1499
- });
1500
- return;
1501
- }
1502
-
1503
- const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`;
1504
- client.sendToolOutput(callId, result);
1505
- client.requestResponse();
1506
- }
1507
-
1508
- async function connectSession(c) {
1509
- try {
1510
- await c.connect();
1511
- return c;
1512
- } catch (err) {
1513
- const code = err?.code;
1514
- if (code === "limit") {
1515
- setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error");
1516
- } else if (code === "queue-full") {
1517
- setCaption("ALL SEATS TAKEN, TRY AGAIN", "error");
1518
- } else if (code === "join-expired") {
1519
- setCaption("SPOT EXPIRED, TAP TO RETRY", "error");
1520
- } else {
1521
- setCaption("CONNECTION FAILED, TAP TO RETRY", "error");
1522
- }
1523
- return null;
1524
- }
1525
- }
1526
-
1527
- async function startVoiceSession(micStream) {
1528
- if (sessionInProgress) return;
1529
- sessionInProgress = true;
1530
- await stage.resume();
1531
-
1532
- if (!micStream) {
1533
- if (new URLSearchParams(location.search).has("fakemic")) {
1534
- const ctx = stage.audioCtx;
1535
- micStream = ctx.createMediaStreamDestination().stream;
1536
- } else {
1537
- try {
1538
- micStream = await navigator.mediaDevices.getUserMedia({
1539
- audio: {
1540
- echoCancellation: true,
1541
- noiseSuppression: true,
1542
- autoGainControl: true
1543
- }
1544
- });
1545
- } catch {
1546
- setCaption("MIC BLOCKED", "error");
1547
- sessionInProgress = false;
1548
- return;
1549
- }
1550
- }
1551
- }
1552
-
1553
- const audioCtx = stage.audioCtx;
1554
- const voiceSink = stage.voiceSink;
1555
- if (!audioCtx || !voiceSink) {
1556
- sessionInProgress = false;
1557
- return;
1558
- }
1559
-
1560
- const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
1561
- if (!preFetchedNews.length) {
1562
- preFetchedNews = newsItems;
1563
- }
1564
-
1565
- // Initialize the image sequence from the current news set.
1566
- newsSeq = preFetchedNews.slice();
1567
- newsSeqIdx = -1;
1568
- newsTopicQuery = "";
1569
-
1570
- if (newsItems.length > 0) {
1571
- latestNewsUrl = newsItems[0].link;
1572
- setCurrentNews(newsItems[0]);
1573
- try {
1574
- latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
1575
- } catch {
1576
- latestNewsSource = "VnExpress";
1577
- }
1578
- }
1579
-
1580
- const c = new S2sWsRealtimeClient({
1581
- ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
1582
- voice: settings.voice,
1583
- instructions: effectiveInstructions(newsItems),
1584
- micStream,
1585
- audioContext: audioCtx,
1586
- outputNode: voiceSink,
1587
- workletBaseUrl: "/worklets/",
1588
- tools: TOOL_DEFS,
1589
- _textOnly: false,
1590
- });
1591
-
1592
- client = c;
1593
- _attachClientEvents(c);
1594
- setCaption("REQUESTING A SLOT…");
1595
-
1596
- const ok = await connectSession(c);
1597
- if (!ok) return;
1598
-
1599
- if (!autoGreetingSent) {
1600
- c.requestResponse();
1601
- }
1602
- }
1603
-
1604
- async function startTextSession(initialText) {
1605
- if (sessionInProgress) {
1606
- if (client && initialText) {
1607
- client.sendUserText(initialText);
1608
- client.requestResponse();
1609
- }
1610
- return;
1611
- }
1612
-
1613
- sessionInProgress = true;
1614
- await stage.resume();
1615
-
1616
- const audioCtx = stage.audioCtx;
1617
- const voiceSink = stage.voiceSink;
1618
- if (!audioCtx || !voiceSink) {
1619
- sessionInProgress = false;
1620
- return;
1621
- }
1622
-
1623
- const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews());
1624
- if (!preFetchedNews.length) {
1625
- preFetchedNews = newsItems;
1626
- }
1627
-
1628
- newsSeq = preFetchedNews.slice();
1629
- newsSeqIdx = -1;
1630
- newsTopicQuery = "";
1631
-
1632
- if (newsItems.length > 0) {
1633
- latestNewsUrl = newsItems[0].link;
1634
- setCurrentNews(newsItems[0]);
1635
- try {
1636
- latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname);
1637
- } catch {
1638
- latestNewsSource = "VnExpress";
1639
- }
1640
- }
1641
-
1642
- const c = new S2sWsRealtimeClient({
1643
- ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }),
1644
- voice: settings.voice,
1645
- instructions: effectiveInstructions(newsItems),
1646
- audioContext: audioCtx,
1647
- outputNode: voiceSink,
1648
- workletBaseUrl: "/worklets/",
1649
- tools: TOOL_DEFS,
1650
- _textOnly: true,
1651
- });
1652
-
1653
- client = c;
1654
- _attachClientEvents(c);
1655
- textMode = true;
1656
- showTextChat(true);
1657
- textModeBtn.classList.add("active");
1658
-
1659
- const ok = await connectSession(c);
1660
- if (!ok) return;
1661
-
1662
- if (!autoGreetingSent) {
1663
- c.requestResponse();
1664
- }
1665
-
1666
- if (initialText) {
1667
- c.sendUserText(initialText);
1668
- c.requestResponse();
1669
- }
1670
- }
1671
-
1672
- function _attachClientEvents(c) {
1673
- c.addEventListener("status", (e) => {
1674
- onStatus(e.detail.status);
1675
- });
1676
-
1677
- c.addEventListener("queue", (e) => {
1678
- const { position } = e.detail;
1679
- setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live");
1680
- });
1681
-
1682
- c.addEventListener("transcript", (e) => {
1683
- const { role, text } = e.detail;
1684
- if (role === "assistant" && text) {
1685
- const normalized = smartNormalize(text);
1686
- showSubtitles(normalized);
1687
- // Attach the next image of the CURRENT news item (loops through its images).
1688
- const img = nextNewsImage();
1689
- const newsItem = curNews ? { ...curNews, image: img || curNews.image } : null;
1690
- addChatMessage("assistant", normalized, newsItem);
1691
- }
1692
- });
1693
-
1694
- c.addEventListener("response-finished", () => {
1695
- autoGreetingSent = true;
1696
- fadeSubtitles();
1697
- if (preFetchedNews.length > 0) {
1698
- showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE));
1699
- }
1700
- _topicTagsRendered = false;
1701
- try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
1702
- // FIX: auto-start voice typing after AI finishes speaking so the user can respond immediately
1703
- if (sessionInProgress) {
1704
- setTimeout(() => startVoiceTyping(), 500);
1705
- }
1706
- });
1707
-
1708
- c.addEventListener("toolcall", (e) => {
1709
- const { name, arguments: args, callId } = e.detail;
1710
- runTool(name, args, callId);
1711
- });
1712
-
1713
- c.addEventListener("server-error", (e) => {
1714
- console.warn("server error:", e.detail.error);
1715
- });
1716
-
1717
- c.addEventListener("error", () => {
1718
- void endSession();
1719
- });
1720
- }
1721
-
1722
- async function endSession(silent = false) {
1723
- const c = client;
1724
- client = null;
1725
- sessionInProgress = false;
1726
- autoGreetingSent = false;
1727
-
1728
- // Stop voice recognition if active
1729
- if (voiceTyper) {
1730
- voiceTyper.stop();
1731
- voiceTyper = null;
1732
- }
1733
-
1734
- if (c) {
1735
- if (c.options.micStream) {
1736
- for (const track of c.options.micStream?.getTracks() ?? []) {
1737
- track.stop();
1738
- }
1739
- }
1740
- await c.close().catch(() => {});
1741
- }
1742
-
1743
- stage.setConversationState("idle");
1744
- subtitles.classList.remove("visible");
1745
-
1746
- if (newsPanel) {
1747
- newsPanel.hidden = true;
1748
- }
1749
-
1750
- if (!silent) {
1751
- setCaption(CAPTIONS.idle);
1752
- setMainButton("start", "Start talking");
1753
- // Show today's real trending topics immediately (also re-shown after greeting).
1754
- try { void renderTopicTags(); } catch (e) { console.warn("[topic] startWithSelection:", e); }
1755
- }
1756
- }
1757
-
1758
- // ── UI events ───────────────────────────────────────────────────────────
1759
- mainBtn.addEventListener("click", () => {
1760
- if (mainAction === "start") {
1761
- if (sessionInProgress) return;
1762
- // Default: always start text chat (no mic required)
1763
- void startTextSession();
1764
- } else if (mainAction === "join") {
1765
- stage.resume();
1766
- client?.join();
1767
- } else if (mainAction === "stop") {
1768
- void endSession();
1769
- }
1770
- });
1771
-
1772
- muteBtn.addEventListener("click", () => {
1773
- muted = !muted;
1774
- client?.setMuted(muted);
1775
- muteBtn.classList.toggle("active", muted);
1776
- muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone");
1777
- });
1778
-
1779
- textModeBtn.addEventListener("click", () => {
1780
- textMode = !textMode;
1781
- showTextChat(textMode);
1782
- textModeBtn.classList.toggle("active", textMode);
1783
- });
1784
-
1785
- chatSendBtn.addEventListener("click", () => sendTextMessage());
1786
-
1787
- chatMicBtn.addEventListener("click", async () => {
1788
- if (!sessionInProgress) return;
1789
- // If already listening, stop
1790
- if (voiceTyper && voiceTyper.active) {
1791
- stopVoiceTyping();
1792
- return;
1793
- }
1794
- // Request mic access
1795
- const micStream = await navigator.mediaDevices.getUserMedia({
1796
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
1797
- }).catch(() => null);
1798
- if (!micStream) {
1799
- setCaption("MIC BLOCKED", "error");
1800
- return;
1801
- }
1802
- micStream.getTracks().forEach((t) => t.stop());
1803
- startVoiceTyping();
1804
- });
1805
-
1806
- chatInput.addEventListener("keypress", (e) => {
1807
- if (e.key === "Enter") {
1808
- sendTextMessage();
1809
- }
1810
- });
1811
-
1812
- chatAvatarSelect.addEventListener("change", () => {
1813
- setAvatarFromSelect(chatAvatarSelect.value);
1814
- void reloadAvatar();
1815
- });
1816
-
1817
- settingsAvatarSelect.addEventListener("change", () => {
1818
- setAvatarFromSelect(settingsAvatarSelect.value);
1819
- chatAvatarSelect.value = settingsAvatarSelect.value;
1820
- void reloadAvatar();
1821
- });
1822
-
1823
- settingsBtn.addEventListener("click", () => {
1824
- inputVoice.value = settings.voice;
1825
- inputInstructions.value = settings.instructions;
1826
- inputDirectUrl.value = settings.directUrl;
1827
- inputSubtitles.checked = settings.subtitles;
1828
- settingsAvatarSelect.value = chatAvatarSelect.value;
1829
- settingsDialog.showModal();
1830
- });
1831
-
1832
- settingsDialog.addEventListener("close", () => {
1833
- settings = {
1834
- voice: inputVoice.value || DEFAULT_VOICE,
1835
- avatar: settingsAvatarSelect.value || "",
1836
- instructions: inputInstructions.value,
1837
- directUrl: inputDirectUrl.value.trim(),
1838
- subtitles: inputSubtitles.checked
1839
- };
1840
-
1841
- if (settings.avatar !== "vuong.glb") {
1842
- autoGreetingSent = false;
1843
- }
1844
-
1845
- saveSettings();
1846
- chatAvatarSelect.value = settings.avatar;
1847
-
1848
- if (!settings.subtitles) {
1849
- subtitles.classList.remove("visible");
1850
- }
1851
-
1852
- client?.updateSession({
1853
- voice: settings.voice,
1854
- instructions: effectiveInstructions(preFetchedNews)
1855
- });
1856
- });
1857
-
1858
- window.addEventListener("beforeunload", () => {
1859
- client?.close();
1860
- });
1861
-
1862
- // ── Boot ────────────────────────────────────────────────────────────────
1863
- async function boot() {
1864
- // 1) Reveal the picker IMMEDIATELY — first screen, never a loader.
1865
- showPicker();
1866
-
1867
- // 2) Voice <select> options.
1868
- for (const v of VOICES) {
1869
- const o = document.createElement("option");
1870
- o.value = v;
1871
- o.textContent = v.replaceAll("_", " ");
1872
- inputVoice.append(o);
1873
- }
1874
- inputVoice.value = settings.voice;
1875
-
1876
- // 3) Best-effort setup; isolate each step so a failure can NEVER hide the picker.
1877
- try {
1878
- const resp = await fetch("api/config");
1879
- if (resp.ok) config = { ...config, ...(await resp.json()) };
1880
- } catch (e) { console.warn("[boot] config:", e); }
1881
- directUrlRow.hidden = !config.allowDirect;
1882
-
1883
- // Allow real mic for voice chat. Never force fakemic automatically.
1884
- // User can click mic button in chat to switch to voice mode.
1885
-
1886
- try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
1887
- try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
1888
- try {
1889
- makeDraggable(); makeResizable(); makeNewsDraggable(); makeNewsResizable(); buildVnewsPanel();
1890
- } catch (e) { console.warn("[boot] ui:", e); }
1891
-
1892
- // 4) Fill the picker (avatar cards + voice chips).
1893
- try { buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
1894
- try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
1895
- try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", e); }
1896
- }
1897
-
1898
- // ── News box draggable + resizable (mirrors the chat box) ────────────────
1899
- function makeNewsDraggable() {
1900
- let dragging = false;
1901
- let startX, startY, startLeft, startTop;
1902
-
1903
- function getPos(e) {
1904
- const p = e.changedTouches ? e.changedTouches[0] : e;
1905
- return { x: p.clientX, y: p.clientY };
1906
- }
1907
-
1908
- function onStart(e) {
1909
- if (e.target.closest("#news-header") === null) return; // only drag from header
1910
- if (e.target.closest("#news-close-btn")) return;
1911
- const p = getPos(e);
1912
- dragging = true;
1913
- const rect = newsPanel.getBoundingClientRect();
1914
- startX = p.x;
1915
- startY = p.y;
1916
- startLeft = rect.left;
1917
- startTop = rect.top;
1918
- newsPanel.classList.add("dragging");
1919
- e.preventDefault();
1920
- }
1921
-
1922
- function onMove(e) {
1923
- if (!dragging) return;
1924
- const p = getPos(e);
1925
- const vw = window.innerWidth, vh = window.innerHeight;
1926
- const w = newsPanel.offsetWidth, h = newsPanel.offsetHeight;
1927
- let l = Math.max(10, Math.min(startLeft + p.x - startX, vw - w - 10));
1928
- let t = Math.max(10, Math.min(startTop + p.y - startY, vh - h - 10));
1929
- newsPanel.style.left = `${l}px`;
1930
- newsPanel.style.top = `${t}px`;
1931
- newsPanel.style.right = "auto";
1932
- newsPanel.style.bottom = "auto";
1933
- e.preventDefault();
1934
- }
1935
-
1936
- function onEnd() {
1937
- if (!dragging) return;
1938
- dragging = false;
1939
- newsPanel.classList.remove("dragging");
1940
- }
1941
-
1942
- const header = $("#news-header");
1943
- header.addEventListener("mousedown", onStart);
1944
- document.addEventListener("mousemove", onMove);
1945
- document.addEventListener("mouseup", onEnd);
1946
- header.addEventListener("touchstart", onStart, { passive: false });
1947
- document.addEventListener("touchmove", onMove, { passive: false });
1948
- document.addEventListener("touchend", onEnd);
1949
- }
1950
-
1951
- function makeNewsResizable() {
1952
- let resizing = false;
1953
- let startX, startY, startW, startH;
1954
-
1955
- function getPos(e) {
1956
- const p = e.changedTouches ? e.changedTouches[0] : e;
1957
- return { x: p.clientX, y: p.clientY };
1958
- }
1959
-
1960
- function onStart(e) {
1961
- resizing = true;
1962
- const rect = newsPanel.getBoundingClientRect();
1963
- const p = getPos(e);
1964
- startX = p.x;
1965
- startY = p.y;
1966
- startW = rect.width;
1967
- startH = rect.height;
1968
- newsPanel.classList.add("resizing");
1969
- e.preventDefault();
1970
- e.stopPropagation();
1971
- }
1972
-
1973
- function onMove(e) {
1974
- if (!resizing) return;
1975
- const p = getPos(e);
1976
- newsPanel.style.width = `${Math.max(300, startW + p.x - startX)}px`;
1977
- newsPanel.style.height = `${Math.max(180, startH + p.y - startY)}px`;
1978
- newsPanel.style.right = "auto";
1979
- newsPanel.style.bottom = "auto";
1980
- e.preventDefault();
1981
- }
1982
-
1983
- function onEnd() {
1984
- if (!resizing) return;
1985
- resizing = false;
1986
- newsPanel.classList.remove("resizing");
1987
- }
1988
-
1989
- newsResizeHandle.addEventListener("mousedown", onStart);
1990
- document.addEventListener("mousemove", onMove);
1991
- document.addEventListener("mouseup", onEnd);
1992
- newsResizeHandle.addEventListener("touchstart", onStart, { passive: false });
1993
- document.addEventListener("touchmove", onMove, { passive: false });
1994
- document.addEventListener("touchend", onEnd);
1995
- }
1996
-
1997
- void boot();
1998
-
1999
- // ── Capture PNG (snapshot of the 3D avatar) ──────────────
2000
  const THUMB_KEY = "avatar.thumbs";
2001
  const avatarThumbPreview = document.getElementById("avatar-thumb-preview");
 
2002
  function loadThumbs() {
2003
  try { return JSON.parse(localStorage.getItem(THUMB_KEY) || "{}"); }
2004
  catch { return {}; }
2005
  }
2006
  function saveThumbs(map) { localStorage.setItem(THUMB_KEY, JSON.stringify(map)); }
 
2007
  function serverThumbUrl(name) {
2008
  if (!name) return "";
2009
- // Don't add timestamp for server-stored thumbnails (they are immutable)
2010
- // Use dataset repo (no Space rebuild on upload)
2011
- return `https://huggingface.co/datasets/bep40/gemma-avatar-thumbnails/resolve/main/public/avatars/${name}.thumb.png`;
2012
  }
2013
- // Show the avatar's representative image: prefer the server-stored thumbnail
2014
- // (persisted on Space), fall back to freshly-captured local copy, then
2015
- // a gradient fallback with the initial letter.
2016
  function updateAvatarThumbPreview() {
2017
  if (!avatarThumbPreview) return;
2018
  const name = chatAvatarSelect.value || settings.avatar || "";
2019
- // Ensure .glb extension for server URL lookup
2020
- const avatarName = name.endsWith(".glb") ? name : (name ? name + ".glb" : "");
2021
- const label = (avatarName || "?").replace(/\.glb$/i, "").replace(/_/g, " ");
2022
- const initial = label.slice(0, 1).toUpperCase();
2023
  const map = loadThumbs();
2024
-
2025
- // Helper: show fallback letter with gradient bg
2026
- function showFallback() {
2027
- avatarThumbPreview.style.display = "none";
2028
- // Find or create the sibling fallback element
2029
- let fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2030
- if (!fb) {
2031
- fb = document.createElement("div");
2032
- fb.className = "thumb-fallback";
2033
- if (avatarThumbPreview.parentNode) {
2034
- avatarThumbPreview.parentNode.insertBefore(fb, avatarThumbPreview);
2035
- }
2036
- }
2037
- fb.textContent = initial;
2038
- fb.style.display = "flex";
2039
- }
2040
-
2041
- function showImg(url) {
2042
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2043
- if (fb) fb.style.display = "none";
2044
- avatarThumbPreview.src = url;
2045
- avatarThumbPreview.style.display = "";
2046
- avatarThumbPreview.hidden = false;
2047
- }
2048
-
2049
- // 1. Try server thumbnail first (persisted on Space, survives restarts)
2050
- if (avatarName) {
2051
- const srv = serverThumbUrl(avatarName);
2052
- avatarThumbPreview.onerror = () => {
2053
- // Server thumbnail failed — try local cache
2054
- const local = map[avatarName] || map[name] || "";
2055
- if (local) {
2056
- showImg(local);
2057
- } else {
2058
- showFallback();
2059
- }
2060
- };
2061
- avatarThumbPreview.onload = () => {
2062
- // Only show if the image actually loaded (not error, not 70-byte placeholder)
2063
- if (avatarThumbPreview.naturalWidth > 0 && avatarThumbPreview.naturalHeight > 0) {
2064
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2065
- if (fb) fb.style.display = "none";
2066
- avatarThumbPreview.style.display = "";
2067
- avatarThumbPreview.hidden = false;
2068
- } else {
2069
- showFallback();
2070
- }
2071
- };
2072
- showImg(srv);
2073
- return;
2074
- }
2075
- // 2. No server thumbnail — try local cache
2076
  const local = map[name] || "";
2077
  if (local) {
2078
- showImg(local);
 
2079
  return;
2080
  }
2081
- showFallback();
 
 
 
 
 
 
2082
  }
 
2083
  if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
2084
 
 
2085
  const snapBtn = document.getElementById("snap-btn");
2086
- const snapDialog = document.getElementById("snap-dialog");
2087
- const snapPreview = document.getElementById("snap-preview");
2088
- const snapDownload = document.getElementById("snap-download");
2089
- const snapSetAvatar = document.getElementById("snap-setavatar");
2090
- let _latestSnap = null;
2091
  if (snapBtn) {
2092
- // Helper: compress data URL - higher quality for avatar thumbnails
2093
- function compressDataUrl(dataUrl, maxBytes = 500 * 1024, maxDim = 512) {
2094
- return new Promise((resolve) => {
2095
- const img = new Image();
2096
- img.onload = () => {
2097
- const canvas = document.createElement("canvas");
2098
- let w = img.width, h = img.height;
2099
- if (w > maxDim || h > maxDim) {
2100
- const scale = Math.min(maxDim / w, maxDim / h);
2101
- w = Math.round(w * scale);
2102
- h = Math.round(h * scale);
2103
- }
2104
- canvas.width = w; canvas.height = h;
2105
- const ctx = canvas.getContext("2d");
2106
- // Better quality: use imageSmoothingQuality
2107
- ctx.imageSmoothingEnabled = true;
2108
- ctx.imageSmoothingQuality = "high";
2109
- ctx.drawImage(img, 0, 0, w, h);
2110
- // Start with quality 0.85 for avatars (higher visual quality)
2111
- let quality = 0.85;
2112
- let out = canvas.toDataURL("image/jpeg", quality);
2113
- // Allow larger files for better quality (500KB base64 ~ 365KB binary)
2114
- while (out.length > maxBytes * 1.37 && quality > 0.5) {
2115
- quality -= 0.05;
2116
- out = canvas.toDataURL("image/jpeg", quality);
2117
- }
2118
- resolve(out);
2119
- };
2120
- img.onerror = () => resolve(dataUrl); // fallback to original
2121
- img.src = dataUrl;
2122
- });
2123
- }
2124
-
2125
- snapBtn.addEventListener("click", async () => {
2126
  if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
2127
  const url = stage.capturePNG();
2128
  if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
2129
- _latestSnap = await compressDataUrl(url);
2130
- snapPreview.src = _latestSnap;
2131
- if (snapDialog.showModal) snapDialog.showModal();
2132
- else snapDialog.setAttribute("open", "");
2133
- });
2134
- }
2135
- if (snapDownload) {
2136
- snapDownload.addEventListener("click", () => {
2137
- if (!_latestSnap) return;
2138
- const name = (settings.avatar || "avatar").replace(/\.glb$/i, "");
2139
- const a = document.createElement("a");
2140
- a.href = _latestSnap; a.download = name + ".png";
2141
- document.body.appendChild(a); a.click(); a.remove();
2142
  });
2143
- }
2144
- if (snapSetAvatar) {
2145
- snapSetAvatar.addEventListener("click", async () => {
2146
- if (!_latestSnap) return;
2147
- const key = settings.avatar || "";
2148
- if (!key) { setCaption("CHON 1 AVATAR TRUOC", "error"); return; }
2149
- // Ensure the avatar name has .glb extension
2150
- const avatarName = key.endsWith(".glb") ? key : key + ".glb";
2151
- console.log("[snap] saving thumbnail for:", avatarName, "dataUrl length:", _latestSnap?.length);
2152
- snapSetAvatar.disabled = true;
2153
- snapSetAvatar.textContent = "⏳ Đang lưu…";
2154
- try {
2155
- const resp = await fetch("/api/avatar-thumbnail", {
2156
- method: "POST",
2157
- headers: { "Content-Type": "application/json" },
2158
- body: JSON.stringify({ avatar: avatarName, dataUrl: _latestSnap }),
2159
- });
2160
- console.log("[snap] response status:", resp.status);
2161
- const data = await resp.json().catch(() => ({}));
2162
- console.log("[snap] response data:", data);
2163
- if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
2164
- // Cache locally too (works offline + instant preview)
2165
- const map = loadThumbs();
2166
- // Prefer the server URL now that it is persisted on Space
2167
- if (data.url) {
2168
- map[avatarName] = data.url;
2169
- saveThumbs(map);
2170
- avatarThumbPreview.src = data.url;
2171
- avatarThumbPreview.hidden = false;
2172
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2173
- if (fb) fb.style.display = "none";
2174
- } else {
2175
- map[avatarName] = _latestSnap;
2176
- saveThumbs(map);
2177
- avatarThumbPreview.src = _latestSnap;
2178
- avatarThumbPreview.hidden = false;
2179
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2180
- if (fb) fb.style.display = "none";
2181
- }
2182
- // Force refresh all avatar previews (picker + chat select + settings select)
2183
- try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
2184
- try { updateAvatarThumbPreview(); } catch (e) { console.warn("[snap] updateThumb:", e); }
2185
- setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
2186
- } catch (e) {
2187
- console.warn("[snap] error:", e);
2188
- setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
2189
- } finally {
2190
- snapSetAvatar.disabled = false;
2191
- snapSetAvatar.textContent = "🖼️ Đặt làm ảnh đại diện";
2192
- }
2193
- });
2194
- }
2195
-
2196
- // ── Onboarding picker (choose character + voice BEFORE loading avatar) ──
2197
- function buildPicker() {
2198
- const grid = document.getElementById("picker-avatars");
2199
- const voices = document.getElementById("picker-voices");
2200
- if (!grid || !voices) return;
2201
- grid.innerHTML = "";
2202
- voices.innerHTML = "";
2203
- const selAvatar = settings.avatar || "";
2204
- const list = avatarList.length ? avatarList : ["vuong.glb"];
2205
- for (const name of list) {
2206
- const card = document.createElement("button");
2207
- card.type = "button";
2208
- card.className = "avatar-card" + (name === selAvatar ? " selected" : "");
2209
- card.dataset.avatar = name;
2210
- const label = name.replace(/\.glb$/i, "").replace(/_/g, " ");
2211
- const thumb = document.createElement("div");
2212
- thumb.className = "avatar-card-thumb";
2213
- const _map = loadThumbs();
2214
- const _img = document.createElement("img");
2215
- _img.alt = label;
2216
- _img.loading = "lazy";
2217
- _img.addEventListener("error", () => {
2218
- // Try localStorage fallback before giving up
2219
- const local = _map[name] || "";
2220
- if (local && _img.src !== local) {
2221
- _img.src = local;
2222
- return;
2223
- }
2224
- _img.remove();
2225
- thumb.textContent = label.slice(0, 1).toUpperCase();
2226
- });
2227
- const _srv = serverThumbUrl(name);
2228
- // Prefer server thumbnail (persisted on Space), fall back to local cache
2229
- _img.src = _srv || _map[name] || "";
2230
- thumb.appendChild(_img);
2231
- const span = document.createElement("span");
2232
- span.className = "avatar-card-name";
2233
- span.textContent = label + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
2234
- card.appendChild(thumb);
2235
- card.appendChild(span);
2236
- card.addEventListener("click", () => {
2237
- grid.querySelectorAll(".avatar-card").forEach((c) => c.classList.remove("selected"));
2238
- card.classList.add("selected");
2239
- settings.avatar = name;
2240
- saveSettings();
2241
- });
2242
- grid.appendChild(card);
2243
- }
2244
- for (const v of VOICES) {
2245
- const chip = document.createElement("button");
2246
- chip.type = "button";
2247
- chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
2248
- chip.dataset.voice = v;
2249
- chip.textContent = v.replaceAll("_", " ");
2250
- chip.addEventListener("click", () => {
2251
- voices.querySelectorAll(".voice-chip").forEach((c) => c.classList.remove("selected"));
2252
- chip.classList.add("selected");
2253
- settings.voice = v;
2254
- saveSettings();
2255
- });
2256
- voices.appendChild(chip);
2257
- }
2258
- }
2259
- function showPicker() {
2260
- const p = document.getElementById("picker");
2261
- if (p) p.hidden = false;
2262
- }
2263
- function hidePicker() {
2264
- const p = document.getElementById("picker");
2265
- if (p) p.hidden = true;
2266
- }
2267
- async function startWithSelection() {
2268
- hidePicker();
2269
- setCaption("Chờ tôi một lát! Tôi sẽ đến với bạn ngay!");
2270
- setMainButton("busy", "Loading…");
2271
- loading.classList.remove("done");
2272
- loading.classList.add("active");
2273
- loading.textContent = "Loading avatar...";
2274
-
2275
- // Show the start progress bar with the friendly message.
2276
- const startProgress = document.getElementById("start-progress");
2277
- const startProgressFill = startProgress ? startProgress.querySelector(".start-progress__fill") : null;
2278
- if (startProgress) startProgress.hidden = false;
2279
- if (startProgressFill) startProgressFill.style.width = "0%";
2280
-
2281
- let progress = 0;
2282
- const progressInterval = setInterval(() => {
2283
- progress = Math.min(progress + 5, 95);
2284
- if (startProgressFill) startProgressFill.style.width = progress + "%";
2285
- }, 150);
2286
-
2287
- const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
2288
- let ok = false;
2289
- try {
2290
- const initPromise = stage.init({
2291
- avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
2292
- onprogress: (ev) => {
2293
- if (ev.lengthComputable) {
2294
- const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
2295
- loading.textContent = `Loading avatar ${pct}%`;
2296
- if (startProgressFill) startProgressFill.style.width = pct + "%";
2297
- progress = pct;
2298
- }
2299
- }
2300
- });
2301
- const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
2302
- await Promise.race([initPromise, timeout]);
2303
- stage.resume(); // start render loop so the avatar is actually visible (not a single black frame)
2304
- ok = true;
2305
- } catch (err) {
2306
- console.error("[startWithSelection] init failed/timeout:", err);
2307
- } finally {
2308
- clearInterval(progressInterval);
2309
- if (startProgressFill) startProgressFill.style.width = "100%";
2310
- // Hide the start progress bar after a brief moment showing 100%
2311
- setTimeout(() => {
2312
- if (startProgress) startProgress.hidden = true;
2313
- if (startProgressFill) startProgressFill.style.width = "0%";
2314
- }, 500);
2315
- try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; }
2316
- newsSeq = preFetchedNews.slice();
2317
- newsSeqIdx = -1;
2318
- newsTopicQuery = "";
2319
- loading.classList.remove("active");
2320
- if (ok) setCaption(CAPTIONS.idle);
2321
- else setCaption("AVATAR KHÔNG HIỂN THỰ — THỬ CHỌN AVATAR KHÁC", "error");
2322
- setMainButton("start", "Start talking");
2323
- }
2324
- }
2325
- const pickerStartBtn = document.getElementById("picker-start");
2326
- if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
 
1
+ // This file needs the thumbnail fix added before the final closing
2
+ // Add this before the last line:
 
 
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  const THUMB_KEY = "avatar.thumbs";
5
  const avatarThumbPreview = document.getElementById("avatar-thumb-preview");
6
+
7
  function loadThumbs() {
8
  try { return JSON.parse(localStorage.getItem(THUMB_KEY) || "{}"); }
9
  catch { return {}; }
10
  }
11
  function saveThumbs(map) { localStorage.setItem(THUMB_KEY, JSON.stringify(map)); }
12
+
13
  function serverThumbUrl(name) {
14
  if (!name) return "";
15
+ return `/avatars/${name}.thumb.png`;
 
 
16
  }
17
+
 
 
18
  function updateAvatarThumbPreview() {
19
  if (!avatarThumbPreview) return;
20
  const name = chatAvatarSelect.value || settings.avatar || "";
 
 
 
 
21
  const map = loadThumbs();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  const local = map[name] || "";
23
  if (local) {
24
+ avatarThumbPreview.src = local;
25
+ avatarThumbPreview.hidden = false;
26
  return;
27
  }
28
+ const srv = serverThumbUrl(name);
29
+ if (srv) {
30
+ avatarThumbPreview.src = srv;
31
+ avatarThumbPreview.hidden = false;
32
+ } else {
33
+ avatarThumbPreview.hidden = true;
34
+ }
35
  }
36
+
37
  if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
38
 
39
+ // Snap button handler:
40
  const snapBtn = document.getElementById("snap-btn");
 
 
 
 
 
41
  if (snapBtn) {
42
+ snapBtn.addEventListener("click", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
44
  const url = stage.capturePNG();
45
  if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
46
+ // TODO: send to /api/avatar-thumbnail
 
 
 
 
 
 
 
 
 
 
 
 
47
  });
48
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/s2s/codec.js CHANGED
@@ -54,4 +54,4 @@ export function base64ToBytes(b64) {
54
  const out = new Uint8Array(len);
55
  for (let i = 0; i < len; i++) out[i] = binary.charCodeAt(i);
56
  return out;
57
- }
 
54
  const out = new Uint8Array(len);
55
  for (let i = 0; i < len; i++) out[i] = binary.charCodeAt(i);
56
  return out;
57
+ }
src/s2s/s2s-ws-client.js CHANGED
@@ -210,7 +210,7 @@ export class S2sWsRealtimeClient extends EventTarget {
210
  _queueSleep(ms) {
211
  return new Promise((resolve) => {
212
  this._queueWake = resolve;
213
- this._queueTimer = setTimeout(() => { this._queueWake = null; resolve(); });
214
  });
215
  }
216
 
 
210
  _queueSleep(ms) {
211
  return new Promise((resolve) => {
212
  this._queueWake = resolve;
213
+ this._queueTimer = setTimeout(() => { this._queueWake = null; resolve(); }, ms);
214
  });
215
  }
216
 
src/style.css CHANGED
@@ -611,15 +611,3 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui,
611
  background: linear-gradient(90deg, #22d3ee, #06b6d4);
612
  transition: width 0.3s ease;
613
  }
614
-
615
- /* ── Avatar thumb fallback (khi chưa có server thumbnail) ── */
616
- .thumb-fallback {
617
- width: 28px; height: 28px; border-radius: 50%;
618
- display: inline-flex; align-items: center; justify-content: center;
619
- font-size: 14px; font-weight: 700;
620
- color: #cfe9ff; background: linear-gradient(135deg, #1b2230, #2a3550);
621
- border: 1px solid rgba(255,255,255,.12);
622
- vertical-align: middle;
623
- margin-left: 6px;
624
- flex-shrink: 0;
625
- }
 
611
  background: linear-gradient(90deg, #22d3ee, #06b6d4);
612
  transition: width 0.3s ease;
613
  }
 
 
 
 
 
 
 
 
 
 
 
 
src/voiceTyper.js DELETED
@@ -1,181 +0,0 @@
1
- /**
2
- * voiceTyper.js — Vietnamese voice recognition with auto-fill for chat input.
3
- *
4
- * Uses the browser's Web Speech API (SpeechRecognition) with vi-VN locale
5
- * to capture the user's spoken Vietnamese, display the transcript in the
6
- * chat input field, and optionally auto-submit after speech ends.
7
- *
8
- * Events emitted via callbacks:
9
- * result — { transcript, isFinal, confidence }
10
- * start — recognition started
11
- * end — recognition ended (audio stop)
12
- * error — { message, code }
13
- * soundstart — microphone capture started
14
- * soundend — microphone capture ended
15
- * nomatch — no speech recognised in a segment
16
- */
17
- export class VoiceTyper {
18
- /**
19
- * @param {{ lang?: string, continuous?: boolean, interimResults?: boolean, autoSubmit?: boolean, silenceMs?: number }} options
20
- */
21
- constructor(options = {}) {
22
- this.lang = options.lang || "vi-VN";
23
- this.continuous = options.continuous ?? true;
24
- this.interimResults = options.interimResults ?? true;
25
- this.autoSubmit = options.autoSubmit ?? false;
26
- this.silenceMs = options.silenceMs ?? 1200;
27
- this.onResult = null;
28
- this.onStart = null;
29
- this.onEnd = null;
30
- this.onError = null;
31
- this.onSoundStart = null;
32
- this.onSoundEnd = null;
33
- this.onNoMatch = null;
34
-
35
- this._recognition = null;
36
- this._active = false;
37
- this._sessionStartMs = 0;
38
- this._silenceTimer = null;
39
- this._lastResultMs = 0;
40
- }
41
-
42
- get active() {
43
- return this._active;
44
- }
45
-
46
- /** Initialise the SpeechRecognition instance. Returns true if supported. */
47
- init() {
48
- const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
49
- if (!SR) return false;
50
-
51
- this._recognition = new SR();
52
- this._recognition.lang = this.lang;
53
- this._recognition.continuous = this.continuous;
54
- this._recognition.interimResults = this.interimResults;
55
- this._recognition.maxAlternatives = 1;
56
-
57
- this._recognition.addEventListener("result", (e) => this._onResult(e));
58
- this._recognition.addEventListener("start", () => this._onStart());
59
- this._recognition.addEventListener("end", () => this._onEnd());
60
- this._recognition.addEventListener("error", (e) => this._onError(e));
61
- this._recognition.addEventListener("soundstart", () => this._onSoundStart());
62
- this._recognition.addEventListener("soundend", () => this._onSoundEnd());
63
- this._recognition.addEventListener("nomatch", () => this._onNoMatch());
64
-
65
- return true;
66
- }
67
-
68
- /** Start listening. */
69
- start() {
70
- if (!this._recognition) return;
71
- if (this._active) return;
72
- try {
73
- this._sessionStartMs = Date.now();
74
- this._lastResultMs = this._sessionStartMs;
75
- this._recognition.start();
76
- this._active = true;
77
- } catch (e) {
78
- this._active = false;
79
- this._emitError("start-failed", e.message);
80
- }
81
- }
82
-
83
- /** Stop listening. */
84
- stop() {
85
- if (!this._recognition) return;
86
- if (!this._active) return;
87
- try {
88
- this._recognition.stop();
89
- } catch (_) {
90
- /* already stopped */
91
- }
92
- this._active = false;
93
- this._clearSilenceTimer();
94
- }
95
-
96
- /** Abort the current session and release resources. */
97
- abort() {
98
- this.stop();
99
- this._recognition = null;
100
- }
101
-
102
- /* ── internals ──────────────────────────────────────────── */
103
-
104
- _onResult(e) {
105
- let transcript = "";
106
- let isFinal = true;
107
-
108
- for (let i = e.resultIndex; i < e.results.length; i++) {
109
- const alt = e.results[i][0];
110
- transcript += alt.transcript;
111
- if (!e.results[i].isFinal) isFinal = false;
112
- }
113
-
114
- this._lastResultMs = Date.now();
115
- this._clearSilenceTimer();
116
-
117
- if (this.onResult) {
118
- this.onResult({
119
- transcript,
120
- isFinal,
121
- confidence: e.results[e.results.length - 1]?.[0]?.confidence ?? 1,
122
- });
123
- }
124
-
125
- if (this.autoSubmit && isFinal) {
126
- this._resetSilenceTimer();
127
- }
128
- }
129
-
130
- _onStart() {
131
- if (this.onStart) this.onStart();
132
- }
133
-
134
- _onEnd() {
135
- this._clearSilenceTimer();
136
- const wasActive = this._active;
137
- this._active = false;
138
- if (this.onEnd) this.onEnd();
139
- }
140
-
141
- _onError(e) {
142
- this._active = false;
143
- this._clearSilenceTimer();
144
- if (this.onError) {
145
- this.onError({ message: e.error, code: e.error });
146
- }
147
- }
148
-
149
- _onSoundStart() {
150
- if (this.onSoundStart) this.onSoundStart();
151
- }
152
-
153
- _onSoundEnd() {
154
- if (this.onSoundEnd) this.onSoundEnd();
155
- }
156
-
157
- _onNoMatch() {
158
- if (this.onNoMatch) this.onNoMatch();
159
- }
160
-
161
- _emitError(code, message) {
162
- if (this.onError) this.onError({ message, code });
163
- }
164
-
165
- _resetSilenceTimer() {
166
- this._clearSilenceTimer();
167
- if (!this.autoSubmit || !this.onResult) return;
168
- this._silenceTimer = setTimeout(() => {
169
- if (this._active) {
170
- this.onResult({ transcript: "", isFinal: true, confidence: 0 });
171
- }
172
- }, this.silenceMs);
173
- }
174
-
175
- _clearSilenceTimer() {
176
- if (this._silenceTimer) {
177
- clearTimeout(this._silenceTimer);
178
- this._silenceTimer = null;
179
- }
180
- }
181
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/withSearchContext.js CHANGED
@@ -45,4 +45,4 @@ export async function withSearchContext(text) {
45
  }
46
 
47
  return `${context}\n\n${text}`;
48
- }
 
45
  }
46
 
47
  return `${context}\n\n${text}`;
48
+ }
style.css CHANGED
@@ -548,7 +548,34 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui,
548
 
549
  /* ── Onboarding picker (choose character + voice BEFORE loading) ── */
550
  #loading { display: none; }
551
- #loading.active { display: grid; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  .picker { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(8,11,18,.82); backdrop-filter: blur(6px); padding: 20px; overflow: auto; }
553
  .picker[hidden] { display: none; }
554
  .picker-card { width: min(720px, 96vw); max-height: 92vh; overflow: auto; background: #11151f; border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 22px 24px; color: #e8edf5; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
@@ -572,42 +599,3 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui,
572
  .topic-chip { border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.08); color: #a5f3fc; border-radius: 999px; padding: 5px 12px; cursor: pointer; font-size: 12.5px; transition: background .15s, transform .1s; }
573
  .topic-chip:hover { background: rgba(34,211,238,.2); transform: translateY(-1px); }
574
  .topic-chip.active { background: #22d3ee; color: #04222a; font-weight: 600; }
575
-
576
- /* ── Start progress bar ── */
577
- .start-progress {
578
- position: absolute;
579
- bottom: 120px;
580
- left: 50%;
581
- transform: translateX(-50%);
582
- display: flex;
583
- flex-direction: column;
584
- align-items: center;
585
- gap: 8px;
586
- padding: 12px 24px;
587
- border-radius: 12px;
588
- background: rgba(10, 11, 16, 0.85);
589
- backdrop-filter: blur(6px);
590
- border: 1px solid rgba(255, 255, 255, 0.12);
591
- z-index: 200;
592
- min-width: 280px;
593
- text-align: center;
594
- }
595
- .start-progress__msg {
596
- font-size: 14px;
597
- font-weight: 500;
598
- color: #a5f3fc;
599
- line-height: 1.4;
600
- }
601
- .start-progress__bar {
602
- width: 100%;
603
- height: 6px;
604
- border-radius: 3px;
605
- background: rgba(255, 255, 255, 0.1);
606
- overflow: hidden;
607
- }
608
- .start-progress__fill {
609
- height: 100%;
610
- border-radius: 3px;
611
- background: linear-gradient(90deg, #22d3ee, #06b6d4);
612
- transition: width 0.3s ease;
613
- }
 
548
 
549
  /* ── Onboarding picker (choose character + voice BEFORE loading) ── */
550
  #loading { display: none; }
551
+ #loading.active { display: flex; }
552
+ #loading .progress-bar-container {
553
+ position: absolute;
554
+ bottom: 80px;
555
+ left: 50%;
556
+ transform: translateX(-50%);
557
+ width: 200px;
558
+ height: 6px;
559
+ background: var(--border);
560
+ border-radius: 3px;
561
+ overflow: hidden;
562
+ }
563
+ #loading .progress-bar-fill {
564
+ height: 100%;
565
+ background: var(--accent, #4a9eff);
566
+ border-radius: 3px;
567
+ transition: width 0.3s ease;
568
+ }
569
+ #loading .progress-text {
570
+ position: absolute;
571
+ bottom: 60px;
572
+ left: 50%;
573
+ transform: translateX(-50%);
574
+ font-family: var(--font-mono);
575
+ font-size: 11px;
576
+ color: var(--text-faint);
577
+ letter-spacing: 0.1em;
578
+ }
579
  .picker { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(8,11,18,.82); backdrop-filter: blur(6px); padding: 20px; overflow: auto; }
580
  .picker[hidden] { display: none; }
581
  .picker-card { width: min(720px, 96vw); max-height: 92vh; overflow: auto; background: #11151f; border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 22px 24px; color: #e8edf5; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
 
599
  .topic-chip { border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.08); color: #a5f3fc; border-radius: 999px; padding: 5px 12px; cursor: pointer; font-size: 12.5px; transition: background .15s, transform .1s; }
600
  .topic-chip:hover { background: rgba(34,211,238,.2); transform: translateY(-1px); }
601
  .topic-chip.active { background: #22d3ee; color: #04222a; font-weight: 600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tsconfig.json CHANGED
@@ -27,4 +27,4 @@
27
  "noUnusedParameters": false,
28
  "noPropertyAccessFromIndexSignature": false
29
  }
30
- }
 
27
  "noUnusedParameters": false,
28
  "noPropertyAccessFromIndexSignature": false
29
  }
30
+ }
upload_service.py DELETED
@@ -1,123 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Sidecar upload service - receives thumbnail batches via HTTP and uploads
4
- to dataset repo using HF API (works with Xet, no Space rebuild).
5
- Run alongside Bun server.
6
- """
7
- import os
8
- import base64
9
- import json
10
- import threading
11
- import time
12
- from http.server import HTTPServer, BaseHTTPRequestHandler
13
- from huggingface_hub import HfApi, CommitOperationAdd
14
-
15
- # Config
16
- DATASET_REPO = os.environ.get("DATASET_REPO", "bep40/gemma-avatar-thumbnails")
17
- HF_TOKEN = os.environ.get("HF_TOKEN")
18
- UPLOAD_PORT = int(os.environ.get("UPLOAD_PORT", "7861"))
19
-
20
- if not HF_TOKEN:
21
- print("ERROR: HF_TOKEN required")
22
- exit(1)
23
-
24
- api = HfApi(token=HF_TOKEN)
25
-
26
- # In-memory queue
27
- upload_queue = []
28
- queue_lock = threading.Lock()
29
- flush_interval = 30 # seconds
30
- last_flush = 0
31
-
32
- def flush_queue():
33
- """Upload queued thumbnails to dataset repo using HF API"""
34
- global last_flush
35
- with queue_lock:
36
- if not upload_queue:
37
- return
38
- items = upload_queue[:]
39
- upload_queue.clear()
40
-
41
- print(f"[upload_service] Flushing {len(items)} thumbnails to {DATASET_REPO}...")
42
-
43
- try:
44
- operations = []
45
- for item in items:
46
- operations.append(CommitOperationAdd(
47
- path_in_repo=item["path"],
48
- path_or_fileobj=item["data"]
49
- ))
50
-
51
- # Use HF API commit - this works with Xet
52
- api.create_commit(
53
- repo_id=DATASET_REPO,
54
- repo_type="dataset",
55
- operations=operations,
56
- commit_message=f"avatar thumbnails: {len(items)} files",
57
- commit_description=f"Batch upload {len(items)} avatar thumbnails"
58
- )
59
- print(f"[upload_service] Successfully uploaded {len(items)} thumbnails")
60
- except Exception as e:
61
- print(f"[upload_service] Upload failed: {e}")
62
- # Re-queue on failure
63
- with queue_lock:
64
- upload_queue[:0] = items
65
- finally:
66
- last_flush = time.time()
67
-
68
- class UploadHandler(BaseHTTPRequestHandler):
69
- def do_POST(self):
70
- if self.path == "/upload":
71
- content_length = int(self.headers.get('Content-Length', 0))
72
- body = self.rfile.read(content_length)
73
- try:
74
- data = json.loads(body)
75
- path = data.get("path", "")
76
- b64_content = data.get("content", "")
77
- if not path or not b64_content:
78
- self.send_response(400)
79
- self.end_headers()
80
- self.wfile.write(b'{"error": "missing path or content"}')
81
- return
82
-
83
- # Decode base64 and queue
84
- binary_data = base64.b64decode(b64_content)
85
- with queue_lock:
86
- upload_queue.append({"path": path, "data": binary_data})
87
-
88
- self.send_response(200)
89
- self.send_header("Content-Type", "application/json")
90
- self.end_headers()
91
- self.wfile.write(b'{"ok": true, "queued": true}')
92
- except Exception as e:
93
- self.send_response(500)
94
- self.end_headers()
95
- self.wfile.write(f'{{"error": "{e}"}}'.encode())
96
- else:
97
- self.send_response(404)
98
- self.end_headers()
99
-
100
- def log_message(self, format, *args):
101
- # Suppress default log
102
- pass
103
-
104
- def flush_loop():
105
- """Background thread to flush queue periodically"""
106
- global last_flush
107
- while True:
108
- time.sleep(5)
109
- with queue_lock:
110
- qsize = len(upload_queue)
111
- print(f"[upload_service] flush_loop check: queue size={qsize}, last_flush={time.time()-last_flush:.1f}s ago, interval={flush_interval}s")
112
- if time.time() - last_flush >= flush_interval:
113
- flush_queue()
114
-
115
- if __name__ == "__main__":
116
- # Start flush thread
117
- threading.Thread(target=flush_loop, daemon=True).start()
118
-
119
- server = HTTPServer(("0.0.0.0", UPLOAD_PORT), UploadHandler)
120
- print(f"[upload_service] Listening on 0.0.0.0:{UPLOAD_PORT}")
121
- print(f"[upload_service] Dataset repo: {DATASET_REPO}")
122
- print(f"[upload_service] HF_TOKEN present: {bool(HF_TOKEN)}")
123
- server.serve_forever()