diff --git a/.gitattributes b/.gitattributes index 73387e2213f2142fc4199f6cb230e29c019adc74..c0a6fadf26577a0e7249eb01c2fcadf66d10a7c4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -51,9 +51,3 @@ public/avatars/ready_player_me_1.glb filter=lfs diff=lfs merge=lfs -text public/avatars/ready_player_me_2.glb filter=lfs diff=lfs merge=lfs -text public/avatars/vuong.glb filter=lfs diff=lfs merge=lfs -text public/avatars/lisamy.glb filter=lfs diff=lfs merge=lfs -text -public/avatars/viverse_avatar_model_209370.vrm filter=lfs diff=lfs merge=lfs -text -public/avatars/scene.glb filter=lfs diff=lfs merge=lfs -text -public/avatars/scene[[:space:]](1).glb filter=lfs diff=lfs merge=lfs -text -public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text -public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text -public/avatars/lisamy1.glb filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index ba79468dcce09f55e21b9932f154f890f82574ef..a14702c409d3f34f9ab001e572dfeaf0da434bba 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .idea # Finder (MacOS) folder config -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.rebuild-trigger b/.rebuild-trigger deleted file mode 100644 index 64506f1f3b979579d4398fbdbd006562e1d1b0a2..0000000000000000000000000000000000000000 --- a/.rebuild-trigger +++ /dev/null @@ -1 +0,0 @@ -Trigger rebuild - fix picker + thumbnails \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9656d126e15d7a44f35d7f9187bf56317505653e..ebda995143b4aa0c8477347866f7bb3fb1952ca5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,4 +108,4 @@ Then, run index.ts bun --hot ./index.ts ``` -For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. \ No newline at end of file +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/Dockerfile b/Dockerfile index 25f3b81b7ef439a81622afe9e3bd9ee3637cfe6f..2dd0b41557b7cefc585e6ef52ea269954a5382ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,27 @@ -# Multi-service: Bun server + Python sidecar for dataset uploads +# Single container: Bun serves the bundled front-end AND the /api/session +# proxy. HF Spaces (sdk: docker) routes traffic to app_port, set to 7860. FROM oven/bun:1-slim WORKDIR /app -# Install Python and supervisor for sidecar -RUN apt-get update && apt-get install -y python3 python3-pip python3-venv supervisor && rm -rf /var/lib/apt/lists/* - -# Create virtual environment for Python -RUN python3 -m venv /app/venv - -# Copy package files for Bun +# Copy just the lockfile and package.json first for layer caching COPY package.json bun.lock ./ +# Retry install once if it fails (network flakiness) RUN bun install --frozen-lockfile --production || (sleep 3 && bun install --frozen-lockfile --production) -# Copy Python requirements and install in venv -COPY requirements.txt ./ -RUN /app/venv/bin/pip install --no-cache-dir -r requirements.txt - -# Copy application code +# Copy the rest of the application COPY . . -# Supervisor config -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 - ENV NODE_ENV=production ENV PORT=7860 -ENV SIDECAR_URL=http://localhost:7861 +# Point at a speech-to-speech backend by setting ONE of these in the Space +# settings (index.ts prefers LOAD_BALANCER_URL): +# LOAD_BALANCER_URL a load balancer's base URL (a secret) — talks to the pool directly +# SESSION_PROXY_URL another deployment's /api to piggyback (e.g. a public demo Space) +# With neither set, the app runs in direct mode (paste a ws:// URL in Settings). + +EXPOSE 7860 -EXPOSE 7860 7861 +USER bun -CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] +CMD ["bun", "index.ts"] \ No newline at end of file diff --git a/README.md b/README.md index 3ad9b2b71e5faf84b25d141d99894c012a84af39..0063a6d2def620b3ecc17bf4f68e48b017d8c543 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,96 @@ emoji: 🗣️ colorFrom: indigo colorTo: purple sdk: docker -pinned: false app_port: 7860 +pinned: false +thumbnail: https://huggingface.co/spaces/victor/gemma-avatar/resolve/main/thumbnail.webp +short_description: Talk to Gemma 4 face to face, with a 3D lip-synced avatar +models: +- google/gemma-4-31B-it +- nvidia/parakeet-tdt-1.1b +- Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice tags: - ml-intern --- -# Gemma Multi-Avatar +# Gemma Avatar + +Realtime voice chat with a 3D talking-head avatar. Same AI stack as the +[smolagents/hf-realtime-voice](https://huggingface.co/spaces/smolagents/hf-realtime-voice) +Space ([blog post](https://huggingface.co/blog/cerebras-gemma4-voice-ai)), but the +orb visualization is replaced by a [TalkingHead](https://github.com/met4citizen/TalkingHead) +3D avatar with real-time audio-driven lip-sync. + +## The pipeline + +``` +you speak → silero-VAD → parakeet-tdt-1.1b (STT) → gemma-4-31B-it on Cerebras → Qwen3-TTS → avatar speaks +``` + +Transport is the OpenAI Realtime GA protocol over WebSocket against Hugging +Face's speech-to-speech backend: mic PCM16 @ 16 kHz goes up as +`input_audio_buffer.append`, TTS PCM16 @ 16 kHz comes back as +`response.output_audio.delta`, transcripts stream alongside. + +## How the avatar works + +- **Rendering / body language** — [TalkingHead](https://github.com/met4citizen/TalkingHead) + (three.js). Blinking, breathing, idle sway, moods, hand gestures, and emoji + expressions are its built-in animation system. +- **Lip-sync** — the backend sends raw PCM only (no word timings, no visemes), + so the mouth is driven from the audio itself with + [HeadAudio](https://github.com/met4citizen/HeadAudio): an AudioWorklet that + classifies MFCC frames into Oculus visemes (~50 ms latency, fully in-browser). + The s2s playback worklet is routed into TalkingHead's audio graph + (`audioAnalyzerNode → audioSpeechGainNode → reverb → speakers`) and HeadAudio + taps the speech gain node. +- **The model plays the avatar** — three function tools are declared to the + backend: `set_mood`, `make_hand_gesture`, `make_facial_expression`. Gemma + calls them mid-conversation (smiles when greeting, shrugs when unsure, + thumbs-up when agreeing). +- **Choreography** — client statuses drive presence: the avatar makes eye + contact when you start talking, gestures with its hands on new utterances, + and barge-in clears the playback buffer so the mouth settles instantly. + +## Run it + +```bash +bun install + +# Pick a backend (one of): +LOAD_BALANCER_URL=https://… bun run dev # a speech-to-speech load balancer +SESSION_PROXY_URL=https://…/api bun run dev # piggyback another deployment's /api (dev) +bun run dev # direct mode: paste a ws:// URL in Settings +``` + +Open http://localhost:3000 and tap **Start talking**. + +\`?fakemic=1\` starts a session with a silent synthetic mic (useful for testing +the full loop without a microphone — trigger a reply from the console with +\`getClient().requestResponse()\`). + +## Layout + +``` +index.ts Bun server: HTML import + /api/session proxy + static assets +index.html App shell (avatar hero, caption, subtitles, settings) +src/app.js Session wiring, tool executor, UI state +src/avatar.js AvatarStage: TalkingHead + HeadAudio + choreography +src/s2s/s2s-ws-client.js Realtime WS client (vendored from the Space; orb removed, + injectable output node + worklet base URL + shared-ctx close) +src/s2s/codec.js PCM/base64 + transcript helpers (vendored, unchanged) +src/vendor/headaudio.min.mjs HeadAudio node class (bundled) +public/worklets/ mic-capture + audio-playback AudioWorklets (vendored, unchanged) +public/vendor/ HeadAudio worklet processor + viseme model (runtime-loaded) +public/avatars/brunette.glb Default avatar (Ready Player Me; CC BY-NC 4.0 — non-commercial) +``` -3D talking-head avatar with real-time voice/text chat. +## Notes -Users can switch between ~20 `.glb` avatar models, capture snapshots as thumbnails, and chat with a Gemma 4 speech-to-speech backend. +- The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme + blend shapes. Ready Player Me avatars work with + \`?morphTargets=ARKit,Oculus%20Visemes\` on the GLB URL. +- TalkingHead owns the AudioContext; the s2s client is handed \`head.audioCtx\` + and never closes it. +- Everything animation-related runs on requestAnimationFrame — a backgrounded + tab freezes the avatar (audio keeps playing). diff --git a/_appdbg.txt b/_appdbg.txt deleted file mode 100644 index 1b82b24b391ee07745c3b84321dbffdd0021c333..0000000000000000000000000000000000000000 --- a/_appdbg.txt +++ /dev/null @@ -1,99 +0,0 @@ -addChatMessage("user", q); - void maybeSwitchTopic(q); - if (!sendTextViaSession(q)) setCaption( ---- -addChatMessage(role, text, newsItem = null) { - const msg = document.createElement("div"); - msg.cla ---- -addChatMessage("user", text); - chatInput.value = ""; - - // New topic? re-filter the news list and r ---- -addChatMessage("assistant", `✅ Đã đăng bài AI tổng hợp (${selected.length} tin nhắn) lên VNEWS:\n${s ---- -addChatMessage("assistant", `⚠️ VNEWS: ${data.error || "không đăng được bài"}.`); - } - } catch (e ---- -addChatMessage("assistant", "⚠️ Lỗi khi kết nối VNEWS để đăng bài."); - } - rewriteBtn.textContent = ---- -addChatMessage("assistant", normalized, newsItem); - } - }); - - c.addEventListener("response-finis ---- -=== _attachClientEvents === -_attachClientEvents(c) { - c.addEventListener("status", (e) => { - onStatus(e.detail.status); - }); - - c.addEventListener("queue", (e) => { - const { position } = e.detail; - setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); - }); - - c.addEventListener("transcript", (e) => { - const { role, text } = e.detail; - if (role === "assistant" && text) { - const normalized = smartNormalize(text); - showSubtitles(normalized); - // Attach the next image of the CURRENT news item (loops through its images). - const img = nextNewsImage(); - const newsItem = curNews ? { ...curNews, image: img || curNews.image } : null; - addChatMessage("assistant", normalized, newsItem); - } - }); - - c.addEventListener("response-finished", () => { - autoGreetingSent = true; - fadeSubtitles(); - if (preFetchedNews.length > 0) { - showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE)); - } - }); - - c.addEventListener("toolcall", (e) => { - const { name, arguments: args, callId } = e.detail; - runTool(name, args, callId); - }); - - c.addEventListener("server-error", (e) => { - console.warn("server error:", e.detail.error); - }); - - c.addEventListener("error", () => { - void endSession(); - }); -} - -async function endSession(silent = false) { - const c = client; - client = null; - sessionInProgress = false; - autoGreetingSent = false; - - if (c) { - if (c.options.micStream) { - for (const track of c.options.micStream?.getTracks() ?? []) { - track.stop(); - } - } - await c.close().catch(() => {}); - } - - stage.setConversationState("idle"); - subtitles.classList.remove("visible"); - - if (newsPanel) { - newsPanel.hidden = true; - } - - if (!silent) { - setCaption(CAPTIONS.idle); - setMainButton("start", \ No newline at end of file diff --git a/_applog.txt b/_applog.txt deleted file mode 100644 index 790832216be3376dabe1e98d790b0a9f7b8f2c0e..0000000000000000000000000000000000000000 --- a/_applog.txt +++ /dev/null @@ -1 +0,0 @@ -HTTP 404 {"error":"Sorry, we can't find the page you are looking for."} \ No newline at end of file diff --git a/_applog2.txt b/_applog2.txt deleted file mode 100644 index 70da45d1e2d5242ae344b3e6837870790da14f75..0000000000000000000000000000000000000000 --- a/_applog2.txt +++ /dev/null @@ -1,46 +0,0 @@ -app HTTP 404 {"error":"Sorry, we can't find the page you are looking for."} -=== build (last 2500) === -home/user/app","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y fakeroot && mv /usr/bin/apt-get /usr/bin/.apt-get && echo '#!/usr/bin/env sh\\nfakeroot /usr/bin/.apt-get $@' > /usr/bin/apt-get && chmod +x /usr/bin/apt-get && \trm -rf /var/lib/apt/lists/* && \tuseradd -m -u 1000 user","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> RUN pip freeze > /tmp/freeze.txt","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> COPY --chown=1000:1000 --from=root / /","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> RUN \tapt-get update && \tapt-get install -y curl && \tcurl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \tapt-get install -y nodejs && \trm -rf /var/lib/apt/lists/* && apt-get clean","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y \tgit \tgit-lfs \tffmpeg \tlibsm6 \tlibxext6 \tcmake \trsync \tlibgl1 \t&& rm -rf /var/lib/apt/lists/* \t&& git lfs install","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> RUN --mount=target=/tmp/requirements.txt,source=requirements.txt pip install --no-cache-dir -r /tmp/requirements.txt \"torch<=2.11.0\" gradio[oauth,mcp]==6.20.0 \"uvicorn>=0.14.0\" \"websockets>=10.4\" spaces==0.51.0","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> COPY --link --chown=1000 ./ /home/user/app","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"DONE 0.0s\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> COPY --from=pipfreeze --link --chown=1000 /tmp/freeze.txt /tmp/freeze.txt","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"DONE 0.0s\n","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"--> Pushing image","timestamp":"2026-07-19T05:26:49.606Z"} - -data: {"data":"DONE 0.9s\n","timestamp":"2026-07-19T05:26:50.461Z"} - -data: {"data":"--> Exporting cache","timestamp":"2026-07-19T05:26:50.461Z"} - -data: {"data":"DONE 0.6s\n","timestamp":"2026-07-19T05:26:50.885Z"} - diff --git a/_applog3.txt b/_applog3.txt deleted file mode 100644 index c4a89c8fc78a0fc80702b7f6a950aa3a08035b8b..0000000000000000000000000000000000000000 --- a/_applog3.txt +++ /dev/null @@ -1,56 +0,0 @@ -app HTTP 404 {"error":"Sorry, we can't find the page you are looking for."} -=== build (last 3000) === -438Z"} - -data: {"data":"--> RUN chown -R 1000:1000 /var/","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y \tgit \tgit-lfs \tffmpeg \tlibsm6 \tlibxext6 \tcmake \trsync \tlibgl1 \t&& rm -rf /var/lib/apt/lists/* \t&& git lfs install","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN \tapt-get update && \tapt-get install -y curl && \tcurl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \tapt-get install -y nodejs && \trm -rf /var/lib/apt/lists/* && apt-get clean","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN pip freeze > /tmp/freeze.txt","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> WORKDIR /home/user/app","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN pip install --no-cache-dir pip -U && pip install --no-cache-dir datasets \"huggingface-hub>=0.30\" \"hf-transfer>=0.1.4\" \"protobuf<4\" \"click<8.1\" torch==2.11.0","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y fakeroot && mv /usr/bin/apt-get /usr/bin/.apt-get && echo '#!/usr/bin/env sh\\nfakeroot /usr/bin/.apt-get $@' > /usr/bin/apt-get && chmod +x /usr/bin/apt-get && \trm -rf /var/lib/apt/lists/* && \tuseradd -m -u 1000 user","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> COPY --chown=1000:1000 --from=root / /","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> RUN --mount=target=/tmp/requirements.txt,source=requirements.txt pip install --no-cache-dir -r /tmp/requirements.txt \"torch<=2.11.0\" gradio[oauth,mcp]==6.20.0 \"uvicorn>=0.14.0\" \"websockets>=10.4\" spaces==0.51.0","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> COPY --link --chown=1000 ./ /home/user/app","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"DONE 0.0s\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> COPY --from=pipfreeze --link --chown=1000 /tmp/freeze.txt /tmp/freeze.txt","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"DONE 0.0s\n","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"--> Pushing image","timestamp":"2026-07-19T08:50:16.651Z"} - -data: {"data":"DONE 0.8s\n","timestamp":"2026-07-19T08:50:17.469Z"} - -data: {"data":"--> Exporting cache","timestamp":"2026-07-19T08:50:17.469Z"} - -data: {"data":"DONE 0.6s\n","timestamp":"2026-07-19T08:50:17.948Z"} - diff --git a/_avatarjs.txt b/_avatarjs.txt deleted file mode 100644 index ef96cd8c1248326c4a5a50c29d8d14f315f4a811..0000000000000000000000000000000000000000 --- a/_avatarjs.txt +++ /dev/null @@ -1,16 +0,0 @@ -LEN 13225 ---- .glb @ 6101 --- -.head); - await head.showAvatar( - { url: opt.avatarUrl ?? "/avatars/vuong.glb", body: opt.body ?? "F", avatarMood: "neutral" }, - (ev) => { - if (ev.lengthComputable) { - const pct = Math.min(100, Math.round((ev.loaded / ev.total) * 100)); - if (t ---- image @ -1 --- ---- thumbnail @ -1 --- ---- img @ -1 --- ---- avatarName @ -1 --- ---- avatar.name @ -1 --- ---- src @ -1 --- ---- public/avatars @ -1 --- \ No newline at end of file diff --git a/_avfiles.txt b/_avfiles.txt deleted file mode 100644 index 548929aa95a9685c24e031ee9dfe09cc1f3e5c5b..0000000000000000000000000000000000000000 --- a/_avfiles.txt +++ /dev/null @@ -1,3 +0,0 @@ -avatar files: ['public/avatars/Ella.glb', 'public/avatars/Lisa.glb', 'public/avatars/avatarsdk.glb', 'public/avatars/avaturn.glb', 'public/avatars/bruce_avaturn.glb', 'public/avatars/brunette-t.glb', 'public/avatars/brunette.glb', 'public/avatars/david.glb', 'public/avatars/fabio_avaturn.glb', 'public/avatars/julia.glb', 'public/avatars/lisamy.glb', 'public/avatars/mpfb.glb', 'public/avatars/ready_player_me_1.glb', 'public/avatars/ready_player_me_2.glb', 'public/avatars/vuong.glb', 'src/avatar.js'] -top dirs: ['public', 'src'] -total: 85 \ No newline at end of file diff --git a/_avlist.txt b/_avlist.txt deleted file mode 100644 index ab372b8d113c0184fb3f5e083f7dc3ee825e1131..0000000000000000000000000000000000000000 --- a/_avlist.txt +++ /dev/null @@ -1,7 +0,0 @@ -===== src/app.js (len 59372) ===== -[avatarList @ 8893] = false; let config = { lb: false, allowDirect: true }; let avatarList = []; let sessionInProgress = false; let autoGreetingSent = false; let preFetchedNews = []; let latestNewsUrl = null; let latestNewsSource = null; // ── News box state -[avatars @ 24432] chAvatarList() { try { const resp = await fetch("/api/avatars"); if (resp.ok) avatarList = (await resp.json()).avatars || []; } catch {} } function populateAvatarSelects(selectedName) { for (const sel of [chatAvatarSelect, se -[/avatars/ @ 25480] await stage.init({ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, onprogress: (ev) => { if (ev.lengthComputable) { loading.textContent = `Loading avatar ${Math.min(100, Math.round( -[case " @ 42381] tatus === "closed" ? "" : "live"); switch (status) { case "idle": case "closed": setMainButton("start", "Start talking"); break; case "error": setMainButton("start", "Retry"); break; case "creating -src/index.html ERR RemoteEntryNotFoundError('404 Client Error. (Request ID: Root=1-6a5cad35-4660b29256e6cd0345252f48;ce084157-0252-44b9-b0f -===== index.html (len 6890) ===== \ No newline at end of file diff --git a/_chk.txt b/_chk.txt deleted file mode 100644 index 6344a4f6ac570038626739b8510b901de5ced441..0000000000000000000000000000000000000000 --- a/_chk.txt +++ /dev/null @@ -1,9 +0,0 @@ -len 22595 -has gemma-2-2b-it: False -has SUMMARY_ENDPOINT: False -has router.huggingface: True -has Step 1: True -MODEL line: const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct"; - let out = ""; - try { - c \ No newline at end of file diff --git a/_chk2.txt b/_chk2.txt deleted file mode 100644 index f7f20dc1e3e3ed57e5a5634f09718a38714b9538..0000000000000000000000000000000000000000 --- a/_chk2.txt +++ /dev/null @@ -1,22 +0,0 @@ -stage: RUNNING -error: None -hw: {"current": "zero-a10g", "requested": "zero-a10g"} -/ HTTP 404 - - - - - - - - - - []) : Promise.resolve([]); -2017: let ok = false; -2018: try { -2019: const initPromise = stage.init({ -2020: avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, -2021: onprogress: (ev) => { -2022: if (ev.lengthComputable) { -2023: loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; -2024: } -2025: } -2026: }); -2027: const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error("init timeout")), 25000)); -2028: await Promise.race([initPromise, timeout]); -2029: stage.resume(); // start render loop so the avatar is actually visible (not a single black frame) -2030: ok = true; -2031: } catch (err) { -2032: console.error("[startWithSelection] init failed/timeout:", err); -2033: } finally { -2034: try { preFetchedNews = await newsPromise; } catch { preFetchedNews = []; } -2035: newsSeq = preFetchedNews.slice(); -2036: newsSeqIdx = -1; -2037: newsTopicQuery = ""; -2038: loading.classList.remove("active"); -2039: if (ok) setCaption(CAPTIONS.idle); -2040: else setCaption("AVATAR KHÔNG HIỂN THỊ — THỬ CHỌN AVATAR KHÁC", "error"); -2041: setMainButton("start", "Start talking"); -2042: } -2043: } -2044: const pickerStartBtn = document.getElementById("picker-start"); -2045: if (pickerStartBtn) pickerStartBtn.addEventListener("click", () => void startWithSelection()); -=== index.html topic-tags line === -H50: \ No newline at end of file diff --git a/_del.txt b/_del.txt deleted file mode 100644 index 78f27bdc643ec96cbc74819ebeb42f7cb557d086..0000000000000000000000000000000000000000 --- a/_del.txt +++ /dev/null @@ -1,10 +0,0 @@ -delete HTTP 404 - - - -Error - - -
Cannot DELETE /api/repos/bep40/summarizer
- - diff --git a/_diag.txt b/_diag.txt deleted file mode 100644 index 252b3750d43cc201ba14724e7401db31c1f0d0c3..0000000000000000000000000000000000000000 --- a/_diag.txt +++ /dev/null @@ -1,9 +0,0 @@ -=== /api/summarize === -fallback: True -title: Người dùng: Gemma 3 ra mắt hôm nay hỗ trợ 140 ngôn ngữ và chạy tốt trên điện tho -body: • Người dùng: Gemma 3 ra mắt hôm nay hỗ trợ 140 ngôn ngữ và chạy tốt trên điện thoại. -Avatar: Đúng, nó có các bản từ 1B đến 27B, rất nhẹ. - -=== VNEWS /api/publish === -/api/publish -> HTTP 400 {"error":"Thieu noi dung bai viet"} -/api/wall -> OK: {"post": {"id": "1784377022902", "title": "t", "text": "x", "source": "user", "video": null, "img": "https://vnexpress.net/image1.jpg", "images": [], "created": 1784377022, "created_str": "12:17 18/07/2026"}, "ok": true} \ No newline at end of file diff --git a/_fb.txt b/_fb.txt deleted file mode 100644 index 42912233ee680c6e9bcbbf523e717ff68a2523b4..0000000000000000000000000000000000000000 --- a/_fb.txt +++ /dev/null @@ -1,24 +0,0 @@ -t = out.slice(mm.index + mm[0].length).trim(); } - if (!title) title = bodyText.slice(0, 80); - return Response.json({ title, body: bodyText || out }); - } - // ── No-model fallback: light paraphrase (NEVER verbatim copy) ── - // Re-flow the chat lines into a short prose summary with a title, - // instead of echoing the raw transcript. - const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean); - const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện"; - const bodyFb = lines - .map((l) => l.charAt(0).toUpperCase() + l.slice(1)) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true }); - } catch (e) { - console.warn(`[summarize] ${e}`); - return Response.json({ error: "summarize failed" }, { status: 502 }); - } - }, - }, - "/api/avatars": { - GET: async () => { - \ No newline at end of file diff --git a/_final_test.txt b/_final_test.txt deleted file mode 100644 index 45addfdd80d26e8e70aeb2990ed3d9d717696353..0000000000000000000000000000000000000000 --- a/_final_test.txt +++ /dev/null @@ -1,5 +0,0 @@ -OK in 0.1s -fallback: True -title: Người dùng: Gemma 3 ra mắt hôm nay hỗ trợ 140 ngôn ngữ và chạy tốt trên điện tho -body: • Người dùng: Gemma 3 ra mắt hôm nay hỗ trợ 140 ngôn ngữ và chạy tốt trên điện thoại. -Avatar: Đúng, nó có các bản từ 1B đến 27B, rất nhẹ. \ No newline at end of file diff --git a/_glbchk.txt b/_glbchk.txt deleted file mode 100644 index 5a028fd2f290652fa446e9f610469c3c41275a49..0000000000000000000000000000000000000000 --- a/_glbchk.txt +++ /dev/null @@ -1,5 +0,0 @@ -Ella.glb downloaded: /root/.cache/huggingface/hub/spaces--bep40--gemma-multi-avatar/snapshots/93b62270a930420118377bd4fde3537c7016cdb5/public/avatars/Ella.glb size=2857KB -trimesh loaded. geometry count: 9 -has SKIN: True -has anim: True -hasImages/texture: True \ No newline at end of file diff --git a/_hw.txt b/_hw.txt deleted file mode 100644 index 46e03c01926d7617a05372a92ef7289bc89cf7a7..0000000000000000000000000000000000000000 --- a/_hw.txt +++ /dev/null @@ -1,4 +0,0 @@ -settings {'hardware': 'zero-a10g', 'zero_gpu': True}: 200 {} -settings {'hardware': 'zero-a10g'}: 200 {} -runtime: {"current": null, "requested": "cpu-basic"} -stage: RUNTIME_ERROR \ No newline at end of file diff --git a/_hw2.txt b/_hw2.txt deleted file mode 100644 index be11b37b1c535e68eb27674f0a42f52019424403..0000000000000000000000000000000000000000 --- a/_hw2.txt +++ /dev/null @@ -1,4 +0,0 @@ -/api/spaces/bep40/summarizer/hardware HTTP 401 {"error":"Invalid username or password."} -/api/spaces/bep40/summarizer/settings PUT: 200 {} -runtime hw: {"current": null, "requested": "cpu-basic"} -stage: RUNTIME_ERROR \ No newline at end of file diff --git a/_hw3.txt b/_hw3.txt deleted file mode 100644 index 3d72869e33dac7809d45017cdac29028feea6e6b..0000000000000000000000000000000000000000 --- a/_hw3.txt +++ /dev/null @@ -1,3 +0,0 @@ -hw: 200 {} -create-hw HTTP 409 {"error":"You already created this space repo: bep40/summarizer","url":"https://huggingface.co/spaces/bep40/summarizer"} -hw now: {"current": null, "requested": "cpu-basic"} \ No newline at end of file diff --git a/_idxchk.txt b/_idxchk.txt deleted file mode 100644 index 7687ad820d7a16fba1a71f228e95a389c33d7854..0000000000000000000000000000000000000000 --- a/_idxchk.txt +++ /dev/null @@ -1,6 +0,0 @@ -len 23470 -has 'fallback: true': True -has 'No-model fallback': True -has 're-flow': False -has SUMMARY_ENDPOINT: True -has 'bullet': True \ No newline at end of file diff --git a/_logs3.txt b/_logs3.txt deleted file mode 100644 index 6f1d713509230b6c50efff6cf5fa9deaa4c83d0d..0000000000000000000000000000000000000000 --- a/_logs3.txt +++ /dev/null @@ -1,7 +0,0 @@ -=== build === -data: {"data":"===== Build Queued at 2026-07-18 15:51:06 / Commit SHA: e1a9445 =====\n","timestamp":"2026-07-18T15:51:06Z"} - -data: {"data":"\n--> ERROR: docker.io/huggingface/zero-gpu:latest: not found\n","timestamp":"2026-07-18T15:51:10.975Z"} - - -app HTTP 404 {"error":"Sorry, we can't find the page you are looking for."} \ No newline at end of file diff --git a/_logs4.txt b/_logs4.txt deleted file mode 100644 index 3d0670c890d1e9163437d9e1e16fbd141869ed42..0000000000000000000000000000000000000000 --- a/_logs4.txt +++ /dev/null @@ -1,40 +0,0 @@ -state ERR NameError("name 'json' is not defined") -app HTTP 404 {"error":"Sorry, we can't find the page you are looking for."} -=== build === -data: {"data":"===== Build Queued at 2026-07-19 01:41:01 / Commit SHA: cb0f05e =====\n","timestamp":"2026-07-19T01:41:01Z"} - -data: {"data":"--> FROM docker.io/library/python:3.12.12@sha256:cb770de9f47e77715f13434d96f7ebaae7ad3a1f4fd9c8b338549bf99c9980ab","timestamp":"2026-07-19T01:41:06.284Z"} - -data: {"data":"DONE 0.0s\n","timestamp":"2026-07-19T01:41:06.284Z"} - -data: {"data":"--> WORKDIR /home/user/app","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y \tgit \tgit-lfs \tffmpeg \tlibsm6 \tlibxext6 \tcmake \trsync \tlibgl1 \t&& rm -rf /var/lib/apt/lists/* \t&& git lfs install","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> RUN pip install --no-cache-dir pip -U && pip install --no-cache-dir datasets \"huggingface-hub>=0.30\" \"hf-transfer>=0.1.4\" \"protobuf<4\" \"click<8.1\" torch==2.11.0","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> RUN apt-get update && apt-get install -y fakeroot && mv /usr/bin/apt-get /usr/bin/.apt-get && echo '#!/usr/bin/env sh\\nfakeroot /usr/bin/.apt-get $@' > /usr/bin/apt-get && chmod +x /usr/bin/apt-get && \trm -rf /var/lib/apt/lists/* && \tuseradd -m -u 1000 user","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> RUN \tapt-get update && \tapt-get install -y curl && \tcurl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \tapt-get install -y nodejs && \trm -rf /var/lib/apt/lists/* && apt-get clean","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> COPY --chown=1000:1000 --from=root / /","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"--> RUN chown -R 1000:1000 /var/","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"CACHED\n\n--> Restoring cache","timestamp":"2026-07-19T01:41:06.386Z"} - -data: {"data":"DONE 24.0s\n\n--> RUN --mount=target=/tmp/requirements.txt,source=requirements.txt pip install --no-cache-dir -r /tmp/requirements.txt \"torch<=2.11.0\" gradio[oauth,mcp]==6.20.0 \"uvicorn>=0.14.0\" \"websockets>=10.4\" spaces==0.51.0","timestamp":"2026-07-19T01:41:30.478Z"} - -d \ No newline at end of file diff --git a/_mintest.txt b/_mintest.txt deleted file mode 100644 index df886d7caceec4e8236a275a9bf31179ccea32d8..0000000000000000000000000000000000000000 --- a/_mintest.txt +++ /dev/null @@ -1,10 +0,0 @@ -stage: RUNTIME_ERROR -HTTP 404 - - - - - HTTP 403 -
- - +
Loading avatar…
@@ -51,12 +32,27 @@
+ + +
diff --git a/index.ts b/index.ts index 6ac7a5a5d8b9145f33b2eff3efa0b2f19a5dc47e..d3351bde24829da194e8d8ba886c760da86c87c8 100644 --- a/index.ts +++ b/index.ts @@ -1,15 +1,15 @@ /** * Gemma Avatar — realtime voice/text chat with a 3D talking-head avatar. + * v4 — 10+ hot news from 17 RSS sources, click-to-summarize, news hook in greeting, fix avatar hang */ import index from "./index.html"; -import { readdir, writeFile } from "fs/promises"; +import { readdir } from "fs/promises"; import { join } from "path"; const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, ""); const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://smolagents-hf-realtime-voice.hf.space/api").trim().replace(/\/$/, ""); const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL; const PORT = Number(Bun.env.PORT ?? 7860); -const REPO_ID = (Bun.env.SPACE_ID || "bep40/gemma-multi-avatar"); function sanitizeSetCookie(sc) { return sc.split(";").map((p) => p.trim()).filter((p) => !/^domain=/i.test(p)).join("; "); @@ -21,19 +21,6 @@ async function proxy(path, req, init = {}) { const cookie = req.headers.get("cookie"); if (cookie) headers.set("Cookie", cookie); const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers }); - - // Handle non-JSON errors (429, 502, etc) from upstream - if (!resp.ok) { - const ct = resp.headers.get("content-type") || ""; - if (ct.includes("text/html")) { - const body = await resp.text().catch(() => ""); - if (resp.status === 429) { - return Response.json({ error: "Dịch vụ giọng nói đang quá tải, thử lại sau", state: "rate_limited" }, { status: 429 }); - } - return Response.json({ error: "Speech service error", state: "unavailable" }, { status: resp.status }); - } - } - const body = await resp.text(); const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } }); 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 = {}) { } function staticFile(dir, name) { - const filePath = join(process.cwd(), "public", dir, name); - const file = Bun.file(filePath); + const file = Bun.file(`${import.meta.dir}/public/${dir}/${name}`); return new Response(file); } -/** List .glb files in public/avatars/ and check for corresponding thumbnails. */ +/** List .glb files in public/avatars/ */ async function listAvatars() { - const avatarsDir = join(process.cwd(), "public/avatars"); + const avatarsDir = join(import.meta.dir, "public/avatars"); const names = []; try { const dir = await readdir(avatarsDir, { withFileTypes: true }); @@ -62,437 +48,118 @@ async function listAvatars() { return names.sort(); } -/** List ALL files in public/avatars/ (for debugging). */ -async function listAllAvatarFiles() { - const avatarsDir = join(process.cwd(), "public/avatars"); - const files = []; - try { - const dir = await readdir(avatarsDir, { withFileTypes: true }); - for (const entry of dir) { - if (entry.isFile()) { - files.push(entry.name); - } - } - } catch {} - return files.sort(); -} - -/** Check if a thumbnail exists for a given avatar name. */ -async function avatarHasThumbnail(avatarName) { - const thumbName = `${avatarName}.thumb.png`; - const thumbPath = join(process.cwd(), "public/avatars", thumbName); - try { - const file = Bun.file(thumbPath); - const exists = await file.exists(); - return exists; - } catch { - return false; - } -} - -const SOURCE_MAP = { - "vnexpress.net": "VnExpress", - "dantri.com.vn": "Dân trí", - "tuoitre.vn": "Tuổi Trẻ", - "thanhnien.vn": "Thanh Niên", - "vietnamnet.vn": "VietNamNet", - "zingnews.vn": "ZingNews", - "kenh14.vn": "Kênh 14", - "cafef.vn": "CafeF", - "genk.vn": "GenK", - "afamily.vn": "Afamily", - "plo.vn": "PLO", - "vtc.vn": "VTC", - "laodong.vn": "Lao Động", - "nguoiduatin.vn": "Người Đưa Tin", - "tienphong.vn": "Tiền Phong", - "vov.vn": "VOV", - "baohatinh.vn": "Báo Hà Tĩnh", -}; - -function sourceNameFromUrl(feedUrl) { - try { - const hostname = new URL(feedUrl).hostname.replace(/^www\./, ""); - return SOURCE_MAP[hostname] || hostname; - } catch { - return "Unknown"; - } -} - -/** Extract the first usable image URL from an RSS block. */ -function extractImage(itemXml) { - const media = itemXml.match(/]*\burl=["']([^"']+)["']/i); - if (media) return media[1]; - const enc = itemXml.match(/]*\btype=["']image\/[^"']*["'][^>]*>/i); - if (enc) { - const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i); - if (urlMatch) return urlMatch[1]; - } - const imgTag = itemXml.match(/\s*([\s\S]*?)<\/url>/i); - if (imgTag) return imgTag[1].replace(//g, "").trim(); - const imgSrc = itemXml.match(/]*\bsrc=["']([^"']+)["']/i); - if (imgSrc) return imgSrc[1]; - return ""; -} - -function extractAllImages(itemXml) { - const imgs = []; - const seen = new Set(); - const push = (u) => { if (u && !seen.has(u) && !u.startsWith("data:")) { seen.add(u); imgs.push(u); } }; - const media = itemXml.match(/]*\burl=["']([^"']+)["']/gi) || []; - for (const m of media) { const u = m.match(/\burl=["']([^"']+)["']/i); if (u) push(u[1]); } - const enc = itemXml.match(/]*\btype=["']image\/[^"']*["'][^>]*>/gi) || []; - for (const e of enc) { const u = e.match(/\burl=["']([^"']+)["']/i); if (u) push(u[1]); } - const descImgs = itemXml.match(/]*\bsrc=["']([^"']+)["']/gi) || []; - for (const d of descImgs) { const u = d.match(/\bsrc=["']([^"']+)["']/i); if (u) push(u[1]); } - return imgs; -} - -function extractDescription(itemXml) { - const dm = itemXml.match(/]*>([\s\S]*?)<\/description>/i); - if (!dm) return ""; - let txt = dm[1].replace(//g, ""); - txt = txt.replace(//gi, "").replace(//gi, "") - .replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim(); - return txt.length > 300 ? txt.slice(0, 300) + "…" : txt; -} - -const FEEDS = [ +/** + * 17 RSS feeds for hot Vietnamese news. + * Covers: VnExpress, Dân trí, Tuổi Trẻ, Thanh Niên, Tiền Phong, + * VietnamNet, VTC News, Người Lao Động, Pháp Luật, Sài Gòn Giải Phóng, + * ZingNews, Công an, Quân đội, Lao Động, Giáo Dục, Phụ Nữ, Sức Khỏe + */ +const RSS_FEEDS = [ "https://vnexpress.net/rss/tin-moi-nhat.rss", "https://dantri.com.vn/trangchu.rss", "https://tuoitre.vn/rss/tin-moi-nhat.rss", - "https://thanhnien.vn/rss/home.rss", - "https://vietnamnet.vn/rss/home.rss", - "https://zingnews.vn/rss/home.rss", - "https://kenh14.vn/rss/home.rss", - "https://cafef.vn/rss/trang-chu.rss", - "https://genk.vn/rss/home.rss", - "https://afamily.vn/rss/home.rss", - "https://plo.vn/rss/home.rss", - "https://vtc.vn/rss/home.rss", - "https://laodong.vn/rss/home.rss", - "https://www.nguoiduatin.vn/rss/home.rss", - "https://tienphong.vn/rss/home.rss", - "https://vov.vn/rss/home.rss", - "https://vietnamnet.vn/rss/the-thao.rss", - "https://vtc.vn/rss/the-thao.rss", - "https://thanhnien.vn/rss/the-thao.rss", - "https://tuoitre.vn/rss/the-thao.rss", - "https://dantri.com.vn/the-thao.rss", - "https://www.bongda.com.vn/rss-bong-da.html", - "https://bongdoanhnghia.vn/feed", - "https://vietnamnet.vn/rss/kinh-te.rss", - "https://cafef.vn/rss/thi-truong.rss", - "https://vtc.vn/rss/kinh-te.rss", - "https://thanhnien.vn/rss/kinh-te.rss", - "https://laodong.vn/kinh-te-doanh-nghiep.rss", - "https://vietnamnet.vn/rss/cong-nghe.rss", - "https://genk.vn/rss/cong-nghe.rss", - "https://cafef.vn/rss/cong-nghe.rss", - "https://vtc.vn/rss/cong-nghe.rss", - "https://thanhnien.vn/rss/cong-nghe.rss", - "https://vietnamnet.vn/rss/thoi-su.rss", - "https://vtc.vn/rss/thoi-su.rss", - "https://thanhnien.vn/rss/thoi-su.rss", - "https://tuoitre.vn/rss/thoi-su.rss", - "https://vietnamnet.vn/rss/giao-duc.rss", - "https://vtc.vn/rss/giao-duc.rss", - "https://thanhnien.vn/rss/giao-duc.rss", - "https://dantri.com.vn/giao-duc.rss", - "https://vietnamnet.vn/rss/suc-khoe.rss", - "https://vtc.vn/rss/suc-khoe.rss", - "https://thanhnien.vn/rss/suc-khoe.rss", - "https://vietnamnet.vn/rss/du-lich.rss", - "https://vtc.vn/rss/du-lich.rss", - "https://thanhnien.vn/rss/du-lich.rss", - "https://vietnamnet.vn/rss/o-to-xe-xem.rss", - "https://vtc.vn/rss/o-to.rss", - "https://vietnamnet.vn/rss/giai-tri.rss", - "https://vtc.vn/rss/giai-tri.rss", - "https://thanhnien.vn/rss/giai-tri.rss", - "https://vietnamnet.vn/rss/phap-luat.rss", - "https://vtc.vn/rss/phap-luat.rss", - "https://thanhnien.vn/rss/phap-luat.rss", + "https://thanhnien.vn/rss/trang-chu.rss", + "https://tienphong.vn/rss/trang-chu.rss", + "https://vietnamnet.vn/rss/trang-chu.rss", + "https://vtcnews.vn/feed.rss", + "https://nld.com.vn/feed.rss", + "https://plo.vn/feed.rss", + "https://www.sggp.org.vn/feed.rss", + "https://zingnews.vn/rss/trang-chu.rss", + "https://cand.com.vn/feed.rss", + "https://www.qdnd.vn/rss/trang-chu.rss", + "https://laodong.vn/rss/trang-chu.rss", + "https://giaoduc.net.vn/feed.rss", + "https://phunuonline.com.vn/feed.rss", + "https://suckhoedoisong.vn/feed.rss", ]; -function decodeHtmlEntities(str) { - if (!str) return ""; - let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec))); - decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); - const namedEntities = { - "Ô": "Ô", "ò": "ò", "Ò": "Ò", "ô": "ô", - "à": "à", "À": "À", "é": "é", "É": "É", - "ù": "ù", "Ù": "Ù", "â": "â", "Â": "Â", - "ê": "ê", "Ê": "Ê", "î": "î", "Î": "Î", - "ô": "ô", "Ô": "Ô", "û": "û", "Û": "Û", - "ñ": "ñ", "Ñ": "Ñ", "&": "&", "<": "<", ">": ">", - """: '"', "'": "'", "'": "'", " ": " ", - "ý": "ý", "Ý": "Ý", "á": "á", "Á": "Á", - "é": "é", "É": "É", "í": "í", "Í": "Í", - "ó": "ó", "Ó": "Ó", "ú": "ú", "Ú": "Ú", - "ü": "ü", "Ü": "Ü", "ö": "ö", "Ö": "Ö", - "ä": "ä", "Ä": "Ä", "ñ": "ñ", "Ñ": "Ñ", - "ç": "ç", "Ç": "Ç", "ñ": "ñ", "Ñ": "Ñ", +/** Map hostname to display name */ +function getSourceName(hostname) { + const name = hostname.replace(/^www\./, "").toLowerCase(); + const known = { + "vnexpress.net": "VnExpress", + "dantri.com.vn": "Dân trí", + "dantri.com": "Dân trí", + "tuoitre.vn": "Tuổi Trẻ", + "thanhnien.vn": "Thanh Niên", + "tienphong.vn": "Tiền Phong", + "vietnamnet.vn": "VietNamNet", + "vtcnews.vn": "VTC News", + "nld.com.vn": "Người Lao Động", + "plo.vn": "PLO", + "sggp.org.vn": "SGGP", + "zingnews.vn": "ZingNews", + "cand.com.vn": "CAND", + "qdnd.vn": "QĐND", + "laodong.vn": "Lao Động", + "giaoduc.net.vn": "Giáo Dục", + "phunuonline.com.vn": "Phụ Nữ", + "suckhoedoisong.vn": "Sức Khỏe", + "nhandan.vn": "Nhân Dân", + "vnexpress": "VnExpress", }; - for (const [entity, char] of Object.entries(namedEntities)) { - decoded = decoded.split(entity).join(char); - } - return decoded; + return known[name] || name; } +/** Fetch hot Vietnamese news from RSS feeds */ async function fetchHotNews() { - const parsed = []; - const seenLinks = new Set(); - await Promise.all( - FEEDS.map(async (feedUrl) => { - try { - const resp = await fetch(feedUrl, { - headers: { "User-Agent": "Mozilla/5.0" }, - signal: AbortSignal.timeout(6000), - }); - const xml = await resp.text(); - const sourceName = sourceNameFromUrl(feedUrl); - const allItems = xml.match(/[\s\S]*?<\/item>/gi) || []; - const items = []; - for (const item of allItems) { - const tm = item.match(/]*>([\s\S]*?)<\/title>/i); - const lm = item.match(/]*>([\s\S]*?)<\/link>/i); - if (!tm || !tm[1]) continue; - const cleanTitle = decodeHtmlEntities(tm[1].replace(//g, "").trim()); + const items = []; // {title, link, source, sourceName} + + // Shuffle feeds to vary the mix + const shuffled = [...RSS_FEEDS].sort(() => Math.random() - 0.5); + + for (const feedUrl of shuffled) { + if (items.length >= 12) break; + try { + const resp = await fetch(feedUrl, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, + signal: AbortSignal.timeout(5000), + }); + const xml = await resp.text(); + // Parse RSS items + const itemRegex = /[\s\S]*?<\/item>/gi; + const titleRegex = /]*>([\s\S]*?)<\/title>/i; + const linkRegex = /]*>([\s\S]*?)<\/link>/i; + const allItems = xml.match(itemRegex) || []; + let feedCount = 0; + for (const item of allItems) { + if (feedCount >= 3) break; // max 3 per feed + const tm = item.match(titleRegex); + const lm = item.match(linkRegex); + if (tm && tm[1]) { + const cleanTitle = tm[1].replace(//g, "").trim(); const cleanLink = lm ? lm[1].replace(//g, "").trim() : ""; - if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) { - seenLinks.add(cleanLink); - const images = extractAllImages(item); - const firstImg = extractImage(item) || (images[0] ?? ""); - items.push({ title: cleanTitle, link: cleanLink, image: firstImg, images, description: extractDescription(item) }); + if (cleanTitle && cleanTitle.length > 10) { + let source = ""; + let sourceName = ""; + try { + const u = new URL(cleanLink || feedUrl); + source = u.hostname; + sourceName = getSourceName(source); + } catch {} + items.push({ title: cleanTitle, link: cleanLink, source, sourceName }); + feedCount++; } } - if (items.length) parsed.push({ source: sourceName, items }); - } catch (e) { - console.warn(`[RSS] ${feedUrl}: ${e}`); - } - }) - ); - const queue = parsed.map((p) => ({ source: p.source, items: p.items.slice() })); - const items = []; - let added = true; - const MAX = 150; - while (added && items.length < MAX) { - added = false; - for (const q of queue) { - if (items.length >= MAX) break; - const it = q.items.shift(); - if (it) { - items.push({ ...it, source: q.source }); - added = true; } + } catch (e) { + console.warn(`[RSS] ${feedUrl}: ${e}`); } } - const shuffled = items.sort(() => Math.random() - 0.5); - return shuffled; -} - -let newsPool = []; -let newsPoolLoadedAt = 0; - -async function refreshNewsPool() { - newsPool = await fetchHotNews(); - newsPoolLoadedAt = Date.now(); - console.log(`[news] pool refreshed with ${newsPool.length} items`); -} -async function getNewsPool() { - if (newsPool.length === 0 || Date.now() - newsPoolLoadedAt > 5 * 60 * 1000) { - await refreshNewsPool(); - } - return newsPool; + return items.slice(0, 12); } -const ALL_TOPICS = [ - { slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] }, - { 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"] }, - { 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"] }, - { slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] }, - { 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"] }, - { slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] }, - { 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"] }, - { 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"] }, - { 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"] }, - { 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"] }, - { 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ế"] }, - { slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] }, - { 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"] }, - { 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"] }, - { 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"] }, - { 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ử"] }, - { 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"] }, -]; - -const VI_STOPWORDS = new Set([ - "và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia", - "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi", - "có", "không", "đã", "sẽ", "đang", "để", "về", "từ", "trên", "dưới", "trong", "ngoài", "trước", "sau", - "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín", "mười", "nhiều", "ít", "cả", "các", - "hôm", "nay", "qua", "đến", "đi", "lên", "xuống", "ra", "vào", "ở", "tại", "thì", "mà", "nhưng", "hoặc", - "vừa", "cũng", "chỉ", "đúng", "tất", "cả", "mỗi", "khác", "mới", "cũ", "lớn", "nhỏ", "cao", "thấp", - "vừa", "rồi", "thì", "mới", "đã", "sẽ", "đang", "được", "để", "về", "từ", "trên", "dưới", "trong", - "ngày", "giờ", "phút", "giây", "năm", "tháng", "tuần", "giờ", "phút", "giây", - "ng", "nh", "nc", "nd", "nt", "nl", "nm", "np", "nk", "nj", "ni", "no", "nr", "ns", "nv", "nz", - "ch", "tr", "ph", "th", "kh", "gh", "nh", "ng", "cn", "cv", "đc", "đk", "đt", "đv", -]); - -async function getTrendingTopics() { - const pool = await getNewsPool(); - const allText = pool - .map((i) => ((i.title || "") + " " + (i.description || "")).replace(/&[a-z]+;/gi, " ")) - .join(" ") - .toLowerCase(); - const tokens = allText - .replace(/[0-9]+/g, " ") - .replace(/[^\p{L}\s]/gu, " ") - .split(/\s+/) - .filter((t) => t.length >= 2 && !VI_STOPWORDS.has(t)); - const bigramFreq = new Map(); - for (let i = 0; i < tokens.length - 1; i++) { - const bigram = tokens[i] + " " + tokens[i + 1]; - if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1])) { - bigramFreq.set(bigram, (bigramFreq.get(bigram) || 0) + 1); - } - } - const trigramFreq = new Map(); - for (let i = 0; i < tokens.length - 2; i++) { - const trigram = tokens[i] + " " + tokens[i + 1] + " " + tokens[i + 2]; - if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1]) && !VI_STOPWORDS.has(tokens[i + 2])) { - trigramFreq.set(trigram, (trigramFreq.get(trigram) || 0) + 1); - } - } - const topicScores = []; - for (const topic of ALL_TOPICS) { - let count = 0; - for (const kw of topic.keywords) { - const kwLower = kw.toLowerCase(); - count += (allText.split(kwLower).length - 1); - } - if (count > 0) topicScores.push({ slug: topic.slug, label: topic.label, count, type: "fixed" }); - } - const knownKeywords = new Set(); - for (const t of ALL_TOPICS) { for (const kw of t.keywords) knownKeywords.add(kw.toLowerCase()); } - const topTrigrams = [...trigramFreq.entries()] - .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 6) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); }) - .sort((a, b) => b[1] - a[1]).slice(0, 10); - const topBigrams = [...bigramFreq.entries()] - .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 4) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); }) - .sort((a, b) => b[1] - a[1]).slice(0, 20); - topicScores.sort((a, b) => b.count - a.count); - const trending = [...topicScores.slice(0, 10)]; - const rawKeywords = [...topTrigrams, ...topBigrams].filter(([phrase, freq]) => freq >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10); - for (const [phrase, freq] of rawKeywords) { - const slug = phrase.replace(/\s+/g, "-").replace(/[^\p{L}\p{N}-]/gu, ""); - if (slug && slug.length > 2 && /^[a-zà-ỹ\-]+$/.test(slug) && !trending.find((t) => t.slug === slug)) { - const label = phrase.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); - trending.push({ slug, label, count: freq, type: "dynamic" }); - } - } - // Always include AI topic regardless of score (it has its own VNEWS AI feed) - if (!trending.find((f) => f.slug === "ai")) { - trending.push({ slug: "ai", label: "AI", count: 1, type: "fixed" }); - } - if (trending.length < 10) { - const defaults = [ - { slug: "congngang", label: "Công nghệ", keywords: [] }, - { slug: "bongda", label: "Bóng đá", keywords: [] }, - { slug: "kinhte", label: "Kinh tế", keywords: [] }, - { slug: "thethao", label: "Thể thao", keywords: [] }, - { slug: "thoisu", label: "Thời sự", keywords: [] }, - { slug: "giao-duc", label: "Giáo dục", keywords: [] }, - { slug: "suc-khoe", label: "Sức khỏe", keywords: [] }, - { slug: "du-lich", label: "Du lịch", keywords: [] }, - { slug: "giai-tri", label: "Giải trí", keywords: [] }, - ]; - for (const d of defaults) { - if (!trending.find((f) => f.slug === d.slug)) trending.push({ ...d, count: 1, type: "fixed" }); - } - } - const now = new Date(); - const start = new Date(now.getFullYear(), 0, 0); - const dayOfYear = Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); - const seed = dayOfYear; - const shuffled = [...trending]; - let r = seed; - for (let i = shuffled.length - 1; i > 0; i--) { - r = (r * 1103515245 + 12345) & 0x7fffffff; - const j = r % (i + 1); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled.slice(0, 20).map(({ slug, label }) => ({ slug, label })); -} - -async function filterNews(query, limit = 15) { - const pool = await getNewsPool(); - const q = (query || "").trim().toLowerCase(); - if (!q) return pool.slice(0, limit); - const SLUG_MAP = { - "worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"], - "aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"], - "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"], - "thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"], - "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"], - "congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"], - "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"], - "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"], - "giao-duc": ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"], - "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"], - "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ế"], - "oto": ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"], - "thegioi": ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"], - "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"], - "giai-tri": ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"], - "am-nhac": ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"], - "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"], - }; - const LABEL_TO_SLUG = {}; - for (const [slug, terms] of Object.entries(SLUG_MAP)) { - const labelKey = terms[0].replace(/\s+/g, "").toLowerCase(); - if (labelKey) LABEL_TO_SLUG[labelKey] = slug; - } - let terms = SLUG_MAP[q]; - if (!terms) { - const qNoSpace = q.replace(/\s+/g, "").toLowerCase(); - const slug = LABEL_TO_SLUG[qNoSpace]; - if (slug) terms = SLUG_MAP[slug]; - } - if (!terms) { - if (q.includes("-")) { terms = q.split("-").filter((t) => t.length > 1); } - else { terms = [q]; } - } - if (!terms || terms.length === 0) return []; -const matched = pool.filter((it) => { - const titleLower = it.title.toLowerCase(); - const descLower = (it.description || "").toLowerCase(); - const text = titleLower + " " + descLower; - return terms.some((term) => { - // Terms with leading/trailing spaces use word-boundary regex - // that handles Vietnamese diacritics (NOT just \w/\b which - // breaks on non-ASCII letters) - if (term.startsWith(" ") || term.endsWith(" ")) { - const word = term.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Match only at whitespace/punctuation/string boundaries - const re = new RegExp(`(^|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\])${word}($|[\\s.,!?;:()\"'\\[\\]{}<>/\\\\-])`, 'i'); - return re.test(text); - } - return titleLower.includes(term) || descLower.includes(term); - }); - }); - return matched.slice(0, limit); -} - -async function fetchArticleContent(url) { +/** Fetch a single news article summary from a URL */ +async function fetchNewsSummary(url) { try { const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, signal: AbortSignal.timeout(8000), }); const html = await resp.text(); + // Remove script, style, nav, header, footer let text = html .replace(/]*>[\s\S]*?<\/script>/gi, "") .replace(/]*>[\s\S]*?<\/style>/gi, "") @@ -501,58 +168,29 @@ async function fetchArticleContent(url) { .replace(/]*>[\s\S]*?<\/footer>/gi, "") .replace(/<[^>]+>/g, " ") .replace(/&[a-z]+;/g, " ") - .replace(/\s+/g, " ").trim(); - return text.slice(0, 4000); - } catch (e) { - console.warn(`[Article] Failed to fetch ${url}: ${e}`); - return null; - } -} - -function formatVietnameseTime(now) { - let h = now.getHours(); - const m = now.getMinutes(); - let part; - if (h >= 0 && h < 5) part = "đêm"; - else if (h < 10) part = "sáng"; - else if (h < 13) part = "trưa"; - else if (h < 18) part = "chiều"; - else part = "tối"; - const hh = h === 0 ? 12 : h; - if (m === 0) return `${hh}h ${part}`; - return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`; -} - -// Upload thumbnails to sidecar service (no Space rebuild) -async function uploadToSidecar(path: string, buf: Uint8Array) { - const sidecarUrl = Bun.env.SIDECAR_URL || "http://localhost:7861"; - const base64 = Buffer.from(buf).toString("base64"); - - try { - const resp = await fetch(`${sidecarUrl}/upload`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path, content: base64 }), - }); - if (!resp.ok) { - const txt = await resp.text(); - console.warn(`[sidecar] upload failed (${resp.status}): ${txt}`); - return false; + .replace(/\s+/g, " ") + .trim(); + // Try to get meaningful content: find article/main + const articleMatch = html.match(/]*>[\s\S]*?<\/article>/i); + if (articleMatch) { + let articleText = articleMatch[0] + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/&[a-z]+;/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (articleText.length > 200) { + text = articleText; + } } - const data = await resp.json(); - console.log(`[sidecar] queued ${path} for dataset upload`); - return true; - } catch (err) { - console.warn(`[sidecar] error: ${err}`); - return false; + const content = text.slice(0, 3000); + return { content, url }; + } catch { + return { content: "", url }; } } -async function uploadToHub(path: string, buf: Uint8Array) { - // Use sidecar service for dataset upload (no Space rebuild) - return uploadToSidecar(path, buf); -} - const server = Bun.serve({ port: PORT, routes: { @@ -560,149 +198,23 @@ const server = Bun.serve({ "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) }, "/api/news/hot": { GET: async () => { - const pool = await getNewsPool(); - return Response.json({ items: pool.slice(0, 15) }); - }, - }, - "/api/news/more": { - GET: async (req) => { - const url = new URL(req.url); - const offset = Math.max(0, Number(url.searchParams.get("offset") ?? "0") || 0); - const limit = Math.min(30, Math.max(1, Number(url.searchParams.get("limit") ?? "10") || 10)); - const pool = await getNewsPool(); - const page = pool.slice(offset, offset + limit); - const nextOffset = offset + page.length; - return Response.json({ - items: page, - offset: nextOffset, - hasMore: nextOffset < pool.length, - total: pool.length, - }); - }, - }, - "/api/news/filter": { - GET: async (req) => { - const url = new URL(req.url); - const q = url.searchParams.get("q") ?? ""; - const limit = Math.min(30, Math.max(1, Number(url.searchParams.get("limit") ?? "15") || 15)); - const items = await filterNews(q, limit); - return Response.json({ query: q, items }); + const newsItems = await fetchHotNews(); + return Response.json({ items: newsItems }); }, }, - "/news/summary": { + "/api/news/summary": { GET: async (req) => { const url = new URL(req.url); - const articleUrl = url.searchParams.get("url"); - if (!articleUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 }); - const content = await fetchArticleContent(articleUrl); - if (!content) return Response.json({ error: "Could not fetch article" }, { status: 502 }); - return Response.json({ content, url: articleUrl }); - }, - }, - "/api/topics/trending": { - GET: async () => { - const topics = await getTrendingTopics(); - return Response.json({ topics }); - }, - }, - "/api/summarize": { - POST: async (req) => { - try { - const body = await req.json(); - const text = (body.text || "").trim(); - if (!text) return Response.json({ error: "Missing text" }, { status: 400 }); - const token = Bun.env.HF_TOKEN; - if (!token) { - const bullets = text.split(/\n\n+/).map((s) => s.trim()).filter(Boolean).slice(0, 6); - return Response.json({ title: text.slice(0, 80), body: bullets.map((b) => `• ${b}`).join("\n"), fallback: true }); - } - const cleanText = text - .split(/\n+/) - .map((line) => line.replace(/^\s*(người dùng|user|avatar|bạn|trợ lí|trợ lý|assistant)\s*[:\-]\s*/i, "").trim()) - .filter(Boolean).join("\n"); - 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" + - "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" + - "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" + - "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 ĐỀ: \n\n"; - const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`; - const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || ""; - const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct"; - let out = ""; - if (SUMMARY_ENDPOINT) { - try { - const sresp = await fetch(SUMMARY_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(90000) }); - if (sresp.ok) { const sdata = await sresp.json(); if (sdata && sdata.body) out = (sdata.body || "").trim(); } - } catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); } - } - if (!out) { - try { - 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) }); - if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); } - } catch (e) { console.warn(`[summarize] router failed: ${e}`); } - } - if (!out && Bun.env.SUMMARY_MODEL_LEGACY) { - try { - 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) }); - if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); } - } catch (e) { console.warn(`[summarize] legacy failed: ${e}`); } - } - if (out) { - let title = "", bodyText = out; - const mm = out.match(/^TIÊU ĐỀ:\s*([^\n]+)/i); - if (mm) { title = mm[1].trim(); bodyText = out.slice(mm.index + mm[0].length).trim(); } - if (!title) title = bodyText.slice(0, 80); - return Response.json({ title, body: bodyText || out }); - } - const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean); - const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện"; - const bodyFb = lines.map((l) => l.charAt(0).toUpperCase() + l.slice(1)).join(" ").replace(/\s+/g, " ").trim(); - return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true }); - } catch (e) { - console.warn(`[summarize] ${e}`); - return Response.json({ error: "summarize failed" }, { status: 502 }); - } - }, - }, - "/api/debug/avatars": { - GET: async () => { - const files = await listAllAvatarFiles(); - const avatarsDir = join(process.cwd(), "public/avatars"); - const avatars = await listAvatars(); - const thumbStatus = {}; - for (const name of avatars) { thumbStatus[name] = await avatarHasThumbnail(name); } - return Response.json({ cwd: process.cwd(), avatarsDir, files, count: files.length, avatars, thumbStatus }); - }, - }, - "/api/debug/thumbnail/:name": { - GET: async (req) => { - const name = req.params.name; - const thumbName = `${name}.thumb.png`; - const thumbPath = join(process.cwd(), "public/avatars", thumbName); - const file = Bun.file(thumbPath); - const exists = await file.exists(); - const size = exists ? file.size : 0; - return Response.json({ name, thumbName, thumbPath, exists, size, url: `/avatars/${thumbName}` }); + const targetUrl = url.searchParams.get("url"); + if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 }); + const result = await fetchNewsSummary(targetUrl); + return Response.json(result); }, }, "/api/avatars": { GET: async () => { const names = await listAvatars(); - const avatars = await Promise.all( - names.map(async (name) => ({ - name, - thumbnail: await avatarHasThumbnail(name) ? `/avatars/${name}.thumb.png` : null, - })) - ); - return Response.json({ avatars }); - }, - }, - "/api/avatars/debug": { - GET: async () => { - const allFiles = await listAllAvatarFiles(); - const glbFiles = allFiles.filter((f) => f.endsWith(".glb")); - const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png")); - const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png")); - return Response.json({ allFiles, glbFiles, thumbFiles, otherFiles, avatarsDir: join(process.cwd(), "public/avatars") }); + return Response.json({ avatars: names }); }, }, "/api/wiki/search": { @@ -711,9 +223,14 @@ const server = Bun.serve({ const q = url.searchParams.get("q"); if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); try { - const resp = await fetch(`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`); + const resp = await fetch( + `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*` + ); const data = await resp.json(); - const results = (data.query?.search ?? []).map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })); + const results = (data.query?.search ?? []).map((r) => ({ + title: r.title, + snippet: r.snippet.replace(/<[^>]+>/g, ""), + })); return Response.json({ results }); } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } }, @@ -724,9 +241,15 @@ const server = Bun.serve({ const title = url.searchParams.get("title"); if (!title) return Response.json({ error: "Missing ?title=" }, { status: 400 }); try { - const resp = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`); + const resp = await fetch( + `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*` + ); const data = await resp.json(); - return Response.json({ title: data.title, extract: data.extract, url: data.content_urls?.desktop?.page }); + return Response.json({ + title: data.title, + extract: data.extract, + url: data.content_urls?.desktop?.page, + }); } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } }, }, @@ -736,7 +259,10 @@ const server = Bun.serve({ const q = url.searchParams.get("q"); if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); try { - const resp = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, { headers: { "User-Agent": "Mozilla/5.0" } }); + const resp = await fetch( + `https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, + { headers: { "User-Agent": "Mozilla/5.0" } } + ); const html = await resp.text(); const results = []; const linkRegex = /]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi; @@ -761,12 +287,26 @@ const server = Bun.serve({ const targetUrl = url.searchParams.get("url"); if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 }); try { - const resp = await fetch(targetUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, signal: AbortSignal.timeout(8000) }); + const resp = await fetch(targetUrl, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, + signal: AbortSignal.timeout(8000), + }); const html = await resp.text(); - let text = html.replace(/]*>[\s\S]*?<\/script>/gi, "").replace(/]*>[\s\S]*?<\/style>/gi, "").replace(/]*>[\s\S]*?<\/nav>/gi, "").replace(/]*>[\s\S]*?<\/header>/gi, "").replace(/]*>[\s\S]*?<\/footer>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim(); + let text = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/nav>/gi, "") + .replace(/]*>[\s\S]*?<\/header>/gi, "") + .replace(/]*>[\s\S]*?<\/footer>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/&[a-z]+;/g, " ") + .replace(/\s+/g, " ") + .trim(); const content = text.slice(0, 3000); return Response.json({ content, url: targetUrl }); - } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); } + } catch { + return Response.json({ error: "Could not fetch page." }, { status: 502 }); + } }, }, "/api/session": { @@ -790,80 +330,9 @@ const server = Bun.serve({ }, "/worklets/:name": (req) => staticFile("worklets", req.params.name), "/vendor/:name": (req) => staticFile("vendor", req.params.name), - "/src/:name": async (req) => { - const filePath = join(process.cwd(), "src", req.params.name); - const file = Bun.file(filePath); - const exists = await file.exists(); - if (!exists) return new Response("Not Found", { status: 404 }); - return new Response(file); - }, - "/api/avatar-thumbnail": { - POST: async (req) => { - try { - const body = await req.json(); - const avatar = (body.avatar || "").toString(); - const dataUrl = (body.dataUrl || "").toString(); - if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) { - return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 }); - } - const m = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/); - if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 }); - let buf; - try { buf = Buffer.from(m[2], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); } - if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 }); - if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 }); - // Accept PNG (89 50 4E 47) or JPEG (FF D8 FF) - const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47; - const isJpeg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff; - if (!isPng && !isJpeg) { - return Response.json({ ok: false, error: "Not a PNG or JPEG" }, { status: 400 }); - } - const fileName = `${avatar}.thumb.png`; - const avatarsDir = join(process.cwd(), "public/avatars"); - const localPath = join(avatarsDir, fileName); - const tmpPath = `/tmp/${fileName}`; // Use /tmp which is always writable - const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); - - // ── 1. Write to /tmp (always writable) for immediate serving ── - try { - await writeFile(tmpPath, uint8Buf); - console.log(`[thumbnail] saved to tmp: ${tmpPath} (${buf.length} bytes)`); - } catch (writeErr) { - console.warn(`[thumbnail] tmp write failed: ${writeErr}`); - } - - // ── 2. Upload to Hub for persistence ── - uploadToHub(`public/avatars/${fileName}`, buf).catch((err) => { - console.warn(`[thumbnail] Hub upload failed: ${err}. Thumbnail in /tmp only.`); - }); - - // ── 3. Serve from /tmp via fallback handler ── - const thumbUrl = `/avatars/${fileName}`; - - return Response.json({ ok: true, url: thumbUrl, size: buf.length }); - } catch (e) { - console.error(`[thumbnail] error: ${e}`); - return Response.json({ ok: false, error: "Internal server error" }, { status: 500 }); - } - }, - }, - }, - // fallback: serve static files from public/ and /tmp/avatars - async fetch(req) { - const url = new URL(req.url); - // Serve avatar thumbnails from /tmp first (where they are written) - if (url.pathname.startsWith("/avatars/")) { - const tmpPath = join("/tmp", url.pathname.split("/").pop() || ""); - const tmpFile = Bun.file(tmpPath); - if (await tmpFile.exists()) return new Response(tmpFile); - } - const filePath = join(process.cwd(), "public", url.pathname); - const file = Bun.file(filePath); - const exists = await file.exists(); - if (exists) return new Response(file); - return new Response("Not Found", { status: 404 }); + "/avatars/:name": (req) => staticFile("avatars", req.params.name), }, }); -console.log(`server running on port ${PORT}`);// force rebuild Sat Jul 25 04:14:36 UTC 2026 -// flush 60s +console.log(`gemma-avatar listening on ${server.url}`); +console.log(UPSTREAM ? `session backend: ${LOAD_BALANCER_URL ? "load balancer" : "session proxy"} (${UPSTREAM})` : "session backend: none"); \ No newline at end of file diff --git a/public/avatars/Ella.glb.thumb.png b/public/avatars/Ella.glb.thumb.png deleted file mode 100644 index 613754cfaf74a7a2d86984231479d5671731f18a..0000000000000000000000000000000000000000 Binary files a/public/avatars/Ella.glb.thumb.png and /dev/null differ diff --git a/public/avatars/lisamy1.glb b/public/avatars/lisamy1.glb deleted file mode 100644 index 4822de86abfefe27f7a4de40b0618f375fd8626b..0000000000000000000000000000000000000000 --- a/public/avatars/lisamy1.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f -size 8320540 diff --git a/public/avatars/scene.glb b/public/avatars/scene.glb deleted file mode 100644 index 9faa6b7c628775d944972ee1fb4416df90403554..0000000000000000000000000000000000000000 --- a/public/avatars/scene.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c88c7c12ee1e978770ed95f1e0ab7e884e2af8c5d368d0ce7c57cbef5a42a47b -size 7490660 diff --git a/public/avatars/test_direct_upload.png b/public/avatars/test_direct_upload.png deleted file mode 100644 index 613754cfaf74a7a2d86984231479d5671731f18a..0000000000000000000000000000000000000000 Binary files a/public/avatars/test_direct_upload.png and /dev/null differ diff --git a/public/avatars/viverse_avatar_model_209370.vrm b/public/avatars/viverse_avatar_model_209370.vrm deleted file mode 100644 index 5e220ffab4bf8cc6326a47f94d6aa5a5f29e1d5a..0000000000000000000000000000000000000000 --- a/public/avatars/viverse_avatar_model_209370.vrm +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75ff29129461144ed2f1d8812d61cc599d1e7694c83efbfee328f7ca2cde8cce -size 8996552 diff --git a/public/avatars/vuong1.glb b/public/avatars/vuong1.glb deleted file mode 100644 index 7a2bb3698a04db05625ea333f402e5863668e64a..0000000000000000000000000000000000000000 --- a/public/avatars/vuong1.glb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a -size 6964484 diff --git a/public/worklets/audio-playback.js b/public/worklets/audio-playback.js index 8c47184cabae299e0f06e984d8fd6224eb0d4858..474020fc522c20f98e9a6dafd527d75364543099 100644 --- a/public/worklets/audio-playback.js +++ b/public/worklets/audio-playback.js @@ -168,4 +168,4 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor { } } -registerProcessor("audio-playback", AudioPlaybackProcessor); \ No newline at end of file +registerProcessor("audio-playback", AudioPlaybackProcessor); diff --git a/public/worklets/mic-capture.js b/public/worklets/mic-capture.js index 1d90355e21777e8551d31519da842d975590d82e..a48860b2c079916442debe8a7a03c0f1babede90 100644 --- a/public/worklets/mic-capture.js +++ b/public/worklets/mic-capture.js @@ -156,4 +156,4 @@ class MicCaptureProcessor extends AudioWorkletProcessor { } } -registerProcessor("mic-capture", MicCaptureProcessor); \ No newline at end of file +registerProcessor("mic-capture", MicCaptureProcessor); diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fcf77b113eea6cd4d606f46cb4096fe0f2f1bb3c..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -huggingface_hub[hf_transfer]>=0.25.0 diff --git a/src/aiTopicPatch.js b/src/aiTopicPatch.js deleted file mode 100644 index a08cd78be78c2e37cba0cb71b5ddbf7b45b01e00..0000000000000000000000000000000000000000 --- a/src/aiTopicPatch.js +++ /dev/null @@ -1,118 +0,0 @@ -const AI_TOPIC_PATCH_VERSION = "1.0.0"; - -function normalizeAiText(text) { - return (text || "") - .toLowerCase() - .replace(/\bai\b/g, " ai ") - .replace(/\ba\.i\b/g, " ai ") - .replace(/a i/g, " ai ") - .replace(/\bllm\b/g, " llm ") - .replace(/\bllms\b/g, " llms ") - .replace(/\bml\b/g, " ml ") - .replace(/\bdl\b/g, " dl ") - .replace(/\bgpt\b/g, " gpt ") - .replace(/\bgemini\b/g, " gemini ") - .replace(/\bqwen\b/g, " qwen ") - .replace(/\bclaude\b/g, " claude ") - .replace(/\bopenai\b/g, " openai ") - .replace(/\bchatgpt\b/g, " chatgpt ") - .replace(/\bdeep learning\b/g, " deep learning ") - .replace(/\bmachine learning\b/g, " machine learning ") - .replace(/\bgenerative ai\b/g, " generative ai ") - .replace(/\bgen ai\b/g, " gen ai ") - .replace(/\bcomputer vision\b/g, " computer vision ") - .replace(/\bnatural language processing\b/g, " natural language processing ") - .replace(/\bnlp\b/g, " nlp ") - .replace(/\brobotics\b/g, " robotics ") - .replace(/\brobot\b/g, " robot ") - .replace(/\bchatbot\b/g, " chatbot ") - .replace(/\bchat bot\b/g, " chatbot ") - .replace(/\btrí tuệ nhân tạo\b/g, " trí tuệ nhân tạo ") - .replace(/\bkhí cụ nhân tạo\b/g, " trí tuệ nhân tạo ") - .replace(/\bthông minh nhân tạo\b/g, " trí tuệ nhân tạo ") - .replace(/\bứng dụng ai\b/g, " ứng dụng ai ") - .replace(/\bdạy học ai\b/g, " dạy học ai ") - .replace(/\btạo ảnh bằng ai\b/g, " tạo ảnh bằng ai ") - .replace(/\btạo video bằng ai\b/g, " tạo video bằng ai ") - .replace(/\bchatbot ai\b/g, " chatbot ai ") - .replace(/\bAI\b/g, " ai ") - .replace(/\bLLM\b/g, " llm ") - .replace(/\bGPT\b/g, " gpt ") - .replace(/\bQwen\b/g, " qwen ") - .replace(/\bClaude\b/g, " claude ") - .replace(/\bOpenAI\b/g, " openai ") - .replace(/\bChatGPT\b/g, " chatgpt ") - .replace(/\bGemini\b/g, " gemini ") - .replace(/\bGenK\b/g, " genk ") - .replace(/\bKimi\b/g, " kimi ") - .replace(/\bKimi K3\b/g, " kimi k3 ") - .replace(/\bKimi k3\b/g, " kimi k3 "); -} - -function detectAiTopic(text) { - const t = normalizeAiText(text); - const aiTerms = [ - " trí tuệ nhân tạo ", " ai ", " artificial intelligence ", " machine learning ", " deep learning ", - " generative ai ", " gen ai ", " llm ", " llms ", " chatbot ", " chatgpt ", " openai ", " gpt ", - " gemini ", " qwen ", " claude ", " kimi ", " kimi k3 ", " nlp ", " natural language processing ", - " computer vision ", " robotics ", " robot ", " chatbot ai ", " ứng dụng ai ", " dạy học ai ", - " tạo ảnh bằng ai ", " tạo video bằng ai " - ]; - return aiTerms.some((term) => t.includes(term)); -} - -function escapeHtml(text) { - return String(text || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function renderAiTopicChip() { - if (document.getElementById("ai-topic-inline")) return; - const chatMessages = document.getElementById("chat-messages"); - if (!chatMessages) return; - - const wrap = document.createElement("div"); - wrap.className = "topic-tags"; - wrap.id = "ai-topic-inline"; - - const chip = document.createElement("button"); - chip.type = "button"; - chip.className = "topic-chip ai-topic-chip"; - chip.textContent = "# AI"; - chip.title = "Mở tin tức AI / trí tuệ nhân tạo"; - chip.addEventListener("click", () => { - const chatInput = document.getElementById("chat-input"); - if (chatInput) chatInput.value = "Tin tức AI, trí tuệ nhân tạo mới nhất"; - const btn = document.getElementById("chat-send-btn"); - if (btn) btn.click(); - }); - - wrap.appendChild(chip); - chatMessages.appendChild(wrap); - chatMessages.scrollTop = chatMessages.scrollHeight; -} - -function injectAiTopicIntoSearchContext(text) { - const original = text || ""; - if (!detectAiTopic(original)) return original; - - 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.`; - if (original.includes(aiText)) return original; - - return `${original}\n\n${aiText}`; -} - -window.AiTopicPatch = { - version: AI_TOPIC_PATCH_VERSION, - detectAiTopic, - injectAiTopicIntoSearchContext, - renderAiTopicChip, -}; - -document.addEventListener("DOMContentLoaded", () => { - window.AiTopicPatch.renderAiTopicChip(); -}); \ No newline at end of file diff --git a/src/app.js b/src/app.js index 4c25707a8b5f213ddbb6b27ac58925089326eb4c..9d05f3acdf82c670ab9e209e63e66a820ea73583 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,13 @@ +// @ts-check +/** + * App wiring: the avatar stage + the speech-to-speech session. + * Modes: voice (mic + VAD) OR text (keyboard only, no mic needed). + * v4 — 10+ hot news cards, click-to-summarize, news hook in greeting, fix avatar hang. + */ + import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; import { smartNormalize, prepareForTTS } from "./viNumberFix.js"; -import { VoiceTyper } from "./voiceTyper.js"; const VOICES = [ "Aiden", "Ryan", "Dylan", "Eric", @@ -9,205 +15,62 @@ const VOICES = [ ]; const DEFAULT_VOICE = "Sohee"; -// ── VNEWS integration ─────────────────────────────────────────────────── -// The VNEWS space (bep40/vnews) is reachable at this subdomain. It exposes a -// clean JSON API (no key required) that we use to pull AI/tech news, answer -// from VNEWS data, and rewrite selected chat content into VNEWS "wall" posts. -const VNEWS_BASE = "https://bep40-vnews.hf.space"; - -// ── News fetching ─────────────────────────────────────────────────────── -const NEWS_PAGE_SIZE = 10; +// ── News ───────────────────────────────────────────────────────────────── +/** @type {Array<{title:string,link:string,source:string,sourceName:string}>} */ +let cachedNewsItems = []; +let newsFetching = false; +let newsFetchPromise = null; -async function getHotNews() { - let items = []; - try { - const resp = await fetch("/api/news/hot"); - if (resp.ok) { - const data = await resp.json(); - if (data.items && data.items.length > 0) { - items = data.items.slice(0, NEWS_PAGE_SIZE); +async function fetchNewsItems() { + if (newsFetching && newsFetchPromise) return newsFetchPromise; + newsFetching = true; + newsFetchPromise = (async () => { + try { + const resp = await fetch("/api/news/hot"); + if (resp.ok) { + const data = await resp.json(); + if (data.items && data.items.length > 0) { + cachedNewsItems = data.items; + } } - } - } catch (e) { console.warn("News API failed:", e); } - return items; -} - -async function getMoreNews(offset, limit = NEWS_PAGE_SIZE) { - try { - const resp = await fetch(`/api/news/more?offset=${offset}&limit=${limit}`); - if (resp.ok) { - const data = await resp.json(); - return { - items: Array.isArray(data.items) ? data.items : [], - nextOffset: data.offset ?? offset + (data.items?.length ?? 0), - hasMore: Boolean(data.hasMore), - }; - } - } catch (e) { console.warn("News more API failed:", e); } - return { items: [], nextOffset: offset, hasMore: false }; -} - -async function getFilteredNews(query, limit = NEWS_PAGE_SIZE) { - try { - const resp = await fetch(`/api/news/filter?q=${encodeURIComponent(query)}&limit=${limit}`); - if (resp.ok) { - const data = await resp.json(); - return Array.isArray(data.items) ? data.items : []; - } - } catch (e) { console.warn("News filter API failed:", e); } - return []; -} - -// ── VNEWS fetch helpers ───────────────────────────────────────────────── -let vnewsConnected = false; -let vnewsArticles = []; // flattened articles from VNEWS homepage -let vnewsStatusTimer = 0; - -async function vnewsFetch(path) { - try { - const resp = await fetch(`${VNEWS_BASE}${path}`, { headers: { "Accept": "application/json" } }); - if (!resp.ok) return null; - return await resp.json(); - } catch (e) { - console.warn("VNEWS fetch failed:", path, e); - return null; - } -} - -/** Pull the GenK "AI" feed (source = genk) — used for the "tin tức về AI" topic. */ -async function getVnewsAIFeed(limit = NEWS_PAGE_SIZE) { - const data = await vnewsFetch(`/api/category/cong-nghe`); - if (!Array.isArray(data)) return []; - return data - .filter((a) => a && a.title && a.link) - .map((a) => ({ - title: a.title, - link: a.link, - image: a.img || "", - source: a.source === "genk" ? "GenK AI" : (a.source || "VNEWS"), - vnews: true, - })) - .slice(0, limit); + } catch (e) { console.warn("News API failed:", e); } + return cachedNewsItems; + })(); + const result = await newsFetchPromise; + newsFetching = false; + newsFetchPromise = null; + return result; } -/** Pull the VNEWS homepage (all categories) and flatten into a searchable list. */ - -// ── Topic tags (chủ đề HOT thời gian thực) — click mở modal tin theo chủ đề ── -// Fetch realtime trending topics from the backend API (extracted from today's news). -async function getTrendingTopics() { - try { - const resp = await fetch("/api/topics/trending"); - if (resp.ok) { - const data = await resp.json(); - if (Array.isArray(data.topics) && data.topics.length > 0) { - return data.topics; +/** + * Fetch a SHORT hot news headline to weave into the greeting. + * Uses one of the top news items, preferring short titles. + */ +async function getHotNewsGreeting() { + const items = await fetchNewsItems(); + if (items.length === 0) return null; + // Pick the shortest title that's still meaningful + let best = null; + for (const item of items) { + const clean = item.title.replace(/^[\d.]+[\s:]*/, "").trim(); + if (clean.length > 10 && clean.length < 200) { + if (!best || clean.length < best.title.length) { + best = { title: clean, link: item.link, sourceName: item.sourceName }; } } - } catch (e) { console.warn("[topic] API failed:", e); } - return []; -} - -async function openTopic(slug, label) { - try { - setCaption("ĐANG TẢI TIN: " + label, "live"); - let items; - // AI topic -> use VNEWS AI feed for better results - if (slug === "ai") { - items = await getVnewsAIFeed(NEWS_PAGE_SIZE); - } else { - const raw = await getFilteredNews(slug); - items = (Array.isArray(raw) ? raw : []).map((n) => ({ - title: n.title, link: n.link, image: n.image || "", - source: n.source || "Tin tức", vnews: Boolean(n.vnews), - })); - } - showNewsPanel(items); - const nt = document.getElementById("news-title"); - if (nt) nt.textContent = "📰 Tin: " + label; - document.querySelectorAll(".topic-chip").forEach((c) => c.classList.toggle("active", c.dataset.slug === slug)); - if (!items.length) setCaption("Không có tin cho: " + label, "error"); - } catch (e) { - console.warn("[topic] openTopic:", e); - setCaption("LỖI TẢI TIN: " + label, "error"); - } -} - -// Render today's real trending topics INSIDE the chat, right under the avatar's -// greeting, so they are always visible. Clicking a chip opens that topic's news. -let _topicTagsRendered = false; -async function renderTopicTags() { - if (_topicTagsRendered) { - // Re-render existing tags (update content, re-add AI if missing) - const oldEl = chatMessages.querySelector("#topic-tags-inline"); - if (oldEl) oldEl.remove(); - _topicTagsRendered = false; - } - let topics = []; - try { topics = await getTrendingTopics(); } catch (e) { console.warn("[topic] getTrending:", e); } - if (!topics.length) return; - const wrap = document.createElement("div"); - wrap.className = "topic-tags"; - wrap.id = "topic-tags-inline"; - for (const c of topics) { - const chip = document.createElement("button"); - chip.type = "button"; - chip.className = "topic-chip"; - chip.dataset.slug = c.slug; - chip.textContent = "#" + c.label.replace(/\s+/g, "_"); - chip.addEventListener("click", () => void openTopic(c.slug, c.label)); - wrap.appendChild(chip); } - const oldEl = chatMessages.querySelector("#topic-tags-inline"); - if (oldEl) oldEl.remove(); - chatMessages.appendChild(wrap); - chatMessages.scrollTop = chatMessages.scrollHeight; - _topicTagsRendered = true; -} - -async function getVnewsHomepage() { - const data = await vnewsFetch(`/api/homepage`); - if (!Array.isArray(data)) return []; - return data - .filter((a) => a && a.title && a.link) - .map((a) => ({ - title: a.title, - link: a.link, - image: a.img || "", - source: a.group || a.source || "VNEWS", - vnews: true, - })); -} - -/** Search VNEWS articles by a free-text query (case-insensitive, in title). */ -function searchVnews(query, limit = 8) { - const q = (query || "").toLowerCase().trim(); - if (!q) return vnewsArticles.slice(0, limit); - return vnewsArticles - .filter((a) => a.title.toLowerCase().includes(q)) - .slice(0, limit); -} - -/** Format VNEWS articles as clickable link cards for the avatar to cite. */ -function vnewsLinksAsHtml(articles) { - if (!articles.length) return "(không có bài viết VNEWS phù hợp)"; - return articles - .map((a, i) => { - const short = `${VNEWS_BASE}/?p=${encodeURIComponent(a.link)}`; - return `${i + 1}. ${a.title} — nguồn ${a.source} (link rút gọn: ${short})`; - }) - .join("
"); + return best; } const DEFAULT_INSTRUCTIONS = [ "You are a friendly voice assistant with a visible, human-like 3D avatar.", - "CRITICAL: Always respond in the SAME LANGUAGE that the user writes or speaks.", + "CRITICAL: Always respond in the SAME LANGUAGE the user writes or speaks.", "If Vietnamese, respond in Vietnamese. If English, respond in English.", "When Vietnamese: write dates as 'ngày 9 tháng 7 năm 2026' (NOT '9/7/2026'),", - "CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35'). This ensures the TTS voice reads them correctly.", + "CRITICAL: When speaking Vietnamese, always write numbers as Vietnamese words (e.g. 'ba mươi lăm' not '35', 'hai nghìn không trăm hai mươi sáu' not '2026'). This ensures the TTS voice reads them correctly. The display will show the number format naturally.", "Keep replies short, natural, warm. Never list-like.", "You can control avatar body with tools: set_mood, make_hand_gesture, make_facial_expression. Use them naturally.", - "NEVER guess. Use search_web, search_wikipedia, or get_vnews_news for ANY factual question.", - "When you cite a VNEWS article, ALWAYS include the direct link as a clickable tag AND mention the short link form.", + "NEVER guess. Use search_web or search_wikipedia for ANY factual question.", "Never mention tools or that you control an avatar.", "Source links should be formatted as popup links with tags.", ].join(" "); @@ -218,78 +81,64 @@ const STORAGE_KEYS = { }; const TOOL_DEFS = [ - { type: "function", name: "set_mood", description: "Change your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS } }, required: ["mood"] } }, - { type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES } }, required: ["gesture"] } }, - { type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string" } }, required: ["emoji"] } }, - { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {} } }, - { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } }, - { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } }, - { type: "function", name: "get_vnews_news", description: "Search the connected VNEWS site for Vietnamese news articles (AI, tech, sports, world...). Returns article titles with direct links and short links. Use this when the user asks about news, especially AI/technology, or after VNEWS is connected.", parameters: { type: "object", properties: { query: { type: "string", description: "Keyword to search, e.g. 'AI', 'bóng đá', 'thời sự'. Empty = latest." } }, required: ["query"] } }, + { type: "function", name: "set_mood", description: "Change your avatar's overall mood/emotional state.", parameters: { type: "object", properties: { mood: { type: "string", enum: AVATAR_MOODS, description: "Mood name." } }, required: ["mood"] } }, + { type: "function", name: "make_hand_gesture", description: "Make a hand gesture with your avatar.", parameters: { type: "object", properties: { gesture: { type: "string", enum: AVATAR_GESTURES, description: "Gesture name." } }, required: ["gesture"] } }, + { type: "function", name: "make_facial_expression", description: "Make a quick facial expression, given as a single face emoji.", parameters: { type: "object", properties: { emoji: { type: "string", description: "A single face emoji." } }, required: ["emoji"] } }, + { type: "function", name: "get_current_datetime", description: "Get current date and time.", parameters: { type: "object", properties: {}, required: [] } }, + { type: "function", name: "search_wikipedia", description: "Search Wikipedia for a topic.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } }, + { type: "function", name: "search_web", description: "Search the web for current info.", parameters: { type: "object", properties: { query: { type: "string", description: "Search query" } }, required: ["query"] } }, + { type: "function", name: "summarize_news", description: "Fetch and summarize a news article from a URL. Use when user clicks a hot news card and asks for a summary.", parameters: { type: "object", properties: { url: { type: "string", description: "URL of the news article to summarize" } }, required: ["url"] } }, ]; -// ── DOM elements ───────────────────────────────────────────────────────── -const $ = (sel) => document.querySelector(sel); +// ── DOM ────────────────────────────────────────────────────────────────── +const $ = (sel) => /** @type {HTMLElement} */ (document.querySelector(sel)); const stageNode = $("#stage"); -const mainBtn = $("#main-btn"); +const mainBtn = /** @type {HTMLButtonElement} */ ($("#main-btn")); const mainBtnLabel = $("#main-btn-label"); -const muteBtn = $("#mute-btn"); -const textModeBtn = $("#text-mode-btn"); +const muteBtn = /** @type {HTMLButtonElement} */ ($("#mute-btn")); +const textModeBtn = /** @type {HTMLButtonElement} */ ($("#text-mode-btn")); const caption = $("#caption"); const subtitles = $("#subtitles"); const loading = $("#loading"); -const settingsBtn = $("#settings-btn"); -const settingsDialog = $("#settings"); -const inputVoice = $("#voice"); -const inputInstructions = $("#instructions"); -const inputDirectUrl = $("#direct-url"); -const inputSubtitles = $("#subtitles-toggle"); +const settingsBtn = /** @type {HTMLButtonElement} */ ($("#settings-btn")); +const settingsDialog = /** @type {HTMLDialogElement} */ ($("#settings")); +const inputVoice = /** @type {HTMLSelectElement} */ ($("#voice")); +const inputInstructions = /** @type {HTMLTextAreaElement} */ ($("#instructions")); +const inputDirectUrl = /** @type {HTMLInputElement} */ ($("#direct-url")); +const inputSubtitles = /** @type {HTMLInputElement} */ ($("#subtitles-toggle")); const directUrlRow = $("#direct-url-row"); -const textChat = $("#text-chat"); -const chatHeader = $("#chat-header"); -const chatMessages = $("#chat-messages"); -const chatInput = $("#chat-input"); -const chatSendBtn = $("#chat-send-btn"); -const chatMicBtn = $("#chat-mic-btn"); -const chatCloseBtn = $("#chat-close-btn"); -const chatResizeHandle = $("#chat-resize-handle"); -const chatAvatarSelect = $("#chat-avatar-select"); -const settingsAvatarSelect = $("#settings-avatar-select"); -const newsPanel = $("#news-panel"); -const newsList = $("#news-list"); -const newsCloseBtn = $("#news-close-btn"); -const newsToggleBtn = $("#news-toggle-btn"); -const newsLoadMore = $("#news-loadmore"); -const newsLoadMoreBtn = $("#news-loadmore-btn"); -const newsSentinel = $("#news-sentinel"); -const newsResizeHandle = $("#news-resize-handle"); - -// ── State ─────────────────────────────────────────────────────────────── +const textChat = /** @type {HTMLElement} */ ($("#text-chat")); +const chatHeader = /** @type {HTMLElement} */ ($("#chat-header")); +const chatMessages = /** @type {HTMLElement} */ ($("#chat-messages")); +const chatInput = /** @type {HTMLInputElement} */ ($("#chat-input")); +const chatSendBtn = /** @type {HTMLButtonElement} */ ($("#chat-send-btn")); +const chatCloseBtn = /** @type {HTMLButtonElement} */ ($("#chat-close-btn")); +const chatResizeHandle = /** @type {HTMLElement} */ ($("#chat-resize-handle")); +const chatAvatarSelect = /** @type {HTMLSelectElement} */ ($("#chat-avatar-select")); +const settingsAvatarSelect = /** @type {HTMLSelectElement} */ ($("#settings-avatar-select")); +const newsCards = /** @type {HTMLElement} */ ($("#news-cards")); +const newsList = /** @type {HTMLElement} */ ($("#news-list")); +const newsCloseBtn = /** @type {HTMLButtonElement} */ ($("#news-close-btn")); + +// ── State (module-level) ───────────────────────────────────────────────── const stage = new AvatarStage(stageNode); +/** @type {S2sWsRealtimeClient | null} */ let client = null; let muted = false; let subtitleTimer = 0; let textMode = false; -let voiceTyper = null; +/** @type {{ lb: boolean, allowDirect: boolean }} */ let config = { lb: false, allowDirect: true }; +let avatarList = []; let sessionInProgress = false; -let _lastVoiceText = ""; -let _lastVoiceSendMs = 0; let autoGreetingSent = false; -let preFetchedNews = []; +/** Pre-fetched short news hook */ +let preFetchedGreeting = null; + +/** URL và tên nguồn tin tức để frontend inject link clickable */ let latestNewsUrl = null; let latestNewsSource = null; -// ── News box state ────────────────────────────────────────────────────── -let newsOffset = 0; // how many items already rendered -let newsLoading = false; // guard against concurrent loads -let newsHasMore = true; -let newsObserver = null; - -// ── News sequence (for walking images through the conversation) ────────── -let newsSeq = []; // ordered list of news items to surface images from -let newsSeqIdx = -1; // index of the NEXT image to show -let newsTopicQuery = ""; // current topic keyword (empty = general hot news) - function loadSettings() { return { voice: localStorage.getItem(STORAGE_KEYS.voice) || DEFAULT_VOICE, @@ -309,581 +158,187 @@ function saveSettings() { localStorage.setItem(STORAGE_KEYS.subtitles, settings.subtitles ? "1" : "0"); } +/** + * Rút gọn hostname thành tên nguồn dễ đọc (vd: "vnexpress.net" → "VnExpress"). + */ function getSourceName(hostname) { const name = hostname.replace(/^www\./, "").toLowerCase(); const known = { "vnexpress.net": "VnExpress", "dantri.com.vn": "Dân trí", + "dantri.com": "Dân trí", "tuoitre.vn": "Tuổi Trẻ", "thanhnien.vn": "Thanh Niên", + "nhandan.vn": "Nhân Dân", "vietnamnet.vn": "VietNamNet", "zingnews.vn": "ZingNews", "cafef.vn": "CafeF", + "techz.vn": "TechZ", "genk.vn": "GenK", + "soha.vn": "Soha", "kenh14.vn": "Kênh 14", "afamily.vn": "Afamily", + "eva.vn": "Eva", + "ngoisao.vn": "Ngôi Sao", + "giadinh.net.vn": "Gia đình", + "tienphong.vn": "Tiền Phong", "plo.vn": "PLO", "vtc.vn": "VTC", + "vtcnews.vn": "VTC News", + "vtv.vn": "VTV", + "nld.com.vn": "Người Lao Động", + "sggp.org.vn": "SGGP", + "cand.com.vn": "CAND", + "qdnd.vn": "QĐND", + "laodong.vn": "Lao Động", + "giaoduc.net.vn": "Giáo Dục", + "phunuonline.com.vn": "Phụ Nữ", + "suckhoedoisong.vn": "Sức Khỏe", + "vnexpress": "VnExpress", }; return known[name] || name; } -/** Short label/initial used for the avatar bubble when no image is available. */ -function sourceInitial(source) { - const map = { - "VnExpress": "Vn", "Dân trí": "DT", "Tuổi Trẻ": "TT", "Thanh Niên": "TN", - "VietNamNet": "VN", "ZingNews": "Z", "Kênh 14": "K14", "CafeF": "CF", - "GenK": "GK", "Afamily": "AF", "PLO": "PLO", "VTC": "VTC", "GenK AI": "AI", - }; - return map[source] || (source ? source.slice(0, 2).toUpperCase() : "?"); -} - -/** Vietnamese date: "thứ Sáu, ngày 17 tháng 7 năm 2026". */ -function formatVietnameseDate(now) { - const weekdays = ["Chủ nhật", "thứ Hai", "thứ Ba", "thứ Tư", "thứ Năm", "thứ Sáu", "thứ Bảy"]; - const wd = weekdays[now.getDay()]; - return `${wd}, ngày ${now.getDate()} tháng ${now.getMonth() + 1} năm ${now.getFullYear()}`; -} - -/** Vietnamese time phrasing: 10 AM -> "10h trưa", 6 PM+ -> "6h tối". */ -function formatVietnameseTime(now) { - let h = now.getHours(); - const m = now.getMinutes(); - let part; - if (h >= 0 && h < 5) part = "đêm"; - else if (h < 10) part = "sáng"; - else if (h < 13) part = "trưa"; // 10h, 11h, 12h -> trưa - else if (h < 18) part = "chiều"; - else part = "tối"; // 18h trở đi -> tối - const hh = h === 0 ? 12 : h; - if (m === 0) return `${hh}h ${part}`; - return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`; -} - -function effectiveInstructions(newsItems) { +/** + * Build system instructions sent to the backend. + * AI gets a short news hook (if any) and is told to greet naturally. + * AI chỉ nói tên nguồn (VD: "nguồn VnExpress"). + * Frontend tự inject link clickable sau khi nhận response. + */ +function effectiveInstructions(newsHook) { const now = new Date(); - const dateStr = formatVietnameseDate(now); - const timeStr = formatVietnameseTime(now); - const dateLine = `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`; + const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); + const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); + const dateLine = `Today is ${dateStr}. The current time is ${timeStr}.`; const extra = settings.instructions.trim(); const introLine = "Tôi tên là Vương đến V.AI STUDIO."; - // Trending topics are now fetched from the backend API /api/topics/trending - // which extracts real topics from today's news. The frontend getTrendingTopics() - // calls this API and renders the topic chips. - - let newsInst = ""; - if (newsItems && newsItems.length > 0) { - const topNews = newsItems[0]; - const sourceName = getSourceName(new URL(topNews.link).hostname); - newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}.\nTin HOT nhất hôm nay: ${topNews.title} (nguồn ${sourceName})"\nInclude the full title and source name in parentheses. After greeting, offer to help with questions.`; - } else { - newsInst = `\n\nWhen you start the conversation, greet the user naturally with this EXACT format:\n"${introLine} ${dateStr}. ${timeStr}."\nThen offer to help with questions.`; - } - - - - - let vnewsInst = ""; - if (vnewsConnected && vnewsArticles.length > 0) { - const sample = vnewsArticles.slice(0, 6).map((a) => `• ${a.title} (${a.source})`).join("\n"); - vnewsInst = `\n\nVNEWS is connected. You can answer using the latest VNEWS headlines and MUST cite them with direct links. Latest VNEWS headlines:\n${sample}`; - } - - const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}${vnewsInst}`; - return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base; -} - -// ── News image card (shown inside chat) ───────────────────────────────── -function newsImageCardHtml(item) { - const imgUrl = item.image || (item.images && item.images[0]) || ""; - const src = imgUrl - ? `` - : `
${sourceInitial(item.source)}
`; - const link = item.link - ? `data-link="${item.link.replace(/"/g, """)}"` + + const sourceName = newsHook ? (newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname)) : ""; + const newsInst = newsHook + ? `\n\nWhen you start the conversation, greet the user naturally and briefly mention today's news hook: "${newsHook.title}"\nAfter the greeting, add the source in EXACT format: "(nguồn ${sourceName})" — WITH parentheses. Example: "Có tin nóng từ báo VnExpress hôm nay: ... (nguồn ${sourceName})"\nKeep your greeting to ONE short sentence.` : ""; - return `
${src}
${item.source || "Tin tức"}${item.title}
`; -} - -// ── News Panel ───────────────────────────────────────────────────────── -function buildNewsItem(item, idx) { - const el = document.createElement("div"); - el.className = "news-item"; - - const thumb = document.createElement("div"); - thumb.className = "news-thumb"; - if (item.image) { - const img = document.createElement("img"); - img.loading = "lazy"; - img.alt = ""; - img.src = item.image; - img.addEventListener("error", () => { - thumb.classList.add("news-thumb--fallback"); - thumb.textContent = sourceInitial(item.source); - img.remove(); - }); - thumb.appendChild(img); - } else { - thumb.classList.add("news-thumb--fallback"); - thumb.textContent = sourceInitial(item.source); - } - - const body = document.createElement("div"); - body.className = "news-body"; - - const meta = document.createElement("div"); - meta.className = "news-meta"; - meta.innerHTML = `${item.source || "Tin tức"}` + - (typeof idx === "number" ? `#${idx + 1}` : ""); - - const title = document.createElement("div"); - title.className = "news-title"; - title.textContent = item.title; - - if (item.description) { - const desc = document.createElement("div"); - desc.className = "news-desc"; - desc.textContent = item.description; - body.appendChild(desc); - } - - body.appendChild(meta); - body.appendChild(title); - - el.appendChild(thumb); - el.appendChild(body); - - // Action row: open source + ask in chat - const actions = document.createElement("div"); - actions.className = "news-actions"; - - const srcBtn = document.createElement("button"); - srcBtn.className = "news-act-btn"; - srcBtn.type = "button"; - srcBtn.textContent = "🔗 Nguồn"; - srcBtn.addEventListener("click", (e) => { - e.stopPropagation(); - // VNEWS items open the short link so highlights/videos play on VNEWS. - const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link; - if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); - }); - - const askBtn = document.createElement("button"); - askBtn.className = "news-act-btn news-act-btn--ask"; - askBtn.type = "button"; - askBtn.textContent = "💬 Hỏi"; - askBtn.addEventListener("click", (e) => { - e.stopPropagation(); - askAboutNews(item); - }); - - actions.appendChild(srcBtn); - actions.appendChild(askBtn); - el.appendChild(actions); - - // Clicking the card body opens the source (VNEWS short link for VNEWS items). - el.addEventListener("click", () => { - const url = item.vnews && item.link ? `${VNEWS_BASE}/?p=${encodeURIComponent(item.link)}` : item.link; - if (url) window.open(url, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); - }); - return el; -} - -/** Fill the chat input with a question about a news item and focus/start chat. */ -function askAboutNews(item) { - const q = `Kể chi tiết về tin này: ${item.title}`; - chatInput.value = q; - showTextChat(true); - textModeBtn.classList.add("active"); - textMode = true; - chatInput.focus(); - if (!client || !sessionInProgress) { - void startTextSession(q); - } else { - addChatMessage("user", q); - void maybeSwitchTopic(q); - if (!sendTextViaSession(q)) setCaption("QUEUED…"); - } -} - -function showNewsPanel(items) { - if (!newsPanel || !newsList) return; - - newsList.innerHTML = ""; - newsOffset = 0; - newsHasMore = true; - - if (!items || items.length === 0) { - const empty = document.createElement("div"); - empty.className = "news-empty"; - empty.textContent = "Không có tin tức cho chủ đề này."; - newsList.appendChild(empty); - newsLoadMore.hidden = true; - newsPanel.hidden = false; - return; - } - - items.forEach((item, i) => newsList.appendChild(buildNewsItem(item, i))); - newsOffset = items.length; - newsHasMore = items.length >= NEWS_PAGE_SIZE; - newsLoadMore.hidden = !newsHasMore; - - newsPanel.hidden = false; - setupNewsObserver(); -} - -function appendNewsItems(items) { - if (!items || items.length === 0) { - newsHasMore = false; - newsLoadMore.hidden = true; - return; - } - items.forEach((item) => newsList.appendChild(buildNewsItem(item, newsOffset++))); -} - -async function loadMoreNews() { - if (newsLoading || !newsHasMore) return; - newsLoading = true; - newsLoadMoreBtn.classList.add("loading"); - newsLoadMoreBtn.disabled = true; - - // If we're on a filtered topic, "load more" just re-fetches the filtered set. - let items; - if (newsTopicQuery) { - items = await getFilteredNews(newsTopicQuery, NEWS_PAGE_SIZE); - } else { - const r = await getMoreNews(newsOffset, NEWS_PAGE_SIZE); - items = r.items; - newsHasMore = r.hasMore; - newsOffset = r.nextOffset; - } - appendNewsItems(items); - if (!newsTopicQuery) newsLoadMore.hidden = !newsHasMore; - else newsLoadMore.hidden = true; // filtered set is already complete - - newsLoadMoreBtn.classList.remove("loading"); - newsLoadMoreBtn.disabled = false; - newsLoading = false; -} - -function setupNewsObserver() { - if (newsObserver) newsObserver.disconnect(); - if (!newsSentinel || !("IntersectionObserver" in window)) return; - newsObserver = new IntersectionObserver((entries) => { - for (const entry of entries) { - if (entry.isIntersecting && newsHasMore && !newsLoading && !newsTopicQuery) { - void loadMoreNews(); - } - } - }, { root: newsList, rootMargin: "120px" }); - newsObserver.observe(newsSentinel); -} - -// ── Topic detection & news sequence ───────────────────────────────────── -const VI_STOPWORDS = new Set([ - "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "minh", - "có", "không", "được", "là", "và", "với", "của", "cho", "trong", "ngoài", "về", "tại", "từ", - "để", "khi", "nếu", "như", "đó", "này", "kia", "gì", "nào", "đâu", "ai", "sao", "thế", - "hãy", "xin", "vui", "lòng", "giúp", "biết", "tin", "tức", "bài", "viết", "nói", "chuyện", - "chủ", "đề", "muốn", "hỏi", "xem", "nghe", "à", "ừ", "ok", "okay", "ồ", "nhé", "nhỉ", "ạ", - "đây", "nè", "một", "hai", "ba", "mấy", "các", "những", "cái", "con", "chiếc", "bản", - "hôm", "nay", "tới", "mới", "nhất", "kể", -]); - -/** Decide if the user is continuing the current news thread (not switching). */ -function isFollowUp(text) { - const t = (text || "").toLowerCase(); - // Only truly-continuation cues force a follow-up. Topic phrases like - // "kể về X", "tin tức X", "cho tôi biết về X" are treated as new topics. - if (/(tiếp|tiếp tục|nữa|thêm tin|tin này|tin đó|tin khác|còn tin|tin hot|tin nóng|tin hôm nay|tóm tắt|tóm lại|giải thích|ý nghĩa|chi tiết hơn|kể thêm)/.test(t)) { - return true; - } - return t.trim().split(/\s+/).length <= 3; -} - -/** Detect an explicit AI / công nghệ request (route to GenK AI feed). */ -function isAITopic(text) { - const t = (text || "").toLowerCase(); - 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); -} - -/** Extract a short keyword query from a user message (Vietnamese-aware). */ -function extractTopicQuery(text) { - if (isFollowUp(text)) return ""; // follow-ups never trigger a topic switch - const words = (text || "") - .toLowerCase() - .replace(/[^a-zà-ỹ0-9\s]/g, " ") - .split(/\s+/) - .filter(Boolean); - // Keep meaningful words (>=2 chars, not a stopword). Compound nouns like - // "bóng đá", "kinh tế", "bóng rổ" stay intact because both parts are kept. - const keywords = words.filter((w) => w.length >= 2 && !VI_STOPWORDS.has(w)); - if (!keywords.length) return ""; - return keywords.slice(0, 2).join(" "); -} - -/** - * Called when the user sends a message. If it's a new topic, re-filter the - * news list to that topic and reset the image sequence. AI topics pull the - * GenK "AI" feed from VNEWS. - */ -async function maybeSwitchTopic(userText) { - if (isFollowUp(userText)) return false; // keep current sequence/list - - // AI topic -> pull GenK AI feed from VNEWS. - if (isAITopic(userText)) { - const aiItems = await getVnewsAIFeed(NEWS_PAGE_SIZE); - if (aiItems.length) { - newsTopicQuery = "ai"; - preFetchedNews = aiItems; - newsSeq = aiItems.slice(); - newsSeqIdx = -1; - showNewsPanel(aiItems); - setCurrentNews(aiItems[0]); - return true; - } - // Fall back to general news filter on "ai". - const filtered = await getFilteredNews("ai", NEWS_PAGE_SIZE); - if (filtered.length) { - newsTopicQuery = "ai"; - preFetchedNews = filtered; - newsSeq = filtered.slice(); - newsSeqIdx = -1; - showNewsPanel(filtered); - setCurrentNews(filtered[0]); - return true; - } - return false; - } - - const query = extractTopicQuery(userText); - if (!query || query === newsTopicQuery) return false; - - const filtered = await getFilteredNews(query, NEWS_PAGE_SIZE); - if (!filtered.length) return false; // no news for this topic -> keep general - - newsTopicQuery = query; - preFetchedNews = filtered; - newsSeq = filtered.slice(); - newsSeqIdx = -1; - showNewsPanel(filtered); - setCurrentNews(filtered[0]); - return true; -} - -// Current news item being discussed + which image to show next. -let curNews = null; -let curImgIdx = 0; - -/** Return the next image URL for the CURRENT news item, looping through its - * images (image[0], image[1], ...). Falls back to a single image. */ -function nextNewsImage() { - if (!curNews) return null; - const imgs = (curNews.images && curNews.images.length) - ? curNews.images - : (curNews.image ? [curNews.image] : []); - if (!imgs.length) return null; - const url = imgs[curImgIdx % imgs.length]; - curImgIdx += 1; - return url; -} - -/** Set the news item currently being discussed (resets the image cursor). */ -function setCurrentNews(item) { - curNews = item || null; - curImgIdx = 0; + + const base = `${dateLine}\n\n${introLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`; + return extra ? `${base}\n\nAdditional user instructions:\n${extra}` : base; } -// ── Avatar list ───────────────────────────────────────────────────────── -let avatarList = []; -let avatarThumbs = {}; // name -> thumbnail URL (persisted on Space) - +// ── Avatar list ────────────────────────────────────────────────────────── async function fetchAvatarList() { - try { - const resp = await fetch("/api/avatars"); - if (resp.ok) { - const data = await resp.json(); - avatarList = (data.avatars || []).map((a) => (typeof a === "string" ? a : a.name)); - // Store thumbnail URLs from the API response - avatarThumbs = {}; - for (const a of (data.avatars || [])) { - if (typeof a === "object" && a.name && a.thumbnail) { - avatarThumbs[a.name] = a.thumbnail; - } - } - } - } catch {} + try { const resp = await fetch("/api/avatars"); if (resp.ok) avatarList = (await resp.json()).avatars || []; } catch {} } - function populateAvatarSelects(selectedName) { for (const sel of [chatAvatarSelect, settingsAvatarSelect]) { sel.innerHTML = ""; - const def = document.createElement("option"); - def.value = ""; - def.textContent = "(Default - Brunette)"; - sel.appendChild(def); - + const def = document.createElement("option"); def.value = ""; def.textContent = "(Default - Brunette)"; sel.appendChild(def); for (const name of avatarList) { - const opt = document.createElement("option"); - opt.value = name; + const opt = document.createElement("option"); opt.value = name; opt.textContent = name.replace(/\.glb$/i, "").replace(/_/g, " ") + (name.toLowerCase() === "vuong.glb" ? " 🎙️" : ""); sel.appendChild(opt); } - - if (selectedName && avatarList.includes(selectedName)) { - sel.value = selectedName; - } + if (selectedName && avatarList.includes(selectedName)) sel.value = selectedName; } } - updateAvatarThumbPreview(); - - -function setAvatarFromSelect(value) { - settings.avatar = value || ""; - saveSettings(); -} - +function setAvatarFromSelect(value) { settings.avatar = value || ""; saveSettings(); } async function reloadAvatar() { if (!stage.head) return; - loading.classList.remove("done"); - loading.textContent = "Loading avatar..."; + loading.classList.remove("done"); loading.textContent = "Loading avatar..."; + try { await stage.init({ avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, onprogress: (ev) => { if (ev.lengthComputable) loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; } }); } catch (err) { console.error(err); } + loading.classList.add("done"); +} - try { - await stage.init({ - avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, - onprogress: (ev) => { - if (ev.lengthComputable) { - loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; - } +// ── News Cards ─────────────────────────────────────────────────────────── +/** Render news cards into the news container */ +function renderNewsCards() { + newsList.innerHTML = ""; + for (const item of cachedNewsItems) { + const card = document.createElement("div"); + card.className = "news-card"; + card.dataset.url = item.link; + card.dataset.title = item.title; + + const source = document.createElement("span"); + source.className = "news-card-source"; + source.textContent = item.sourceName || getSourceName(item.source) || "Nguồn"; + + const title = document.createElement("span"); + title.className = "news-card-title"; + title.textContent = item.title; + + card.appendChild(source); + card.appendChild(title); + + // Click to summarize + card.addEventListener("click", () => { + if (client && sessionInProgress) { + // Send a text message asking the AI to summarize this news + // The AI will use the summarize_news tool to fetch the article content + const msg = `Hãy tóm tắt ngắn gọn và đầy đủ tin tức sau từ ${item.sourceName || item.source || "báo"}: "${item.title}". Đường dẫn: ${item.link}`; + addChatMessage("user", `📰 Tóm tắt: ${item.title}`); + client.sendUserText(msg); + client.requestResponse(); + } else { + // No session — open in a popup window + window.open(item.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); } }); - stage.resume(); // keep the render loop running so the avatar stays visible - } catch (err) { - console.error(err); + + newsList.appendChild(card); } +} - loading.classList.add("done"); +/** Show news cards */ +function showNewsCards() { + if (cachedNewsItems.length === 0) return; + renderNewsCards(); + newsCards.hidden = false; } -// ── Chat / captions ──────────────────────────────────────────────────── +/** Hide news cards */ +function hideNewsCards() { + newsCards.hidden = true; +} + +// News close button +if (newsCloseBtn) { + newsCloseBtn.addEventListener("click", () => hideNewsCards()); +} + +// ── Chat / captions ────────────────────────────────────────────────────── function clampRect() { const vw = window.innerWidth, vh = window.innerHeight; const r = textChat.getBoundingClientRect(); let l = r.left, t = r.top; - l = Math.max(10, Math.min(l, vw - r.width - 10)); - t = Math.max(10, Math.min(t, vh - r.height - 10)); - textChat.style.left = `${l}px`; - textChat.style.top = `${t}px`; + l = Math.max(10, Math.min(l, vw - r.width - 10)); t = Math.max(10, Math.min(t, vh - r.height - 10)); + textChat.style.left = `${l}px`; textChat.style.top = `${t}px`; } - function makeDraggable() { - let dragging = false; - let startX, startY, startLeft, startTop; - - function getPos(e) { - const p = e.changedTouches ? e.changedTouches[0] : e; - return { x: p.clientX, y: p.clientY }; - } - - function onStart(e) { - if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) { - return; - } - const p = getPos(e); - dragging = true; - const rect = textChat.getBoundingClientRect(); - startX = p.x; - startY = p.y; - startLeft = rect.left; - startTop = rect.top; - textChat.classList.add("dragging"); - e.preventDefault(); - } - - function onMove(e) { - if (!dragging) return; - const p = getPos(e); - textChat.style.left = `${startLeft + p.x - startX}px`; - textChat.style.top = `${startTop + p.y - startY}px`; - textChat.style.right = "auto"; - textChat.style.bottom = "auto"; - textChat.style.width = ""; - textChat.style.height = ""; - e.preventDefault(); - } - - function onEnd() { - if (!dragging) return; - dragging = false; - textChat.classList.remove("dragging"); - clampRect(); - } - - chatHeader.addEventListener("mousedown", onStart); - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onEnd); - chatHeader.addEventListener("touchstart", onStart, { passive: false }); - document.addEventListener("touchmove", onMove, { passive: false }); - document.addEventListener("touchend", onEnd); + let dragging = false; let startX, startY, startLeft, startTop; + function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; } + function onStart(e) { if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) return; const p = getPos(e); dragging = true; const rect = textChat.getBoundingClientRect(); startX = p.x; startY = p.y; startLeft = rect.left; startTop = rect.top; textChat.classList.add("dragging"); e.preventDefault(); } + function onMove(e) { if (!dragging) return; const p = getPos(e); textChat.style.left = `${startLeft + p.x - startX}px`; textChat.style.top = `${startTop + p.y - startY}px`; textChat.style.right = "auto"; textChat.style.bottom = "auto"; textChat.style.width = ""; textChat.style.height = ""; e.preventDefault(); } + function onEnd() { if (!dragging) return; dragging = false; textChat.classList.remove("dragging"); clampRect(); } + chatHeader.addEventListener("mousedown", onStart); document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onEnd); + chatHeader.addEventListener("touchstart", onStart, { passive: false }); document.addEventListener("touchmove", onMove, { passive: false }); document.addEventListener("touchend", onEnd); } - function makeResizable() { - let resizing = false; - let startX, startY, startW, startH; - - function getPos(e) { - const p = e.changedTouches ? e.changedTouches[0] : e; - return { x: p.clientX, y: p.clientY }; - } - - function onStart(e) { - resizing = true; - const rect = textChat.getBoundingClientRect(); - const p = getPos(e); - startX = p.x; - startY = p.y; - startW = rect.width; - startH = rect.height; - textChat.classList.add("resizing"); - e.preventDefault(); - e.stopPropagation(); - } - - function onMove(e) { - if (!resizing) return; - const p = getPos(e); - textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`; - textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`; - textChat.style.right = "auto"; - textChat.style.bottom = "auto"; - e.preventDefault(); - } - - function onEnd() { - if (!resizing) return; - resizing = false; - textChat.classList.remove("resizing"); - } - - chatResizeHandle.addEventListener("mousedown", onStart); - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onEnd); - chatResizeHandle.addEventListener("touchstart", onStart, { passive: false }); - document.addEventListener("touchmove", onMove, { passive: false }); - document.addEventListener("touchend", onEnd); -} - -function setCaption(text, kind = "") { - caption.textContent = text; - caption.className = kind; + let resizing = false; let startX, startY, startW, startH; + function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; } + function onStart(e) { resizing = true; const rect = textChat.getBoundingClientRect(); const p = getPos(e); startX = p.x; startY = p.y; startW = rect.width; startH = rect.height; textChat.classList.add("resizing"); e.preventDefault(); e.stopPropagation(); } + function onMove(e) { if (!resizing) return; const p = getPos(e); textChat.style.width = `${Math.max(260, startW + p.x - startX)}px`; textChat.style.height = `${Math.max(120, startH + p.y - startY)}px`; textChat.style.right = "auto"; textChat.style.bottom = "auto"; e.preventDefault(); } + function onEnd() { if (!resizing) return; resizing = false; textChat.classList.remove("resizing"); } + chatResizeHandle.addEventListener("mousedown", onStart); document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onEnd); + chatResizeHandle.addEventListener("touchstart", onStart, { passive: false }); document.addEventListener("touchmove", onMove, { passive: false }); document.addEventListener("touchend", onEnd); } +function setCaption(text, kind = "") { caption.textContent = text; caption.className = kind; } +/** + * Show subtitles with HTML support — renders
links as clickable popup links. + */ function showSubtitles(text) { if (!settings.subtitles) return; clearTimeout(subtitleTimer); subtitles.innerHTML = text; subtitles.classList.add("visible"); - subtitles.querySelectorAll("a[href]").forEach((a) => { a.addEventListener("click", (e) => { e.preventDefault(); @@ -894,51 +349,30 @@ function showSubtitles(text) { }); }); } +function fadeSubtitles(delayMs = 2600) { clearTimeout(subtitleTimer); subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); } -function fadeSubtitles(delayMs = 2600) { - clearTimeout(subtitleTimer); - subtitleTimer = window.setTimeout(() => subtitles.classList.remove("visible"), delayMs); -} - +/** Escape special regex characters in a string */ function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function addChatMessage(role, text, newsItem = null) { +/** + * Add a chat message with optional source link as popup. + * Tự động biến "(nguồn VnExpress)" thành link clickable. + */ +function addChatMessage(role, text) { const msg = document.createElement("div"); msg.className = `chat-message ${role}`; - msg.dataset.text = text; // store plain text for rewrite selection - if (newsItem) { - if (newsItem.image) msg.dataset.img = newsItem.image; - if (newsItem.link) msg.dataset.src = newsItem.link; - } - - // Detect AI keywords in assistant messages for tag display - let detectedAIKeywords = []; - if (role === "assistant" && text) { - const t = text.toLowerCase(); - const AI_PATTERNS = [ - "AI", "trí tuệ nhân tạo", "LLM", "GPT", "Qwen", "Kimi", "ChatGPT", - "Gemini", "OpenAI", "Claude", "machine learning", "deep learning", - "chatbot", "ứng dụng AI", "tạo ảnh", "tạo video", "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", - "neural network", "mạng nơ-ron" - ]; - for (const kw of AI_PATTERNS) { - if (t.includes(kw.toLowerCase())) { - detectedAIKeywords.push(kw); - } - } - } - + + // Inject link clickable: match "(nguồn TênNguồn)" trong text let displayText = text; if (role === "assistant" && latestNewsUrl && latestNewsSource) { const sourceEscaped = escapeRegex(latestNewsSource); const patterns = [ new RegExp(`\\(nguồn\\s*${sourceEscaped}\\)`, 'gi'), new RegExp(`\\(Nguồn\\s*${sourceEscaped}\\)`, 'gi'), + new RegExp(`nguồn\\s*${sourceEscaped}`, 'gi'), ]; - for (const pattern of patterns) { if (pattern.test(displayText)) { displayText = displayText.replace(pattern, (match) => @@ -948,47 +382,10 @@ function addChatMessage(role, text, newsItem = null) { } } } - + msg.innerHTML = displayText; - - // Attach a news image card (for greeting + each subsequent answer). - // Render even when the image is missing: newsImageCardHtml shows a - // gradient fallback using the source initials, so the user always sees - // a clickable source card. - if ((role === "assistant") && newsItem) { - const card = document.createElement("div"); - card.innerHTML = newsImageCardHtml(newsItem).trim(); - const cardEl = card.firstElementChild; - if (cardEl) { - cardEl.addEventListener("click", () => { - if (newsItem.link) window.open(newsItem.link, "_blank", "noopener,noreferrer,width=800,height=600,scrollbars=yes"); - }); - msg.appendChild(cardEl); - } - } - - // Render AI keyword tags under assistant messages - if (role === "assistant" && detectedAIKeywords.length > 0) { - const tagRow = document.createElement("div"); - tagRow.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;"; - for (const kw of detectedAIKeywords) { - const tag = document.createElement("span"); - tag.textContent = "\ud83e\udd16 " + kw; - 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);"; - tagRow.appendChild(tag); - } - msg.appendChild(tagRow); - } - - // Click a message to toggle multi-select (avatar + user messages) for rewrite. - msg.addEventListener("click", () => { - msg.classList.toggle("selected"); - updateRewriteButton(); - }); - chatMessages.appendChild(msg); chatMessages.scrollTop = chatMessages.scrollHeight; - msg.querySelectorAll("a[href]").forEach((a) => { a.addEventListener("click", (e) => { e.preventDefault(); @@ -998,1329 +395,244 @@ function addChatMessage(role, text, newsItem = null) { } }); }); - - if (role === "assistant" && preFetchedNews.length > 0) { - showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE)); - } -} - -function showTextChat(show) { - textChat.hidden = !show; - if (chatMicBtn) chatMicBtn.hidden = !show; - // Remove fakemic flag so mic button can request real mic later - if (show) { - const url = new URL(location.href); - url.searchParams.delete("fakemic"); - history.replaceState(null, "", url.href); - } -} - -/** Handle Vietnamese voice recognition result: fill chat input, auto-send on "Gửi". */ -function onVoiceResult(e) { - if (!e || !e.transcript) return; - if (!chatInput) return; - // Ignore results when AI is speaking or session not in user-turn - if (!sessionInProgress) return; - const text = e.transcript.trim(); - chatInput.value = text; - if (e.isFinal && sessionInProgress) { - // Auto-send trigger phrases (Vietnamese + English) - const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i; - if (triggerRe.test(text)) { - setCaption("Đang gửi…", "live"); - sendTextMessage(); - // Stop voice typing after auto-send — user must click mic again to speak - if (voiceTyper && voiceTyper.active) { - voiceTyper.stop(); - } - } else { - setCaption("Nhấp Send để gửi", "live"); - } - } } -/** Start (or restart) Vietnamese voice recognition in text chat mode. */ -function startVoiceTyping() { - if (!sessionInProgress || !chatInput) return false; - // Don't restart if already listening - if (voiceTyper && voiceTyper.active) return true; - // FIX: stop any previous VoiceTyper first — orphaned instances steal the mic - if (voiceTyper) { - voiceTyper.stop(); - voiceTyper = null; - } - voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); - if (!voiceTyper.init()) { - // Browser may not support Vietnamese locale — try English as fallback - voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false }); - if (!voiceTyper.init()) { - setCaption("VOICE NOT SUPPORTED", "error"); - voiceTyper = null; - return false; - } - } - voiceTyper.onResult = onVoiceResult; - voiceTyper.onError = (e) => console.warn("[voiceTyper]", e); - voiceTyper.onSoundStart = () => setCaption("🎤 Listening…", "live"); - voiceTyper.onSoundEnd = () => { - // Keep listening — only stop when AI speaks or user clicks mic again - if (voiceTyper && voiceTyper.active) { - // Do nothing; continuous listening is intentional - } - }; - voiceTyper.start(); - setCaption("🎤 Listening…", "live"); - return true; -} - -/** Stop Vietnamese voice recognition and return to text input. */ -function stopVoiceTyping() { - if (voiceTyper && voiceTyper.active) { - voiceTyper.stop(); - voiceTyper = null; - setCaption("Voice stopped", "live"); - } -} +function showTextChat(show) { textChat.hidden = !show; if (show) chatInput.focus(); } function sendTextViaSession(text) { if (!client) return false; const st = client._status; - if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") { - return false; - } - setCaption("SENDING…"); - client.sendUserText(text); - client.requestResponse(); - return true; + if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") return false; + setCaption("SENDING…"); client.sendUserText(text); client.requestResponse(); return true; } function sendTextMessage() { const text = chatInput.value.trim(); if (!text) return; - - // Dedup: ignore if this exact message was already sent recently - if (text === _lastVoiceText && Date.now() - _lastVoiceSendMs < 3000) return; - _lastVoiceText = text; - _lastVoiceSendMs = Date.now(); - - addChatMessage("user", text); - - // New topic? re-filter the news list and reset the image sequence. - if (client && sessionInProgress) { - void maybeSwitchTopic(text); - } - + addChatMessage("user", text); chatInput.value = ""; if (!client || !sessionInProgress) { - void startTextSession(text); - return; - } - - if (!sendTextViaSession(text)) { - setCaption("QUEUED…"); - } - // Voice typing stays active for continuous conversation; - // it will auto-stop when the AI starts speaking. -} - -if (newsCloseBtn) { - newsCloseBtn.addEventListener("click", (e) => { - e.stopPropagation(); - newsPanel.hidden = true; - }); -} - -if (newsToggleBtn) { - newsToggleBtn.addEventListener("click", () => { - newsPanel.hidden = !newsPanel.hidden; - }); -} - -if (newsLoadMoreBtn) { - newsLoadMoreBtn.addEventListener("click", () => void loadMoreNews()); -} - -if (chatCloseBtn) { - chatCloseBtn.addEventListener("click", (e) => { - e.stopPropagation(); - textMode = false; - if (voiceTyper) { - voiceTyper.stop(); - voiceTyper = null; - } - showTextChat(false); - textModeBtn.classList.remove("active"); - }); -} - -// ── VNEWS control panel (created dynamically, no index.html change) ───── -let vnewsPanel, vnewsConnectBtn, vnewsStatus, rewriteBtn, vnewsCloseBtn, vnewsToggleBtn; - -function buildVnewsPanel() { - vnewsPanel = document.createElement("div"); - vnewsPanel.id = "vnews-panel"; - vnewsPanel.hidden = true; - - const header = document.createElement("div"); - header.id = "vnews-header"; - const title = document.createElement("span"); - title.id = "vnews-title"; - title.textContent = "VNEWS"; - vnewsCloseBtn = document.createElement("button"); - vnewsCloseBtn.id = "vnews-close-btn"; - vnewsCloseBtn.className = "icon-btn small"; - vnewsCloseBtn.textContent = "✕"; - vnewsCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); vnewsPanel.hidden = true; }); - header.appendChild(title); - header.appendChild(vnewsCloseBtn); - - vnewsConnectBtn = document.createElement("button"); - vnewsConnectBtn.id = "vnews-connect-btn"; - vnewsConnectBtn.className = "vnews-btn"; - vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS"; - vnewsConnectBtn.addEventListener("click", () => void connectVnews()); - - vnewsStatus = document.createElement("div"); - vnewsStatus.id = "vnews-status"; - vnewsStatus.textContent = "Chưa kết nối"; - - rewriteBtn = document.createElement("button"); - rewriteBtn.id = "vnews-rewrite-btn"; - rewriteBtn.className = "vnews-btn"; - rewriteBtn.textContent = "📝 Đăng bài rewrite"; - rewriteBtn.disabled = true; - rewriteBtn.hidden = true; - rewriteBtn.addEventListener("click", () => void doRewriteSelected()); - - vnewsPanel.appendChild(header); - vnewsPanel.appendChild(vnewsConnectBtn); - vnewsPanel.appendChild(vnewsStatus); - vnewsPanel.appendChild(rewriteBtn); - document.body.appendChild(vnewsPanel); - - // A small toggle button on the top bar. - vnewsToggleBtn = document.createElement("button"); - vnewsToggleBtn.id = "vnews-toggle-btn"; - vnewsToggleBtn.className = "icon-btn"; - vnewsToggleBtn.title = "VNEWS"; - vnewsToggleBtn.textContent = "📰"; - vnewsToggleBtn.addEventListener("click", () => { vnewsPanel.hidden = !vnewsPanel.hidden; }); - const topbar = document.querySelector("#topbar"); - if (topbar) topbar.appendChild(vnewsToggleBtn); - - makeVnewsDraggable(); -} - -async function connectVnews() { - vnewsConnectBtn.disabled = true; - vnewsConnectBtn.textContent = "⏳ Đang kết nối…"; - vnewsStatus.textContent = "Đang tải dữ liệu VNEWS…"; - const articles = await getVnewsHomepage(); - if (articles.length) { - vnewsConnected = true; - vnewsArticles = articles; - vnewsStatus.textContent = `✅ Đã kết nối · ${articles.length} bài`; - vnewsConnectBtn.textContent = "✅ VNEWS đã kết nối"; - rewriteBtn.hidden = false; - rewriteBtn.disabled = true; // enabled when a message is selected - updateRewriteButton(); - // Refresh the avatar's instructions so it can answer from VNEWS. - if (client) client.updateSession({ instructions: effectiveInstructions(preFetchedNews) }); - } else { - vnewsStatus.textContent = "❌ Không thể kết nối VNEWS"; - vnewsConnectBtn.disabled = false; - vnewsConnectBtn.textContent = "🔗 Kết nối VNEWS"; + const url = new URL(location.href); url.searchParams.set("fakemic", "1"); history.replaceState(null, "", url.href); + void startTextSession(text); return; } + if (!sendTextViaSession(text)) setCaption("QUEUED…"); } -function updateRewriteButton() { - const selected = chatMessages.querySelectorAll(".chat-message.selected"); - let hasText = false; - selected.forEach((m) => { if ((m.dataset.text || "").trim().length > 0) hasText = true; }); - // Caption hint when connected and nothing selected yet. - if (vnewsConnected && selected.length === 0) { - setCaption("CHỌN 1 HAY NHIỀU TIN NHẮN ĐỂ ĐĂNG VNEWS", "live"); - } - rewriteBtn.disabled = !vnewsConnected || selected.length === 0 || !hasText; -} - -/** Rewrite the selected chat content into a VNEWS wall post. */ -async function doRewriteSelected() { - const selected = [...chatMessages.querySelectorAll(".chat-message.selected")]; - if (selected.length === 0) { setCaption("CHỌN TIN NHẮN ĐỂ REWRITE", "error"); return; } - // Combine ALL selected messages (avatar + user) into one post. - const context = selected - .map((m) => (m.dataset.text || "").trim()) - .filter((t) => t.length > 0) - .join("\n\n"); - if (context.length < 20) { setCaption("TIN NHẮN QUÁ NGẮN", "error"); return; } - // Prefer the original source URL of a news item so VNEWS can pull its image. - let srcUrl = ""; - for (const m of selected) { if (m.dataset.src) { srcUrl = m.dataset.src; break; } } - - rewriteBtn.disabled = true; - rewriteBtn.textContent = "⏳ AI đang xào nấu bài viết…"; - let title = ""; - let article = context; - try { - const sresp = await fetch("/api/summarize", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: context }), - }); - if (sresp.ok) { - const sdata = await sresp.json(); - if (sdata && sdata.body) { article = sdata.body; title = sdata.title || ""; } - } - } catch (e) { /* fall back to raw context */ } +if (chatCloseBtn) chatCloseBtn.addEventListener("click", (e) => { e.stopPropagation(); textMode = false; showTextChat(false); textModeBtn.classList.remove("active"); }); - rewriteBtn.textContent = "⏳ Đăng…"; - try { - // Resolve a REAL hero image from the source article (VNEWS scrapes og:image). - let heroImage = srcUrl; // fallback: the article URL itself - if (srcUrl) { - try { - const artResp = await fetch(`${VNEWS_BASE}/api/article?url=${encodeURIComponent(srcUrl)}`, { headers: { "Accept": "application/json" } }); - if (artResp.ok) { - const art = await artResp.json(); - if (art && art.og_image) heroImage = art.og_image; - } - } catch (_) { /* keep fallback */ } - } - // Publish the AI-composed article directly (not just re-split). - const payload = { body: article }; - if (title) payload.title = title; - if (heroImage) payload.image = heroImage; - if (srcUrl) payload.url = srcUrl; - const resp = await fetch(`${VNEWS_BASE}/api/publish`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - const data = await resp.json(); - if (data && data.post) { - const shown = (title ? `**${title}**\n\n` : "") + article; - addChatMessage("assistant", `✅ Đã đăng bài AI tổng hợp (${selected.length} tin nhắn) lên VNEWS:\n${shown}`); - setCaption("ĐÃ ĐĂNG LÊN VNEWS", "live"); - } else { - addChatMessage("assistant", `⚠️ VNEWS: ${data.error || "không đăng được bài"}.`); - } - } catch (e) { - addChatMessage("assistant", "⚠️ Lỗi khi kết nối VNEWS để đăng bài."); - } - rewriteBtn.textContent = "📝 Đăng bài rewrite"; - // Clear selection after posting. - selected.forEach((m) => m.classList.remove("selected")); - updateRewriteButton(); -} - -function makeVnewsDraggable() { - let dragging = false, startX, startY, startLeft, startTop; - const header = vnewsPanel.querySelector("#vnews-header"); - function getPos(e) { const p = e.changedTouches ? e.changedTouches[0] : e; return { x: p.clientX, y: p.clientY }; } - header.addEventListener("mousedown", (e) => { - if (e.target.closest("#vnews-close-btn")) return; - dragging = true; - const rect = vnewsPanel.getBoundingClientRect(); - startX = e.clientX; startY = e.clientY; - startLeft = rect.left; startTop = rect.top; - vnewsPanel.classList.add("dragging"); - e.preventDefault(); - }); - document.addEventListener("mousemove", (e) => { - if (!dragging) return; - const vw = window.innerWidth, vh = window.innerHeight; - const w = vnewsPanel.offsetWidth, h = vnewsPanel.offsetHeight; - const l = Math.max(10, Math.min(startLeft + e.clientX - startX, vw - w - 10)); - const t = Math.max(10, Math.min(startTop + e.clientY - startY, vh - h - 10)); - vnewsPanel.style.left = `${l}px`; vnewsPanel.style.top = `${t}px`; - vnewsPanel.style.right = "auto"; vnewsPanel.style.bottom = "auto"; - }); - document.addEventListener("mouseup", () => { dragging = false; vnewsPanel.classList.remove("dragging"); }); -} - -// ── Button logic ──────────────────────────────────────────────────────── +// ── Button ─────────────────────────────────────────────────────────────── let mainAction = "start"; - -function setMainButton(action, label) { - mainAction = action; - mainBtnLabel.textContent = label; - mainBtn.disabled = action === "busy"; - mainBtn.classList.toggle("live", action === "stop"); - muteBtn.hidden = action !== "stop"; - textModeBtn.hidden = false; -} +function setMainButton(action, label) { mainAction = action; mainBtnLabel.textContent = label; mainBtn.disabled = action === "busy"; mainBtn.classList.toggle("live", action === "stop"); muteBtn.hidden = action !== "stop"; textModeBtn.hidden = false; } const CAPTIONS = { - idle: "TAP TO TALK", - "creating-session": "REQUESTING A SLOT…", - queued: "WAITING IN LINE…", - "your-turn": "YOUR TURN, TAP TO JOIN", - connecting: "CONNECTING…", - connected: "GO AHEAD, I'M LISTENING", - "user-speaking": "LISTENING", - processing: "THINKING…", - "ai-speaking": "SPEAKING", - closed: "TAP TO TALK", - error: "SOMETHING BROKE, TAP TO RETRY", + idle: "TAP TO TALK", "creating-session": "REQUESTING A SLOT…", queued: "WAITING IN LINE…", + "your-turn": "YOUR TURN, TAP TO JOIN", connecting: "CONNECTING…", connected: "GO AHEAD, I'M LISTENING", + "user-speaking": "LISTENING", processing: "THINKING…", "ai-speaking": "SPEAKING", + closed: "TAP TO TALK", error: "SOMETHING BROKE, TAP TO RETRY", }; function onStatus(status) { stage.setConversationState(status); setCaption(CAPTIONS[status] ?? status, status === "error" ? "error" : status === "idle" || status === "closed" ? "" : "live"); - switch (status) { - case "idle": - case "closed": - setMainButton("start", "Start talking"); - break; - case "error": - setMainButton("start", "Retry"); - break; - case "creating-session": - case "connecting": - setMainButton("busy", "Connecting…"); - break; - case "queued": - setMainButton("stop", "Leave queue"); - break; - case "your-turn": - setMainButton("join", "Join now"); - break; - default: - setMainButton("stop", "End conversation"); - break; - } - - if (status === "ai-speaking") { - // AI is speaking — stop voice typing so mic doesn't pick up audio - if (voiceTyper && voiceTyper.active) { - voiceTyper.stop(); - } - subtitles.classList.add("visible"); - } - - if (status === "user-speaking") { - // User's turn to speak — auto-start voice typing so user can speak - // immediately without clicking the mic button - setTimeout(() => startVoiceTyping(), 400); - subtitles.classList.remove("visible"); - showTextChat(textMode); + case "idle": case "closed": setMainButton("start", "Start talking"); break; + case "error": setMainButton("start", "Retry"); break; + case "creating-session": case "connecting": setMainButton("busy", "Connecting…"); break; + case "queued": setMainButton("stop", "Leave queue"); break; + case "your-turn": setMainButton("join", "Join now"); break; + default: setMainButton("stop", "End conversation"); break; } + if (status === "user-speaking") { subtitles.classList.remove("visible"); showTextChat(textMode); } } -// ── Tool runner ───────────────────────────────────────────────────────── +// ── Tool runner ────────────────────────────────────────────────────────── function runTool(name, argsJson, callId) { if (!client) return; - let args = {}; try { args = JSON.parse(argsJson || "{}"); } catch {} - - if (name === "get_vnews_news") { - const query = (args.query || "").trim(); - let articles; - if (vnewsConnected) articles = searchVnews(query, 8); - else articles = []; // not connected yet - const out = vnewsLinksAsHtml(articles); - client.sendToolOutput(callId, `Kết quả từ VNEWS${query ? ` cho "${query}"` : " (mới nhất)"}:\n${out}`); - client.requestResponse(); - return; - } - if (name === "get_current_datetime") { const now = new Date(); - const dateStr = formatVietnameseDate(now); - const timeStr = formatVietnameseTime(now); - client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Thời gian hiện tại là ${timeStr}.`); - client.requestResponse(); - return; + client.sendToolOutput(callId, `Current date/time: ${now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" })} at ${now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}.`); + client.requestResponse(); return; } - if (name === "search_wikipedia") { - const query = args.query || ""; - if (!query) { - client.sendToolOutput(callId, `No query.`); - client.requestResponse(); - return; - } - - fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`) - .then(r => r.json()) - .then(data => { - if (data.extract) { - client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`); - client.requestResponse(); - return; - } - fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`) - .then(r => r.json()) - .then(sd => { - if (!sd.results?.length) { - client.sendToolOutput(callId, `No Wikipedia results for "${query}".`); - client.requestResponse(); - return; - } - fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`) - .then(r => r.json()) - .then(sum => { - client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0, 3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`); - client.requestResponse(); - }); - }); - }) - .catch(() => { - client.sendToolOutput(callId, `Wikipedia search failed.`); - client.requestResponse(); + const query = /** @type {string} */ (args.query || ""); + if (!query) { client.sendToolOutput(callId, `No query.`); client.requestResponse(); return; } + fetch(`/api/wiki/summary?title=${encodeURIComponent(query.replace(/\s+/g, "_"))}`).then(r => r.json()).then(data => { + if (data.extract) { client.sendToolOutput(callId, `From Wikipedia (${data.title}): ${data.extract}\nSource: ${data.url}`); client.requestResponse(); return; } + fetch(`/api/wiki/search?q=${encodeURIComponent(query)}`).then(r => r.json()).then(sd => { + if (!sd.results?.length) { client.sendToolOutput(callId, `No Wikipedia results for "${query}".`); client.requestResponse(); return; } + fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`).then(r => r.json()).then(sum => { client.sendToolOutput(callId, sum.extract ? `From Wikipedia (${sum.title}): ${sum.extract}\nSource: ${sum.url}` : `Wikipedia: ${sd.results.slice(0,3).map(r => `${r.title}: ${r.snippet}`).join("\n")}`); client.requestResponse(); }); }); - return; + }).catch(() => { client.sendToolOutput(callId, `Wikipedia search failed.`); client.requestResponse(); }); return; } - if (name === "search_web") { - const query = args.query || ""; - if (!query) { - client.sendToolOutput(callId, `No query.`); + const query = /** @type {string} */ (args.query || ""); + if (!query) { client.sendToolOutput(callId, `No query.`); client.requestResponse(); return; } + fetch(`/api/web/search?q=${encodeURIComponent(query)}`).then(r => r.json()).then(data => { + if (!data.results?.length) { client.sendToolOutput(callId, `No web results for "${query}".`); client.requestResponse(); return; } + client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0,3).map((r,i) => `${i+1}. ${r.title}\n ${r.snippet}`).join("\n")}`); client.requestResponse(); - return; - } - - fetch(`/api/web/search?q=${encodeURIComponent(query)}`) - .then(r => r.json()) - .then(data => { - if (!data.results?.length) { - client.sendToolOutput(callId, `No web results for "${query}".`); - client.requestResponse(); - return; - } - client.sendToolOutput(callId, `Web results for "${query}":\n${data.results.slice(0, 3).map((r, i) => `${i + 1}. ${r.title}\n ${r.snippet}`).join("\n")}`); - client.requestResponse(); - }) - .catch(() => { - client.sendToolOutput(callId, `Web search failed.`); - client.requestResponse(); - }); - return; + }).catch(() => { client.sendToolOutput(callId, `Web search failed.`); client.requestResponse(); }); return; + } + if (name === "summarize_news") { + const url = /** @type {string} */ (args.url || ""); + if (!url) { client.sendToolOutput(callId, `No URL provided.`); client.requestResponse(); return; } + fetch(`/api/news/summary?url=${encodeURIComponent(url)}`).then(r => r.json()).then(data => { + if (data.content && data.content.length > 50) { + client.sendToolOutput(callId, `Tóm tắt tin tức từ ${url}:\n\n${data.content.slice(0, 2000)}`); + } else { + client.sendToolOutput(callId, `Không thể đọc nội dung từ ${url}. Vui lòng thử lại sau.`); + } + client.requestResponse(); + }).catch(() => { client.sendToolOutput(callId, `Failed to fetch news summary.`); client.requestResponse(); }); return; } - const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`; - client.sendToolOutput(callId, result); - client.requestResponse(); + client.sendToolOutput(callId, result); client.requestResponse(); } async function connectSession(c) { - try { - await c.connect(); - return c; - } catch (err) { - const code = err?.code; - if (code === "limit") { - setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error"); - } else if (code === "queue-full") { - setCaption("ALL SEATS TAKEN, TRY AGAIN", "error"); - } else if (code === "join-expired") { - setCaption("SPOT EXPIRED, TAP TO RETRY", "error"); - } else { - setCaption("CONNECTION FAILED, TAP TO RETRY", "error"); - } - return null; + try { await c.connect(); return c; } + catch (err) { + const code = /** @type {Error & {code?: string}} */ (err)?.code; + if (code === "limit") setCaption("DAILY LIMIT REACHED, TRY AGAIN TOMORROW", "error"); + else if (code === "queue-full") setCaption("ALL SEATS TAKEN, TRY AGAIN", "error"); + else if (code === "join-expired") setCaption("SPOT EXPIRED, TAP TO RETRY", "error"); + else if (code !== "aborted") { console.error(err); setCaption("COULD NOT CONNECT, TAP TO RETRY", "error"); } + await endSession(true); return null; } } -async function startVoiceSession(micStream) { +async function startVoiceSession() { if (sessionInProgress) return; sessionInProgress = true; await stage.resume(); - - if (!micStream) { - if (new URLSearchParams(location.search).has("fakemic")) { - const ctx = stage.audioCtx; - micStream = ctx.createMediaStreamDestination().stream; - } else { - try { - micStream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - } - }); - } catch { - setCaption("MIC BLOCKED", "error"); - sessionInProgress = false; - return; - } - } - } - - const audioCtx = stage.audioCtx; - const voiceSink = stage.voiceSink; - if (!audioCtx || !voiceSink) { - sessionInProgress = false; - return; - } - - const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews()); - if (!preFetchedNews.length) { - preFetchedNews = newsItems; + let micStream; + if (new URLSearchParams(location.search).has("fakemic")) { const ctx = /** @type {AudioContext} */ (stage.audioCtx); micStream = ctx.createMediaStreamDestination().stream; } + else { try { micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); } catch { setCaption("MIC BLOCKED", "error"); sessionInProgress = false; return; } } + const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; + if (!audioCtx || !voiceSink) { sessionInProgress = false; return; } + const greeting = preFetchedGreeting || (await getHotNewsGreeting()); + if (greeting) { + latestNewsUrl = greeting.link; + latestNewsSource = greeting.sourceName || getSourceName(new URL(greeting.link).hostname); } - - // Initialize the image sequence from the current news set. - newsSeq = preFetchedNews.slice(); - newsSeqIdx = -1; - newsTopicQuery = ""; - - if (newsItems.length > 0) { - latestNewsUrl = newsItems[0].link; - setCurrentNews(newsItems[0]); - try { - latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname); - } catch { - latestNewsSource = "VnExpress"; - } - } - const c = new S2sWsRealtimeClient({ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), - voice: settings.voice, - instructions: effectiveInstructions(newsItems), - micStream, - audioContext: audioCtx, - outputNode: voiceSink, - workletBaseUrl: "/worklets/", - tools: TOOL_DEFS, - _textOnly: false, + voice: settings.voice, instructions: effectiveInstructions(greeting), + micStream, audioContext: audioCtx, outputNode: voiceSink, + workletBaseUrl: "/worklets/", tools: TOOL_DEFS, _textOnly: false, }); - - client = c; - _attachClientEvents(c); + client = c; _attachClientEvents(c); setCaption("REQUESTING A SLOT…"); - const ok = await connectSession(c); if (!ok) return; - - if (!autoGreetingSent) { - c.requestResponse(); - } + if (!autoGreetingSent) { c.requestResponse(); } } +/** + * Start a text-only session. + */ async function startTextSession(initialText) { - if (sessionInProgress) { - if (client && initialText) { - client.sendUserText(initialText); - client.requestResponse(); - } - return; - } - + if (sessionInProgress) { if (client && initialText) { client.sendUserText(initialText); client.requestResponse(); } return; } sessionInProgress = true; await stage.resume(); - - const audioCtx = stage.audioCtx; - const voiceSink = stage.voiceSink; - if (!audioCtx || !voiceSink) { - sessionInProgress = false; - return; - } - - const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews()); - if (!preFetchedNews.length) { - preFetchedNews = newsItems; - } - - newsSeq = preFetchedNews.slice(); - newsSeqIdx = -1; - newsTopicQuery = ""; - - if (newsItems.length > 0) { - latestNewsUrl = newsItems[0].link; - setCurrentNews(newsItems[0]); - try { - latestNewsSource = getSourceName(new URL(newsItems[0].link).hostname); - } catch { - latestNewsSource = "VnExpress"; - } + const audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; + if (!audioCtx || !voiceSink) { sessionInProgress = false; return; } + const newsHook = preFetchedGreeting || (await getHotNewsGreeting()); + if (newsHook) { + latestNewsUrl = newsHook.link; + latestNewsSource = newsHook.sourceName || getSourceName(new URL(newsHook.link).hostname); } - const c = new S2sWsRealtimeClient({ ...(config.lb ? { sessionUrl: "api/session" } : { directUrl: settings.directUrl }), - voice: settings.voice, - instructions: effectiveInstructions(newsItems), - audioContext: audioCtx, - outputNode: voiceSink, - workletBaseUrl: "/worklets/", - tools: TOOL_DEFS, - _textOnly: true, + voice: settings.voice, instructions: effectiveInstructions(newsHook), + audioContext: audioCtx, outputNode: voiceSink, + workletBaseUrl: "/worklets/", tools: TOOL_DEFS, _textOnly: true, }); - - client = c; - _attachClientEvents(c); - textMode = true; - showTextChat(true); - textModeBtn.classList.add("active"); - + client = c; _attachClientEvents(c); + textMode = true; showTextChat(true); textModeBtn.classList.add("active"); const ok = await connectSession(c); if (!ok) return; - - if (!autoGreetingSent) { - c.requestResponse(); - } - - if (initialText) { - c.sendUserText(initialText); - c.requestResponse(); - } + if (!autoGreetingSent) { c.requestResponse(); } + if (initialText) { c.sendUserText(initialText); c.requestResponse(); } } function _attachClientEvents(c) { - c.addEventListener("status", (e) => { - onStatus(e.detail.status); - }); - - c.addEventListener("queue", (e) => { - const { position } = e.detail; - setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); - }); - + c.addEventListener("status", (e) => onStatus(/** @type {CustomEvent} */ (e).detail.status)); + c.addEventListener("queue", (e) => { const { position } = /** @type {CustomEvent} */ (e).detail; setCaption(position > 0 ? `#${position} IN LINE…` : "ALMOST THERE…", "live"); }); c.addEventListener("transcript", (e) => { - const { role, text } = e.detail; - if (role === "assistant" && text) { - const normalized = smartNormalize(text); - showSubtitles(normalized); - // Attach the next image of the CURRENT news item (loops through its images). - const img = nextNewsImage(); - const newsItem = curNews ? { ...curNews, image: img || curNews.image } : null; - addChatMessage("assistant", normalized, newsItem); + const { role, text } = /** @type {CustomEvent} */ (e).detail; + if (role === "assistant" && text) { + const normalized = smartNormalize(text); + showSubtitles(normalized); + addChatMessage("assistant", normalized); } }); - - c.addEventListener("response-finished", () => { - autoGreetingSent = true; - fadeSubtitles(); - if (preFetchedNews.length > 0) { - showNewsPanel(preFetchedNews.slice(0, NEWS_PAGE_SIZE)); - } - _topicTagsRendered = false; - try { void renderTopicTags(); } catch (e) { console.warn("[topic] render:", e); } - // FIX: auto-start voice typing after AI finishes speaking so the user can respond immediately - if (sessionInProgress) { - setTimeout(() => startVoiceTyping(), 500); - } - }); - - c.addEventListener("toolcall", (e) => { - const { name, arguments: args, callId } = e.detail; - runTool(name, args, callId); - }); - - c.addEventListener("server-error", (e) => { - console.warn("server error:", e.detail.error); - }); - - c.addEventListener("error", () => { - void endSession(); + c.addEventListener("response-finished", () => { + autoGreetingSent = true; + fadeSubtitles(); + // Show news cards after each response + showNewsCards(); }); + c.addEventListener("toolcall", (e) => { const { name, arguments: args, callId } = /** @type {CustomEvent} */ (e).detail; runTool(name, args, callId); }); + c.addEventListener("server-error", (e) => console.warn("server error:", /** @type {CustomEvent} */ (e).detail.error)); + c.addEventListener("error", () => { void endSession(); }); } async function endSession(silent = false) { - const c = client; - client = null; - sessionInProgress = false; - autoGreetingSent = false; - - // Stop voice recognition if active - if (voiceTyper) { - voiceTyper.stop(); - voiceTyper = null; - } - - if (c) { - if (c.options.micStream) { - for (const track of c.options.micStream?.getTracks() ?? []) { - track.stop(); - } - } - await c.close().catch(() => {}); - } - - stage.setConversationState("idle"); - subtitles.classList.remove("visible"); - - if (newsPanel) { - newsPanel.hidden = true; - } - - if (!silent) { - setCaption(CAPTIONS.idle); - setMainButton("start", "Start talking"); - // Show today's real trending topics immediately (also re-shown after greeting). - try { void renderTopicTags(); } catch (e) { console.warn("[topic] startWithSelection:", e); } - } + const c = client; client = null; sessionInProgress = false; autoGreetingSent = false; + if (c) { if (c.options.micStream) for (const track of c.options.micStream?.getTracks() ?? []) track.stop(); await c.close().catch(() => {}); } + stage.setConversationState("idle"); subtitles.classList.remove("visible"); + hideNewsCards(); + if (!silent) setCaption(CAPTIONS.idle); setMainButton("start", "Start talking"); } -// ── UI events ─────────────────────────────────────────────────────────── +// ── UI events ──────────────────────────────────────────────────────────── mainBtn.addEventListener("click", () => { if (mainAction === "start") { if (sessionInProgress) return; - // Default: always start text chat (no mic required) - void startTextSession(); - } else if (mainAction === "join") { - stage.resume(); - client?.join(); - } else if (mainAction === "stop") { - void endSession(); + if (new URLSearchParams(location.search).has("fakemic")) { + void startTextSession(); + } else { + void startVoiceSession(); + } } + else if (mainAction === "join") { stage.resume(); client?.join(); } + else if (mainAction === "stop") void endSession(); }); - -muteBtn.addEventListener("click", () => { - muted = !muted; - client?.setMuted(muted); - muteBtn.classList.toggle("active", muted); - muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); -}); - -textModeBtn.addEventListener("click", () => { - textMode = !textMode; - showTextChat(textMode); - textModeBtn.classList.toggle("active", textMode); -}); - +muteBtn.addEventListener("click", () => { muted = !muted; client?.setMuted(muted); muteBtn.classList.toggle("active", muted); muteBtn.setAttribute("aria-label", muted ? "Unmute microphone" : "Mute microphone"); }); +textModeBtn.addEventListener("click", () => { textMode = !textMode; showTextChat(textMode); textModeBtn.classList.toggle("active", textMode); }); chatSendBtn.addEventListener("click", () => sendTextMessage()); - -chatMicBtn.addEventListener("click", async () => { - if (!sessionInProgress) return; - // If already listening, stop - if (voiceTyper && voiceTyper.active) { - stopVoiceTyping(); - return; - } - // Request mic access - const micStream = await navigator.mediaDevices.getUserMedia({ - audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } - }).catch(() => null); - if (!micStream) { - setCaption("MIC BLOCKED", "error"); - return; - } - micStream.getTracks().forEach((t) => t.stop()); - startVoiceTyping(); -}); - -chatInput.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - sendTextMessage(); - } -}); - -chatAvatarSelect.addEventListener("change", () => { - setAvatarFromSelect(chatAvatarSelect.value); - void reloadAvatar(); -}); - -settingsAvatarSelect.addEventListener("change", () => { - setAvatarFromSelect(settingsAvatarSelect.value); - chatAvatarSelect.value = settingsAvatarSelect.value; - void reloadAvatar(); -}); - -settingsBtn.addEventListener("click", () => { - inputVoice.value = settings.voice; - inputInstructions.value = settings.instructions; - inputDirectUrl.value = settings.directUrl; - inputSubtitles.checked = settings.subtitles; - settingsAvatarSelect.value = chatAvatarSelect.value; - settingsDialog.showModal(); -}); - +chatInput.addEventListener("keypress", (e) => { if (e.key === "Enter") sendTextMessage(); }); +chatAvatarSelect.addEventListener("change", () => { setAvatarFromSelect(chatAvatarSelect.value); void reloadAvatar(); }); +settingsAvatarSelect.addEventListener("change", () => { setAvatarFromSelect(settingsAvatarSelect.value); chatAvatarSelect.value = settingsAvatarSelect.value; void reloadAvatar(); }); +settingsBtn.addEventListener("click", () => { inputVoice.value = settings.voice; inputInstructions.value = settings.instructions; inputDirectUrl.value = settings.directUrl; inputSubtitles.checked = settings.subtitles; settingsAvatarSelect.value = chatAvatarSelect.value; settingsDialog.showModal(); }); settingsDialog.addEventListener("close", () => { - settings = { - voice: inputVoice.value || DEFAULT_VOICE, - avatar: settingsAvatarSelect.value || "", - instructions: inputInstructions.value, - directUrl: inputDirectUrl.value.trim(), - subtitles: inputSubtitles.checked - }; - - if (settings.avatar !== "vuong.glb") { - autoGreetingSent = false; - } - - saveSettings(); - chatAvatarSelect.value = settings.avatar; - - if (!settings.subtitles) { - subtitles.classList.remove("visible"); - } - - client?.updateSession({ - voice: settings.voice, - instructions: effectiveInstructions(preFetchedNews) - }); + settings = { voice: inputVoice.value || DEFAULT_VOICE, avatar: settingsAvatarSelect.value || "", instructions: inputInstructions.value, directUrl: inputDirectUrl.value.trim(), subtitles: inputSubtitles.checked }; + if (settings.avatar !== "vuong.glb") autoGreetingSent = false; + saveSettings(); chatAvatarSelect.value = settings.avatar; if (!settings.subtitles) subtitles.classList.remove("visible"); client?.updateSession({ voice: settings.voice, instructions: effectiveInstructions(preFetchedGreeting || "") }); }); +window.addEventListener("beforeunload", () => { client?.close(); }); -window.addEventListener("beforeunload", () => { - client?.close(); -}); - -// ── Boot ──────────────────────────────────────────────────────────────── +// ── Boot ───────────────────────────────────────────────────────────────── async function boot() { - // 1) Reveal the picker IMMEDIATELY — first screen, never a loader. - showPicker(); - - // 2) Voice