Spaces:
Paused
Paused
Restore to commit c4c37df
#6
by bep40 - opened
- .gitattributes +0 -6
- .gitignore +1 -1
- .rebuild-trigger +0 -1
- CLAUDE.md +1 -1
- Dockerfile +14 -19
- README.md +86 -4
- _boot3.txt +53 -0
- _bp2.txt +48 -0
- _check2.txt +49 -0
- _dbg.txt +0 -40
- _tail2.txt +25 -0
- _v.txt +2 -0
- _vf.txt +20 -7
- _voices.txt +18 -0
- _vv.txt +13 -0
- index.html +3 -28
- index.ts +208 -432
- public/avatars/Ella.glb.thumb.png +0 -0
- public/avatars/lisamy1.glb +0 -3
- public/avatars/scene.glb +0 -3
- public/avatars/test_direct_upload.png +0 -0
- public/avatars/viverse_avatar_model_209370.vrm +0 -3
- 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 +147 -415
- src/s2s/codec.js +1 -1
- src/s2s/s2s-ws-client.js +1 -1
- src/style.css +3 -51
- src/voiceTyper.js +0 -181
- src/withSearchContext.js +1 -1
- style.css +0 -613
- 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
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Trigger rebuild - fix picker + thumbnails
|
|
|
|
|
|
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,96 @@ 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).
|
_boot3.txt
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== boot function ==
|
| 2 |
+
1708: async function boot() {
|
| 3 |
+
1709: // 1) Reveal the picker IMMEDIATELY — first screen, never a loader.
|
| 4 |
+
1710: showPicker();
|
| 5 |
+
1711:
|
| 6 |
+
1712: // 2) Voice <select> options.
|
| 7 |
+
1713: for (const v of VOICES) {
|
| 8 |
+
1714: const o = document.createElement("option");
|
| 9 |
+
1715: o.value = v;
|
| 10 |
+
1716: o.textContent = v.replaceAll("_", " ");
|
| 11 |
+
1717: inputVoice.append(o);
|
| 12 |
+
1718: }
|
| 13 |
+
1719: inputVoice.value = settings.voice;
|
| 14 |
+
1720:
|
| 15 |
+
1721: // 3) Best-effort setup; isolate each step so a failure can NEVER hide the picker.
|
| 16 |
+
1722: try {
|
| 17 |
+
1723: const resp = await fetch("api/config");
|
| 18 |
+
1724: if (resp.ok) config = { ...config, ...(await resp.json()) };
|
| 19 |
+
1725: } catch (e) { console.warn("[boot] config:", e); }
|
| 20 |
+
1726: directUrlRow.hidden = !config.allowDirect;
|
| 21 |
+
1727:
|
| 22 |
+
1728: // Keep using the fake microphone (no real getUserMedia) so we never hit
|
| 23 |
+
1729: // the "MIC BLOCKED" permission prompt. Restored from the original boot().
|
| 24 |
+
1730: try {
|
| 25 |
+
1731: const url = new URL(location.href);
|
| 26 |
+
1732: url.searchParams.set("fakemic", "1");
|
| 27 |
+
1733: history.replaceState(null, "", url.href);
|
| 28 |
+
1734: } catch (e) { console.warn("[boot] fakemic:", e); }
|
| 29 |
+
1735:
|
| 30 |
+
1736: try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
|
| 31 |
+
1737: try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
|
| 32 |
+
1738: try {
|
| 33 |
+
1739: makeDraggable(); makeResizable(); makeNewsDraggable(); makeNewsResizable(); buildVnewsPanel();
|
| 34 |
+
1740: } catch (e) { console.warn("[boot] ui:", e); }
|
| 35 |
+
1741:
|
| 36 |
+
1742: // 4) Fill the picker (avatar cards + voice chips).
|
| 37 |
+
1743: try { buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
|
| 38 |
+
1744: try { void renderTopicsPicker(); } catch (e) { console.warn("[boot] topics:", e); }
|
| 39 |
+
1745: try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
|
| 40 |
+
1746: try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", e); }
|
| 41 |
+
1747: }
|
| 42 |
+
1748:
|
| 43 |
+
=== end of boot ==
|
| 44 |
+
1843: newsResizeHandle.addEventListener("touchstart", onStart, { passive: false });
|
| 45 |
+
1844: document.addEventListener("touchmove", onMove, { passive: false });
|
| 46 |
+
1845: document.addEventListener("touchend", onEnd);
|
| 47 |
+
1846: }
|
| 48 |
+
1847:
|
| 49 |
+
1848: void boot();
|
| 50 |
+
1849:
|
| 51 |
+
1850: // ── Capture PNG (snapshot of the 3D avatar) ──────────────
|
| 52 |
+
1851: const THUMB_KEY = "avatar.thumbs";
|
| 53 |
+
1852: const avatarThumbPreview = document.getElementById("avatar-thumb-preview");
|
_bp2.txt
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== buildPicker function 1964-2010 ===
|
| 2 |
+
1964: async function buildPicker() {
|
| 3 |
+
1965: const grid = document.getElementById("picker-avatars");
|
| 4 |
+
1966: const voices = document.getElementById("picker-voices");
|
| 5 |
+
1967: if (!grid || !voices) return;
|
| 6 |
+
1968: grid.innerHTML = "";
|
| 7 |
+
1969: voices.innerHTML = "";
|
| 8 |
+
1970: const selAvatar = settings.avatar || "";
|
| 9 |
+
1971: const list = avatarList.length ? avatarList : ["vuong.glb"];
|
| 10 |
+
1972: for (const name of list) {
|
| 11 |
+
1973: const card = document.createElement("button");
|
| 12 |
+
1974: card.type = "button";
|
| 13 |
+
1975: card.className = "avatar-card" + (name === selAvatar ? " selected" : "");
|
| 14 |
+
1976: card.dataset.avatar = name;
|
| 15 |
+
1977: const label = name.replace(/\.glb$/i, "").replace(/_/g, " ");
|
| 16 |
+
1978: const thumb = document.createElement("div");
|
| 17 |
+
1979: thumb.className = "avatar-card-thumb";
|
| 18 |
+
1980: const _map = loadThumbs();
|
| 19 |
+
1981: const _img = document.createElement("img");
|
| 20 |
+
1982: _img.alt = label;
|
| 21 |
+
1983: _img.loading = "lazy";
|
| 22 |
+
1984: _img.addEventListener("error", () => {
|
| 23 |
+
1985: _img.remove();
|
| 24 |
+
1986: thumb.textContent = label.slice(0, 1).toUpperCase();
|
| 25 |
+
1987: });
|
| 26 |
+
1988: const _srv = serverThumbUrl(name);
|
| 27 |
+
1989: _img.src = (_srv && !_map[name]) ? _srv : (_map[name] || _srv);
|
| 28 |
+
1990: thumb.appendChild(_img);
|
| 29 |
+
1991: const span = document.createElement("span");
|
| 30 |
+
1992: span.className = "avatar-card-name";
|
| 31 |
+
1993: span.textContent = label + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
|
| 32 |
+
1994: card.appendChild(thumb);
|
| 33 |
+
1995: card.appendChild(span);
|
| 34 |
+
1996: card.addEventListener("click", () => {
|
| 35 |
+
1997: grid.querySelectorAll(".avatar-card").forEach((c) => c.classList.remove("selected"));
|
| 36 |
+
1998: card.classList.add("selected");
|
| 37 |
+
1999: settings.avatar = name;
|
| 38 |
+
2000: saveSettings();
|
| 39 |
+
2001: });
|
| 40 |
+
2002: grid.appendChild(card);
|
| 41 |
+
2003: }
|
| 42 |
+
2004: for (const v of VOICES) {
|
| 43 |
+
2005: const chip = document.createElement("button");
|
| 44 |
+
2006: chip.type = "button";
|
| 45 |
+
2007: chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
|
| 46 |
+
2008: chip.dataset.voice = v;
|
| 47 |
+
2009: chip.textContent = v.replaceAll("_", " ");
|
| 48 |
+
2010: chip.addEventListener("click", () => {
|
_check2.txt
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== buildPicker (full) ===
|
| 2 |
+
1964: function buildPicker() { {
|
| 3 |
+
1965: const grid = document.getElementById("picker-avatars");
|
| 4 |
+
1966: const voices = document.getElementById("picker-voices");
|
| 5 |
+
1967: if (!grid || !voices) return;
|
| 6 |
+
1968: grid.innerHTML = "";
|
| 7 |
+
1969: voices.innerHTML = "";
|
| 8 |
+
1970: const selAvatar = settings.avatar || "";
|
| 9 |
+
1971: const list = avatarList.length ? avatarList : ["vuong.glb"];
|
| 10 |
+
1972: for (const name of list) {
|
| 11 |
+
1973: const card = document.createElement("button");
|
| 12 |
+
1974: card.type = "button";
|
| 13 |
+
1975: card.className = "avatar-card" + (name === selAvatar ? " selected" : "");
|
| 14 |
+
1976: card.dataset.avatar = name;
|
| 15 |
+
1977: const label = name.replace(/\.glb$/i, "").replace(/_/g, " ");
|
| 16 |
+
1978: const thumb = document.createElement("div");
|
| 17 |
+
1979: thumb.className = "avatar-card-thumb";
|
| 18 |
+
1980: const _map = loadThumbs();
|
| 19 |
+
1981: const _img = document.createElement("img");
|
| 20 |
+
1982: _img.alt = label;
|
| 21 |
+
1983: _img.loading = "lazy";
|
| 22 |
+
1984: _img.addEventListener("error", () => {
|
| 23 |
+
1985: _img.remove();
|
| 24 |
+
1986: thumb.textContent = label.slice(0, 1).toUpperCase();
|
| 25 |
+
1987: });
|
| 26 |
+
1988: const _srv = serverThumbUrl(name);
|
| 27 |
+
1989: _img.src = (_srv && !_map[name]) ? _srv : (_map[name] || _srv);
|
| 28 |
+
1990: thumb.appendChild(_img);
|
| 29 |
+
1991: const span = document.createElement("span");
|
| 30 |
+
1992: span.className = "avatar-card-name";
|
| 31 |
+
1993: span.textContent = label + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : "");
|
| 32 |
+
1994: card.appendChild(thumb);
|
| 33 |
+
1995: card.appendChild(span);
|
| 34 |
+
1996: card.addEventListener("click", () => {
|
| 35 |
+
1997: grid.querySelectorAll(".avatar-card").forEach((c) => c.classList.remove("selected"));
|
| 36 |
+
1998: card.classList.add("selected");
|
| 37 |
+
1999: settings.avatar = name;
|
| 38 |
+
2000: saveSettings();
|
| 39 |
+
2001: });
|
| 40 |
+
2002: grid.appendChild(card);
|
| 41 |
+
2003: }
|
| 42 |
+
2004: for (const v of VOICES) {
|
| 43 |
+
2005: const chip = document.createElement("button");
|
| 44 |
+
2006: chip.type = "button";
|
| 45 |
+
2007: chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
|
| 46 |
+
2008: chip.dataset.voice = v;
|
| 47 |
+
2009: chip.textContent = v.replaceAll("_", " ");
|
| 48 |
+
=== boot voice loop ===
|
| 49 |
+
1712: for (const v of VOICES) {
|
_dbg.txt
DELETED
|
@@ -1,40 +0,0 @@
|
|
| 1 |
-
=== startWithSelection tail ===
|
| 2 |
-
2009: async function startWithSelection() {
|
| 3 |
-
2010: hidePicker();
|
| 4 |
-
2011: setCaption("WAKING HER UP…");
|
| 5 |
-
2012: setMainButton("busy", "Loading…");
|
| 6 |
-
2013: loading.classList.remove("done");
|
| 7 |
-
2014: loading.classList.add("active");
|
| 8 |
-
2015: loading.textContent = "Loading avatar...";
|
| 9 |
-
2016: const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
|
| 10 |
-
2017: let ok = false;
|
| 11 |
-
2018: try {
|
| 12 |
-
2019: const initPromise = stage.init({
|
| 13 |
-
2020: avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
|
| 14 |
-
2021: onprogress: (ev) => {
|
| 15 |
-
2022: if (ev.lengthComputable) {
|
| 16 |
-
2023: loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
|
| 17 |
-
2024: }
|
| 18 |
-
2025: }
|
| 19 |
-
2026: });
|
| 20 |
-
2027: const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
|
| 21 |
-
2028: await Promise.race([initPromise, timeout]);
|
| 22 |
-
2029: stage.resume(); // start render loop so the avatar is actually visible (not a single black frame)
|
| 23 |
-
2030: ok = true;
|
| 24 |
-
2031: } catch (err) {
|
| 25 |
-
2032: console.error("[startWithSelection] init failed/timeout:", err);
|
| 26 |
-
2033: } finally {
|
| 27 |
-
2034: try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; }
|
| 28 |
-
2035: newsSeq = preFetchedNews.slice();
|
| 29 |
-
2036: newsSeqIdx = -1;
|
| 30 |
-
2037: newsTopicQuery = "";
|
| 31 |
-
2038: loading.classList.remove("active");
|
| 32 |
-
2039: if (ok) setCaption(CAPTIONS.idle);
|
| 33 |
-
2040: else setCaption("AVATAR KHÔNG HIỂN THỊ — THỬ CHỌN AVATAR KHÁC", "error");
|
| 34 |
-
2041: setMainButton("start", "Start talking");
|
| 35 |
-
2042: }
|
| 36 |
-
2043: }
|
| 37 |
-
2044: const pickerStartBtn = document.getElementById("picker-start");
|
| 38 |
-
2045: if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
|
| 39 |
-
=== index.html topic-tags line ===
|
| 40 |
-
H50: <div id="topic-tags" class="topic-tags" hidden></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_tail2.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
buildPicker end:
|
| 2 |
+
2004: for (const v of VOICES) {
|
| 3 |
+
2005: const chip = document.createElement("button");
|
| 4 |
+
2006: chip.type = "button";
|
| 5 |
+
2007: chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
|
| 6 |
+
2008: chip.dataset.voice = v;
|
| 7 |
+
2009: chip.textContent = v.replaceAll("_", " ");
|
| 8 |
+
2010: chip.addEventListener("click", () => {
|
| 9 |
+
2011: voices.querySelectorAll(".voice-chip").forEach((c) => c.classList.remove("selected"));
|
| 10 |
+
2012: chip.classList.add("selected");
|
| 11 |
+
2013: settings.voice = v;
|
| 12 |
+
2014: saveSettings();
|
| 13 |
+
2015: });
|
| 14 |
+
2016: voices.appendChild(chip);
|
| 15 |
+
2017: await renderTopicsPicker();
|
| 16 |
+
2018: }
|
| 17 |
+
2019: }
|
| 18 |
+
2020: function showPicker() {
|
| 19 |
+
2021: const p = document.getElementById("picker");
|
| 20 |
+
2022: if (p) p.hidden = false;
|
| 21 |
+
2023: }
|
| 22 |
+
2024: function hidePicker() {
|
| 23 |
+
2025: const p = document.getElementById("picker");
|
| 24 |
+
2026: if (p) p.hidden = true;
|
| 25 |
+
braces: {{ = 539 }} = 539
|
_v.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
braces={=539 }=539
|
| 2 |
+
renderTopicsPicker present: yes
|
_vf.txt
CHANGED
|
@@ -1,7 +1,20 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== voices loop in buildPicker (2004+) ===
|
| 2 |
+
2004: for (const v of VOICES) {
|
| 3 |
+
2005: const chip = document.createElement("button");
|
| 4 |
+
2006: chip.type = "button";
|
| 5 |
+
2007: chip.className = "voice-chip" + (v === settings.voice ? " selected" : "");
|
| 6 |
+
2008: chip.dataset.voice = v;
|
| 7 |
+
2009: chip.textContent = v.replaceAll("_", " ");
|
| 8 |
+
2010: chip.addEventListener("click", () => {
|
| 9 |
+
2011: voices.querySelectorAll(".voice-chip").forEach((c) => c.classList.remove("selected"));
|
| 10 |
+
2012: chip.classList.add("selected");
|
| 11 |
+
2013: settings.voice = v;
|
| 12 |
+
2014: saveSettings();
|
| 13 |
+
2015: });
|
| 14 |
+
2016: voices.appendChild(chip);
|
| 15 |
+
2017: await renderTopicsPicker();
|
| 16 |
+
2018: }
|
| 17 |
+
=== check textContent in voices ===
|
| 18 |
+
V127: chip.textContent = "#" + c.label.replace(/\s+/g, "_");
|
| 19 |
+
V1958: chip.textContent = "#" + c.label.replace(/\s+/g, "_");
|
| 20 |
+
V2009: chip.textContent = v.replaceAll("_", " ");
|
_voices.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== VOICES array ===
|
| 2 |
+
5: const VOICES = [
|
| 3 |
+
1712: for (const v of VOICES) {
|
| 4 |
+
2004: for (const v of VOICES) {
|
| 5 |
+
=== surrounding VOICES ===
|
| 6 |
+
3: import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
|
| 7 |
+
4:
|
| 8 |
+
5: const VOICES = [
|
| 9 |
+
6: "Aiden", "Ryan", "Dylan", "Eric",
|
| 10 |
+
7: "Ono_Anna", "Serena", "Sohee", "Uncle_Fu", "Vivian",
|
| 11 |
+
8: ];
|
| 12 |
+
9: const DEFAULT_VOICE = "Sohee";
|
| 13 |
+
10:
|
| 14 |
+
11: // ── VNEWS integration ───────────────────────────────────────────────────
|
| 15 |
+
12: // The VNEWS space (bep40/vnews) is reachable at this subdomain. It exposes a
|
| 16 |
+
13: // clean JSON API (no key required) that we use to pull AI/tech news, answer
|
| 17 |
+
14: // from VNEWS data, and rewrite selected chat content into VNEWS "wall" posts.
|
| 18 |
+
15: const VNEWS_BASE = "https://bep40-vnews.hf.space";
|
_vv.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== VOICES definition ===
|
| 2 |
+
V5: const VOICES = [
|
| 3 |
+
=== buildPicker voices loop ===
|
| 4 |
+
B1712: for (const v of VOICES) {
|
| 5 |
+
B1966: const voices = document.getElementById("picker-voices");
|
| 6 |
+
B2004: for (const v of VOICES) {
|
| 7 |
+
B2016: voices.appendChild(chip);
|
| 8 |
+
=== boot buildPicker call ===
|
| 9 |
+
1742: try { await buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
|
| 10 |
+
buildPicker lines:
|
| 11 |
+
1742: try { await buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
|
| 12 |
+
1931: try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
|
| 13 |
+
1964: async function buildPicker() {
|
index.html
CHANGED
|
@@ -11,30 +11,11 @@
|
|
| 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">
|
| 30 |
<div id="stage" aria-label="3D avatar"></div>
|
| 31 |
<div id="loading" hidden>Loading avatar…</div>
|
| 32 |
-
<div id="start-progress" class="start-progress" hidden>
|
| 33 |
-
<div class="start-progress__msg">Chờ tôi một lát! Tôi sẽ đến với bạn ngay!</div>
|
| 34 |
-
<div class="start-progress__bar">
|
| 35 |
-
<div class="start-progress__fill" style="width: 0%"></div>
|
| 36 |
-
</div>
|
| 37 |
-
</div>
|
| 38 |
|
| 39 |
<header id="topbar">
|
| 40 |
<div id="identity">
|
|
@@ -68,14 +49,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>
|
|
@@ -168,6 +141,8 @@
|
|
| 168 |
<div id="picker-avatars" class="picker-avatars"></div>
|
| 169 |
<h2>Giọng nói</h2>
|
| 170 |
<div id="picker-voices" class="picker-voices"></div>
|
|
|
|
|
|
|
| 171 |
<button id="picker-start" class="primary picker-start">Bắt đầu ▶</button>
|
| 172 |
</div>
|
| 173 |
</div>
|
|
@@ -175,4 +150,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">
|
| 17 |
<div id="stage" aria-label="3D avatar"></div>
|
| 18 |
<div id="loading" hidden>Loading avatar…</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
<header id="topbar">
|
| 21 |
<div id="identity">
|
|
|
|
| 49 |
<div id="chat-messages"></div>
|
| 50 |
<div id="chat-input-container">
|
| 51 |
<input id="chat-input" type="text" placeholder="Type a message..." autocomplete="off" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
<button id="chat-send-btn" class="icon-btn" aria-label="Send message">
|
| 53 |
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 54 |
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
|
|
| 141 |
<div id="picker-avatars" class="picker-avatars"></div>
|
| 142 |
<h2>Giọng nói</h2>
|
| 143 |
<div id="picker-voices" class="picker-voices"></div>
|
| 144 |
+
<h2 class="picker-sub">Chủ đề HOT hôm nay</h2>
|
| 145 |
+
<div id="picker-topics" class="picker-voices"></div>
|
| 146 |
<button id="picker-start" class="primary picker-start">Bắt đầu ▶</button>
|
| 147 |
</div>
|
| 148 |
</div>
|
|
|
|
| 150 |
</main>
|
| 151 |
<script type="module" src="./src/app.js"></script>
|
| 152 |
</body>
|
| 153 |
+
</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,14 +29,13 @@ async function proxy(path, req, init = {}) {
|
|
| 42 |
}
|
| 43 |
|
| 44 |
function staticFile(dir, name) {
|
| 45 |
-
const
|
| 46 |
-
const file = Bun.file(filePath);
|
| 47 |
return new Response(file);
|
| 48 |
}
|
| 49 |
|
| 50 |
-
/** List .glb files in public/avatars/
|
| 51 |
async function listAvatars() {
|
| 52 |
-
const avatarsDir = join(
|
| 53 |
const names = [];
|
| 54 |
try {
|
| 55 |
const dir = await readdir(avatarsDir, { withFileTypes: true });
|
|
@@ -62,34 +48,6 @@ async function listAvatars() {
|
|
| 62 |
return names.sort();
|
| 63 |
}
|
| 64 |
|
| 65 |
-
/** List ALL files in public/avatars/ (for debugging). */
|
| 66 |
-
async function listAllAvatarFiles() {
|
| 67 |
-
const avatarsDir = join(process.cwd(), "public/avatars");
|
| 68 |
-
const files = [];
|
| 69 |
-
try {
|
| 70 |
-
const dir = await readdir(avatarsDir, { withFileTypes: true });
|
| 71 |
-
for (const entry of dir) {
|
| 72 |
-
if (entry.isFile()) {
|
| 73 |
-
files.push(entry.name);
|
| 74 |
-
}
|
| 75 |
-
}
|
| 76 |
-
} catch {}
|
| 77 |
-
return files.sort();
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
/** Check if a thumbnail exists for a given avatar name. */
|
| 81 |
-
async function avatarHasThumbnail(avatarName) {
|
| 82 |
-
const thumbName = `${avatarName}.thumb.png`;
|
| 83 |
-
const thumbPath = join(process.cwd(), "public/avatars", thumbName);
|
| 84 |
-
try {
|
| 85 |
-
const file = Bun.file(thumbPath);
|
| 86 |
-
const exists = await file.exists();
|
| 87 |
-
return exists;
|
| 88 |
-
} catch {
|
| 89 |
-
return false;
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
const SOURCE_MAP = {
|
| 94 |
"vnexpress.net": "VnExpress",
|
| 95 |
"dantri.com.vn": "Dân trí",
|
|
@@ -121,15 +79,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 "";
|
|
@@ -153,7 +115,7 @@ function extractDescription(itemXml) {
|
|
| 153 |
if (!dm) return "";
|
| 154 |
let txt = dm[1].replace(/<!\[CDATA\[|\]\]>/g, "");
|
| 155 |
txt = txt.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "")
|
| 156 |
-
.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/
|
| 157 |
return txt.length > 300 ? txt.slice(0, 300) + "…" : txt;
|
| 158 |
}
|
| 159 |
|
|
@@ -174,75 +136,21 @@ 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 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
"ê": "ê", "Ê": "Ê", "î": "î", "Î": "Î",
|
| 227 |
-
"ô": "ô", "Ô": "Ô", "û": "û", "Û": "Û",
|
| 228 |
-
"ñ": "ñ", "Ñ": "Ñ", "&": "&", "<": "<", ">": ">",
|
| 229 |
-
""": '"', "'": "'", "'": "'", " ": " ",
|
| 230 |
-
"ý": "ý", "Ý": "Ý", "á": "á", "Á": "Á",
|
| 231 |
-
"é": "é", "É": "É", "í": "í", "Í": "Í",
|
| 232 |
-
"ó": "ó", "Ó": "Ó", "ú": "ú", "Ú": "Ú",
|
| 233 |
-
"ü": "ü", "Ü": "Ü", "ö": "ö", "Ö": "Ö",
|
| 234 |
-
"ä": "ä", "Ä": "Ä", "ñ": "ñ", "Ñ": "Ñ",
|
| 235 |
-
"ç": "ç", "Ç": "Ç", "ñ": "ñ", "Ñ": "Ñ",
|
| 236 |
-
};
|
| 237 |
-
for (const [entity, char] of Object.entries(namedEntities)) {
|
| 238 |
-
decoded = decoded.split(entity).join(char);
|
| 239 |
-
}
|
| 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 +166,7 @@ async function fetchHotNews() {
|
|
| 258 |
const tm = item.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
| 259 |
const lm = item.match(/<link[^>]*>([\s\S]*?)<\/link>/i);
|
| 260 |
if (!tm || !tm[1]) continue;
|
| 261 |
-
const cleanTitle =
|
| 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 +181,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 =
|
| 280 |
while (added && items.length < MAX) {
|
| 281 |
added = false;
|
| 282 |
for (const q of queue) {
|
|
@@ -288,10 +199,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,190 +216,23 @@ 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 |
-
|
| 312 |
-
{ slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] },
|
| 313 |
-
{ slug: "aseancup", label: "ASEAN Cup", keywords: ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"] },
|
| 314 |
-
{ slug: "bongda", label: "Bóng đá", keywords: ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"] },
|
| 315 |
-
{ slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] },
|
| 316 |
-
{ slug: "ai", label: "AI", keywords: ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi k3", "kimi"] },
|
| 317 |
-
{ slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] },
|
| 318 |
-
{ slug: "kinhte", label: "Kinh tế", keywords: ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"] },
|
| 319 |
-
{ slug: "thoisu", label: "Thời sự", keywords: ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"] },
|
| 320 |
-
{ slug: "giao-duc", label: "Giáo dục", keywords: ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"] },
|
| 321 |
-
{ slug: "suc-khoe", label: "Sức khỏe", keywords: ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"] },
|
| 322 |
-
{ slug: "du-lich", label: "Du lịch", keywords: ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"] },
|
| 323 |
-
{ slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] },
|
| 324 |
-
{ slug: "thegioi", label: "Thế giới", keywords: ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"] },
|
| 325 |
-
{ slug: "doisong", label: "Đời sống", keywords: ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"] },
|
| 326 |
-
{ slug: "giai-tri", label: "Giải trí", keywords: ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"] },
|
| 327 |
-
{ slug: "am-nhac", label: "Âm nhạc", keywords: ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"] },
|
| 328 |
-
{ slug: "phap-luat", label: "Pháp luật", keywords: ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"] },
|
| 329 |
-
];
|
| 330 |
-
|
| 331 |
-
const VI_STOPWORDS = new Set([
|
| 332 |
-
"và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia",
|
| 333 |
-
"tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi",
|
| 334 |
-
"có", "không", "đã", "sẽ", "đang", "để", "về", "từ", "trên", "dưới", "trong", "ngoài", "trước", "sau",
|
| 335 |
-
"một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín", "mười", "nhiều", "ít", "cả", "các",
|
| 336 |
-
"hôm", "nay", "qua", "đến", "đi", "lên", "xuống", "ra", "vào", "ở", "tại", "thì", "mà", "nhưng", "hoặc",
|
| 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];
|
| 358 |
-
if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1])) {
|
| 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];
|
| 365 |
-
if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1]) && !VI_STOPWORDS.has(tokens[i + 2])) {
|
| 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;
|
| 372 |
-
for (const kw of topic.keywords) {
|
| 373 |
-
const kwLower = kw.toLowerCase();
|
| 374 |
-
count += (allText.split(kwLower).length - 1);
|
| 375 |
-
}
|
| 376 |
-
if (count > 0) topicScores.push({ slug: topic.slug, label: topic.label, count, type: "fixed" });
|
| 377 |
-
}
|
| 378 |
-
const knownKeywords = new Set();
|
| 379 |
-
for (const t of ALL_TOPICS) { for (const kw of t.keywords) knownKeywords.add(kw.toLowerCase()); }
|
| 380 |
-
const topTrigrams = [...trigramFreq.entries()]
|
| 381 |
-
.filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 6) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
|
| 382 |
-
.sort((a, b) => b[1] - a[1]).slice(0, 10);
|
| 383 |
-
const topBigrams = [...bigramFreq.entries()]
|
| 384 |
-
.filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 4) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
|
| 385 |
-
.sort((a, b) => b[1] - a[1]).slice(0, 20);
|
| 386 |
-
topicScores.sort((a, b) => b.count - a.count);
|
| 387 |
-
const trending = [...topicScores.slice(0, 10)];
|
| 388 |
-
const rawKeywords = [...topTrigrams, ...topBigrams].filter(([phrase, freq]) => freq >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
| 389 |
-
for (const [phrase, freq] of rawKeywords) {
|
| 390 |
-
const slug = phrase.replace(/\s+/g, "-").replace(/[^\p{L}\p{N}-]/gu, "");
|
| 391 |
-
if (slug && slug.length > 2 && /^[a-zà-ỹ\-]+$/.test(slug) && !trending.find((t) => t.slug === slug)) {
|
| 392 |
-
const label = phrase.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
| 393 |
-
trending.push({ slug, label, count: freq, type: "dynamic" });
|
| 394 |
-
}
|
| 395 |
-
}
|
| 396 |
-
// Always include AI topic regardless of score (it has its own VNEWS AI feed)
|
| 397 |
-
if (!trending.find((f) => f.slug === "ai")) {
|
| 398 |
-
trending.push({ slug: "ai", label: "AI", count: 1, type: "fixed" });
|
| 399 |
-
}
|
| 400 |
-
if (trending.length < 10) {
|
| 401 |
-
const defaults = [
|
| 402 |
-
{ slug: "congngang", label: "Công nghệ", keywords: [] },
|
| 403 |
-
{ slug: "bongda", label: "Bóng đá", keywords: [] },
|
| 404 |
-
{ slug: "kinhte", label: "Kinh tế", keywords: [] },
|
| 405 |
-
{ slug: "thethao", label: "Thể thao", keywords: [] },
|
| 406 |
-
{ slug: "thoisu", label: "Thời sự", keywords: [] },
|
| 407 |
-
{ slug: "giao-duc", label: "Giáo dục", keywords: [] },
|
| 408 |
-
{ slug: "suc-khoe", label: "Sức khỏe", keywords: [] },
|
| 409 |
-
{ slug: "du-lich", label: "Du lịch", keywords: [] },
|
| 410 |
-
{ slug: "giai-tri", label: "Giải trí", keywords: [] },
|
| 411 |
-
];
|
| 412 |
-
for (const d of defaults) {
|
| 413 |
-
if (!trending.find((f) => f.slug === d.slug)) trending.push({ ...d, count: 1, type: "fixed" });
|
| 414 |
-
}
|
| 415 |
-
}
|
| 416 |
-
const now = new Date();
|
| 417 |
-
const start = new Date(now.getFullYear(), 0, 0);
|
| 418 |
-
const dayOfYear = Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
| 419 |
-
const seed = dayOfYear;
|
| 420 |
-
const shuffled = [...trending];
|
| 421 |
-
let r = seed;
|
| 422 |
-
for (let i = shuffled.length - 1; i > 0; i--) {
|
| 423 |
-
r = (r * 1103515245 + 12345) & 0x7fffffff;
|
| 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
|
| 435 |
-
"worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"],
|
| 436 |
-
"aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"],
|
| 437 |
-
"bongda": ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"],
|
| 438 |
-
"thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"],
|
| 439 |
-
"ai": ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi"],
|
| 440 |
-
"congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"],
|
| 441 |
-
"kinhte": ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"],
|
| 442 |
-
"thoisu": ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"],
|
| 443 |
-
"giao-duc": ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"],
|
| 444 |
-
"suc-khoe": ["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"],
|
| 445 |
-
"du-lich": ["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ế"],
|
| 446 |
-
"oto": ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"],
|
| 447 |
-
"thegioi": ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"],
|
| 448 |
-
"doisong": ["đờ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"],
|
| 449 |
-
"giai-tri": ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"],
|
| 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();
|
| 461 |
-
const slug = LABEL_TO_SLUG[qNoSpace];
|
| 462 |
-
if (slug) terms = SLUG_MAP[slug];
|
| 463 |
-
}
|
| 464 |
-
if (!terms) {
|
| 465 |
-
if (q.includes("-")) { terms = q.split("-").filter((t) => t.length > 1); }
|
| 466 |
-
else { terms = [q]; }
|
| 467 |
-
}
|
| 468 |
-
if (!terms || terms.length === 0) return [];
|
| 469 |
-
const matched = pool.filter((it) => {
|
| 470 |
-
const titleLower = it.title.toLowerCase();
|
| 471 |
-
const descLower = (it.description || "").toLowerCase();
|
| 472 |
-
const text = titleLower + " " + descLower;
|
| 473 |
-
return terms.some((term) => {
|
| 474 |
-
// Terms with leading/trailing spaces use word-boundary regex
|
| 475 |
-
// that handles Vietnamese diacritics (NOT just \w/\b which
|
| 476 |
-
// breaks on non-ASCII letters)
|
| 477 |
-
if (term.startsWith(" ") || term.endsWith(" ")) {
|
| 478 |
-
const word = term.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
| 479 |
-
// Match only at whitespace/punctuation/string boundaries
|
| 480 |
-
const re = new RegExp(`(^|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\])${word}($|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\-])`, 'i');
|
| 481 |
-
return re.test(text);
|
| 482 |
-
}
|
| 483 |
-
return titleLower.includes(term) || descLower.includes(term);
|
| 484 |
-
});
|
| 485 |
-
});
|
| 486 |
return matched.slice(0, limit);
|
| 487 |
}
|
| 488 |
|
|
|
|
| 489 |
async function fetchArticleContent(url) {
|
| 490 |
try {
|
| 491 |
const resp = await fetch(url, {
|
|
@@ -493,6 +240,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 +250,82 @@ 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 |
try {
|
| 532 |
-
const
|
| 533 |
method: "POST",
|
| 534 |
-
headers: { "Content-Type": "application/json" },
|
| 535 |
-
body: JSON.stringify({ path,
|
| 536 |
});
|
| 537 |
-
if (!
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
}
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
}
|
| 555 |
|
| 556 |
const server = Bun.serve({
|
|
@@ -564,6 +339,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 +357,7 @@ const server = Bun.serve({
|
|
| 580 |
});
|
| 581 |
},
|
| 582 |
},
|
|
|
|
| 583 |
"/api/news/filter": {
|
| 584 |
GET: async (req) => {
|
| 585 |
const url = new URL(req.url);
|
|
@@ -589,22 +367,18 @@ const server = Bun.serve({
|
|
| 589 |
return Response.json({ query: q, items });
|
| 590 |
},
|
| 591 |
},
|
| 592 |
-
"/news/summary": {
|
| 593 |
GET: async (req) => {
|
| 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();
|
| 605 |
-
return Response.json({ topics });
|
| 606 |
-
},
|
| 607 |
-
},
|
| 608 |
"/api/summarize": {
|
| 609 |
POST: async (req) => {
|
| 610 |
try {
|
|
@@ -616,33 +390,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 +464,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}`);
|
|
@@ -663,46 +481,10 @@ const server = Bun.serve({
|
|
| 663 |
}
|
| 664 |
},
|
| 665 |
},
|
| 666 |
-
"/api/debug/avatars": {
|
| 667 |
-
GET: async () => {
|
| 668 |
-
const files = await listAllAvatarFiles();
|
| 669 |
-
const avatarsDir = join(process.cwd(), "public/avatars");
|
| 670 |
-
const avatars = await listAvatars();
|
| 671 |
-
const thumbStatus = {};
|
| 672 |
-
for (const name of avatars) { thumbStatus[name] = await avatarHasThumbnail(name); }
|
| 673 |
-
return Response.json({ cwd: process.cwd(), avatarsDir, files, count: files.length, avatars, thumbStatus });
|
| 674 |
-
},
|
| 675 |
-
},
|
| 676 |
-
"/api/debug/thumbnail/:name": {
|
| 677 |
-
GET: async (req) => {
|
| 678 |
-
const name = req.params.name;
|
| 679 |
-
const thumbName = `${name}.thumb.png`;
|
| 680 |
-
const thumbPath = join(process.cwd(), "public/avatars", thumbName);
|
| 681 |
-
const file = Bun.file(thumbPath);
|
| 682 |
-
const exists = await file.exists();
|
| 683 |
-
const size = exists ? file.size : 0;
|
| 684 |
-
return Response.json({ name, thumbName, thumbPath, exists, size, url: `/avatars/${thumbName}` });
|
| 685 |
-
},
|
| 686 |
-
},
|
| 687 |
"/api/avatars": {
|
| 688 |
GET: async () => {
|
| 689 |
const names = await listAvatars();
|
| 690 |
-
|
| 691 |
-
names.map(async (name) => ({
|
| 692 |
-
name,
|
| 693 |
-
thumbnail: await avatarHasThumbnail(name) ? `/avatars/${name}.thumb.png` : null,
|
| 694 |
-
}))
|
| 695 |
-
);
|
| 696 |
-
return Response.json({ avatars });
|
| 697 |
-
},
|
| 698 |
-
},
|
| 699 |
-
"/api/avatars/debug": {
|
| 700 |
-
GET: async () => {
|
| 701 |
-
const allFiles = await listAllAvatarFiles();
|
| 702 |
-
const glbFiles = allFiles.filter((f) => f.endsWith(".glb"));
|
| 703 |
-
const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png"));
|
| 704 |
-
const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png"));
|
| 705 |
-
return Response.json({ allFiles, glbFiles, thumbFiles, otherFiles, avatarsDir: join(process.cwd(), "public/avatars") });
|
| 706 |
},
|
| 707 |
},
|
| 708 |
"/api/wiki/search": {
|
|
@@ -711,9 +493,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 +511,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 +529,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 +557,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 +600,46 @@ 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) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
|
| 811 |
let buf;
|
| 812 |
-
try { buf = Buffer.from(m[
|
| 813 |
-
if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
|
| 814 |
if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
const isJpeg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
|
| 818 |
-
if (!isPng && !isJpeg) {
|
| 819 |
-
return Response.json({ ok: false, error: "Not a PNG or JPEG" }, { status: 400 });
|
| 820 |
}
|
| 821 |
const fileName = `${avatar}.thumb.png`;
|
| 822 |
-
const
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
await writeFile(tmpPath, uint8Buf);
|
| 830 |
-
console.log(`[thumbnail] saved to tmp: ${tmpPath} (${buf.length} bytes)`);
|
| 831 |
-
} catch (writeErr) {
|
| 832 |
-
console.warn(`[thumbnail] tmp write failed: ${writeErr}`);
|
| 833 |
-
}
|
| 834 |
-
|
| 835 |
-
// ── 2. Upload to Hub for persistence ──
|
| 836 |
-
uploadToHub(`public/avatars/${fileName}`, buf).catch((err) => {
|
| 837 |
-
console.warn(`[thumbnail] Hub upload failed: ${err}. Thumbnail in /tmp only.`);
|
| 838 |
-
});
|
| 839 |
-
|
| 840 |
-
// ── 3. Serve from /tmp via fallback handler ──
|
| 841 |
-
const thumbUrl = `/avatars/${fileName}`;
|
| 842 |
-
|
| 843 |
-
return Response.json({ ok: true, url: thumbUrl, size: buf.length });
|
| 844 |
} catch (e) {
|
| 845 |
-
console.
|
| 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 |
+
const file = Bun.file(`${import.meta.dir}/public/${dir}/${name}`);
|
|
|
|
| 33 |
return new Response(file);
|
| 34 |
}
|
| 35 |
|
| 36 |
+
/** List .glb files in public/avatars/ */
|
| 37 |
async function listAvatars() {
|
| 38 |
+
const avatarsDir = join(import.meta.dir, "public/avatars");
|
| 39 |
const names = [];
|
| 40 |
try {
|
| 41 |
const dir = await readdir(avatarsDir, { withFileTypes: true });
|
|
|
|
| 48 |
return names.sort();
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const SOURCE_MAP = {
|
| 52 |
"vnexpress.net": "VnExpress",
|
| 53 |
"dantri.com.vn": "Dân trí",
|
|
|
|
| 79 |
|
| 80 |
/** Extract the first usable image URL from an RSS <item> block. */
|
| 81 |
function extractImage(itemXml) {
|
| 82 |
+
// 1) media:thumbnail / media:content with url=
|
| 83 |
const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/i);
|
| 84 |
if (media) return media[1];
|
| 85 |
+
// 2) <enclosure type="image/..." url="...">
|
| 86 |
const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/i);
|
| 87 |
if (enc) {
|
| 88 |
const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i);
|
| 89 |
if (urlMatch) return urlMatch[1];
|
| 90 |
}
|
| 91 |
+
// 3) <image><url>...</url></image>
|
| 92 |
const imgTag = itemXml.match(/<image>\s*<url>([\s\S]*?)<\/url>/i);
|
| 93 |
if (imgTag) return imgTag[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
|
| 94 |
+
// 4) any <img ... src="..."> inside description
|
| 95 |
const imgSrc = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/i);
|
| 96 |
if (imgSrc) return imgSrc[1];
|
| 97 |
return "";
|
|
|
|
| 115 |
if (!dm) return "";
|
| 116 |
let txt = dm[1].replace(/<!\[CDATA\[|\]\]>/g, "");
|
| 117 |
txt = txt.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "")
|
| 118 |
+
.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim();
|
| 119 |
return txt.length > 300 ? txt.slice(0, 300) + "…" : txt;
|
| 120 |
}
|
| 121 |
|
|
|
|
| 136 |
"https://www.nguoiduatin.vn/rss/home.rss",
|
| 137 |
"https://tienphong.vn/rss/home.rss",
|
| 138 |
"https://vov.vn/rss/home.rss",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
];
|
| 140 |
|
| 141 |
+
/**
|
| 142 |
+
* Fetch hot Vietnamese news from many RSS feeds.
|
| 143 |
+
*
|
| 144 |
+
* Uses ROUND-ROBIN selection: we pull an equal number of items from each
|
| 145 |
+
* source before moving on, so no single source (e.g. VnExpress) can dominate
|
| 146 |
+
* the list. The combined result is then shuffled for extra variety. Every
|
| 147 |
+
* item carries a thumbnail image when the feed provides one.
|
| 148 |
+
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
async function fetchHotNews() {
|
| 150 |
+
const parsed = []; // [{source, items:[{title,link,image}]}]
|
| 151 |
const seenLinks = new Set();
|
| 152 |
+
|
| 153 |
+
// Fetch all feeds in parallel for speed.
|
| 154 |
await Promise.all(
|
| 155 |
FEEDS.map(async (feedUrl) => {
|
| 156 |
try {
|
|
|
|
| 166 |
const tm = item.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
| 167 |
const lm = item.match(/<link[^>]*>([\s\S]*?)<\/link>/i);
|
| 168 |
if (!tm || !tm[1]) continue;
|
| 169 |
+
const cleanTitle = tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
|
| 170 |
const cleanLink = lm ? lm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
|
| 171 |
if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) {
|
| 172 |
seenLinks.add(cleanLink);
|
|
|
|
| 181 |
}
|
| 182 |
})
|
| 183 |
);
|
| 184 |
+
|
| 185 |
+
// Round-robin: take 1 item from each source in turn, repeat until we have
|
| 186 |
+
// enough (or sources are exhausted). This guarantees source diversity.
|
| 187 |
const queue = parsed.map((p) => ({ source: p.source, items: p.items.slice() }));
|
| 188 |
+
const items = []; // {title, link, source, image}
|
| 189 |
let added = true;
|
| 190 |
+
const MAX = 80;
|
| 191 |
while (added && items.length < MAX) {
|
| 192 |
added = false;
|
| 193 |
for (const q of queue) {
|
|
|
|
| 199 |
}
|
| 200 |
}
|
| 201 |
}
|
| 202 |
+
|
| 203 |
+
// Final shuffle so the order is not source-clustered.
|
| 204 |
const shuffled = items.sort(() => Math.random() - 0.5);
|
| 205 |
return shuffled;
|
| 206 |
}
|
| 207 |
|
| 208 |
+
// ── In-memory news pool (refreshed periodically) for stable pagination ──
|
| 209 |
let newsPool = [];
|
| 210 |
let newsPoolLoadedAt = 0;
|
| 211 |
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
async function getNewsPool() {
|
| 219 |
+
// Refresh if empty or older than 5 minutes.
|
| 220 |
if (newsPool.length === 0 || Date.now() - newsPoolLoadedAt > 5 * 60 * 1000) {
|
| 221 |
await refreshNewsPool();
|
| 222 |
}
|
| 223 |
return newsPool;
|
| 224 |
}
|
| 225 |
|
| 226 |
+
/** Filter the cached pool by a keyword (case-insensitive, in title). */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
async function filterNews(query, limit = 15) {
|
| 228 |
const pool = await getNewsPool();
|
| 229 |
const q = (query || "").trim().toLowerCase();
|
| 230 |
if (!q) return pool.slice(0, limit);
|
| 231 |
+
const matched = pool.filter((it) => it.title.toLowerCase().includes(q));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
return matched.slice(0, limit);
|
| 233 |
}
|
| 234 |
|
| 235 |
+
/** Fetch and extract article content for summarization */
|
| 236 |
async function fetchArticleContent(url) {
|
| 237 |
try {
|
| 238 |
const resp = await fetch(url, {
|
|
|
|
| 240 |
signal: AbortSignal.timeout(8000),
|
| 241 |
});
|
| 242 |
const html = await resp.text();
|
| 243 |
+
|
| 244 |
+
// Extract text content (simple extraction)
|
| 245 |
let text = html
|
| 246 |
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
| 247 |
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
|
|
| 250 |
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
|
| 251 |
.replace(/<[^>]+>/g, " ")
|
| 252 |
.replace(/&[a-z]+;/g, " ")
|
| 253 |
+
.replace(/\s+/g, " ")
|
| 254 |
+
.trim();
|
| 255 |
+
|
| 256 |
+
return text.slice(0, 4000); // Return first 4000 chars
|
| 257 |
} catch (e) {
|
| 258 |
console.warn(`[Article] Failed to fetch ${url}: ${e}`);
|
| 259 |
return null;
|
| 260 |
}
|
| 261 |
}
|
| 262 |
|
| 263 |
+
/** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */
|
| 264 |
function formatVietnameseTime(now) {
|
| 265 |
let h = now.getHours();
|
| 266 |
const m = now.getMinutes();
|
| 267 |
let part;
|
| 268 |
if (h >= 0 && h < 5) part = "đêm";
|
| 269 |
else if (h < 10) part = "sáng";
|
| 270 |
+
else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa
|
| 271 |
else if (h < 18) part = "chiều";
|
| 272 |
+
else part = "tối"; // 18h trở đi -> tối
|
| 273 |
+
const hh = h === 0 ? 12 : h; // midnight shown as 12h đêm
|
| 274 |
if (m === 0) return `${hh}h ${part}`;
|
| 275 |
return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
|
| 276 |
}
|
| 277 |
|
| 278 |
+
|
| 279 |
+
// Upload a small file to this Space's Hub repo (persists across restarts).
|
| 280 |
+
// Uses the Hub REST API: preupload -> PUT bytes -> commit.
|
| 281 |
+
async function uploadToHub(path, buf) {
|
| 282 |
+
const token = Bun.env.HF_TOKEN;
|
| 283 |
+
if (!token) throw new Error("no HF_TOKEN");
|
| 284 |
+
const base = `https://huggingface.co/api/spaces/${REPO_ID}`;
|
| 285 |
+
// 1) preupload: learn how to upload this path
|
| 286 |
+
let uploadUrl, uploadHeaders = {}, sha, oid, size;
|
| 287 |
try {
|
| 288 |
+
const pre = await fetch(`${base}/preupload/main`, {
|
| 289 |
method: "POST",
|
| 290 |
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
| 291 |
+
body: JSON.stringify({ files: [{ path, size: buf.length }] }),
|
| 292 |
});
|
| 293 |
+
if (!pre.ok) throw new Error("preupload " + pre.status);
|
| 294 |
+
const preJson = await pre.json();
|
| 295 |
+
const fileInfo = preJson.files?.[path] ?? preJson.files?.[0];
|
| 296 |
+
const action = fileInfo?.action;
|
| 297 |
+
if (action?.uploadUrl) { uploadUrl = action.uploadUrl; uploadHeaders = action.headers ?? {}; }
|
| 298 |
+
sha = action?.sha256 ?? fileInfo?.sha256;
|
| 299 |
+
} catch (e) { console.warn("[uploadToHub] preupload:", e); }
|
| 300 |
+
// 2) PUT the bytes
|
| 301 |
+
if (uploadUrl) {
|
| 302 |
+
try {
|
| 303 |
+
const up = await fetch(uploadUrl, {
|
| 304 |
+
method: "PUT",
|
| 305 |
+
headers: { ...uploadHeaders, "Content-Type": "application/octet-stream" },
|
| 306 |
+
body: buf,
|
| 307 |
+
});
|
| 308 |
+
if (!up.ok) throw new Error("upload " + up.status);
|
| 309 |
+
} catch (e) { console.warn("[uploadToHub] put:", e); throw e; }
|
| 310 |
}
|
| 311 |
+
// 3) commit
|
| 312 |
+
const header = { key: "header", value: { summary: `avatar thumbnail ${path}`, description: "" } };
|
| 313 |
+
const op = {
|
| 314 |
+
key: "operation",
|
| 315 |
+
value: {
|
| 316 |
+
operation: "addOrUpdate",
|
| 317 |
+
path,
|
| 318 |
+
...(sha ? { sha256: sha, size: buf.length } : {}),
|
| 319 |
+
},
|
| 320 |
+
};
|
| 321 |
+
const ndjson = JSON.stringify(header) + "\n" + JSON.stringify(op) + "\n";
|
| 322 |
+
const cm = await fetch(`${base}/commit/main`, {
|
| 323 |
+
method: "POST",
|
| 324 |
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-ndjson" },
|
| 325 |
+
body: ndjson,
|
| 326 |
+
});
|
| 327 |
+
if (!cm.ok) throw new Error("commit " + cm.status);
|
| 328 |
+
return true;
|
| 329 |
}
|
| 330 |
|
| 331 |
const server = Bun.serve({
|
|
|
|
| 339 |
return Response.json({ items: pool.slice(0, 15) });
|
| 340 |
},
|
| 341 |
},
|
| 342 |
+
// Lazy-load endpoint: returns the next page of cached news.
|
| 343 |
+
// ?offset=10&limit=10 → items 10..19 from the cached pool.
|
| 344 |
"/api/news/more": {
|
| 345 |
GET: async (req) => {
|
| 346 |
const url = new URL(req.url);
|
|
|
|
| 357 |
});
|
| 358 |
},
|
| 359 |
},
|
| 360 |
+
// Filter the cached news by a keyword (newest/top matches first).
|
| 361 |
"/api/news/filter": {
|
| 362 |
GET: async (req) => {
|
| 363 |
const url = new URL(req.url);
|
|
|
|
| 367 |
return Response.json({ query: q, items });
|
| 368 |
},
|
| 369 |
},
|
| 370 |
+
"/api/news/summary": {
|
| 371 |
GET: async (req) => {
|
| 372 |
const url = new URL(req.url);
|
| 373 |
const articleUrl = url.searchParams.get("url");
|
| 374 |
if (!articleUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
|
| 375 |
+
|
| 376 |
const content = await fetchArticleContent(articleUrl);
|
| 377 |
if (!content) return Response.json({ error: "Could not fetch article" }, { status: 502 });
|
| 378 |
+
|
| 379 |
return Response.json({ content, url: articleUrl });
|
| 380 |
},
|
| 381 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
"/api/summarize": {
|
| 383 |
POST: async (req) => {
|
| 384 |
try {
|
|
|
|
| 390 |
const bullets = text.split(/\n\n+/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
|
| 391 |
return Response.json({ title: text.slice(0, 80), body: bullets.map((b) => `• ${b}`).join("\n"), fallback: true });
|
| 392 |
}
|
| 393 |
+
// ── Step 1: clean the chat transcript ──────────────────────────────
|
| 394 |
const cleanText = text
|
| 395 |
.split(/\n+/)
|
| 396 |
.map((line) => line.replace(/^\s*(người dùng|user|avatar|bạn|trợ lí|trợ lý|assistant)\s*[:\-]\s*/i, "").trim())
|
| 397 |
+
.filter(Boolean)
|
| 398 |
+
.join("\n");
|
| 399 |
+
|
| 400 |
+
const SYSTEM_PROMPT =
|
| 401 |
+
"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" +
|
| 402 |
+
"QUY TẮC:\n" +
|
| 403 |
+
"1. KHÔNG sao chép nguyên văn. Diễn đạt lại (paraphrase), tóm gọn, tự nhiên.\n" +
|
| 404 |
+
"2. KHÔNG giữ định dạng chat 'Người dùng:'/'Avatar:'.\n" +
|
| 405 |
+
"3. Dòng đầu là TIÊU ĐỀ (dưới 90 ký tự).\n" +
|
| 406 |
+
"4. Tiếp theo: 1 mở đầu, 3-4 đoạn thân bài, 1 kết luận.\n" +
|
| 407 |
+
"5. Tiếng Việt chuẩn, KHÔNG liên kết, KHÔNG đánh số đầu dòng.\n" +
|
| 408 |
+
"6. Định dạng: TIÊU ĐỀ: <tiêu đề>\n\n<nội dung>";
|
| 409 |
const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`;
|
| 410 |
const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || "";
|
| 411 |
const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct";
|
| 412 |
let out = "";
|
| 413 |
+
// ── Prefer the self-hosted summarizer Space (reliable, no geo-block) ──
|
| 414 |
if (SUMMARY_ENDPOINT) {
|
| 415 |
try {
|
| 416 |
+
const sresp = await fetch(SUMMARY_ENDPOINT, {
|
| 417 |
+
method: "POST",
|
| 418 |
+
headers: { "Content-Type": "application/json" },
|
| 419 |
+
body: JSON.stringify({ text }),
|
| 420 |
+
signal: AbortSignal.timeout(90000),
|
| 421 |
+
});
|
| 422 |
+
if (sresp.ok) {
|
| 423 |
+
const sdata = await sresp.json();
|
| 424 |
+
if (sdata && sdata.body) out = (sdata.body || "").trim();
|
| 425 |
+
}
|
| 426 |
} catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); }
|
| 427 |
}
|
| 428 |
+
// ── Fall back to the HF router (OpenAI-compatible) ──
|
| 429 |
if (!out) {
|
| 430 |
try {
|
| 431 |
+
const rresp = await fetch("https://router.huggingface.co/v1/chat/completions", {
|
| 432 |
+
method: "POST",
|
| 433 |
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
| 434 |
+
body: JSON.stringify({
|
| 435 |
+
model: MODEL,
|
| 436 |
+
messages: [
|
| 437 |
+
{ role: "system", content: SYSTEM_PROMPT },
|
| 438 |
+
{ role: "user", content: USER_PROMPT },
|
| 439 |
+
],
|
| 440 |
+
max_tokens: 800,
|
| 441 |
+
temperature: 0.8,
|
| 442 |
+
top_p: 0.9,
|
| 443 |
+
}),
|
| 444 |
+
signal: AbortSignal.timeout(45000),
|
| 445 |
+
});
|
| 446 |
if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); }
|
| 447 |
} catch (e) { console.warn(`[summarize] router failed: ${e}`); }
|
| 448 |
}
|
| 449 |
if (!out && Bun.env.SUMMARY_MODEL_LEGACY) {
|
| 450 |
try {
|
| 451 |
+
const lresp = await fetch(`https://api-inference.huggingface.co/models/${Bun.env.SUMMARY_MODEL_LEGACY}`, {
|
| 452 |
+
method: "POST",
|
| 453 |
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
| 454 |
+
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 } }),
|
| 455 |
+
signal: AbortSignal.timeout(45000),
|
| 456 |
+
});
|
| 457 |
if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); }
|
| 458 |
} catch (e) { console.warn(`[summarize] legacy failed: ${e}`); }
|
| 459 |
}
|
|
|
|
| 464 |
if (!title) title = bodyText.slice(0, 80);
|
| 465 |
return Response.json({ title, body: bodyText || out });
|
| 466 |
}
|
| 467 |
+
// ── No-model fallback: light paraphrase (NEVER verbatim copy) ──
|
| 468 |
+
// Re-flow the chat lines into a short prose summary with a title,
|
| 469 |
+
// instead of echoing the raw transcript.
|
| 470 |
const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean);
|
| 471 |
const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện";
|
| 472 |
+
const bodyFb = lines
|
| 473 |
+
.map((l) => l.charAt(0).toUpperCase() + l.slice(1))
|
| 474 |
+
.join(" ")
|
| 475 |
+
.replace(/\s+/g, " ")
|
| 476 |
+
.trim();
|
| 477 |
return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true });
|
| 478 |
} catch (e) {
|
| 479 |
console.warn(`[summarize] ${e}`);
|
|
|
|
| 481 |
}
|
| 482 |
},
|
| 483 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
"/api/avatars": {
|
| 485 |
GET: async () => {
|
| 486 |
const names = await listAvatars();
|
| 487 |
+
return Response.json({ avatars: names });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
},
|
| 489 |
},
|
| 490 |
"/api/wiki/search": {
|
|
|
|
| 493 |
const q = url.searchParams.get("q");
|
| 494 |
if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
|
| 495 |
try {
|
| 496 |
+
const resp = await fetch(
|
| 497 |
+
`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`
|
| 498 |
+
);
|
| 499 |
const data = await resp.json();
|
| 500 |
+
const results = (data.query?.search ?? []).map((r) => ({
|
| 501 |
+
title: r.title,
|
| 502 |
+
snippet: r.snippet.replace(/<[^>]+>/g, ""),
|
| 503 |
+
}));
|
| 504 |
return Response.json({ results });
|
| 505 |
} catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
|
| 506 |
},
|
|
|
|
| 511 |
const title = url.searchParams.get("title");
|
| 512 |
if (!title) return Response.json({ error: "Missing ?title=" }, { status: 400 });
|
| 513 |
try {
|
| 514 |
+
const resp = await fetch(
|
| 515 |
+
`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`
|
| 516 |
+
);
|
| 517 |
const data = await resp.json();
|
| 518 |
+
return Response.json({
|
| 519 |
+
title: data.title,
|
| 520 |
+
extract: data.extract,
|
| 521 |
+
url: data.content_urls?.desktop?.page,
|
| 522 |
+
});
|
| 523 |
} catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
|
| 524 |
},
|
| 525 |
},
|
|
|
|
| 529 |
const q = url.searchParams.get("q");
|
| 530 |
if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
|
| 531 |
try {
|
| 532 |
+
const resp = await fetch(
|
| 533 |
+
`https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`,
|
| 534 |
+
{ headers: { "User-Agent": "Mozilla/5.0" } }
|
| 535 |
+
);
|
| 536 |
const html = await resp.text();
|
| 537 |
const results = [];
|
| 538 |
const linkRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
|
|
| 557 |
const targetUrl = url.searchParams.get("url");
|
| 558 |
if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
|
| 559 |
try {
|
| 560 |
+
const resp = await fetch(targetUrl, {
|
| 561 |
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" },
|
| 562 |
+
signal: AbortSignal.timeout(8000),
|
| 563 |
+
});
|
| 564 |
const html = await resp.text();
|
| 565 |
+
let text = html
|
| 566 |
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
| 567 |
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
| 568 |
+
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
|
| 569 |
+
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
|
| 570 |
+
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
|
| 571 |
+
.replace(/<[^>]+>/g, " ")
|
| 572 |
+
.replace(/&[a-z]+;/g, " ")
|
| 573 |
+
.replace(/\s+/g, " ")
|
| 574 |
+
.trim();
|
| 575 |
const content = text.slice(0, 3000);
|
| 576 |
return Response.json({ content, url: targetUrl });
|
| 577 |
+
} catch {
|
| 578 |
+
return Response.json({ error: "Could not fetch page." }, { status: 502 });
|
| 579 |
+
}
|
| 580 |
},
|
| 581 |
},
|
| 582 |
"/api/session": {
|
|
|
|
| 600 |
},
|
| 601 |
"/worklets/:name": (req) => staticFile("worklets", req.params.name),
|
| 602 |
"/vendor/:name": (req) => staticFile("vendor", req.params.name),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
"/api/avatar-thumbnail": {
|
| 604 |
POST: async (req) => {
|
| 605 |
try {
|
| 606 |
const body = await req.json();
|
| 607 |
const avatar = (body.avatar || "").toString();
|
| 608 |
const dataUrl = (body.dataUrl || "").toString();
|
| 609 |
+
// Only allow a known .glb name + a PNG data URL.
|
| 610 |
if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) {
|
| 611 |
return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 });
|
| 612 |
}
|
| 613 |
+
const m = dataUrl.match(/^data:image\/png;base64,(.+)$/);
|
| 614 |
if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
|
| 615 |
let buf;
|
| 616 |
+
try { buf = Buffer.from(m[1], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); }
|
|
|
|
| 617 |
if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
|
| 618 |
+
if (!(buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)) {
|
| 619 |
+
return Response.json({ ok: false, error: "Not a PNG" }, { status: 400 });
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
const fileName = `${avatar}.thumb.png`;
|
| 622 |
+
const dir = join(import.meta.dir, "public/avatars");
|
| 623 |
+
// Write locally so it serves immediately this session.
|
| 624 |
+
try { await writeFile(join(dir, fileName), buf); } catch (e) { console.warn("[thumb] local write:", e); }
|
| 625 |
+
// Persist to the Hub repo so it survives restarts.
|
| 626 |
+
try { await uploadToHub(`public/avatars/${fileName}`, buf); }
|
| 627 |
+
catch (e) { console.warn("[thumb] hub upload:", e); }
|
| 628 |
+
return Response.json({ ok: true, url: `/avatars/${fileName}` });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
} catch (e) {
|
| 630 |
+
console.warn(`[thumb] ${e}`);
|
| 631 |
+
return Response.json({ ok: false, error: "save failed" }, { status: 500 });
|
| 632 |
}
|
| 633 |
},
|
| 634 |
},
|
| 635 |
+
"/avatars/:name": (req) => staticFile("avatars", req.params.name),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
},
|
| 637 |
});
|
| 638 |
|
| 639 |
+
// Warm the news pool on boot so the first request is instant.
|
| 640 |
+
void refreshNewsPool();
|
| 641 |
+
|
| 642 |
+
console.log(`gemma-avatar listening on ${server.url}`);
|
| 643 |
+
console.log(UPSTREAM ? `session backend: ${LOAD_BALANCER_URL ? "load balancer" : "session proxy"} (${UPSTREAM})` : "session backend: none");
|
| 644 |
+
|
| 645 |
+
/* rebuild trigger */
|
public/avatars/Ella.glb.thumb.png
DELETED
|
Binary file (70 Bytes)
|
|
|
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/test_direct_upload.png
DELETED
|
Binary file (70 Bytes)
|
|
|
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/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,7 +1,6 @@
|
|
| 1 |
import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js";
|
| 2 |
import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js";
|
| 3 |
import { smartNormalize, prepareForTTS } from "./viNumberFix.js";
|
| 4 |
-
import { VoiceTyper } from "./voiceTyper.js";
|
| 5 |
|
| 6 |
const VOICES = [
|
| 7 |
"Aiden", "Ryan", "Dylan", "Eric",
|
|
@@ -93,34 +92,14 @@ async function getVnewsAIFeed(limit = NEWS_PAGE_SIZE) {
|
|
| 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 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
}
|
| 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;
|
|
@@ -136,32 +115,23 @@ async function openTopic(slug, label) {
|
|
| 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)
|
| 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 =
|
| 161 |
if (oldEl) oldEl.remove();
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
_topicTagsRendered = true;
|
| 165 |
}
|
| 166 |
|
| 167 |
async function getVnewsHomepage() {
|
|
@@ -249,7 +219,6 @@ 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");
|
|
@@ -269,11 +238,9 @@ 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;
|
|
@@ -367,9 +334,42 @@ function effectiveInstructions(newsItems) {
|
|
| 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
|
| 371 |
-
//
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
let newsInst = "";
|
| 375 |
if (newsItems && newsItems.length > 0) {
|
|
@@ -498,6 +498,9 @@ function askAboutNews(item) {
|
|
| 498 |
textMode = true;
|
| 499 |
chatInput.focus();
|
| 500 |
if (!client || !sessionInProgress) {
|
|
|
|
|
|
|
|
|
|
| 501 |
void startTextSession(q);
|
| 502 |
} else {
|
| 503 |
addChatMessage("user", q);
|
|
@@ -604,7 +607,7 @@ function isFollowUp(text) {
|
|
| 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
|
| 608 |
}
|
| 609 |
|
| 610 |
/** Extract a short keyword query from a user message (Vietnamese-aware). */
|
|
@@ -695,23 +698,10 @@ function setCurrentNews(item) {
|
|
| 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 |
|
|
@@ -913,24 +903,6 @@ function addChatMessage(role, text, newsItem = null) {
|
|
| 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);
|
|
@@ -967,19 +939,6 @@ function addChatMessage(role, text, newsItem = null) {
|
|
| 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");
|
|
@@ -1006,80 +965,7 @@ function addChatMessage(role, text, newsItem = null) {
|
|
| 1006 |
|
| 1007 |
function showTextChat(show) {
|
| 1008 |
textChat.hidden = !show;
|
| 1009 |
-
if (
|
| 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) {
|
|
@@ -1098,12 +984,8 @@ 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) {
|
|
@@ -1111,6 +993,9 @@ function sendTextMessage() {
|
|
| 1111 |
}
|
| 1112 |
|
| 1113 |
if (!client || !sessionInProgress) {
|
|
|
|
|
|
|
|
|
|
| 1114 |
void startTextSession(text);
|
| 1115 |
return;
|
| 1116 |
}
|
|
@@ -1118,8 +1003,6 @@ function sendTextMessage() {
|
|
| 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) {
|
|
@@ -1143,10 +1026,6 @@ 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 |
});
|
|
@@ -1391,18 +1270,7 @@ function onStatus(status) {
|
|
| 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 |
}
|
|
@@ -1517,36 +1385,38 @@ async function connectSession(c) {
|
|
| 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 |
-
|
|
|
|
| 1522 |
}
|
|
|
|
| 1523 |
return null;
|
| 1524 |
}
|
| 1525 |
}
|
| 1526 |
|
| 1527 |
-
async function startVoiceSession(
|
| 1528 |
if (sessionInProgress) return;
|
| 1529 |
sessionInProgress = true;
|
|
|
|
| 1530 |
await stage.resume();
|
| 1531 |
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
}
|
| 1550 |
}
|
| 1551 |
}
|
| 1552 |
|
|
@@ -1699,10 +1569,6 @@ function _attachClientEvents(c) {
|
|
| 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) => {
|
|
@@ -1725,12 +1591,6 @@ async function endSession(silent = false) {
|
|
| 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() ?? []) {
|
|
@@ -1759,8 +1619,11 @@ async function endSession(silent = false) {
|
|
| 1759 |
mainBtn.addEventListener("click", () => {
|
| 1760 |
if (mainAction === "start") {
|
| 1761 |
if (sessionInProgress) return;
|
| 1762 |
-
|
| 1763 |
-
|
|
|
|
|
|
|
|
|
|
| 1764 |
} else if (mainAction === "join") {
|
| 1765 |
stage.resume();
|
| 1766 |
client?.join();
|
|
@@ -1784,25 +1647,6 @@ textModeBtn.addEventListener("click", () => {
|
|
| 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();
|
|
@@ -1880,8 +1724,13 @@ async function boot() {
|
|
| 1880 |
} catch (e) { console.warn("[boot] config:", e); }
|
| 1881 |
directUrlRow.hidden = !config.allowDirect;
|
| 1882 |
|
| 1883 |
-
//
|
| 1884 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1885 |
|
| 1886 |
try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
|
| 1887 |
try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
|
|
@@ -1889,8 +1738,8 @@ async function boot() {
|
|
| 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 |
}
|
|
@@ -2006,79 +1855,28 @@ function loadThumbs() {
|
|
| 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 |
-
// Show the avatar's representative image: prefer the
|
| 2014 |
-
//
|
| 2015 |
-
// a gradient fallback with the initial letter.
|
| 2016 |
function updateAvatarThumbPreview() {
|
| 2017 |
if (!avatarThumbPreview) return;
|
| 2018 |
-
const name = chatAvatarSelect.value
|
| 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 |
-
|
| 2026 |
-
|
| 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 |
-
|
| 2076 |
-
|
| 2077 |
-
|
| 2078 |
-
|
|
|
|
| 2079 |
return;
|
| 2080 |
}
|
| 2081 |
-
|
| 2082 |
}
|
| 2083 |
if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
|
| 2084 |
|
|
@@ -2089,45 +1887,12 @@ 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 |
-
_latestSnap =
|
| 2130 |
-
snapPreview.src =
|
| 2131 |
if (snapDialog.showModal) snapDialog.showModal();
|
| 2132 |
else snapDialog.setAttribute("open", "");
|
| 2133 |
});
|
|
@@ -2146,45 +1911,27 @@ if (snapSetAvatar) {
|
|
| 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:
|
| 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 |
-
|
| 2167 |
-
|
| 2168 |
-
|
| 2169 |
-
|
| 2170 |
-
|
| 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(
|
| 2188 |
setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
|
| 2189 |
} finally {
|
| 2190 |
snapSetAvatar.disabled = false;
|
|
@@ -2194,7 +1941,27 @@ if (snapSetAvatar) {
|
|
| 2194 |
}
|
| 2195 |
|
| 2196 |
// ── Onboarding picker (choose character + voice BEFORE loading avatar) ──
|
| 2197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2198 |
const grid = document.getElementById("picker-avatars");
|
| 2199 |
const voices = document.getElementById("picker-voices");
|
| 2200 |
if (!grid || !voices) return;
|
|
@@ -2215,18 +1982,11 @@ function buildPicker() {
|
|
| 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 |
-
|
| 2229 |
-
_img.src = _srv || _map[name] || "";
|
| 2230 |
thumb.appendChild(_img);
|
| 2231 |
const span = document.createElement("span");
|
| 2232 |
span.className = "avatar-card-name";
|
|
@@ -2255,35 +2015,21 @@ function buildPicker() {
|
|
| 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("
|
| 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 {
|
|
@@ -2291,12 +2037,7 @@ async function startWithSelection() {
|
|
| 2291 |
avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
|
| 2292 |
onprogress: (ev) => {
|
| 2293 |
if (ev.lengthComputable) {
|
| 2294 |
-
|
| 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]);
|
|
@@ -2305,22 +2046,13 @@ async function startWithSelection() {
|
|
| 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
|
| 2322 |
setMainButton("start", "Start talking");
|
| 2323 |
-
}
|
| 2324 |
-
}
|
| 2325 |
const pickerStartBtn = document.getElementById("picker-start");
|
| 2326 |
if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
|
|
|
|
| 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",
|
|
|
|
| 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 |
async function openTopic(slug, label) {
|
| 96 |
try {
|
| 97 |
setCaption("ĐANG TẢI TIN: " + label, "live");
|
| 98 |
+
const raw = await getFilteredNews(label);
|
| 99 |
+
const items = (Array.isArray(raw) ? raw : []).map((n) => ({
|
| 100 |
+
title: n.title, link: n.link, image: n.image || "",
|
| 101 |
+
source: n.source || "Tin tức", vnews: Boolean(n.vnews),
|
| 102 |
+
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
showNewsPanel(items);
|
| 104 |
const nt = document.getElementById("news-title");
|
| 105 |
if (nt) nt.textContent = "📰 Tin: " + label;
|
|
|
|
| 115 |
// greeting, so they are always visible. Clicking a chip opens that topic's news.
|
| 116 |
let _topicTagsRendered = false;
|
| 117 |
async function renderTopicTags() {
|
| 118 |
+
if (window._topicTagsRendered) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
let topics = [];
|
| 120 |
try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); }
|
| 121 |
+
if (!topics.length) topics = TREND_KEYWORDS.map(([s,l]) => ({slug:s,label:l}));
|
| 122 |
const wrap = document.createElement("div");
|
| 123 |
+
wrap.className = "topic-tags"; wrap.id = "topic-tags-inline";
|
|
|
|
| 124 |
for (const c of topics) {
|
| 125 |
const chip = document.createElement("button");
|
| 126 |
+
chip.type = "button"; chip.className = "topic-chip"; chip.dataset.slug = c.slug; chip.textContent = "#" + c.label.replace(/\s+/g, "_");
|
|
|
|
|
|
|
|
|
|
| 127 |
chip.addEventListener("click", () => void openTopic(c.slug, c.label));
|
| 128 |
wrap.appendChild(chip);
|
| 129 |
}
|
| 130 |
+
const oldEl = document.getElementById("topic-tags-inline");
|
| 131 |
if (oldEl) oldEl.remove();
|
| 132 |
+
const msgs = document.getElementById("chat-messages");
|
| 133 |
+
if (msgs) { msgs.appendChild(wrap); msgs.scrollTop = msgs.scrollHeight; }
|
| 134 |
+
window._topicTagsRendered = true;
|
| 135 |
}
|
| 136 |
|
| 137 |
async function getVnewsHomepage() {
|
|
|
|
| 219 |
const chatMessages = $("#chat-messages");
|
| 220 |
const chatInput = $("#chat-input");
|
| 221 |
const chatSendBtn = $("#chat-send-btn");
|
|
|
|
| 222 |
const chatCloseBtn = $("#chat-close-btn");
|
| 223 |
const chatResizeHandle = $("#chat-resize-handle");
|
| 224 |
const chatAvatarSelect = $("#chat-avatar-select");
|
|
|
|
| 238 |
let muted = false;
|
| 239 |
let subtitleTimer = 0;
|
| 240 |
let textMode = false;
|
|
|
|
| 241 |
let config = { lb: false, allowDirect: true };
|
| 242 |
+
let avatarList = [];
|
| 243 |
let sessionInProgress = false;
|
|
|
|
|
|
|
| 244 |
let autoGreetingSent = false;
|
| 245 |
let preFetchedNews = [];
|
| 246 |
let latestNewsUrl = null;
|
|
|
|
| 334 |
const dateLine = `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`;
|
| 335 |
const extra = settings.instructions.trim();
|
| 336 |
const introLine = "Tôi tên là Vương đến V.AI STUDIO.";
|
| 337 |
+
// Trending keyword pool — which of these is "hot" is decided at runtime
|
| 338 |
+
// from today's real HOT news (getHotNews), so the topic list is time-sensitive.
|
| 339 |
+
const TREND_KEYWORDS = [
|
| 340 |
+
["worldcup", "World Cup"], ["bongda", "Bóng đá"], ["chuyennhuong", "Chuyển nhượng"],
|
| 341 |
+
["ai", "AI"], ["congnghe", "Công nghệ"], ["thethao", "Thể thao"], ["kinhte", "Kinh tế"],
|
| 342 |
+
["giaitri", "Giải trí"], ["thoisu", "Thời sự"], ["giaoduc", "Giáo dục"], ["suckhoe", "Sức khỏe"],
|
| 343 |
+
["dulich", "Du lịch"], ["oto", "Ô tô"], ["thegioi", "Thế giới"], ["amnhac", "Âm nhạc"], ["doisong", "Đời sống"],
|
| 344 |
+
];
|
| 345 |
+
function _topicMatches(titles, slug, label) {
|
| 346 |
+
const t = titles;
|
| 347 |
+
if (t.includes(label.toLowerCase())) return true;
|
| 348 |
+
const bare = slug.replace(/[_-]/g, "");
|
| 349 |
+
if (bare.length > 2 && t.includes(bare)) return true;
|
| 350 |
+
if (slug === "ai") return /(trí tuệ nhân tạo|\bai\b)/.test(t);
|
| 351 |
+
return false;
|
| 352 |
+
}
|
| 353 |
+
// Returns today's real trending topics derived from the live HOT news feed.
|
| 354 |
+
async function getTrendingTopics() {
|
| 355 |
+
let items = [];
|
| 356 |
+
try { items = await getHotNews(); } catch (e) { console.warn("[topic] hot news:", e); }
|
| 357 |
+
const titles = items.map((i) => ((i.title || "") + " " + (i.source || ""))).join(" ").toLowerCase();
|
| 358 |
+
const found = [];
|
| 359 |
+
for (const [slug, label] of TREND_KEYWORDS) {
|
| 360 |
+
if (_topicMatches(titles, slug, label)) found.push({ slug, label });
|
| 361 |
+
}
|
| 362 |
+
// Guarantee a useful minimum even on a quiet news day.
|
| 363 |
+
if (found.length < 6) {
|
| 364 |
+
for (const [slug, label] of TREND_KEYWORDS) {
|
| 365 |
+
if (!found.find((f) => f.slug === slug)) {
|
| 366 |
+
found.push({ slug, label });
|
| 367 |
+
if (found.length >= 8) break;
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
return found.slice(0, 12);
|
| 372 |
+
}
|
| 373 |
|
| 374 |
let newsInst = "";
|
| 375 |
if (newsItems && newsItems.length > 0) {
|
|
|
|
| 498 |
textMode = true;
|
| 499 |
chatInput.focus();
|
| 500 |
if (!client || !sessionInProgress) {
|
| 501 |
+
const url = new URL(location.href);
|
| 502 |
+
url.searchParams.set("fakemic", "1");
|
| 503 |
+
history.replaceState(null, "", url.href);
|
| 504 |
void startTextSession(q);
|
| 505 |
} else {
|
| 506 |
addChatMessage("user", q);
|
|
|
|
| 607 |
/** Detect an explicit AI / công nghệ request (route to GenK AI feed). */
|
| 608 |
function isAITopic(text) {
|
| 609 |
const t = (text || "").toLowerCase();
|
| 610 |
+
return /(\bai\b|trí tuệ nhân tạo|a\.i|a i|robot|machine learning|deep learning|chatbot|generative|gen ai)/.test(t);
|
| 611 |
}
|
| 612 |
|
| 613 |
/** Extract a short keyword query from a user message (Vietnamese-aware). */
|
|
|
|
| 698 |
}
|
| 699 |
|
| 700 |
// ── Avatar list ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 701 |
async function fetchAvatarList() {
|
| 702 |
try {
|
| 703 |
const resp = await fetch("/api/avatars");
|
| 704 |
+
if (resp.ok) avatarList = (await resp.json()).avatars || [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
} catch {}
|
| 706 |
}
|
| 707 |
|
|
|
|
| 903 |
if (newsItem.link) msg.dataset.src = newsItem.link;
|
| 904 |
}
|
| 905 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
let displayText = text;
|
| 907 |
if (role === "assistant" && latestNewsUrl && latestNewsSource) {
|
| 908 |
const sourceEscaped = escapeRegex(latestNewsSource);
|
|
|
|
| 939 |
}
|
| 940 |
}
|
| 941 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
// Click a message to toggle multi-select (avatar + user messages) for rewrite.
|
| 943 |
msg.addEventListener("click", () => {
|
| 944 |
msg.classList.toggle("selected");
|
|
|
|
| 965 |
|
| 966 |
function showTextChat(show) {
|
| 967 |
textChat.hidden = !show;
|
| 968 |
+
if (show) chatInput.focus();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
}
|
| 970 |
|
| 971 |
function sendTextViaSession(text) {
|
|
|
|
| 984 |
const text = chatInput.value.trim();
|
| 985 |
if (!text) return;
|
| 986 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
addChatMessage("user", text);
|
| 988 |
+
chatInput.value = "";
|
| 989 |
|
| 990 |
// New topic? re-filter the news list and reset the image sequence.
|
| 991 |
if (client && sessionInProgress) {
|
|
|
|
| 993 |
}
|
| 994 |
|
| 995 |
if (!client || !sessionInProgress) {
|
| 996 |
+
const url = new URL(location.href);
|
| 997 |
+
url.searchParams.set("fakemic", "1");
|
| 998 |
+
history.replaceState(null, "", url.href);
|
| 999 |
void startTextSession(text);
|
| 1000 |
return;
|
| 1001 |
}
|
|
|
|
| 1003 |
if (!sendTextViaSession(text)) {
|
| 1004 |
setCaption("QUEUED…");
|
| 1005 |
}
|
|
|
|
|
|
|
| 1006 |
}
|
| 1007 |
|
| 1008 |
if (newsCloseBtn) {
|
|
|
|
| 1026 |
chatCloseBtn.addEventListener("click", (e) => {
|
| 1027 |
e.stopPropagation();
|
| 1028 |
textMode = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
showTextChat(false);
|
| 1030 |
textModeBtn.classList.remove("active");
|
| 1031 |
});
|
|
|
|
| 1270 |
break;
|
| 1271 |
}
|
| 1272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1273 |
if (status === "user-speaking") {
|
|
|
|
|
|
|
|
|
|
| 1274 |
subtitles.classList.remove("visible");
|
| 1275 |
showTextChat(textMode);
|
| 1276 |
}
|
|
|
|
| 1385 |
setCaption("ALL SEATS TAKEN, TRY AGAIN", "error");
|
| 1386 |
} else if (code === "join-expired") {
|
| 1387 |
setCaption("SPOT EXPIRED, TAP TO RETRY", "error");
|
| 1388 |
+
} else if (code !== "aborted") {
|
| 1389 |
+
console.error(err);
|
| 1390 |
+
setCaption("COULD NOT CONNECT, TAP TO RETRY", "error");
|
| 1391 |
}
|
| 1392 |
+
await endSession(true);
|
| 1393 |
return null;
|
| 1394 |
}
|
| 1395 |
}
|
| 1396 |
|
| 1397 |
+
async function startVoiceSession() {
|
| 1398 |
if (sessionInProgress) return;
|
| 1399 |
sessionInProgress = true;
|
| 1400 |
+
|
| 1401 |
await stage.resume();
|
| 1402 |
|
| 1403 |
+
let micStream;
|
| 1404 |
+
if (new URLSearchParams(location.search).has("fakemic")) {
|
| 1405 |
+
const ctx = stage.audioCtx;
|
| 1406 |
+
micStream = ctx.createMediaStreamDestination().stream;
|
| 1407 |
+
} else {
|
| 1408 |
+
try {
|
| 1409 |
+
micStream = await navigator.mediaDevices.getUserMedia({
|
| 1410 |
+
audio: {
|
| 1411 |
+
echoCancellation: true,
|
| 1412 |
+
noiseSuppression: true,
|
| 1413 |
+
autoGainControl: true
|
| 1414 |
+
}
|
| 1415 |
+
});
|
| 1416 |
+
} catch {
|
| 1417 |
+
setCaption("MIC BLOCKED", "error");
|
| 1418 |
+
sessionInProgress = false;
|
| 1419 |
+
return;
|
|
|
|
| 1420 |
}
|
| 1421 |
}
|
| 1422 |
|
|
|
|
| 1569 |
}
|
| 1570 |
_topicTagsRendered = false;
|
| 1571 |
try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1572 |
});
|
| 1573 |
|
| 1574 |
c.addEventListener("toolcall", (e) => {
|
|
|
|
| 1591 |
sessionInProgress = false;
|
| 1592 |
autoGreetingSent = false;
|
| 1593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1594 |
if (c) {
|
| 1595 |
if (c.options.micStream) {
|
| 1596 |
for (const track of c.options.micStream?.getTracks() ?? []) {
|
|
|
|
| 1619 |
mainBtn.addEventListener("click", () => {
|
| 1620 |
if (mainAction === "start") {
|
| 1621 |
if (sessionInProgress) return;
|
| 1622 |
+
if (new URLSearchParams(location.search).has("fakemic")) {
|
| 1623 |
+
void startTextSession();
|
| 1624 |
+
} else {
|
| 1625 |
+
void startVoiceSession();
|
| 1626 |
+
}
|
| 1627 |
} else if (mainAction === "join") {
|
| 1628 |
stage.resume();
|
| 1629 |
client?.join();
|
|
|
|
| 1647 |
|
| 1648 |
chatSendBtn.addEventListener("click", () => sendTextMessage());
|
| 1649 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
chatInput.addEventListener("keypress", (e) => {
|
| 1651 |
if (e.key === "Enter") {
|
| 1652 |
sendTextMessage();
|
|
|
|
| 1724 |
} catch (e) { console.warn("[boot] config:", e); }
|
| 1725 |
directUrlRow.hidden = !config.allowDirect;
|
| 1726 |
|
| 1727 |
+
// Keep using the fake microphone (no real getUserMedia) so we never hit
|
| 1728 |
+
// the "MIC BLOCKED" permission prompt. Restored from the original boot().
|
| 1729 |
+
try {
|
| 1730 |
+
const url = new URL(location.href);
|
| 1731 |
+
url.searchParams.set("fakemic", "1");
|
| 1732 |
+
history.replaceState(null, "", url.href);
|
| 1733 |
+
} catch (e) { console.warn("[boot] fakemic:", e); }
|
| 1734 |
|
| 1735 |
try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
|
| 1736 |
try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
|
|
|
|
| 1738 |
makeDraggable(); makeResizable(); makeNewsDraggable(); makeNewsResizable(); buildVnewsPanel();
|
| 1739 |
} catch (e) { console.warn("[boot] ui:", e); }
|
| 1740 |
|
| 1741 |
+
// 4) Fill the picker (avatar cards + voice chips, topics).
|
| 1742 |
+
try { await buildPicker(); } catch (e) { console.warn("[boot] buildPicker:", e); }
|
| 1743 |
try { void renderTopicTags(); } catch (e) { console.warn("[boot] topics:", e); }
|
| 1744 |
try { updateAvatarThumbPreview(); } catch (e) { console.warn("[boot] thumb:", e); }
|
| 1745 |
}
|
|
|
|
| 1855 |
function saveThumbs(map) { localStorage.setItem(THUMB_KEY, JSON.stringify(map)); }
|
| 1856 |
function serverThumbUrl(name) {
|
| 1857 |
if (!name) return "";
|
| 1858 |
+
return `/avatars/${name}.thumb.png`;
|
|
|
|
|
|
|
| 1859 |
}
|
| 1860 |
+
// Show the avatar's representative image: prefer the freshly-captured local
|
| 1861 |
+
// copy (instant), fall back to the server-stored thumbnail (uploaded earlier).
|
|
|
|
| 1862 |
function updateAvatarThumbPreview() {
|
| 1863 |
if (!avatarThumbPreview) return;
|
| 1864 |
+
const name = chatAvatarSelect.value;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1865 |
const map = loadThumbs();
|
| 1866 |
+
const local = map[name] || "";
|
| 1867 |
+
if (local) {
|
| 1868 |
+
avatarThumbPreview.src = local;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1869 |
avatarThumbPreview.hidden = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
return;
|
| 1871 |
}
|
| 1872 |
+
const srv = serverThumbUrl(name);
|
| 1873 |
+
if (srv) {
|
| 1874 |
+
avatarThumbPreview.onerror = () => { avatarThumbPreview.hidden = true; };
|
| 1875 |
+
avatarThumbPreview.src = srv;
|
| 1876 |
+
avatarThumbPreview.hidden = false;
|
| 1877 |
return;
|
| 1878 |
}
|
| 1879 |
+
avatarThumbPreview.hidden = true;
|
| 1880 |
}
|
| 1881 |
if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
|
| 1882 |
|
|
|
|
| 1887 |
const snapSetAvatar = document.getElementById("snap-setavatar");
|
| 1888 |
let _latestSnap = null;
|
| 1889 |
if (snapBtn) {
|
| 1890 |
+
snapBtn.addEventListener("click", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1891 |
if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
|
| 1892 |
const url = stage.capturePNG();
|
| 1893 |
if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
|
| 1894 |
+
_latestSnap = url;
|
| 1895 |
+
snapPreview.src = url;
|
| 1896 |
if (snapDialog.showModal) snapDialog.showModal();
|
| 1897 |
else snapDialog.setAttribute("open", "");
|
| 1898 |
});
|
|
|
|
| 1911 |
if (!_latestSnap) return;
|
| 1912 |
const key = settings.avatar || "";
|
| 1913 |
if (!key) { setCaption("CHON 1 AVATAR TRUOC", "error"); return; }
|
|
|
|
|
|
|
|
|
|
| 1914 |
snapSetAvatar.disabled = true;
|
| 1915 |
snapSetAvatar.textContent = "⏳ Đang lưu…";
|
| 1916 |
try {
|
| 1917 |
const resp = await fetch("/api/avatar-thumbnail", {
|
| 1918 |
method: "POST",
|
| 1919 |
headers: { "Content-Type": "application/json" },
|
| 1920 |
+
body: JSON.stringify({ avatar: key, dataUrl: _latestSnap }),
|
| 1921 |
});
|
|
|
|
| 1922 |
const data = await resp.json().catch(() => ({}));
|
|
|
|
| 1923 |
if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
|
| 1924 |
// Cache locally too (works offline + instant preview)
|
| 1925 |
const map = loadThumbs();
|
| 1926 |
+
map[key] = _latestSnap;
|
| 1927 |
+
saveThumbs(map);
|
| 1928 |
+
// Prefer the server URL now that it is persisted.
|
| 1929 |
+
if (data.url) { avatarThumbPreview.src = data.url; avatarThumbPreview.hidden = false; map[key] = data.url; saveThumbs(map); }
|
| 1930 |
+
else updateAvatarThumbPreview();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1931 |
try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
|
|
|
|
| 1932 |
setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
|
| 1933 |
} catch (e) {
|
| 1934 |
+
console.warn(e);
|
| 1935 |
setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
|
| 1936 |
} finally {
|
| 1937 |
snapSetAvatar.disabled = false;
|
|
|
|
| 1941 |
}
|
| 1942 |
|
| 1943 |
// ── Onboarding picker (choose character + voice BEFORE loading avatar) ──
|
| 1944 |
+
|
| 1945 |
+
// Render topics in the picker (shows immediately on open)
|
| 1946 |
+
async function renderTopicsPicker() {
|
| 1947 |
+
const box = document.getElementById("picker-topics");
|
| 1948 |
+
if (!box) return;
|
| 1949 |
+
let topics = [];
|
| 1950 |
+
try { topics = await getTrendingTopics(); } catch {}
|
| 1951 |
+
if (!topics.length) topics = TREND_KEYWORDS.map(([s,l]) => ({slug:s, label:l}));
|
| 1952 |
+
box.innerHTML = "";
|
| 1953 |
+
for (const c of topics) {
|
| 1954 |
+
const chip = document.createElement("button");
|
| 1955 |
+
chip.type = "button";
|
| 1956 |
+
chip.className = "topic-chip";
|
| 1957 |
+
chip.dataset.slug = c.slug;
|
| 1958 |
+
chip.textContent = "#" + c.label.replace(/\s+/g, "_");
|
| 1959 |
+
chip.addEventListener("click", () => void openTopic(c.slug, c.label));
|
| 1960 |
+
box.appendChild(chip);
|
| 1961 |
+
}
|
| 1962 |
+
}
|
| 1963 |
+
|
| 1964 |
+
async function buildPicker() {
|
| 1965 |
const grid = document.getElementById("picker-avatars");
|
| 1966 |
const voices = document.getElementById("picker-voices");
|
| 1967 |
if (!grid || !voices) return;
|
|
|
|
| 1982 |
_img.alt = label;
|
| 1983 |
_img.loading = "lazy";
|
| 1984 |
_img.addEventListener("error", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1985 |
_img.remove();
|
| 1986 |
thumb.textContent = label.slice(0, 1).toUpperCase();
|
| 1987 |
});
|
| 1988 |
const _srv = serverThumbUrl(name);
|
| 1989 |
+
_img.src = (_srv && !_map[name]) ? _srv : (_map[name] || _srv);
|
|
|
|
| 1990 |
thumb.appendChild(_img);
|
| 1991 |
const span = document.createElement("span");
|
| 1992 |
span.className = "avatar-card-name";
|
|
|
|
| 2015 |
});
|
| 2016 |
voices.appendChild(chip);
|
| 2017 |
}
|
| 2018 |
+
await renderTopicsPicker();
|
| 2019 |
function showPicker() {
|
| 2020 |
+
await renderTopicsPicker();
|
| 2021 |
const p = document.getElementById("picker");
|
| 2022 |
if (p) p.hidden = false;
|
|
|
|
| 2023 |
function hidePicker() {
|
| 2024 |
const p = document.getElementById("picker");
|
| 2025 |
if (p) p.hidden = true;
|
|
|
|
| 2026 |
async function startWithSelection() {
|
| 2027 |
hidePicker();
|
| 2028 |
+
setCaption("WAKING HER UP…");
|
| 2029 |
setMainButton("busy", "Loading…");
|
| 2030 |
loading.classList.remove("done");
|
| 2031 |
loading.classList.add("active");
|
| 2032 |
loading.textContent = "Loading avatar...";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2033 |
const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
|
| 2034 |
let ok = false;
|
| 2035 |
try {
|
|
|
|
| 2037 |
avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
|
| 2038 |
onprogress: (ev) => {
|
| 2039 |
if (ev.lengthComputable) {
|
| 2040 |
+
loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2041 |
});
|
| 2042 |
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
|
| 2043 |
await Promise.race([initPromise, timeout]);
|
|
|
|
| 2046 |
} catch (err) {
|
| 2047 |
console.error("[startWithSelection] init failed/timeout:", err);
|
| 2048 |
} finally {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; }
|
| 2050 |
newsSeq = preFetchedNews.slice();
|
| 2051 |
newsSeqIdx = -1;
|
| 2052 |
newsTopicQuery = "";
|
| 2053 |
loading.classList.remove("active");
|
| 2054 |
if (ok) setCaption(CAPTIONS.idle);
|
| 2055 |
+
else setCaption("AVATAR KHÔNG HIỂN THỊ — THỬ CHỌN AVATAR KHÁC", "error");
|
| 2056 |
setMainButton("start", "Start talking");
|
|
|
|
|
|
|
| 2057 |
const pickerStartBtn = document.getElementById("picker-start");
|
| 2058 |
if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
|
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
|
@@ -562,6 +562,9 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui,
|
|
| 562 |
.avatar-card-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; }
|
| 563 |
.avatar-card-thumb { width: 84px; height: 84px; border-radius: 50%; overflow: hidden; background: linear-gradient(135deg, #1b2230, #2a3550); display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 600; color: #cfe9ff; border: 1px solid rgba(255,255,255,.12); }
|
| 564 |
.avatar-card-name { font-size: 12px; line-height: 1.2; }
|
|
|
|
|
|
|
|
|
|
| 565 |
.picker-voices { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 566 |
.voice-chip { border: 1px solid rgba(255,255,255,.15); background: #0c0f17; color: #e8edf5; border-radius: 999px; padding: 7px 14px; cursor: pointer; font-size: 13px; }
|
| 567 |
.voice-chip.selected { background: #22d3ee; color: #04222a; border-color: #22d3ee; font-weight: 600; }
|
|
@@ -572,54 +575,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 |
-
}
|
| 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 |
-
}
|
|
|
|
| 562 |
.avatar-card-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; }
|
| 563 |
.avatar-card-thumb { width: 84px; height: 84px; border-radius: 50%; overflow: hidden; background: linear-gradient(135deg, #1b2230, #2a3550); display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 600; color: #cfe9ff; border: 1px solid rgba(255,255,255,.12); }
|
| 564 |
.avatar-card-name { font-size: 12px; line-height: 1.2; }
|
| 565 |
+
.picker-voices,
|
| 566 |
+
.picker-topics { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 567 |
+
.picker-topics { margin-top: 4px; }
|
| 568 |
.picker-voices { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 569 |
.voice-chip { border: 1px solid rgba(255,255,255,.15); background: #0c0f17; color: #e8edf5; border-radius: 999px; padding: 7px 14px; cursor: pointer; font-size: 13px; }
|
| 570 |
.voice-chip.selected { background: #22d3ee; color: #04222a; border-color: #22d3ee; font-weight: 600; }
|
|
|
|
| 575 |
.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; }
|
| 576 |
.topic-chip:hover { background: rgba(34,211,238,.2); transform: translateY(-1px); }
|
| 577 |
.topic-chip.active { background: #22d3ee; color: #04222a; font-weight: 600; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
DELETED
|
@@ -1,613 +0,0 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
--bg: #0a0b10;
|
| 3 |
-
--bg-elev: #13151c;
|
| 4 |
-
--bg-elev-2: #1b1e29;
|
| 5 |
-
--border: rgba(255, 255, 255, 0.08);
|
| 6 |
-
--border-strong: rgba(255, 255, 255, 0.16);
|
| 7 |
-
--text: #f5f6fa;
|
| 8 |
-
--text-dim: rgba(245, 246, 250, 0.65);
|
| 9 |
-
--text-faint: rgba(245, 246, 250, 0.42);
|
| 10 |
-
--error: #ff6a75;
|
| 11 |
-
--live: #22d3ee;
|
| 12 |
-
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 13 |
-
--radius-sm: 8px;
|
| 14 |
-
--radius-md: 14px;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
* { box-sizing: border-box; }
|
| 18 |
-
html, body { height: 100%; margin: 0; }
|
| 19 |
-
body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, -apple-system, sans-serif; overflow: hidden; }
|
| 20 |
-
#app { position: relative; height: 100dvh; }
|
| 21 |
-
#stage { position: absolute; inset: 0; }
|
| 22 |
-
#stage canvas { display: block; }
|
| 23 |
-
[hidden] { display: none !important; }
|
| 24 |
-
#loading { position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: 12px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-faint); background: var(--bg); transition: opacity 0.6s ease; pointer-events: none; }
|
| 25 |
-
#loading.done { opacity: 0; }
|
| 26 |
-
|
| 27 |
-
#topbar { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 20px; pointer-events: none; }
|
| 28 |
-
#topbar > * { pointer-events: auto; }
|
| 29 |
-
#identity h1 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: 0.01em; }
|
| 30 |
-
#identity p { margin: 4px 0 0; max-width: 340px; font-size: 12px; line-height: 1.45; color: var(--text-faint); }
|
| 31 |
-
#identity strong { color: var(--text-dim); font-weight: 500; }
|
| 32 |
-
|
| 33 |
-
.icon-btn { display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-elev); color: var(--text-dim); cursor: pointer; touch-action: manipulation; transition: color 0.15s ease, border-color 0.15s ease; }
|
| 34 |
-
.icon-btn:hover { color: var(--text); border-color: var(--border-strong); }
|
| 35 |
-
.icon-btn.active { color: var(--live); border-color: var(--border-strong); }
|
| 36 |
-
.icon-btn.small { width: 28px; height: 28px; }
|
| 37 |
-
|
| 38 |
-
#main-btn { min-width: 190px; height: 46px; padding: 0 26px; border: none; border-radius: 23px; background: var(--text); color: var(--bg); font-family: Inter, system-ui, sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; touch-action: manipulation; transition: transform 0.12s ease, opacity 0.15s ease; }
|
| 39 |
-
#main-btn:hover:not(:disabled) { transform: translateY(-1px); }
|
| 40 |
-
#main-btn:disabled { opacity: 0.45; cursor: default; }
|
| 41 |
-
#main-btn.live { background: var(--bg-elev); color: var(--text); border: 1px solid var(--border-strong); }
|
| 42 |
-
|
| 43 |
-
#controls { position: absolute; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 0 20px calc(22px + env(safe-area-inset-bottom)); z-index: 100; }
|
| 44 |
-
#buttons { display: flex; align-items: center; gap: 10px; }
|
| 45 |
-
#caption { font-family: var(--font-mono); font-size: 11px; font-weight: 500; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-faint); transition: color 0.2s ease; }
|
| 46 |
-
#caption.live { color: var(--live); }
|
| 47 |
-
#caption.error { color: var(--error); }
|
| 48 |
-
|
| 49 |
-
#subtitles { position: absolute; left: 50%; bottom: 118px; transform: translateX(-50%); max-width: min(640px, calc(100vw - 48px)); padding: 10px 16px; border-radius: var(--radius-md); background: rgba(10, 11, 16, 0.72); backdrop-filter: blur(6px); border: 1px solid var(--border); font-size: 15px; line-height: 1.5; text-align: center; text-wrap: balance; color: var(--text); opacity: 0; transition: opacity 0.25s ease; pointer-events: none; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
| 50 |
-
#subtitles.visible { opacity: 1; }
|
| 51 |
-
|
| 52 |
-
#settings { width: min(440px, calc(100vw - 40px)); padding: 22px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--bg-elev); color: var(--text); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.32); }
|
| 53 |
-
#settings::backdrop { background: rgba(5, 6, 9, 0.6); backdrop-filter: blur(2px); }
|
| 54 |
-
#settings h2 { margin: 0 0 16px; font-size: 15px; font-weight: 600; }
|
| 55 |
-
#settings label { display: block; margin-bottom: 14px; font-size: 12px; color: var(--text-dim); }
|
| 56 |
-
#settings select, #settings textarea, #settings input { display: block; width: 100%; margin-top: 6px; padding: 9px 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-elev-2); color: var(--text); font-family: inherit; font-size: 13px; }
|
| 57 |
-
#settings select:focus-visible, #settings textarea:focus-visible, #settings input:focus-visible { outline: 1px solid var(--text-dim); outline-offset: 1px; }
|
| 58 |
-
#settings textarea { resize: vertical; }
|
| 59 |
-
#settings .check-row { display: flex; align-items: center; gap: 9px; }
|
| 60 |
-
#settings .check-row input { display: inline-block; width: auto; margin: 0; accent-color: var(--text); }
|
| 61 |
-
.dialog-actions { display: flex; justify-content: flex-end; margin-top: 6px; }
|
| 62 |
-
.dialog-actions .primary { padding: 9px 20px; border: none; border-radius: var(--radius-sm); background: var(--text); color: var(--bg); font-weight: 600; font-size: 13px; cursor: pointer; }
|
| 63 |
-
|
| 64 |
-
/* ── Draggable / Resizable Chat ───────────────────────────────────────────── */
|
| 65 |
-
#text-chat {
|
| 66 |
-
position: fixed;
|
| 67 |
-
z-index: 9999;
|
| 68 |
-
top: 100px;
|
| 69 |
-
left: 20px;
|
| 70 |
-
width: min(380px, calc(100vw - 40px));
|
| 71 |
-
height: 50vh;
|
| 72 |
-
display: flex;
|
| 73 |
-
flex-direction: column;
|
| 74 |
-
background: rgba(10, 11, 16, 0.92);
|
| 75 |
-
backdrop-filter: blur(10px);
|
| 76 |
-
-webkit-backdrop-filter: blur(10px);
|
| 77 |
-
border: 1px solid var(--border-strong);
|
| 78 |
-
border-radius: var(--radius-md);
|
| 79 |
-
overflow: hidden;
|
| 80 |
-
min-height: 2.5rem;
|
| 81 |
-
max-height: 80vh;
|
| 82 |
-
cursor: default;
|
| 83 |
-
resize: none;
|
| 84 |
-
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
| 85 |
-
touch-action: none;
|
| 86 |
-
}
|
| 87 |
-
#text-chat.dragging { cursor: grabbing !important; user-select: none !important; }
|
| 88 |
-
#text-chat.resizing { user-select: none !important; }
|
| 89 |
-
|
| 90 |
-
/* Chat header bar — drag handle */
|
| 91 |
-
#chat-header {
|
| 92 |
-
display: flex;
|
| 93 |
-
align-items: center;
|
| 94 |
-
justify-content: space-between;
|
| 95 |
-
padding: 8px 12px;
|
| 96 |
-
border-bottom: 1px solid var(--border);
|
| 97 |
-
background: var(--bg-elev);
|
| 98 |
-
cursor: grab;
|
| 99 |
-
flex-shrink: 0;
|
| 100 |
-
touch-action: none;
|
| 101 |
-
}
|
| 102 |
-
#text-chat.dragging #chat-header { cursor: grabbing !important; }
|
| 103 |
-
#chat-title { font-size: 12px; font-weight: 600; color: var(--text-dim); letter-spacing: 0.04em; }
|
| 104 |
-
#chat-header-actions { display: flex; align-items: center; gap: 6px; pointer-events: auto; }
|
| 105 |
-
#chat-avatar-select { font-size: 11px; padding: 2px 6px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-elev-2); color: var(--text); cursor: pointer; max-width: 100px; pointer-events: auto; }
|
| 106 |
-
|
| 107 |
-
#chat-messages { flex: 1; padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
|
| 108 |
-
#chat-messages::-webkit-scrollbar { width: 6px; }
|
| 109 |
-
#chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 110 |
-
.chat-message { max-width: 85%; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; word-wrap: break-word; cursor: pointer; }
|
| 111 |
-
.chat-message.user { align-self: flex-end; background: var(--live); color: var(--bg); }
|
| 112 |
-
.chat-message.assistant { align-self: flex-start; background: var(--bg-elev-2); color: var(--text); border: 1px solid var(--border); }
|
| 113 |
-
.chat-message.selected { outline: 2px solid var(--live); outline-offset: 1px; }
|
| 114 |
-
.chat-message a { color: var(--live); }
|
| 115 |
-
|
| 116 |
-
#chat-input-container { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--border); background: var(--bg-elev); flex-shrink: 0; }
|
| 117 |
-
#chat-input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px; }
|
| 118 |
-
#chat-input:focus { outline: 1px solid var(--text-dim); outline-offset: 1px; }
|
| 119 |
-
|
| 120 |
-
/* Resize handle at bottom-right corner — bigger, better contrast */
|
| 121 |
-
#chat-resize-handle {
|
| 122 |
-
position: absolute;
|
| 123 |
-
bottom: 0;
|
| 124 |
-
right: 0;
|
| 125 |
-
width: 24px;
|
| 126 |
-
height: 24px;
|
| 127 |
-
cursor: nwse-resize;
|
| 128 |
-
background: transparent;
|
| 129 |
-
z-index: 1;
|
| 130 |
-
touch-action: none;
|
| 131 |
-
}
|
| 132 |
-
#chat-resize-handle::after {
|
| 133 |
-
content: '';
|
| 134 |
-
position: absolute;
|
| 135 |
-
bottom: 3px;
|
| 136 |
-
right: 3px;
|
| 137 |
-
width: 14px;
|
| 138 |
-
height: 14px;
|
| 139 |
-
border-right: 2px solid var(--text-dim);
|
| 140 |
-
border-bottom: 2px solid var(--text-dim);
|
| 141 |
-
opacity: 0.4;
|
| 142 |
-
transition: opacity 0.15s;
|
| 143 |
-
}
|
| 144 |
-
#chat-resize-handle:hover::after { opacity: 1; }
|
| 145 |
-
#text-chat.resizing #chat-resize-handle::after { opacity: 1; }
|
| 146 |
-
|
| 147 |
-
/* ── News Box (draggable + resizable, with thumbnails) ─────────────────── */
|
| 148 |
-
#news-panel {
|
| 149 |
-
position: fixed;
|
| 150 |
-
z-index: 9998;
|
| 151 |
-
bottom: 80px;
|
| 152 |
-
right: 20px;
|
| 153 |
-
width: min(380px, calc(100vw - 40px));
|
| 154 |
-
height: min(520px, 70vh);
|
| 155 |
-
display: flex;
|
| 156 |
-
flex-direction: column;
|
| 157 |
-
background: rgba(10, 11, 16, 0.95);
|
| 158 |
-
backdrop-filter: blur(12px);
|
| 159 |
-
-webkit-backdrop-filter: blur(12px);
|
| 160 |
-
border: 1px solid var(--border-strong);
|
| 161 |
-
border-radius: var(--radius-md);
|
| 162 |
-
overflow: hidden;
|
| 163 |
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
| 164 |
-
resize: none;
|
| 165 |
-
touch-action: none;
|
| 166 |
-
min-width: 300px;
|
| 167 |
-
min-height: 180px;
|
| 168 |
-
}
|
| 169 |
-
#news-panel.dragging { cursor: grabbing !important; user-select: none !important; }
|
| 170 |
-
#news-panel.resizing { user-select: none !important; }
|
| 171 |
-
|
| 172 |
-
#news-header {
|
| 173 |
-
display: flex;
|
| 174 |
-
align-items: center;
|
| 175 |
-
justify-content: space-between;
|
| 176 |
-
padding: 10px 14px;
|
| 177 |
-
border-bottom: 1px solid var(--border);
|
| 178 |
-
background: var(--bg-elev);
|
| 179 |
-
flex-shrink: 0;
|
| 180 |
-
cursor: grab;
|
| 181 |
-
touch-action: none;
|
| 182 |
-
}
|
| 183 |
-
#news-panel.dragging #news-header { cursor: grabbing !important; }
|
| 184 |
-
|
| 185 |
-
#news-title {
|
| 186 |
-
font-size: 13px;
|
| 187 |
-
font-weight: 600;
|
| 188 |
-
color: var(--text);
|
| 189 |
-
letter-spacing: 0.02em;
|
| 190 |
-
pointer-events: none;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
#news-list {
|
| 194 |
-
flex: 1;
|
| 195 |
-
padding: 10px;
|
| 196 |
-
overflow-y: auto;
|
| 197 |
-
display: flex;
|
| 198 |
-
flex-direction: column;
|
| 199 |
-
gap: 8px;
|
| 200 |
-
}
|
| 201 |
-
#news-list::-webkit-scrollbar { width: 6px; }
|
| 202 |
-
#news-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 203 |
-
|
| 204 |
-
.news-item {
|
| 205 |
-
display: flex;
|
| 206 |
-
flex-wrap: wrap;
|
| 207 |
-
align-items: flex-start;
|
| 208 |
-
gap: 10px;
|
| 209 |
-
padding: 8px;
|
| 210 |
-
border-radius: var(--radius-sm);
|
| 211 |
-
background: var(--bg-elev-2);
|
| 212 |
-
border: 1px solid var(--border);
|
| 213 |
-
cursor: pointer;
|
| 214 |
-
transition: border-color 0.15s ease, background 0.15s ease, transform 0.1s ease;
|
| 215 |
-
}
|
| 216 |
-
.news-item:hover {
|
| 217 |
-
border-color: var(--border-strong);
|
| 218 |
-
background: var(--bg-elev);
|
| 219 |
-
}
|
| 220 |
-
.news-item:active { transform: scale(0.99); }
|
| 221 |
-
|
| 222 |
-
.news-thumb {
|
| 223 |
-
flex-shrink: 0;
|
| 224 |
-
width: 56px;
|
| 225 |
-
height: 56px;
|
| 226 |
-
border-radius: 6px;
|
| 227 |
-
overflow: hidden;
|
| 228 |
-
background: var(--bg);
|
| 229 |
-
border: 1px solid var(--border);
|
| 230 |
-
display: grid;
|
| 231 |
-
place-items: center;
|
| 232 |
-
}
|
| 233 |
-
.news-thumb img {
|
| 234 |
-
width: 100%;
|
| 235 |
-
height: 100%;
|
| 236 |
-
object-fit: cover;
|
| 237 |
-
display: block;
|
| 238 |
-
}
|
| 239 |
-
.news-thumb--fallback {
|
| 240 |
-
font-size: 16px;
|
| 241 |
-
font-weight: 600;
|
| 242 |
-
color: var(--text);
|
| 243 |
-
background: linear-gradient(135deg, #1f6feb55, #22d3ee44);
|
| 244 |
-
letter-spacing: 0.02em;
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
.news-body {
|
| 248 |
-
flex: 1 1 auto;
|
| 249 |
-
min-width: 0;
|
| 250 |
-
display: flex;
|
| 251 |
-
flex-direction: column;
|
| 252 |
-
gap: 4px;
|
| 253 |
-
overflow: hidden;
|
| 254 |
-
}
|
| 255 |
-
.news-meta {
|
| 256 |
-
display: flex;
|
| 257 |
-
align-items: center;
|
| 258 |
-
justify-content: space-between;
|
| 259 |
-
gap: 8px;
|
| 260 |
-
}
|
| 261 |
-
.news-source {
|
| 262 |
-
font-size: 11px;
|
| 263 |
-
color: var(--live);
|
| 264 |
-
font-weight: 600;
|
| 265 |
-
white-space: nowrap;
|
| 266 |
-
}
|
| 267 |
-
.news-index {
|
| 268 |
-
font-size: 10px;
|
| 269 |
-
color: var(--text-faint);
|
| 270 |
-
font-family: var(--font-mono);
|
| 271 |
-
}
|
| 272 |
-
.news-title {
|
| 273 |
-
font-size: 12.5px;
|
| 274 |
-
line-height: 1.4;
|
| 275 |
-
color: var(--text);
|
| 276 |
-
display: -webkit-box;
|
| 277 |
-
-webkit-line-clamp: 2;
|
| 278 |
-
-webkit-box-orient: vertical;
|
| 279 |
-
overflow: hidden;
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
.news-empty {
|
| 283 |
-
padding: 20px;
|
| 284 |
-
text-align: center;
|
| 285 |
-
color: var(--text-dim);
|
| 286 |
-
font-size: 13px;
|
| 287 |
-
}
|
| 288 |
-
|
| 289 |
-
/* Load more */
|
| 290 |
-
#news-loadmore {
|
| 291 |
-
flex-shrink: 0;
|
| 292 |
-
padding: 8px 10px 10px;
|
| 293 |
-
border-top: 1px solid var(--border);
|
| 294 |
-
background: var(--bg-elev);
|
| 295 |
-
position: relative;
|
| 296 |
-
}
|
| 297 |
-
.news-loadmore-btn {
|
| 298 |
-
width: 100%;
|
| 299 |
-
padding: 9px 12px;
|
| 300 |
-
border: 1px solid var(--border-strong);
|
| 301 |
-
border-radius: var(--radius-sm);
|
| 302 |
-
background: var(--bg-elev-2);
|
| 303 |
-
color: var(--text-dim);
|
| 304 |
-
font-family: inherit;
|
| 305 |
-
font-size: 12px;
|
| 306 |
-
font-weight: 500;
|
| 307 |
-
cursor: pointer;
|
| 308 |
-
transition: color 0.15s ease, border-color 0.15s ease;
|
| 309 |
-
}
|
| 310 |
-
.news-loadmore-btn:hover:not(:disabled) {
|
| 311 |
-
color: var(--text);
|
| 312 |
-
border-color: var(--live);
|
| 313 |
-
}
|
| 314 |
-
.news-loadmore-btn:disabled { opacity: 0.6; cursor: default; }
|
| 315 |
-
.news-loadmore-btn.loading::after {
|
| 316 |
-
content: "";
|
| 317 |
-
display: inline-block;
|
| 318 |
-
width: 11px;
|
| 319 |
-
height: 11px;
|
| 320 |
-
margin-left: 8px;
|
| 321 |
-
vertical-align: -1px;
|
| 322 |
-
border: 2px solid var(--text-faint);
|
| 323 |
-
border-top-color: var(--live);
|
| 324 |
-
border-radius: 50%;
|
| 325 |
-
animation: news-spin 0.7s linear infinite;
|
| 326 |
-
}
|
| 327 |
-
@keyframes news-spin { to { transform: rotate(360deg); } }
|
| 328 |
-
#news-sentinel { position: absolute; bottom: 0; left: 0; right: 0; height: 1px; pointer-events: none; }
|
| 329 |
-
|
| 330 |
-
/* News resize handle (bottom-right) */
|
| 331 |
-
#news-resize-handle {
|
| 332 |
-
position: absolute;
|
| 333 |
-
bottom: 0;
|
| 334 |
-
right: 0;
|
| 335 |
-
width: 24px;
|
| 336 |
-
height: 24px;
|
| 337 |
-
cursor: nwse-resize;
|
| 338 |
-
background: transparent;
|
| 339 |
-
z-index: 2;
|
| 340 |
-
touch-action: none;
|
| 341 |
-
}
|
| 342 |
-
#news-resize-handle::after {
|
| 343 |
-
content: '';
|
| 344 |
-
position: absolute;
|
| 345 |
-
bottom: 3px;
|
| 346 |
-
right: 3px;
|
| 347 |
-
width: 14px;
|
| 348 |
-
height: 14px;
|
| 349 |
-
border-right: 2px solid var(--text-dim);
|
| 350 |
-
border-bottom: 2px solid var(--text-dim);
|
| 351 |
-
opacity: 0.4;
|
| 352 |
-
transition: opacity 0.15s;
|
| 353 |
-
}
|
| 354 |
-
#news-resize-handle:hover::after { opacity: 1; }
|
| 355 |
-
#news-panel.resizing #news-resize-handle::after { opacity: 1; }
|
| 356 |
-
|
| 357 |
-
/* ── News image card inside chat ──────────────────────────────────────── */
|
| 358 |
-
.news-card {
|
| 359 |
-
margin-top: 8px;
|
| 360 |
-
display: flex;
|
| 361 |
-
gap: 10px;
|
| 362 |
-
align-items: center;
|
| 363 |
-
padding: 8px;
|
| 364 |
-
border-radius: var(--radius-sm);
|
| 365 |
-
background: var(--bg);
|
| 366 |
-
border: 1px solid var(--border);
|
| 367 |
-
cursor: pointer;
|
| 368 |
-
transition: border-color 0.15s ease;
|
| 369 |
-
max-width: 100%;
|
| 370 |
-
}
|
| 371 |
-
.news-card:hover { border-color: var(--border-strong); }
|
| 372 |
-
.news-card-img {
|
| 373 |
-
flex-shrink: 0;
|
| 374 |
-
width: 64px;
|
| 375 |
-
height: 64px;
|
| 376 |
-
border-radius: 6px;
|
| 377 |
-
object-fit: cover;
|
| 378 |
-
display: block;
|
| 379 |
-
background: var(--bg-elev);
|
| 380 |
-
border: 1px solid var(--border);
|
| 381 |
-
}
|
| 382 |
-
.news-card-img--fallback {
|
| 383 |
-
display: grid;
|
| 384 |
-
place-items: center;
|
| 385 |
-
font-size: 18px;
|
| 386 |
-
font-weight: 600;
|
| 387 |
-
color: var(--text);
|
| 388 |
-
background: linear-gradient(135deg, #1f6feb55, #22d3ee44);
|
| 389 |
-
}
|
| 390 |
-
.news-card-meta {
|
| 391 |
-
flex: 1;
|
| 392 |
-
min-width: 0;
|
| 393 |
-
display: flex;
|
| 394 |
-
flex-direction: column;
|
| 395 |
-
gap: 3px;
|
| 396 |
-
}
|
| 397 |
-
.news-card-source {
|
| 398 |
-
font-size: 10.5px;
|
| 399 |
-
font-weight: 600;
|
| 400 |
-
color: var(--live);
|
| 401 |
-
white-space: nowrap;
|
| 402 |
-
}
|
| 403 |
-
.news-card-title {
|
| 404 |
-
font-size: 12px;
|
| 405 |
-
line-height: 1.4;
|
| 406 |
-
color: var(--text);
|
| 407 |
-
display: -webkit-box;
|
| 408 |
-
-webkit-line-clamp: 3;
|
| 409 |
-
-webkit-box-orient: vertical;
|
| 410 |
-
overflow: hidden;
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
/* ── VNEWS control panel ─────────────────────────────────────────────── */
|
| 414 |
-
#vnews-panel {
|
| 415 |
-
position: fixed;
|
| 416 |
-
z-index: 9997;
|
| 417 |
-
top: 80px;
|
| 418 |
-
right: 20px;
|
| 419 |
-
width: 280px;
|
| 420 |
-
display: flex;
|
| 421 |
-
flex-direction: column;
|
| 422 |
-
gap: 10px;
|
| 423 |
-
padding: 14px;
|
| 424 |
-
background: rgba(10, 11, 16, 0.95);
|
| 425 |
-
backdrop-filter: blur(12px);
|
| 426 |
-
-webkit-backdrop-filter: blur(12px);
|
| 427 |
-
border: 1px solid var(--border-strong);
|
| 428 |
-
border-radius: var(--radius-md);
|
| 429 |
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
| 430 |
-
touch-action: none;
|
| 431 |
-
}
|
| 432 |
-
#vnews-panel.dragging { user-select: none !important; }
|
| 433 |
-
#vnews-header {
|
| 434 |
-
display: flex;
|
| 435 |
-
align-items: center;
|
| 436 |
-
justify-content: space-between;
|
| 437 |
-
cursor: grab;
|
| 438 |
-
touch-action: none;
|
| 439 |
-
}
|
| 440 |
-
#vnews-title {
|
| 441 |
-
font-size: 14px;
|
| 442 |
-
font-weight: 700;
|
| 443 |
-
letter-spacing: 0.03em;
|
| 444 |
-
color: var(--live);
|
| 445 |
-
}
|
| 446 |
-
.vnews-btn {
|
| 447 |
-
width: 100%;
|
| 448 |
-
padding: 10px 12px;
|
| 449 |
-
border: 1px solid var(--border-strong);
|
| 450 |
-
border-radius: var(--radius-sm);
|
| 451 |
-
background: var(--bg-elev-2);
|
| 452 |
-
color: var(--text);
|
| 453 |
-
font-family: inherit;
|
| 454 |
-
font-size: 13px;
|
| 455 |
-
font-weight: 600;
|
| 456 |
-
cursor: pointer;
|
| 457 |
-
transition: border-color 0.15s ease, background 0.15s ease;
|
| 458 |
-
}
|
| 459 |
-
.vnews-btn:hover:not(:disabled) {
|
| 460 |
-
border-color: var(--live);
|
| 461 |
-
background: var(--bg-elev);
|
| 462 |
-
}
|
| 463 |
-
.vnews-btn:disabled { opacity: 0.5; cursor: default; }
|
| 464 |
-
#vnews-status {
|
| 465 |
-
font-size: 11.5px;
|
| 466 |
-
color: var(--text-dim);
|
| 467 |
-
line-height: 1.4;
|
| 468 |
-
}
|
| 469 |
-
#vnews-toggle-btn { margin-left: 8px; }
|
| 470 |
-
|
| 471 |
-
@media (max-width: 600px) {
|
| 472 |
-
#identity p { display: none; }
|
| 473 |
-
#subtitles { bottom: 128px; font-size: 14px; }
|
| 474 |
-
#text-chat { top: 60px; left: 10px; width: calc(100vw - 20px); height: 40vh; }
|
| 475 |
-
#news-panel {
|
| 476 |
-
bottom: 70px;
|
| 477 |
-
right: 10px;
|
| 478 |
-
left: 10px;
|
| 479 |
-
width: calc(100vw - 20px);
|
| 480 |
-
height: 60vh;
|
| 481 |
-
min-width: 0;
|
| 482 |
-
}
|
| 483 |
-
#vnews-panel {
|
| 484 |
-
top: 60px;
|
| 485 |
-
right: 10px;
|
| 486 |
-
left: 10px;
|
| 487 |
-
width: calc(100vw - 20px);
|
| 488 |
-
}
|
| 489 |
-
}
|
| 490 |
-
|
| 491 |
-
/* ── News card action buttons (Nguồn / Hỏi) ───────────────────────────── */
|
| 492 |
-
.news-actions {
|
| 493 |
-
display: flex;
|
| 494 |
-
gap: 6px;
|
| 495 |
-
margin-top: 6px;
|
| 496 |
-
flex: 1 1 100%;
|
| 497 |
-
width: 100%;
|
| 498 |
-
}
|
| 499 |
-
.news-act-btn {
|
| 500 |
-
flex: 1 1 0;
|
| 501 |
-
min-width: 0;
|
| 502 |
-
padding: 5px 6px;
|
| 503 |
-
border: 1px solid var(--border-strong);
|
| 504 |
-
border-radius: var(--radius-sm);
|
| 505 |
-
background: var(--bg-elev-2);
|
| 506 |
-
color: var(--text-dim);
|
| 507 |
-
font-family: inherit;
|
| 508 |
-
font-size: 11px;
|
| 509 |
-
font-weight: 600;
|
| 510 |
-
cursor: pointer;
|
| 511 |
-
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
| 512 |
-
white-space: nowrap;
|
| 513 |
-
overflow: hidden;
|
| 514 |
-
text-overflow: ellipsis;
|
| 515 |
-
}
|
| 516 |
-
.news-act-btn:hover {
|
| 517 |
-
color: var(--text);
|
| 518 |
-
border-color: var(--live);
|
| 519 |
-
background: var(--bg-elev);
|
| 520 |
-
}
|
| 521 |
-
.news-act-btn--ask {
|
| 522 |
-
color: var(--live);
|
| 523 |
-
}
|
| 524 |
-
.news-act-btn--ask:hover {
|
| 525 |
-
background: var(--bg-elev);
|
| 526 |
-
}
|
| 527 |
-
|
| 528 |
-
/* News item description (mô tả ngắn dưới tiêu đề) */
|
| 529 |
-
.news-desc {
|
| 530 |
-
font-size: 11.5px;
|
| 531 |
-
line-height: 1.4;
|
| 532 |
-
color: var(--text-dim);
|
| 533 |
-
display: -webkit-box;
|
| 534 |
-
-webkit-line-clamp: 2;
|
| 535 |
-
-webkit-box-orient: vertical;
|
| 536 |
-
overflow: hidden;
|
| 537 |
-
}
|
| 538 |
-
|
| 539 |
-
/* ── Snap (capture PNG) ──────────────────────────────── */
|
| 540 |
-
#snap-preview { max-width: 100%; border-radius: 8px; display: block; margin: 0 auto; }
|
| 541 |
-
#snap-dialog img { background: #0b0e14; }
|
| 542 |
-
#snap-dialog .dialog-actions { flex-wrap: wrap; }
|
| 543 |
-
.avatar-thumb-preview {
|
| 544 |
-
width: 28px; height: 28px; border-radius: 50%;
|
| 545 |
-
object-fit: cover; vertical-align: middle; margin-left: 6px;
|
| 546 |
-
border: 1px solid rgba(255,255,255,.3);
|
| 547 |
-
}
|
| 548 |
-
|
| 549 |
-
/* ── Onboarding picker (choose character + voice BEFORE loading) ── */
|
| 550 |
-
#loading { display: none; }
|
| 551 |
-
#loading.active { display: grid; }
|
| 552 |
-
.picker { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(8,11,18,.82); backdrop-filter: blur(6px); padding: 20px; overflow: auto; }
|
| 553 |
-
.picker[hidden] { display: none; }
|
| 554 |
-
.picker-card { width: min(720px, 96vw); max-height: 92vh; overflow: auto; background: #11151f; border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 22px 24px; color: #e8edf5; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
| 555 |
-
.picker-card h1 { font-size: 20px; margin: 0 0 4px; }
|
| 556 |
-
.picker-sub { color: #9aa6b8; margin: 0 0 14px; font-size: 13px; }
|
| 557 |
-
.picker-card h2 { font-size: 14px; margin: 18px 0 10px; color: #cdd6e4; }
|
| 558 |
-
.picker-avatars { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 12px; }
|
| 559 |
-
.avatar-card { display: flex; flex-direction: column; align-items: center; gap: 8px; background: #0c0f17; border: 2px solid transparent; border-radius: 12px; padding: 10px 8px; cursor: pointer; color: #e8edf5; text-align: center; transition: border-color .15s, transform .1s; }
|
| 560 |
-
.avatar-card:hover { transform: translateY(-2px); }
|
| 561 |
-
.avatar-card.selected { border-color: #22d3ee; }
|
| 562 |
-
.avatar-card-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; }
|
| 563 |
-
.avatar-card-thumb { width: 84px; height: 84px; border-radius: 50%; overflow: hidden; background: linear-gradient(135deg, #1b2230, #2a3550); display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 600; color: #cfe9ff; border: 1px solid rgba(255,255,255,.12); }
|
| 564 |
-
.avatar-card-name { font-size: 12px; line-height: 1.2; }
|
| 565 |
-
.picker-voices { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 566 |
-
.voice-chip { border: 1px solid rgba(255,255,255,.15); background: #0c0f17; color: #e8edf5; border-radius: 999px; padding: 7px 14px; cursor: pointer; font-size: 13px; }
|
| 567 |
-
.voice-chip.selected { background: #22d3ee; color: #04222a; border-color: #22d3ee; font-weight: 600; }
|
| 568 |
-
.picker-start { margin-top: 20px; width: 100%; font-size: 15px; padding: 12px; }
|
| 569 |
-
|
| 570 |
-
/* ── Topic tags (chủ đề HOT, clickable) ── */
|
| 571 |
-
.topic-tags { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 10px; border-top: 1px solid rgba(255,255,255,.06); }
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|