Spaces:
Paused
Paused
Restore main to commit 891774c
#7
by bep40 - opened
- .gitattributes +0 -6
- .gitignore +1 -1
- .rebuild-trigger +1 -1
- CLAUDE.md +1 -1
- Dockerfile +14 -19
- README.md +87 -4
- app.js +2102 -0
- avatars +0 -0
- index.html +1 -22
- index.ts +521 -151
- public/avatars/lisamy1.glb +0 -3
- public/avatars/scene.glb +0 -3
- public/avatars/viverse_avatar_model_209370.vrm +0 -3
- public/avatars/vuong.glb.thumb.png +1 -0
- public/avatars/vuong1.glb +0 -3
- public/worklets/audio-playback.js +1 -1
- public/worklets/mic-capture.js +1 -1
- requirements.txt +0 -1
- src/aiTopicPatch.js +0 -118
- src/app.js +20 -2298
- src/s2s/codec.js +1 -1
- src/s2s/s2s-ws-client.js +1 -1
- src/style.css +0 -12
- src/voiceTyper.js +0 -181
- src/withSearchContext.js +1 -1
- style.css +28 -40
- tsconfig.json +1 -1
- upload_service.py +0 -123
.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 |
-
|
|
|
|
| 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 |
-
#
|
|
|
|
| 2 |
FROM oven/bun:1-slim
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
-
#
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
-
CMD ["
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, """)}"`
|
| 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 |
"Ô": "Ô", "ò": "ò", "Ò": "Ò", "ô": "ô",
|
| 224 |
"à": "à", "À": "À", "é": "é", "É": "É",
|
|
@@ -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 |
-
|
| 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 |
-
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 377 |
}
|
|
|
|
|
|
|
| 378 |
const knownKeywords = new Set();
|
| 379 |
-
for (const t of ALL_TOPICS) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
const topTrigrams = [...trigramFreq.entries()]
|
| 381 |
-
.filter(([phrase]) => {
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
const topBigrams = [...bigramFreq.entries()]
|
| 384 |
-
.filter(([phrase]) => {
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
topicScores.sort((a, b) => b.count - a.count);
|
|
|
|
|
|
|
| 387 |
const trending = [...topicScores.slice(0, 10)];
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 393 |
trending.push({ slug, label, count: freq, type: "dynamic" });
|
| 394 |
}
|
| 395 |
}
|
| 396 |
-
|
| 397 |
-
|
| 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))
|
|
|
|
|
|
|
| 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", "
|
| 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 |
-
|
| 466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
}
|
|
|
|
|
|
|
| 468 |
if (!terms || terms.length === 0) return [];
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 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, " ")
|
| 505 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
method: "POST",
|
| 534 |
-
headers: { "Content-Type": "application/json" },
|
| 535 |
-
body
|
| 536 |
});
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
}
|
| 542 |
-
|
| 543 |
-
console.
|
| 544 |
-
return
|
| 545 |
-
} catch (err) {
|
| 546 |
-
console.warn(`[sidecar] error: ${err}`);
|
| 547 |
-
return false;
|
| 548 |
}
|
| 549 |
}
|
| 550 |
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, {
|
| 634 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}`, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) {
|
| 673 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 715 |
const data = await resp.json();
|
| 716 |
-
const results = (data.query?.search ?? []).map((r) => ({
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 728 |
const data = await resp.json();
|
| 729 |
-
return Response.json({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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, {
|
|
|
|
|
|
|
|
|
|
| 765 |
const html = await resp.text();
|
| 766 |
-
let text = html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
const content = text.slice(0, 3000);
|
| 768 |
return Response.json({ content, url: targetUrl });
|
| 769 |
-
} catch {
|
|
|
|
|
|
|
| 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\/
|
| 810 |
-
if (!m)
|
|
|
|
|
|
|
|
|
|
| 811 |
let buf;
|
| 812 |
-
try { buf = Buffer.from(m[
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 828 |
try {
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
console.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
}
|
| 834 |
|
| 835 |
-
// ── 2.
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
|
|
|
|
|
|
|
|
|
| 839 |
|
| 840 |
-
// ── 3.
|
| 841 |
const thumbUrl = `/avatars/${fileName}`;
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
} catch (e) {
|
| 845 |
-
console.
|
| 846 |
-
return Response.json({ ok: false, error: "
|
| 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 |
-
|
| 869 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (', ô) and named entities (Ô, à, ô).
|
| 227 |
+
*/
|
| 228 |
function decodeHtmlEntities(str) {
|
| 229 |
if (!str) return "";
|
| 230 |
+
// Handle numeric entities: ' -> ', ô -> ô, etc.
|
| 231 |
let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
|
| 232 |
+
// Handle hex entities: ' -> ', 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 |
"Ô": "Ô", "ò": "ò", "Ò": "Ò", "ô": "ô",
|
| 237 |
"à": "à", "À": "À", "é": "é", "É": "É",
|
|
|
|
| 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 (& -> &, ' -> ', 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 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" +
|
| 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, "&")
|
| 67 |
-
.replace(/</g, "<")
|
| 68 |
-
.replace(/>/g, ">")
|
| 69 |
-
.replace(/"/g, """)
|
| 70 |
-
.replace(/'/g, "'");
|
| 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 |
-
|
| 2 |
-
|
| 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, """)}"`
|
| 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 |
-
//
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 2079 |
return;
|
| 2080 |
}
|
| 2081 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|