Restore to commit c4c37df

#6
by bep40 - opened
.gitattributes CHANGED
@@ -51,9 +51,3 @@ public/avatars/ready_player_me_1.glb filter=lfs diff=lfs merge=lfs -text
51
  public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text
52
  public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
53
  public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
54
- public/avatars/viverse_avatar_model_209370.vrm filter=lfs diff=lfs merge=lfs -text
55
- public/avatars/scene.glb filter=lfs diff=lfs merge=lfs -text
56
- public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text
57
- public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text
58
- public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text
59
- public/avatars/lisamy1.glb filter=lfs diff=lfs merge=lfs -text
 
51
  public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text
52
  public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text
53
  public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
.gitignore CHANGED
@@ -31,4 +31,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
31
  .idea
32
 
33
  # Finder (MacOS) folder config
34
- .DS_Store
 
31
  .idea
32
 
33
  # Finder (MacOS) folder config
34
+ .DS_Store
.rebuild-trigger 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
- # Multi-service: Bun server + Python sidecar for dataset uploads
 
2
  FROM oven/bun:1-slim
3
 
4
  WORKDIR /app
5
 
6
- # Install Python and supervisor for sidecar
7
- RUN apt-get update && apt-get install -y python3 python3-pip python3-venv supervisor && rm -rf /var/lib/apt/lists/*
8
-
9
- # Create virtual environment for Python
10
- RUN python3 -m venv /app/venv
11
-
12
- # Copy package files for Bun
13
  COPY package.json bun.lock ./
 
14
  RUN bun install --frozen-lockfile --production || (sleep 3 && bun install --frozen-lockfile --production)
15
 
16
- # Copy Python requirements and install in venv
17
- COPY requirements.txt ./
18
- RUN /app/venv/bin/pip install --no-cache-dir -r requirements.txt
19
-
20
- # Copy application code
21
  COPY . .
22
 
23
- # Supervisor config
24
- RUN echo '[supervisord]\nnodaemon=true\n\n[program:sidecar]\ncommand=/app/venv/bin/python upload_service.py\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautostart=true\nautorestart=true\n\n[program:bun]\ncommand=bun index.ts\nstdout_logfile=/dev/stdout\nstdout_logfile_maxbytes=0\nstderr_logfile=/dev/stderr\nstderr_logfile_maxbytes=0\nautostart=true\nautorestart=true\n' > /etc/supervisor/conf.d/supervisord.conf
25
-
26
  ENV NODE_ENV=production
27
  ENV PORT=7860
28
- ENV SIDECAR_URL=http://localhost:7861
 
 
 
 
 
 
29
 
30
- EXPOSE 7860 7861
31
 
32
- CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
 
1
+ # Single container: Bun serves the bundled front-end AND the /api/session
2
+ # proxy. HF Spaces (sdk: docker) routes traffic to app_port, set to 7860.
3
  FROM oven/bun:1-slim
4
 
5
  WORKDIR /app
6
 
7
+ # Copy just the lockfile and package.json first for layer caching
 
 
 
 
 
 
8
  COPY package.json bun.lock ./
9
+ # Retry install once if it fails (network flakiness)
10
  RUN bun install --frozen-lockfile --production || (sleep 3 && bun install --frozen-lockfile --production)
11
 
12
+ # Copy the rest of the application
 
 
 
 
13
  COPY . .
14
 
 
 
 
15
  ENV NODE_ENV=production
16
  ENV PORT=7860
17
+ # Point at a speech-to-speech backend by setting ONE of these in the Space
18
+ # settings (index.ts prefers LOAD_BALANCER_URL):
19
+ # LOAD_BALANCER_URL a load balancer's base URL (a secret) — talks to the pool directly
20
+ # SESSION_PROXY_URL another deployment's /api to piggyback (e.g. a public demo Space)
21
+ # With neither set, the app runs in direct mode (paste a ws:// URL in Settings).
22
+
23
+ EXPOSE 7860
24
 
25
+ USER bun
26
 
27
+ CMD ["bun", "index.ts"]
README.md CHANGED
@@ -4,14 +4,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 Multi-Avatar
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- 3D talking-head avatar with real-time voice/text chat.
16
 
17
- Users can switch between ~20 `.glb` avatar models, capture snapshots as thumbnails, and chat with a Gemma 4 speech-to-speech backend.
 
 
 
 
 
 
 
4
  colorFrom: indigo
5
  colorTo: purple
6
  sdk: docker
 
7
  app_port: 7860
8
+ pinned: false
9
+ thumbnail: https://huggingface.co/spaces/victor/gemma-avatar/resolve/main/thumbnail.webp
10
+ short_description: Talk to Gemma 4 face to face, with a 3D lip-synced avatar
11
+ models:
12
+ - google/gemma-4-31B-it
13
+ - nvidia/parakeet-tdt-1.1b
14
+ - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
15
  tags:
16
  - ml-intern
17
  ---
18
 
19
+ # Gemma Avatar
20
+
21
+ Realtime voice chat with a 3D talking-head avatar. Same AI stack as the
22
+ [smolagents/hf-realtime-voice](https://huggingface.co/spaces/smolagents/hf-realtime-voice)
23
+ Space ([blog post](https://huggingface.co/blog/cerebras-gemma4-voice-ai)), but the
24
+ orb visualization is replaced by a [TalkingHead](https://github.com/met4citizen/TalkingHead)
25
+ 3D avatar with real-time audio-driven lip-sync.
26
+
27
+ ## The pipeline
28
+
29
+ ```
30
+ you speak → silero-VAD → parakeet-tdt-1.1b (STT) → gemma-4-31B-it on Cerebras → Qwen3-TTS → avatar speaks
31
+ ```
32
+
33
+ Transport is the OpenAI Realtime GA protocol over WebSocket against Hugging
34
+ Face's speech-to-speech backend: mic PCM16 @ 16 kHz goes up as
35
+ `input_audio_buffer.append`, TTS PCM16 @ 16 kHz comes back as
36
+ `response.output_audio.delta`, transcripts stream alongside.
37
+
38
+ ## How the avatar works
39
+
40
+ - **Rendering / body language** — [TalkingHead](https://github.com/met4citizen/TalkingHead)
41
+ (three.js). Blinking, breathing, idle sway, moods, hand gestures, and emoji
42
+ expressions are its built-in animation system.
43
+ - **Lip-sync** — the backend sends raw PCM only (no word timings, no visemes),
44
+ so the mouth is driven from the audio itself with
45
+ [HeadAudio](https://github.com/met4citizen/HeadAudio): an AudioWorklet that
46
+ classifies MFCC frames into Oculus visemes (~50 ms latency, fully in-browser).
47
+ The s2s playback worklet is routed into TalkingHead's audio graph
48
+ (`audioAnalyzerNode → audioSpeechGainNode → reverb → speakers`) and HeadAudio
49
+ taps the speech gain node.
50
+ - **The model plays the avatar** — three function tools are declared to the
51
+ backend: `set_mood`, `make_hand_gesture`, `make_facial_expression`. Gemma
52
+ calls them mid-conversation (smiles when greeting, shrugs when unsure,
53
+ thumbs-up when agreeing).
54
+ - **Choreography** — client statuses drive presence: the avatar makes eye
55
+ contact when you start talking, gestures with its hands on new utterances,
56
+ and barge-in clears the playback buffer so the mouth settles instantly.
57
+
58
+ ## Run it
59
+
60
+ ```bash
61
+ bun install
62
+
63
+ # Pick a backend (one of):
64
+ LOAD_BALANCER_URL=https://… bun run dev # a speech-to-speech load balancer
65
+ SESSION_PROXY_URL=https://…/api bun run dev # piggyback another deployment's /api (dev)
66
+ bun run dev # direct mode: paste a ws:// URL in Settings
67
+ ```
68
+
69
+ Open http://localhost:3000 and tap **Start talking**.
70
+
71
+ \`?fakemic=1\` starts a session with a silent synthetic mic (useful for testing
72
+ the full loop without a microphone — trigger a reply from the console with
73
+ \`getClient().requestResponse()\`).
74
+
75
+ ## Layout
76
+
77
+ ```
78
+ index.ts Bun server: HTML import + /api/session proxy + static assets
79
+ index.html App shell (avatar hero, caption, subtitles, settings)
80
+ src/app.js Session wiring, tool executor, UI state
81
+ src/avatar.js AvatarStage: TalkingHead + HeadAudio + choreography
82
+ src/s2s/s2s-ws-client.js Realtime WS client (vendored from the Space; orb removed,
83
+ injectable output node + worklet base URL + shared-ctx close)
84
+ src/s2s/codec.js PCM/base64 + transcript helpers (vendored, unchanged)
85
+ src/vendor/headaudio.min.mjs HeadAudio node class (bundled)
86
+ public/worklets/ mic-capture + audio-playback AudioWorklets (vendored, unchanged)
87
+ public/vendor/ HeadAudio worklet processor + viseme model (runtime-loaded)
88
+ public/avatars/brunette.glb Default avatar (Ready Player Me; CC BY-NC 4.0 — non-commercial)
89
+ ```
90
 
91
+ ## Notes
92
 
93
+ - The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme
94
+ blend shapes. Ready Player Me avatars work with
95
+ \`?morphTargets=ARKit,Oculus%20Visemes\` on the GLB URL.
96
+ - TalkingHead owns the AudioContext; the s2s client is handed \`head.audioCtx\`
97
+ and never closes it.
98
+ - Everything animation-related runs on requestAnimationFrame — a backgrounded
99
+ tab freezes the avatar (audio keeps playing).
_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
- app braces={=532 }=532
2
- renderTopicTags in startWithSelection: yes
3
- renderTopicTags appends to chatMessages: yes
4
- _topicTagsRendered flag: yes
5
- fixed #topic-tags removed: yes
6
- hidden rule removed: yes
7
- topic-tags base rule present: yes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 filePath = join(process.cwd(), "public", dir, name);
46
- const file = Bun.file(filePath);
47
  return new Response(file);
48
  }
49
 
50
- /** List .glb files in public/avatars/ and check for corresponding thumbnails. */
51
  async function listAvatars() {
52
- const avatarsDir = join(process.cwd(), "public/avatars");
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]+;/gi, " ").replace(/\s+/g, " ").trim();
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
- function decodeHtmlEntities(str) {
219
- if (!str) return "";
220
- let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
221
- decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
222
- const namedEntities = {
223
- "&Ocirc;": "Ô", "&ograve;": "ò", "&Ograve;": "Ò", "&ocirc;": "ô",
224
- "&agrave;": "à", "&Agrave;": "À", "&eacute;": "é", "&Eacute;": "É",
225
- "&ugrave;": "ù", "&Ugrave;": "Ù", "&acirc;": "â", "&Acirc;": "Â",
226
- "&ecirc;": "ê", "&Ecirc;": "Ê", "&icirc;": "î", "&Icirc;": "Î",
227
- "&ocirc;": "ô", "&Ocirc;": "Ô", "&ucirc;": "û", "&Ucirc;": "Û",
228
- "&ntilde;": "ñ", "&Ntilde;": "Ñ", "&amp;": "&", "&lt;": "<", "&gt;": ">",
229
- "&quot;": '"', "&#039;": "'", "&apos;": "'", "&nbsp;": " ",
230
- "&yacute;": "ý", "&Yacute;": "Ý", "&aacute;": "á", "&Aacute;": "Á",
231
- "&eacute;": "é", "&Eacute;": "É", "&iacute;": "í", "&Iacute;": "Í",
232
- "&oacute;": "ó", "&Oacute;": "Ó", "&uacute;": "ú", "&Uacute;": "Ú",
233
- "&uuml;": "ü", "&Uuml;": "Ü", "&ouml;": "ö", "&Ouml;": "Ö",
234
- "&auml;": "ä", "&Auml;": "Ä", "&ntilde;": "ñ", "&Ntilde;": "Ñ",
235
- "&ccedil;": "ç", "&Ccedil;": "Ç", "&ntilde;": "ñ", "&Ntilde;": "Ñ",
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 = decodeHtmlEntities(tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim());
262
  const cleanLink = lm ? lm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
263
  if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) {
264
  seenLinks.add(cleanLink);
@@ -273,10 +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 = 150;
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
- const ALL_TOPICS = [
312
- { slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] },
313
- { slug: "aseancup", label: "ASEAN Cup", keywords: ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"] },
314
- { slug: "bongda", label: "Bóng đá", keywords: ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"] },
315
- { slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] },
316
- { slug: "ai", label: "AI", keywords: ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi k3", "kimi"] },
317
- { slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] },
318
- { slug: "kinhte", label: "Kinh tế", keywords: ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"] },
319
- { slug: "thoisu", label: "Thời sự", keywords: ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"] },
320
- { slug: "giao-duc", label: "Giáo dục", keywords: ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"] },
321
- { slug: "suc-khoe", label: "Sức khỏe", keywords: ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"] },
322
- { slug: "du-lich", label: "Du lịch", keywords: ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"] },
323
- { slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] },
324
- { slug: "thegioi", label: "Thế giới", keywords: ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"] },
325
- { slug: "doisong", label: "Đời sống", keywords: ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"] },
326
- { slug: "giai-tri", label: "Giải trí", keywords: ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"] },
327
- { slug: "am-nhac", label: "Âm nhạc", keywords: ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"] },
328
- { slug: "phap-luat", label: "Pháp luật", keywords: ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"] },
329
- ];
330
-
331
- const VI_STOPWORDS = new Set([
332
- "và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia",
333
- "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi",
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 SLUG_MAP = {
435
- "worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"],
436
- "aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"],
437
- "bongda": ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"],
438
- "thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"],
439
- "ai": ["trí tuệ nhân tạo", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "gpt", "qwen", "kimi", "chatgpt", "gemini", "openai", "claude", "tạo ảnh", "tạo video", "ứng dụng ai", "ai tạo sinh", "thông minh nhân tạo", "mô hình ngôn ngữ", "học sâu", "học máy", "mạng nơ-ron", "neural network", "kimik3", "kimi"],
440
- "congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"],
441
- "kinhte": ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"],
442
- "thoisu": ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"],
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, " ").trim();
505
- return text.slice(0, 4000);
 
 
506
  } catch (e) {
507
  console.warn(`[Article] Failed to fetch ${url}: ${e}`);
508
  return null;
509
  }
510
  }
511
 
 
512
  function formatVietnameseTime(now) {
513
  let h = now.getHours();
514
  const m = now.getMinutes();
515
  let part;
516
  if (h >= 0 && h < 5) part = "đêm";
517
  else if (h < 10) part = "sáng";
518
- else if (h < 13) part = "trưa";
519
  else if (h < 18) part = "chiều";
520
- else part = "tối";
521
- const hh = h === 0 ? 12 : h;
522
  if (m === 0) return `${hh}h ${part}`;
523
  return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
524
  }
525
 
526
- // Upload thumbnails to sidecar service (no Space rebuild)
527
- async function uploadToSidecar(path: string, buf: Uint8Array) {
528
- const sidecarUrl = Bun.env.SIDECAR_URL || "http://localhost:7861";
529
- const base64 = Buffer.from(buf).toString("base64");
530
-
 
 
 
 
531
  try {
532
- const resp = await fetch(`${sidecarUrl}/upload`, {
533
  method: "POST",
534
- headers: { "Content-Type": "application/json" },
535
- body: JSON.stringify({ path, content: base64 }),
536
  });
537
- if (!resp.ok) {
538
- const txt = await resp.text();
539
- console.warn(`[sidecar] upload failed (${resp.status}): ${txt}`);
540
- return false;
541
- }
542
- const data = await resp.json();
543
- console.log(`[sidecar] queued ${path} for dataset upload`);
544
- return true;
545
- } catch (err) {
546
- console.warn(`[sidecar] error: ${err}`);
547
- return false;
 
 
 
 
 
 
548
  }
549
- }
550
-
551
- async function uploadToHub(path: string, buf: Uint8Array) {
552
- // Use sidecar service for dataset upload (no Space rebuild)
553
- return uploadToSidecar(path, buf);
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  }
555
 
556
  const server = Bun.serve({
@@ -564,6 +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).join("\n");
623
- const SYSTEM_PROMPT = "Bạn là một BIÊN TẬP VIÊN báo chí tiếng Việt. VIẾT LẠI nội dung thành MỘT BÀI HOÀN CHỈNH bằng NGÔN TỪ CỦA BẠN.\n" +
624
- "QUY TẮC:\n1. KHÔNG sao chép nguyên văn. Diễn đạt lại (paraphrase), tóm gọn, tự nhiên.\n" +
625
- "2. KHÔNG giữ định dạng chat 'Người dùng:'/'Avatar:'.\n3. Dòng đầu là TIÊU ĐỀ (dưới 90 ký tự).\n" +
626
- "4. Tiếp theo: 1 mở đầu, 3-4 đoạn thân bài, 1 kết luận.\n5. Tiếng Việt chuẩn, KHÔNG liên kết, KHÔNG đánh số đầu dòng.\n6. Định dạng: TIÊU ĐỀ: <tiêu đề>\n\n<nội dung>";
 
 
 
 
 
 
 
627
  const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`;
628
  const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || "";
629
  const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct";
630
  let out = "";
 
631
  if (SUMMARY_ENDPOINT) {
632
  try {
633
- const sresp = await fetch(SUMMARY_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(90000) });
634
- if (sresp.ok) { const sdata = await sresp.json(); if (sdata && sdata.body) out = (sdata.body || "").trim(); }
 
 
 
 
 
 
 
 
635
  } catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); }
636
  }
 
637
  if (!out) {
638
  try {
639
- const rresp = await fetch("https://router.huggingface.co/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: MODEL, messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: USER_PROMPT }], max_tokens: 800, temperature: 0.8, top_p: 0.9 }), signal: AbortSignal.timeout(45000) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
640
  if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); }
641
  } catch (e) { console.warn(`[summarize] router failed: ${e}`); }
642
  }
643
  if (!out && Bun.env.SUMMARY_MODEL_LEGACY) {
644
  try {
645
- const lresp = await fetch(`https://api-inference.huggingface.co/models/${Bun.env.SUMMARY_MODEL_LEGACY}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ inputs: `${SYSTEM_PROMPT}\n\n${USER_PROMPT}`, parameters: { max_new_tokens: 700, return_full_text: false, temperature: 0.8, do_sample: true } }), signal: AbortSignal.timeout(45000) });
 
 
 
 
 
646
  if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); }
647
  } catch (e) { console.warn(`[summarize] legacy failed: ${e}`); }
648
  }
@@ -653,9 +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.map((l) => l.charAt(0).toUpperCase() + l.slice(1)).join(" ").replace(/\s+/g, " ").trim();
 
 
 
 
659
  return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true });
660
  } catch (e) {
661
  console.warn(`[summarize] ${e}`);
@@ -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
- const avatars = await Promise.all(
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(`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`);
 
 
715
  const data = await resp.json();
716
- const results = (data.query?.search ?? []).map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") }));
 
 
 
717
  return Response.json({ results });
718
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
719
  },
@@ -724,9 +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(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`);
 
 
728
  const data = await resp.json();
729
- return Response.json({ title: data.title, extract: data.extract, url: data.content_urls?.desktop?.page });
 
 
 
 
730
  } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
731
  },
732
  },
@@ -736,7 +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(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, { headers: { "User-Agent": "Mozilla/5.0" } });
 
 
 
740
  const html = await resp.text();
741
  const results = [];
742
  const linkRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
@@ -761,12 +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, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, signal: AbortSignal.timeout(8000) });
 
 
 
765
  const html = await resp.text();
766
- let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "").replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "").replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim();
 
 
 
 
 
 
 
 
 
767
  const content = text.slice(0, 3000);
768
  return Response.json({ content, url: targetUrl });
769
- } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); }
 
 
770
  },
771
  },
772
  "/api/session": {
@@ -790,80 +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\/(png|jpeg);base64,(.+)$/);
810
  if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
811
  let buf;
812
- try { buf = Buffer.from(m[2], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); }
813
- if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
814
  if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
815
- // Accept PNG (89 50 4E 47) or JPEG (FF D8 FF)
816
- const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
817
- const isJpeg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
818
- if (!isPng && !isJpeg) {
819
- return Response.json({ ok: false, error: "Not a PNG or JPEG" }, { status: 400 });
820
  }
821
  const fileName = `${avatar}.thumb.png`;
822
- const avatarsDir = join(process.cwd(), "public/avatars");
823
- const localPath = join(avatarsDir, fileName);
824
- const tmpPath = `/tmp/${fileName}`; // Use /tmp which is always writable
825
- const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
826
-
827
- // ── 1. Write to /tmp (always writable) for immediate serving ──
828
- try {
829
- await writeFile(tmpPath, uint8Buf);
830
- console.log(`[thumbnail] saved to tmp: ${tmpPath} (${buf.length} bytes)`);
831
- } catch (writeErr) {
832
- console.warn(`[thumbnail] tmp write failed: ${writeErr}`);
833
- }
834
-
835
- // ── 2. Upload to Hub for persistence ──
836
- uploadToHub(`public/avatars/${fileName}`, buf).catch((err) => {
837
- console.warn(`[thumbnail] Hub upload failed: ${err}. Thumbnail in /tmp only.`);
838
- });
839
-
840
- // ── 3. Serve from /tmp via fallback handler ──
841
- const thumbUrl = `/avatars/${fileName}`;
842
-
843
- return Response.json({ ok: true, url: thumbUrl, size: buf.length });
844
  } catch (e) {
845
- console.error(`[thumbnail] error: ${e}`);
846
- return Response.json({ ok: false, error: "Internal server error" }, { status: 500 });
847
  }
848
  },
849
  },
850
- },
851
- // fallback: serve static files from public/ and /tmp/avatars
852
- async fetch(req) {
853
- const url = new URL(req.url);
854
- // Serve avatar thumbnails from /tmp first (where they are written)
855
- if (url.pathname.startsWith("/avatars/")) {
856
- const tmpPath = join("/tmp", url.pathname.split("/").pop() || "");
857
- const tmpFile = Bun.file(tmpPath);
858
- if (await tmpFile.exists()) return new Response(tmpFile);
859
- }
860
- const filePath = join(process.cwd(), "public", url.pathname);
861
- const file = Bun.file(filePath);
862
- const exists = await file.exists();
863
- if (exists) return new Response(file);
864
- return new Response("Not Found", { status: 404 });
865
  },
866
  });
867
 
868
- console.log(`server running on port ${PORT}`);// force rebuild Sat Jul 25 04:14:36 UTC 2026
869
- // flush 60s
 
 
 
 
 
 
21
  const cookie = req.headers.get("cookie");
22
  if (cookie) headers.set("Cookie", cookie);
23
  const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers });
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  const body = await resp.text();
25
  const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
26
  const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
 
29
  }
30
 
31
  function staticFile(dir, name) {
32
+ 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 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, "&amp;")
67
- .replace(/</g, "&lt;")
68
- .replace(/>/g, "&gt;")
69
- .replace(/"/g, "&quot;")
70
- .replace(/'/g, "&#039;");
71
- }
72
-
73
- function renderAiTopicChip() {
74
- if (document.getElementById("ai-topic-inline")) return;
75
- const chatMessages = document.getElementById("chat-messages");
76
- if (!chatMessages) return;
77
-
78
- const wrap = document.createElement("div");
79
- wrap.className = "topic-tags";
80
- wrap.id = "ai-topic-inline";
81
-
82
- const chip = document.createElement("button");
83
- chip.type = "button";
84
- chip.className = "topic-chip ai-topic-chip";
85
- chip.textContent = "# AI";
86
- chip.title = "Mở tin tức AI / trí tuệ nhân tạo";
87
- chip.addEventListener("click", () => {
88
- const chatInput = document.getElementById("chat-input");
89
- if (chatInput) chatInput.value = "Tin tức AI, trí tuệ nhân tạo mới nhất";
90
- const btn = document.getElementById("chat-send-btn");
91
- if (btn) btn.click();
92
- });
93
-
94
- wrap.appendChild(chip);
95
- chatMessages.appendChild(wrap);
96
- chatMessages.scrollTop = chatMessages.scrollHeight;
97
- }
98
-
99
- function injectAiTopicIntoSearchContext(text) {
100
- const original = text || "";
101
- if (!detectAiTopic(original)) return original;
102
-
103
- const aiText = `Chủ đề AI cần ưu tiên: tin tức AI, trí tuệ nhân tạo, machine learning, deep learning, chatbot, generative AI, LLM, Kimi, Qwen, Gemini, GPT, OpenAI, Claude, ứng dụng AI, tạo ảnh bằng AI, tạo video bằng AI.`;
104
- if (original.includes(aiText)) return original;
105
-
106
- return `${original}\n\n${aiText}`;
107
- }
108
-
109
- window.AiTopicPatch = {
110
- version: AI_TOPIC_PATCH_VERSION,
111
- detectAiTopic,
112
- injectAiTopicIntoSearchContext,
113
- renderAiTopicChip,
114
- };
115
-
116
- document.addEventListener("DOMContentLoaded", () => {
117
- window.AiTopicPatch.renderAiTopicChip();
118
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app.js CHANGED
@@ -1,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
- let items;
114
- // AI topic -> use VNEWS AI feed for better results
115
- if (slug === "ai") {
116
- items = await getVnewsAIFeed(NEWS_PAGE_SIZE);
117
- } else {
118
- const raw = await getFilteredNews(slug);
119
- items = (Array.isArray(raw) ? raw : []).map((n) => ({
120
- title: n.title, link: n.link, image: n.image || "",
121
- source: n.source || "Tin tức", vnews: Boolean(n.vnews),
122
- }));
123
- }
124
  showNewsPanel(items);
125
  const nt = document.getElementById("news-title");
126
  if (nt) nt.textContent = "📰 Tin: " + label;
@@ -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) return;
148
  const wrap = document.createElement("div");
149
- wrap.className = "topic-tags";
150
- wrap.id = "topic-tags-inline";
151
  for (const c of topics) {
152
  const chip = document.createElement("button");
153
- chip.type = "button";
154
- chip.className = "topic-chip";
155
- chip.dataset.slug = c.slug;
156
- chip.textContent = "#" + c.label.replace(/\s+/g, "_");
157
  chip.addEventListener("click", () => void openTopic(c.slug, c.label));
158
  wrap.appendChild(chip);
159
  }
160
- const oldEl = chatMessages.querySelector("#topic-tags-inline");
161
  if (oldEl) oldEl.remove();
162
- chatMessages.appendChild(wrap);
163
- chatMessages.scrollTop = chatMessages.scrollHeight;
164
- _topicTagsRendered = true;
165
  }
166
 
167
  async function getVnewsHomepage() {
@@ -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 topics are now fetched from the backend API /api/topics/trending
371
- // which extracts real topics from today's news. The frontend getTrendingTopics()
372
- // calls this API and renders the topic chips.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
  let newsInst = "";
375
  if (newsItems && newsItems.length > 0) {
@@ -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|llm|gpt|qwen|kimi|chatgpt|gemini|openai|claude|tạo ảnh|tạo video|ứng dụng ai)/.test(t);
608
  }
609
 
610
  /** Extract a short keyword query from a user message (Vietnamese-aware). */
@@ -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 (chatMicBtn) chatMicBtn.hidden = !show;
1010
- // Remove fakemic flag so mic button can request real mic later
1011
- if (show) {
1012
- const url = new URL(location.href);
1013
- url.searchParams.delete("fakemic");
1014
- history.replaceState(null, "", url.href);
1015
- }
1016
- }
1017
-
1018
- /** Handle Vietnamese voice recognition result: fill chat input, auto-send on "Gửi". */
1019
- function onVoiceResult(e) {
1020
- if (!e || !e.transcript) return;
1021
- if (!chatInput) return;
1022
- // Ignore results when AI is speaking or session not in user-turn
1023
- if (!sessionInProgress) return;
1024
- const text = e.transcript.trim();
1025
- chatInput.value = text;
1026
- if (e.isFinal && sessionInProgress) {
1027
- // Auto-send trigger phrases (Vietnamese + English)
1028
- const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i;
1029
- if (triggerRe.test(text)) {
1030
- setCaption("Đang gửi…", "live");
1031
- sendTextMessage();
1032
- // Stop voice typing after auto-send — user must click mic again to speak
1033
- if (voiceTyper && voiceTyper.active) {
1034
- voiceTyper.stop();
1035
- }
1036
- } else {
1037
- setCaption("Nhấp Send để gửi", "live");
1038
- }
1039
- }
1040
- }
1041
-
1042
- /** Start (or restart) Vietnamese voice recognition in text chat mode. */
1043
- function startVoiceTyping() {
1044
- if (!sessionInProgress || !chatInput) return false;
1045
- // Don't restart if already listening
1046
- if (voiceTyper && voiceTyper.active) return true;
1047
- // FIX: stop any previous VoiceTyper first — orphaned instances steal the mic
1048
- if (voiceTyper) {
1049
- voiceTyper.stop();
1050
- voiceTyper = null;
1051
- }
1052
- voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false });
1053
- if (!voiceTyper.init()) {
1054
- // Browser may not support Vietnamese locale — try English as fallback
1055
- voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false });
1056
- if (!voiceTyper.init()) {
1057
- setCaption("VOICE NOT SUPPORTED", "error");
1058
- voiceTyper = null;
1059
- return false;
1060
- }
1061
- }
1062
- voiceTyper.onResult = onVoiceResult;
1063
- voiceTyper.onError = (e) => console.warn("[voiceTyper]", e);
1064
- voiceTyper.onSoundStart = () => setCaption("🎤 Listening…", "live");
1065
- voiceTyper.onSoundEnd = () => {
1066
- // Keep listening — only stop when AI speaks or user clicks mic again
1067
- if (voiceTyper && voiceTyper.active) {
1068
- // Do nothing; continuous listening is intentional
1069
- }
1070
- };
1071
- voiceTyper.start();
1072
- setCaption("🎤 Listening…", "live");
1073
- return true;
1074
- }
1075
-
1076
- /** Stop Vietnamese voice recognition and return to text input. */
1077
- function stopVoiceTyping() {
1078
- if (voiceTyper && voiceTyper.active) {
1079
- voiceTyper.stop();
1080
- voiceTyper = null;
1081
- setCaption("Voice stopped", "live");
1082
- }
1083
  }
1084
 
1085
  function sendTextViaSession(text) {
@@ -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
- setCaption("CONNECTION FAILED, TAP TO RETRY", "error");
 
1522
  }
 
1523
  return null;
1524
  }
1525
  }
1526
 
1527
- async function startVoiceSession(micStream) {
1528
  if (sessionInProgress) return;
1529
  sessionInProgress = true;
 
1530
  await stage.resume();
1531
 
1532
- if (!micStream) {
1533
- if (new URLSearchParams(location.search).has("fakemic")) {
1534
- const ctx = stage.audioCtx;
1535
- micStream = ctx.createMediaStreamDestination().stream;
1536
- } else {
1537
- try {
1538
- micStream = await navigator.mediaDevices.getUserMedia({
1539
- audio: {
1540
- echoCancellation: true,
1541
- noiseSuppression: true,
1542
- autoGainControl: true
1543
- }
1544
- });
1545
- } catch {
1546
- setCaption("MIC BLOCKED", "error");
1547
- sessionInProgress = false;
1548
- return;
1549
- }
1550
  }
1551
  }
1552
 
@@ -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
- // Default: always start text chat (no mic required)
1763
- void startTextSession();
 
 
 
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
- // Allow real mic for voice chat. Never force fakemic automatically.
1884
- // User can click mic button in chat to switch to voice mode.
 
 
 
 
 
1885
 
1886
  try { await fetchAvatarList(); } catch (e) { console.warn("[boot] avatars:", e); }
1887
  try { populateAvatarSelects(settings.avatar); } catch (e) { console.warn("[boot] populate:", e); }
@@ -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
- // Don't add timestamp for server-stored thumbnails (they are immutable)
2010
- // Use dataset repo (no Space rebuild on upload)
2011
- return `https://huggingface.co/datasets/bep40/gemma-avatar-thumbnails/resolve/main/public/avatars/${name}.thumb.png`;
2012
  }
2013
- // Show the avatar's representative image: prefer the server-stored thumbnail
2014
- // (persisted on Space), fall back to freshly-captured local copy, then
2015
- // a gradient fallback with the initial letter.
2016
  function updateAvatarThumbPreview() {
2017
  if (!avatarThumbPreview) return;
2018
- const name = chatAvatarSelect.value || settings.avatar || "";
2019
- // Ensure .glb extension for server URL lookup
2020
- const avatarName = name.endsWith(".glb") ? name : (name ? name + ".glb" : "");
2021
- const label = (avatarName || "?").replace(/\.glb$/i, "").replace(/_/g, " ");
2022
- const initial = label.slice(0, 1).toUpperCase();
2023
  const map = loadThumbs();
2024
-
2025
- // Helper: show fallback letter with gradient bg
2026
- function showFallback() {
2027
- avatarThumbPreview.style.display = "none";
2028
- // Find or create the sibling fallback element
2029
- let fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2030
- if (!fb) {
2031
- fb = document.createElement("div");
2032
- fb.className = "thumb-fallback";
2033
- if (avatarThumbPreview.parentNode) {
2034
- avatarThumbPreview.parentNode.insertBefore(fb, avatarThumbPreview);
2035
- }
2036
- }
2037
- fb.textContent = initial;
2038
- fb.style.display = "flex";
2039
- }
2040
-
2041
- function showImg(url) {
2042
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2043
- if (fb) fb.style.display = "none";
2044
- avatarThumbPreview.src = url;
2045
- avatarThumbPreview.style.display = "";
2046
  avatarThumbPreview.hidden = false;
2047
- }
2048
-
2049
- // 1. Try server thumbnail first (persisted on Space, survives restarts)
2050
- if (avatarName) {
2051
- const srv = serverThumbUrl(avatarName);
2052
- avatarThumbPreview.onerror = () => {
2053
- // Server thumbnail failed — try local cache
2054
- const local = map[avatarName] || map[name] || "";
2055
- if (local) {
2056
- showImg(local);
2057
- } else {
2058
- showFallback();
2059
- }
2060
- };
2061
- avatarThumbPreview.onload = () => {
2062
- // Only show if the image actually loaded (not error, not 70-byte placeholder)
2063
- if (avatarThumbPreview.naturalWidth > 0 && avatarThumbPreview.naturalHeight > 0) {
2064
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2065
- if (fb) fb.style.display = "none";
2066
- avatarThumbPreview.style.display = "";
2067
- avatarThumbPreview.hidden = false;
2068
- } else {
2069
- showFallback();
2070
- }
2071
- };
2072
- showImg(srv);
2073
  return;
2074
  }
2075
- // 2. No server thumbnail — try local cache
2076
- const local = map[name] || "";
2077
- if (local) {
2078
- showImg(local);
 
2079
  return;
2080
  }
2081
- showFallback();
2082
  }
2083
  if (chatAvatarSelect) chatAvatarSelect.addEventListener("change", updateAvatarThumbPreview);
2084
 
@@ -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
- // Helper: compress data URL - higher quality for avatar thumbnails
2093
- function compressDataUrl(dataUrl, maxBytes = 500 * 1024, maxDim = 512) {
2094
- return new Promise((resolve) => {
2095
- const img = new Image();
2096
- img.onload = () => {
2097
- const canvas = document.createElement("canvas");
2098
- let w = img.width, h = img.height;
2099
- if (w > maxDim || h > maxDim) {
2100
- const scale = Math.min(maxDim / w, maxDim / h);
2101
- w = Math.round(w * scale);
2102
- h = Math.round(h * scale);
2103
- }
2104
- canvas.width = w; canvas.height = h;
2105
- const ctx = canvas.getContext("2d");
2106
- // Better quality: use imageSmoothingQuality
2107
- ctx.imageSmoothingEnabled = true;
2108
- ctx.imageSmoothingQuality = "high";
2109
- ctx.drawImage(img, 0, 0, w, h);
2110
- // Start with quality 0.85 for avatars (higher visual quality)
2111
- let quality = 0.85;
2112
- let out = canvas.toDataURL("image/jpeg", quality);
2113
- // Allow larger files for better quality (500KB base64 ~ 365KB binary)
2114
- while (out.length > maxBytes * 1.37 && quality > 0.5) {
2115
- quality -= 0.05;
2116
- out = canvas.toDataURL("image/jpeg", quality);
2117
- }
2118
- resolve(out);
2119
- };
2120
- img.onerror = () => resolve(dataUrl); // fallback to original
2121
- img.src = dataUrl;
2122
- });
2123
- }
2124
-
2125
- snapBtn.addEventListener("click", async () => {
2126
  if (!stage.head) { setCaption("AVATAR CHUA SAN SANG", "error"); return; }
2127
  const url = stage.capturePNG();
2128
  if (!url) { setCaption("KHONG CHUP DUOC ANH", "error"); return; }
2129
- _latestSnap = await compressDataUrl(url);
2130
- snapPreview.src = _latestSnap;
2131
  if (snapDialog.showModal) snapDialog.showModal();
2132
  else snapDialog.setAttribute("open", "");
2133
  });
@@ -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: avatarName, dataUrl: _latestSnap }),
2159
  });
2160
- console.log("[snap] response status:", resp.status);
2161
  const data = await resp.json().catch(() => ({}));
2162
- console.log("[snap] response data:", data);
2163
  if (!resp.ok || !data.ok) throw new Error(data.error || "upload failed");
2164
  // Cache locally too (works offline + instant preview)
2165
  const map = loadThumbs();
2166
- // Prefer the server URL now that it is persisted on Space
2167
- if (data.url) {
2168
- map[avatarName] = data.url;
2169
- saveThumbs(map);
2170
- avatarThumbPreview.src = data.url;
2171
- avatarThumbPreview.hidden = false;
2172
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2173
- if (fb) fb.style.display = "none";
2174
- } else {
2175
- map[avatarName] = _latestSnap;
2176
- saveThumbs(map);
2177
- avatarThumbPreview.src = _latestSnap;
2178
- avatarThumbPreview.hidden = false;
2179
- const fb = avatarThumbPreview.parentNode?.querySelector(".thumb-fallback");
2180
- if (fb) fb.style.display = "none";
2181
- }
2182
- // Force refresh all avatar previews (picker + chat select + settings select)
2183
  try { buildPicker(); } catch (e) { console.warn("[snap] buildPicker:", e); }
2184
- try { updateAvatarThumbPreview(); } catch (e) { console.warn("[snap] updateThumb:", e); }
2185
  setCaption("ĐÃ LƯU ẢNH ĐẠI DIỆN LÊN SPACE", "live");
2186
  } catch (e) {
2187
- console.warn("[snap] error:", e);
2188
  setCaption("LƯU THẤT BẠI: " + (e.message || e), "error");
2189
  } finally {
2190
  snapSetAvatar.disabled = false;
@@ -2194,7 +1941,27 @@ if (snapSetAvatar) {
2194
  }
2195
 
2196
  // ── Onboarding picker (choose character + voice BEFORE loading avatar) ──
2197
- function buildPicker() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2198
  const grid = document.getElementById("picker-avatars");
2199
  const voices = document.getElementById("picker-voices");
2200
  if (!grid || !voices) return;
@@ -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
- // Prefer server thumbnail (persisted on Space), fall back to local cache
2229
- _img.src = _srv || _map[name] || "";
2230
  thumb.appendChild(_img);
2231
  const span = document.createElement("span");
2232
  span.className = "avatar-card-name";
@@ -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("Chờ tôi một lát! Tôi sẽ đến với bạn ngay!");
2270
  setMainButton("busy", "Loading…");
2271
  loading.classList.remove("done");
2272
  loading.classList.add("active");
2273
  loading.textContent = "Loading avatar...";
2274
-
2275
- // Show the start progress bar with the friendly message.
2276
- const startProgress = document.getElementById("start-progress");
2277
- const startProgressFill = startProgress ? startProgress.querySelector(".start-progress__fill") : null;
2278
- if (startProgress) startProgress.hidden = false;
2279
- if (startProgressFill) startProgressFill.style.width = "0%";
2280
-
2281
- let progress = 0;
2282
- const progressInterval = setInterval(() => {
2283
- progress = Math.min(progress + 5, 95);
2284
- if (startProgressFill) startProgressFill.style.width = progress + "%";
2285
- }, 150);
2286
-
2287
  const newsPromise = (settings.avatar === "vuong.glb" || !settings.avatar) ? getHotNews().catch(() => []) : Promise.resolve([]);
2288
  let ok = false;
2289
  try {
@@ -2291,12 +2037,7 @@ async function startWithSelection() {
2291
  avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined,
2292
  onprogress: (ev) => {
2293
  if (ev.lengthComputable) {
2294
- const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100));
2295
- loading.textContent = `Loading avatar ${pct}%`;
2296
- if (startProgressFill) startProgressFill.style.width = pct + "%";
2297
- progress = pct;
2298
- }
2299
- }
2300
  });
2301
  const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000));
2302
  await Promise.race([initPromise, timeout]);
@@ -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 THỰ — THỬ CHỌN AVATAR KHÁC", "error");
2322
  setMainButton("start", "Start talking");
2323
- }
2324
- }
2325
  const pickerStartBtn = document.getElementById("picker-start");
2326
  if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection());
 
1
  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()