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..0248649771a7a19c24bd78cc77108f685d92a342 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). \ No newline at end of file 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…
@@ -56,7 +37,6 @@ Text Chat
-
- - -
-

📸 Ảnh đại diện

-

preview

-
- - - -
-
-
@@ -161,17 +124,6 @@
- -
diff --git a/index.ts b/index.ts index 6ac7a5a5d8b9145f33b2eff3efa0b2f19a5dc47e..23da423e55dc8ba1690df188ec26389609bc3b96 100644 --- a/index.ts +++ b/index.ts @@ -2,14 +2,13 @@ * Gemma Avatar — realtime voice/text chat with a 3D talking-head avatar. */ 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 +20,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 +28,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,430 +47,142 @@ 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 = []; +/** Extract OG image from HTML */ +async function fetchOgImage(url) { try { - const dir = await readdir(avatarsDir, { withFileTypes: true }); - for (const entry of dir) { - if (entry.isFile()) { - files.push(entry.name); + const resp = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, + signal: AbortSignal.timeout(5000), + }); + const html = await resp.text(); + + // Try og:image + const ogMatch = html.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) + || html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i); + if (ogMatch) return ogMatch[1]; + + // Try twitter:image + const twitterMatch = html.match(/]+property=["']twitter:image["'][^>]+content=["']([^"']+)["']/i) + || html.match(/]+content=["']([^"']+)["'][^>]+property=["']twitter:image["']/i); + if (twitterMatch) return twitterMatch[1]; + + // Try first img tag + const imgMatch = html.match(/]+src=["']([^"']+)["']/i); + if (imgMatch) { + let imgUrl = imgMatch[1]; + // Handle relative URLs + if (imgUrl.startsWith("//")) imgUrl = "https:" + imgUrl; + else if (imgUrl.startsWith("/")) { + const baseUrl = new URL(url); + imgUrl = baseUrl.origin + imgUrl; } + return imgUrl; } } 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 = [ - "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", -]; - -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 = { - "Ô": "Ô", "ò": "ò", "Ò": "Ò", "ô": "ô", - "à": "à", "À": "À", "é": "é", "É": "É", - "ù": "ù", "Ù": "Ù", "â": "â", "Â": "Â", - "ê": "ê", "Ê": "Ê", "î": "î", "Î": "Î", - "ô": "ô", "Ô": "Ô", "û": "û", "Û": "Û", - "ñ": "ñ", "Ñ": "Ñ", "&": "&", "<": "<", ">": ">", - """: '"', "'": "'", "'": "'", " ": " ", - "ý": "ý", "Ý": "Ý", "á": "á", "Á": "Á", - "é": "é", "É": "É", "í": "í", "Í": "Í", - "ó": "ó", "Ó": "Ó", "ú": "ú", "Ú": "Ú", - "ü": "ü", "Ü": "Ü", "ö": "ö", "Ö": "Ö", - "ä": "ä", "Ä": "Ä", "ñ": "ñ", "Ñ": "Ñ", - "ç": "ç", "Ç": "Ç", "ñ": "ñ", "Ñ": "Ñ", - }; - for (const [entity, char] of Object.entries(namedEntities)) { - decoded = decoded.split(entity).join(char); - } - return decoded; + return null; } -async function fetchHotNews() { - const parsed = []; +/** Fetch hot Vietnamese news from multiple RSS feeds */ +async function fetchHotNews(limit = 20) { + const 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", + ]; + + const items = []; // {title, link, source, image} const seenLinks = new Set(); - await Promise.all( - FEEDS.map(async (feedUrl) => { + + for (const feedUrl of feeds) { + if (items.length >= limit) break; + + try { + const resp = await fetch(feedUrl, { + headers: { "User-Agent": "Mozilla/5.0" }, + signal: AbortSignal.timeout(5000), + }); + const xml = await resp.text(); + + // Extract source name from feed URL + let sourceName = "Unknown"; 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 url = new URL(feedUrl); + const hostname = url.hostname.replace(/^www\./, ""); + const sourceMap = { + "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", + }; + sourceName = sourceMap[hostname] || hostname; + } catch {} + + // Parse ...... + const itemRegex = /[\s\S]*?<\/item>/gi; + const titleRegex = /]*>([\s\S]*?)<\/title>/i; + const linkRegex = /]*>([\s\S]*?)<\/link>/i; + const enclosureRegex = /]+url=["']([^"']+)["'][^>]*type=["']image[^"']*["']/i; + const mediaContentRegex = /]+url=["']([^"']+)["'][^>]*type=["']image[^"']*["']/i; + const allItems = xml.match(itemRegex) || []; + + for (const item of allItems) { + if (items.length >= limit) break; + + 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) }); + + // Try to extract image from enclosure or media:content + let image = null; + const encMatch = item.match(enclosureRegex); + if (encMatch) image = encMatch[1]; + else { + const mediaMatch = item.match(mediaContentRegex); + if (mediaMatch) image = mediaMatch[1]; + } + + items.push({ + title: cleanTitle, + link: cleanLink, + source: sourceName, + image: image // May be null, will fetch OG image later if needed + }); } } - 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; -} -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); + // Shuffle and return + const shuffled = items.sort(() => Math.random() - 0.5); + return shuffled.slice(0, limit); } +/** Fetch and extract article content for summarization */ async function fetchArticleContent(url) { try { const resp = await fetch(url, { @@ -493,6 +190,8 @@ async function fetchArticleContent(url) { signal: AbortSignal.timeout(8000), }); const html = await resp.text(); + + // Extract text content (simple extraction) let text = html .replace(/]*>[\s\S]*?<\/script>/gi, "") .replace(/]*>[\s\S]*?<\/style>/gi, "") @@ -501,208 +200,68 @@ async function fetchArticleContent(url) { .replace(/]*>[\s\S]*?<\/footer>/gi, "") .replace(/<[^>]+>/g, " ") .replace(/&[a-z]+;/g, " ") - .replace(/\s+/g, " ").trim(); - return text.slice(0, 4000); + .replace(/\s+/g, " ") + .trim(); + + return text.slice(0, 4000); // Return first 4000 chars } 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; - } - const data = await resp.json(); - console.log(`[sidecar] queued ${path} for dataset upload`); - return true; - } catch (err) { - console.warn(`[sidecar] error: ${err}`); - return false; - } -} - -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: { "/": index, "/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, + const page = parseInt(url.searchParams.get("page") || "1"); + const pageSize = parseInt(url.searchParams.get("pageSize") || "10"); + + // Fetch more items than needed to account for any filtering + const allNews = await fetchHotNews(page * pageSize + 10); + + // Apply pagination + const startIdx = (page - 1) * pageSize; + const pageItems = allNews.slice(startIdx, startIdx + pageSize); + + // Fetch OG images for items that don't have images (limit to avoid too many requests) + const itemsWithImages = await Promise.all( + pageItems.map(async (item) => { + if (!item.image) { + item.image = await fetchOgImage(item.link); + } + return item; + }) + ); + + return Response.json({ + items: itemsWithImages, + page, + pageSize, + hasMore: allNews.length > startIdx + pageSize }); }, }, - "/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 }); - }, - }, - "/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}` }); - }, - }, "/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 +270,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 +288,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 +306,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 +334,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 +377,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..afd0002259a9102e2a9b4a2d1b016703a2aadc59 100644 --- a/src/app.js +++ b/src/app.js @@ -1,7 +1,6 @@ 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,195 +8,22 @@ 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; - -async function getHotNews() { +async function getHotNews(page = 1, pageSize = 10) { let items = []; try { - const resp = await fetch("/api/news/hot"); + const resp = await fetch(`/api/news/hot?page=${page}&pageSize=${pageSize}`); if (resp.ok) { const data = await resp.json(); if (data.items && data.items.length > 0) { - items = data.items.slice(0, NEWS_PAGE_SIZE); + items = data.items; + newsHasMore = data.hasMore || false; } } } 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); -} - -/** 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; - } - } - } 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("
"); -} - 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.", @@ -206,8 +32,7 @@ const DEFAULT_INSTRUCTIONS = [ "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.", "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(" "); @@ -224,7 +49,6 @@ const TOOL_DEFS = [ { 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"] } }, ]; // ── DOM elements ───────────────────────────────────────────────────────── @@ -234,6 +58,7 @@ const mainBtn = $("#main-btn"); const mainBtnLabel = $("#main-btn-label"); const muteBtn = $("#mute-btn"); const textModeBtn = $("#text-mode-btn"); +const newsToggleFooter = $("#news-toggle-footer"); const caption = $("#caption"); const subtitles = $("#subtitles"); const loading = $("#loading"); @@ -249,19 +74,18 @@ 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 newsListContainer = $("#news-list-container"); 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"); +const newsChevronUp = $("#news-chevron-up"); +const newsChevronDown = $("#news-chevron-down"); +const newsLoadMoreBtn = $("#news-load-more"); // ── State ─────────────────────────────────────────────────────────────── const stage = new AvatarStage(stageNode); @@ -269,26 +93,18 @@ let client = null; let muted = false; let subtitleTimer = 0; let textMode = false; -let voiceTyper = null; let config = { lb: false, allowDirect: true }; +let avatarList = []; let sessionInProgress = false; -let _lastVoiceText = ""; -let _lastVoiceSendMs = 0; let autoGreetingSent = false; let preFetchedNews = []; 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 newsCollapsed = false; +let newsPage = 1; +const NEWS_PAGE_SIZE = 10; +let newsLoading = false; 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 { @@ -328,49 +144,32 @@ function getSourceName(hostname) { 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", +// Format date/time in Vietnamese +function formatDateTimeVN(date) { + const weekdays = ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"]; + const months = ["tháng 1", "tháng 2", "tháng 3", "tháng 4", "tháng 5", "tháng 6", + "tháng 7", "tháng 8", "tháng 9", "tháng 10", "tháng 11", "tháng 12"]; + + const day = date.getDate(); + const month = months[date.getMonth()]; + const year = date.getFullYear(); + const weekday = weekdays[date.getDay()]; + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + + return { + dateStr: `${weekday}, ngày ${day} ${month} năm ${year}`, + timeStr: `${hours}:${minutes}` }; - 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) { 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, timeStr } = formatDateTimeVN(now); + const dateLine = `Hôm nay là ${dateStr}. Bây giờ là ${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]; @@ -379,389 +178,172 @@ function effectiveInstructions(newsItems) { } 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}`; + + const base = `${dateLine}\n\n${DEFAULT_INSTRUCTIONS}${newsInst}`; 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, """)}"` - : ""; - 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; +function toggleNewsPanel() { + if (!newsPanel) return; + + if (newsPanel.hidden) { newsPanel.hidden = false; - return; + newsToggleFooter?.classList.add("active"); + loadNewsPage(1, true); + } else { + newsPanel.hidden = true; + newsToggleFooter?.classList.remove("active"); } - - 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; +function toggleNewsCollapse() { + if (!newsPanel) return; + + newsCollapsed = !newsCollapsed; + newsPanel.classList.toggle("collapsed", newsCollapsed); + + if (newsChevronUp && newsChevronDown) { + newsChevronUp.hidden = newsCollapsed; + newsChevronDown.hidden = !newsCollapsed; } - items.forEach((item) => newsList.appendChild(buildNewsItem(item, newsOffset++))); } -async function loadMoreNews() { - if (newsLoading || !newsHasMore) return; +async function loadNewsPage(page = 1, reset = false) { + if (newsLoading) 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; + + if (newsLoadMoreBtn) { + newsLoadMoreBtn.textContent = "Đang tải..."; + newsLoadMoreBtn.disabled = true; } - 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(); - } + + try { + const items = await getHotNews(page); + + if (reset && newsList) { + newsList.innerHTML = ""; + preFetchedNews = []; } - }, { 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; + + if (items.length > 0) { + preFetchedNews = [...preFetchedNews, ...items]; + appendNewsItems(items); + newsPage = page; } - // 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; + + if (newsLoadMoreBtn) { + newsLoadMoreBtn.hidden = !newsHasMore; + newsLoadMoreBtn.textContent = "Xem thêm tin tức ↓"; + newsLoadMoreBtn.disabled = false; + } + } catch (e) { + console.warn("Failed to load news page:", e); + if (newsLoadMoreBtn) { + newsLoadMoreBtn.textContent = "Lỗi tải tin. Thử lại ↓"; + newsLoadMoreBtn.disabled = false; } - 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; + + newsLoading = false; } -// 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; +function appendNewsItems(items) { + if (!newsList) return; + + items.forEach((item, idx) => { + const newsItem = document.createElement("div"); + newsItem.className = "news-item"; + + const globalIdx = preFetchedNews.length - items.length + idx + 1; + + let imageHtml = ""; + if (item.image) { + imageHtml = ``; + } + + newsItem.innerHTML = ` + ${imageHtml} +
+ ${globalIdx}. + ${item.title} + (${item.source}) +
+ `; + + newsItem.addEventListener("click", () => { + summarizeArticle(item.link, item.title); + }); + + newsList.appendChild(newsItem); + }); } -/** Set the news item currently being discussed (resets the image cursor). */ -function setCurrentNews(item) { - curNews = item || null; - curImgIdx = 0; +async function summarizeArticle(url, title) { + const summaryText = `Hãy tóm tắt ngắn gọn và đầy đủ nhất nội dung bài viết này: "${title}" (${url})`; + + if (!client || !sessionInProgress) { + void startTextSession(summaryText); + return; + } + + addChatMessage("user", `📰 ${title}`); + client.sendUserText(summaryText); + client.requestResponse(); } // ── Avatar list ───────────────────────────────────────────────────────── -let avatarList = []; -let avatarThumbs = {}; // name -> thumbnail URL (persisted on Space) - 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; - } - } - } + 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)"; + const def = document.createElement("option"); + def.value = ""; + def.textContent = "(Default - Brunette)"; sel.appendChild(def); - + for (const name of avatarList) { - const opt = document.createElement("option"); + 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; } } } - 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.classList.remove("done"); loading.textContent = "Loading avatar..."; - - try { - await stage.init({ - avatarUrl: settings.avatar ? `/avatars/${settings.avatar}` : undefined, - onprogress: (ev) => { + + 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))}%`; + loading.textContent = `Loading avatar ${Math.min(100, Math.round((ev.loaded / ev.total) * 100))}%`; } - } - }); - stage.resume(); // keep the render loop running so the avatar stays visible - } catch (err) { - console.error(err); + } + }); + } catch (err) { + console.error(err); } - + loading.classList.add("done"); } @@ -770,112 +352,112 @@ 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)); + 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.left = `${l}px`; textChat.style.top = `${t}px`; } function makeDraggable() { - let dragging = false; + 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 getPos(e) { + const p = e.changedTouches ? e.changedTouches[0] : e; + return { x: p.clientX, y: p.clientY }; } - - function onStart(e) { + + function onStart(e) { if (e.target.closest("#chat-header-actions") || e.target.closest("#chat-avatar-select")) { - return; + 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); + 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 }); + chatHeader.addEventListener("touchstart", onStart, { passive: false }); + document.addEventListener("touchmove", onMove, { passive: false }); document.addEventListener("touchend", onEnd); } function makeResizable() { - let resizing = false; + 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); + + 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 }); + 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; +function setCaption(text, kind = "") { + caption.textContent = text; + caption.className = kind; } function showSubtitles(text) { @@ -883,7 +465,7 @@ function showSubtitles(text) { clearTimeout(subtitleTimer); subtitles.innerHTML = text; subtitles.classList.add("visible"); - + subtitles.querySelectorAll("a[href]").forEach((a) => { a.addEventListener("click", (e) => { e.preventDefault(); @@ -895,42 +477,19 @@ 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); } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function addChatMessage(role, text, newsItem = null) { +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); - } - } - } - + let displayText = text; if (role === "assistant" && latestNewsUrl && latestNewsSource) { const sourceEscaped = escapeRegex(latestNewsSource); @@ -938,7 +497,7 @@ function addChatMessage(role, text, newsItem = null) { 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 +507,11 @@ 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,88 +521,15 @@ 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); + showNewsPanel(preFetchedNews.slice(0, 10)); } } -/** 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) { @@ -1088,426 +538,227 @@ function sendTextViaSession(text) { if (st !== "connected" && st !== "ai-speaking" && st !== "processing" && st !== "user-speaking") { return false; } - setCaption("SENDING…"); - client.sendUserText(text); - client.requestResponse(); + 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); + 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…"); } - // Voice typing stays active for continuous conversation; - // it will auto-stop when the AI starts speaking. } +// ── Event listeners for news panel ───────────────────────────────────── if (newsCloseBtn) { - newsCloseBtn.addEventListener("click", (e) => { - e.stopPropagation(); - newsPanel.hidden = true; + newsCloseBtn.addEventListener("click", (e) => { + e.stopPropagation(); + newsPanel.hidden = true; + newsToggleFooter?.classList.remove("active"); }); } if (newsToggleBtn) { - newsToggleBtn.addEventListener("click", () => { - newsPanel.hidden = !newsPanel.hidden; + newsToggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + toggleNewsCollapse(); }); } -if (newsLoadMoreBtn) { - newsLoadMoreBtn.addEventListener("click", () => void loadMoreNews()); -} - -if (chatCloseBtn) { - chatCloseBtn.addEventListener("click", (e) => { - e.stopPropagation(); - textMode = false; - if (voiceTyper) { - voiceTyper.stop(); - voiceTyper = null; +if (newsPanel) { + newsPanel.addEventListener("click", (e) => { + if (e.target.closest("#news-header") && !e.target.closest("#news-header-actions")) { + toggleNewsCollapse(); } - 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"; - } -} - -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; +if (newsLoadMoreBtn) { + newsLoadMoreBtn.addEventListener("click", () => { + loadNewsPage(newsPage + 1); + }); } -/** 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 */ } - - 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(); +if (newsToggleFooter) { + newsToggleFooter.addEventListener("click", () => { + toggleNewsPanel(); + }); } -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"; +if (chatCloseBtn) { + chatCloseBtn.addEventListener("click", (e) => { + e.stopPropagation(); + textMode = false; + showTextChat(false); + textModeBtn.classList.remove("active"); }); - document.addEventListener("mouseup", () => { dragging = false; vnewsPanel.classList.remove("dragging"); }); } // ── Button logic ──────────────────────────────────────────────────────── 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; + newsToggleFooter.hidden = false; } const CAPTIONS = { - idle: "TAP TO TALK", - "creating-session": "REQUESTING A SLOT…", + idle: "TAP TO TALK", + "creating-session": "REQUESTING A SLOT…", queued: "WAITING IN LINE…", - "your-turn": "YOUR TURN, TAP TO JOIN", - connecting: "CONNECTING…", + "your-turn": "YOUR TURN, TAP TO JOIN", + connecting: "CONNECTING…", connected: "GO AHEAD, I'M LISTENING", - "user-speaking": "LISTENING", - processing: "THINKING…", + "user-speaking": "LISTENING", + processing: "THINKING…", "ai-speaking": "SPEAKING", - closed: "TAP TO TALK", + 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"); + case "idle": + case "closed": + setMainButton("start", "Start talking"); break; - case "error": - setMainButton("start", "Retry"); + case "error": + setMainButton("start", "Retry"); break; - case "creating-session": - case "connecting": - setMainButton("busy", "Connecting…"); + case "creating-session": + case "connecting": + setMainButton("busy", "Connecting…"); break; - case "queued": - setMainButton("stop", "Leave queue"); + case "queued": + setMainButton("stop", "Leave queue"); break; - case "your-turn": - setMainButton("join", "Join now"); + case "your-turn": + setMainButton("join", "Join now"); break; - default: - setMainButton("stop", "End conversation"); + 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); + + if (status === "user-speaking") { + subtitles.classList.remove("visible"); + showTextChat(textMode); } } // ── 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; - } - + try { + args = JSON.parse(argsJson || "{}"); + } catch {} + 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(); + const { dateStr, timeStr } = formatDateTimeVN(now); + client.sendToolOutput(callId, `Hôm nay là ${dateStr}. Bây giờ là ${timeStr}.`); + client.requestResponse(); return; } - + if (name === "search_wikipedia") { const query = args.query || ""; - if (!query) { - client.sendToolOutput(callId, `No query.`); - client.requestResponse(); - return; + 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; + 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; + 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(); + .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(); - }); + .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.`); - client.requestResponse(); - return; + 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; + 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.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(); - }); + .catch(() => { + client.sendToolOutput(callId, `Web search failed.`); + client.requestResponse(); + }); return; } - + const result = stage.runTool(name, args) ?? `Unknown tool: ${name}`; - client.sendToolOutput(callId, result); + client.sendToolOutput(callId, result); client.requestResponse(); } async function connectSession(c) { - try { - await c.connect(); + try { + await c.connect(); return c; } catch (err) { const code = err?.code; @@ -1517,155 +768,146 @@ async function connectSession(c) { 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"); + } 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; + + let 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; + if (!audioCtx || !voiceSink) { + sessionInProgress = false; + return; } - + const newsItems = preFetchedNews.length > 0 ? preFetchedNews : (await getHotNews()); if (!preFetchedNews.length) { preFetchedNews = newsItems; } - - // 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"; + 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, + voice: settings.voice, instructions: effectiveInstructions(newsItems), - micStream, - audioContext: audioCtx, + micStream, + audioContext: audioCtx, outputNode: voiceSink, - workletBaseUrl: "/worklets/", - tools: TOOL_DEFS, + workletBaseUrl: "/worklets/", + tools: TOOL_DEFS, _textOnly: false, }); - - client = 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(); } } 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 audioCtx = stage.audioCtx; const voiceSink = stage.voiceSink; - if (!audioCtx || !voiceSink) { - sessionInProgress = false; - return; + 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"; + 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, + voice: settings.voice, instructions: effectiveInstructions(newsItems), - audioContext: audioCtx, + audioContext: audioCtx, outputNode: voiceSink, - workletBaseUrl: "/worklets/", - tools: TOOL_DEFS, + workletBaseUrl: "/worklets/", + tools: TOOL_DEFS, _textOnly: true, }); - - client = c; + + client = c; _attachClientEvents(c); - textMode = true; - showTextChat(true); + textMode = true; + showTextChat(true); textModeBtn.classList.add("active"); - + const ok = await connectSession(c); if (!ok) return; - - if (!autoGreetingSent) { - c.requestResponse(); + + if (!autoGreetingSent) { + c.requestResponse(); } - - if (initialText) { - c.sendUserText(initialText); - c.requestResponse(); + + if (initialText) { + c.sendUserText(initialText); + c.requestResponse(); } } @@ -1673,85 +915,63 @@ 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("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); + 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("response-finished", () => { + autoGreetingSent = true; + fadeSubtitles(); + if (preFetchedNews.length > 0 && !newsPanel.hidden) { } }); - + 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("error", () => { + void endSession(); }); } async function endSession(silent = false) { - const c = client; - client = null; - sessionInProgress = 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) { if (c.options.micStream) { for (const track of c.options.micStream?.getTracks() ?? []) { track.stop(); } } - await c.close().catch(() => {}); + await c.close().catch(() => {}); } - - stage.setConversationState("idle"); + + stage.setConversationState("idle"); subtitles.classList.remove("visible"); - - if (newsPanel) { - newsPanel.hidden = true; - } - + if (!silent) { - setCaption(CAPTIONS.idle); + 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); } } } @@ -1759,568 +979,146 @@ async function endSession(silent = false) { 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(); + 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"); +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); +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) => { +chatInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { sendTextMessage(); } }); -chatAvatarSelect.addEventListener("change", () => { - setAvatarFromSelect(chatAvatarSelect.value); - void reloadAvatar(); +chatAvatarSelect.addEventListener("change", () => { + setAvatarFromSelect(chatAvatarSelect.value); + void reloadAvatar(); }); -settingsAvatarSelect.addEventListener("change", () => { - setAvatarFromSelect(settingsAvatarSelect.value); - chatAvatarSelect.value = settingsAvatarSelect.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(); +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 + 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; - + + saveSettings(); + chatAvatarSelect.value = settings.avatar; + if (!settings.subtitles) { subtitles.classList.remove("visible"); } - - client?.updateSession({ - voice: settings.voice, - instructions: effectiveInstructions(preFetchedNews) + + client?.updateSession({ + voice: settings.voice, + instructions: effectiveInstructions(preFetchedNews) }); }); -window.addEventListener("beforeunload", () => { - client?.close(); +window.addEventListener("beforeunload", () => { + client?.close(); }); // ── Boot ──────────────────────────────────────────────────────────────── async function boot() { - // 1) Reveal the picker IMMEDIATELY — first screen, never a loader. - showPicker(); - - // 2) Voice