diff --git a/.build_trigger b/.build_trigger new file mode 100644 index 0000000000000000000000000000000000000000..96eb09148d97da3a3f62ba415db10fa85fdc3a0c --- /dev/null +++ b/.build_trigger @@ -0,0 +1 @@ +1785805873 diff --git a/.cache_bust b/.cache_bust new file mode 100644 index 0000000000000000000000000000000000000000..b6a3bda13f9cd2bb0a37b5a80188332bbe1afa82 --- /dev/null +++ b/.cache_bust @@ -0,0 +1 @@ +v12-deferred-avatar+lisamy-sohee+reliability diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..d309dc361a174709e3c8a1a031b3bcb5ca3207e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.git +.cache +*.log +Dockerfile +.dockerignore +.bun \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 8be22d05015c270728b5144fc7f6612bc2fa76cc..2688dffbc94d48c5553219781bb324ae29ba4328 100644 --- a/.gitattributes +++ b/.gitattributes @@ -63,6 +63,3 @@ san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg filter=lfs diff=lfs me public/avatars/vuong1.glb filter=lfs diff=lfs merge=lfs -text public/avatars/scene[[:space:]](3).glb filter=lfs diff=lfs merge=lfs -text public/avatars/vuong2.glb filter=lfs diff=lfs merge=lfs -text -image[[:space:]]-[[:space:]]2026-08-05T195019.510.png filter=lfs diff=lfs merge=lfs -text -public/avatars/vaistudio.png filter=lfs diff=lfs merge=lfs -text -public/avatars/vaistudio_center.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a14702c409d3f34f9ab001e572dfeaf0da434bba --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..ebda995143b4aa0c8477347866f7bb3fb1952ca5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1b70f1f5fbc35d6d96e4f5a78eb2437bb7ba539e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Bun Dockerfile for gemma-avatar +FROM oven/bun:1 +WORKDIR /app +COPY package.json ./ +RUN bun install --production +RUN rm -rf /root/.bun /home/bun/.bun /tmp/* 2>/dev/null; echo "clear-rebuild" +COPY . /app/ +ENV NODE_ENV=production +ENV PORT=7860 +EXPOSE 7860 +USER bun +CMD ["bun", "index.ts"] + +# Rebuild trigger: 1785725738.0884876 diff --git a/HOT_TAGS_V3_TRIGGER.txt b/HOT_TAGS_V3_TRIGGER.txt new file mode 100644 index 0000000000000000000000000000000000000000..524430a75ba08271422ca71683ea81db986f965c --- /dev/null +++ b/HOT_TAGS_V3_TRIGGER.txt @@ -0,0 +1,3 @@ +# HOT Tags V3 Fix Trigger +# Generated: 9ef64b29ef1be950 +# Files: app.js (59382 bytes), style.css (15138 bytes) diff --git a/README.md b/README.md index c84e455f27f7f360ba6ec9ae8d776426f924a233..0063a6d2def620b3ecc17bf4f68e48b017d8c543 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,99 @@ --- +title: Gemma Avatar +emoji: 🗣️ +colorFrom: indigo +colorTo: purple +sdk: docker +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 --- -# bep40/vai-avatar2 +# 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) +``` + +## Notes + +- The avatar GLB must have a Mixamo-compatible rig plus ARKit and Oculus-viseme + blend shapes. Ready Player Me avatars work with + \`?morphTargets=ARKit,Oculus%20Visemes\` on the GLB URL. +- TalkingHead owns the AudioContext; the s2s client is handed \`head.audioCtx\` + and never closes it. +- Everything animation-related runs on requestAnimationFrame — a backgrounded + tab freezes the avatar (audio keeps playing). diff --git a/REBUILD.md b/REBUILD.md new file mode 100644 index 0000000000000000000000000000000000000000..b6c1b251d9b3b3595e2935f494b103314e80b462 --- /dev/null +++ b/REBUILD.md @@ -0,0 +1 @@ +TRIGGER \ No newline at end of file diff --git a/TRIGGER_REBUILD.txt b/TRIGGER_REBUILD.txt new file mode 100644 index 0000000000000000000000000000000000000000..8ac7573c2b564cb1c7a1b93fa011b762321fbd60 --- /dev/null +++ b/TRIGGER_REBUILD.txt @@ -0,0 +1 @@ +1785850254749 \ No newline at end of file diff --git a/build-trigger b/build-trigger new file mode 100644 index 0000000000000000000000000000000000000000..6a2d3a54947d72546e8239c176e554d49bf65543 --- /dev/null +++ b/build-trigger @@ -0,0 +1 @@ +rebuild=1754000000000 diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..0770324164c149c4589a52702c4bc42070180e9c --- /dev/null +++ b/index.html @@ -0,0 +1,529 @@ + + + + + + V.AI AVATAR + V.AI STUDIO - Chat & Voice + + + + + + + +
+
+
Loading avatar…
+ + +
+
+
🤖
+

Chào mừng đến với V.AI AVATAR

+

Bạn muốn giao tiếp thế nào?

+ + +
+

Chọn đại diện của em:

+
+ +
+
+ +
+ + + +
+
+
🗣️Nhận diện giọng nói tiếng Việt — tự động gõ vào ô chat (nhấn nút mic 🎤 để bật)
+
🎙️Chế độ voice call — nói chuyện trực tiếp với AI, phụ đề song ngữ
+
🛒8000+ sản phẩm gia dụng — Malloca, Eurogold, Grob, Canzy & khoá thông minh
+
🧠AI Avatar + 11 công cụ: tra cứu, web, Wikipedia, thời gian thực
+
🎭3D avatar lip-sync — biểu cảm, cử chỉ, giọng nói Qwen3-TTS
+
⚙️Tuỳ chỉnh giọng, avatar, hướng dẫn riêng, phụ đề
+
+

Bạn luôn có thể chuyển đổi chế độ sau

+
+
+ + + + +
+
+

V.AI AVATAR + V.AI STUDIO

+

Chat or talk to AI Avatar. Text → Qwen3-TTS → lip-sync avatar. Control V.AI STUDIO catalog.

+
+
+ + + +
+
+ +
+ + + +
+
LOADING…
+
+ + + +
+
+ + +
+

Settings

+ + + + + +
+
+
+
+ + + + + + + + + + + +
+
+

Giỏ Hàng

+
+ +
+ + +
+
+ +
+

Lập Báo Giá

+

Điền thông tin khách hàng và chiết khấu để xuất báo giá

+
+
+
+

Thông tin khách hàng

+
+
+
+
+
+
+
+
+
+
+ + + 🔒 +
+ +
+
+
+

Chi tiết sản phẩm

+
+ + + + +
STTHình ảnhTên sản phẩmMã SPThông tinSLĐơn giáĐơn giá CKThành tiềnGhi chú
TỔNG CỘNG:0₫
+
+
+
+ + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/index.ts b/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..34706f0cbb0097f4e191fda46117054f674840cd --- /dev/null +++ b/index.ts @@ -0,0 +1,910 @@ +/** + * Gemma Avatar — realtime voice/text chat with 3D talking-head avatar + V.AI STUDIO. + * Products: frontend loads DIRECTLY from dataset JSON URL (1 HTTP GET, no backend proxy). + * Backend keeps /api/vaix/products for backward compat / debugging. + * Fast, reliable, no WASM, no compiled deps. + * + * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access). + * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode. + */ +import { readFileSync } from "fs"; +// Read index.html directly as text to avoid Bun HTMLBundle object +const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8"); +import { readdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, ""); +const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, ""); +const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL; +const PORT = Number(Bun.env.PORT ?? 7860); + +// ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ── +const JSON_URL = + "https://huggingface.co/datasets/bep40/grob-products-updated" + + "/resolve/main/products_with_slugs.json"; + +let VAIX_PRODUCTS: any[] | null = null; +let VAIX_LOADING = false; +let VAIX_LOADED = false; +let VAIX_LAST_SYNC = Date.now(); + +async function loadVaixProducts(): Promise { + if (VAIX_LOADED && VAIX_PRODUCTS?.length) return; + if (VAIX_LOADING) { + for (let i = 0; i < 120; i++) { + await new Promise(r => setTimeout(r, 500)); + if (VAIX_LOADED) return; + } + } + VAIX_LOADING = true; + const startMs = Date.now(); + console.log("[VAIX] Fetching JSON from bep40/grob-products-updated..."); + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 120_000); + const resp = await fetch(JSON_URL, { signal: ctrl.signal }); + clearTimeout(timer); + if (!resp.ok) throw new Error("HTTP " + resp.status); + const data = await resp.json(); + VAIX_PRODUCTS = Array.isArray(data) ? data : []; + VAIX_LAST_SYNC = Date.now(); + VAIX_PRODUCTS = VAIX_PRODUCTS.slice(0, 1000); // Limit to 1000 for memory + console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products (capped from " + data.length + ") in " + (Date.now() - startMs) + "ms"); + } catch (err: any) { + console.error("[VAIX] Error:", err.message); + setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000); + } finally { + VAIX_LOADING = false; + } +} +loadVaixProducts().catch(() => {}); +setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000); + +function norm(s: string): string { + return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); +} + +async function proxy(path: string, req: Request, init: any = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + const cookie = req.headers.get("cookie"); + if (cookie) headers.set("Cookie", cookie); + const resp = await fetch(UPSTREAM + path, { ...init, headers }); + 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")] : []); + for (const sc of setCookies) out.headers.append("Set-Cookie", sc.split(";").map((p: string) => p.trim()).filter((p: string) => !/^domain=/i.test(p)).join("; ")); + return out; +} + +function getMimeType(name: string): string { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + const types: Record = { + "js": "application/javascript", + "mjs": "application/javascript", + "cjs": "application/javascript", + "css": "text/css", + "html": "text/html", + "json": "application/json", + "png": "image/png", + "jpg": "image/jpeg", + "gif": "image/gif", + "svg": "image/svg+xml", + "webp": "image/webp", + "ico": "image/x-icon", + "woff2": "font/woff2", + "woff": "font/woff", + "ttf": "font/ttf", + "bin": "application/octet-stream", + "mjs": "application/javascript", + }; + return types[ext] ?? "application/octet-stream"; +} +function staticFile(dir: string, name: string) { + const path = import.meta.dir + "/public/" + dir + "/" + name; + const mime = getMimeType(name); + return new Response(Bun.file(path), { headers: { "Content-Type": mime } }); +} +function srcFile(dir: string, name: string) { + const path = import.meta.dir + "/" + dir + "/" + name; + const mime = getMimeType(name); + return new Response(Bun.file(path), { headers: { "Content-Type": mime } }); +} + +async function listAvatars() { + const avatarsDir = join(import.meta.dir, "public/avatars"); + const names: string[] = []; + try { + const dir = await readdir(avatarsDir, { withFileTypes: true }); + for (const e of dir) { + if (e.isFile() && e.name.endsWith(".glb")) names.push(e.name); + } + } catch {} + return names.sort(); +} + +function formatProduct(p: any, idx: number) { + return { + name: p.name || p.n || "", + title_clean: p.name || p.n || "", + brand: p.brand || "", + price: p.p || p.price || "", + priceNum: Number(p.pn ?? 0), + category: p.c || p.cat || "", + category_slug: p.cs || "", + category_icon: p.ci || "fa-box", + sku: p.sku || "", + model: p.mod || p.model || "", + slug: p.slug || "", + description: p.desc || "", + summary: p.sum || p.summary || "", + features: Array.isArray(p.feats) ? p.feats : [], + specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {}, + video: p.vid || "", + image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || (Array.isArray(p.images) ? p.images[0] : ""), + images: p.imgs || p.images || [], + link: p.l || "", + _idx: p._idx ?? idx, + }; +} + +async function serveProductOG(req: Request): Promise { + const url = new URL(req.url); + const productSlug = url.searchParams.get("product"); + + function esc(s: string): string { + return (s || "") + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, " ") + .replace(/\r/g, " ") + .trim() + .slice(0, 350); + } + + const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"; + + if (productSlug && VAIX_PRODUCTS?.length) { + const foundProduct = VAIX_PRODUCTS.find((p: any) => { + const slug = p.slug || p._source_alias || ""; + const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); + }); + if (foundProduct) { + const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); + const fpName = esc(fp.name || fp.model || "Sản phẩm"); + const fpBrand = esc(fp.brand || "V.AI STUDIO"); + // ── Image URL: use escURL (not esc) because we need proper URL encoding for og:image ── + const fpImageRaw = fp.image || (fp.images && fp.images[0]) || ""; + const fpImg = fpImageRaw ? escURL(fpImageRaw) : fallbackImg; + + // ── Description: 600 chars to fit full summary + specs ── + const fpDesc = esc((fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 600)); + + // ── Specs: show all specs up to 12 keys ── + const fpSpecs = fp.specs || {}; + let specsStr = ""; + const specKeys = Object.keys(fpSpecs).slice(0, 12); + for (const sk of specKeys) { + specsStr += (specsStr ? ", " : " | ") + esc(sk) + ": " + esc(String(fpSpecs[sk])); + } + const ogDescription = fpDesc + (specsStr ? " | " + specsStr.trim() : "") || "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; + + // ── Price: formatted VND string for og:title ── + const priceNum = fp.priceNum || Number(fp.pn) || 0; + const ogPrice = priceNum > 0 ? " - " + Number(priceNum).toLocaleString("vi-VN") + "₫" : ""; + const ogTitle = fpName + " | " + fpBrand + ogPrice + " - V.AI STUDIO"; + + const ogTags = buildOGTags(ogTitle, ogDescription, fpImg, url.href, true, priceNum); + return new Response(index.replace(/<\/head>/, ogTags + "\n "), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + } + + // Fallback OG tags — always set for any request (no product match or no ?product param) + const fallbackTitle = "Gemma Avatar + V.AI STUDIO - Chat & Voice AI"; + const fallbackDesc = "Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng."; + const ogTags = buildOGTags(fallbackTitle, fallbackDesc, fallbackImg, url.href, false); + return new Response(index.replace(/<\/head>/, ogTags + "\n "), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} + +function buildOGTags(title: string, desc: string, image: string, href: string, isProduct: boolean, price = 0): string { + return [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + isProduct ? '' : '', + isProduct ? '' : '', + isProduct ? '' : '', + isProduct ? '' : '', + '', + '', + '', + '', + '', + '', + '', + ].filter(Boolean).join("\n "); +} + +function escURL(s: string): string { + // Only escape what's needed for HTML attribute — URL-safe + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + + + +function serveStatic(req: Request): Response { + const url = new URL(req.url); + const fullPath = join("/app", url.pathname.slice(1)); + if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 }); + return new Response(Bun.file(fullPath)); +} + +async function queueHandler(req: Request): Promise { + if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); + const url = new URL(req.url); + const parts = url.pathname.split("/"); + const id = parts[parts.length - 1]; + if (!id) return Response.json({ error: "Missing id" }, { status: 400 }); + try { + if (req.method === "DELETE") { + return await proxy("/queue/" + encodeURIComponent(id), req, { method: "DELETE" }); + } + return await proxy("/queue/" + encodeURIComponent(id), req); + } catch { + return Response.json({ error: "Speech service unreachable." }, { status: 502 }); + } +} + +async function sessionHandler(req: Request): Promise { + if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); + try { + return await proxy("/session", req, { method: "POST", body: "{}" }); + } catch { + return Response.json({ error: "Speech service unreachable." }, { status: 502 }); + } +} + +// ── Text-only chat endpoint ── +// Uses S2S backend in text-only mode: POST /session → text chat via WebSocket +async function textChatHandler(req: Request): Promise { + if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 }); + try { + const reqBody = await req.json(); + const userMessage = reqBody.message || ""; + if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 }); + + // Create S2S session in text-only mode + const sessionResp = await fetch(UPSTREAM + "/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!sessionResp.ok) { + const errBody = await sessionResp.text().catch(() => ""); + return Response.json({ + error: "Failed to create S2S session", + status: sessionResp.status, + detail: errBody.slice(0, 500), + }, { status: 502 }); + } + const sessionData = await sessionResp.json(); + if (sessionData.state === "queued") { + // Poll the queue + let queueResp; + let attempts = 0; + do { + await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000)); + queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), { + headers: { "Content-Type": "application/json" }, + }); + if (!queueResp.ok) continue; + const queueData = await queueResp.json(); + if (queueData.state === "queued") { + attempts++; + if (attempts > 30) { + return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 }); + } + continue; + } + const grant = queueData; + // Connect WebSocket + const ws = new WebSocket(grant.connect_url); + + const wsPromise = new Promise((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Session created — wait for session.updated then send text + const waitForSession = async () => { + while (!msgReceived) { + // Wait for session.created + // We'll use a timeout approach + } + }; + + // Send session.update then user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.", + audio: { output: { voice: "Dylan" } } + } + })); + }, 500); + + // Send user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect responses + let fullTranscript = ""; + let audioBuffer = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") { + const delta = (event.transcript || event.delta || ""); + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + resolve({ + transcript: fullTranscript.trim(), + audio: audioBuffer, + audioFormat: "pcm16", + }); + } + } catch {} + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => reject(new Error("WebSocket error")); + + // Timeout + const timeout = setTimeout(() => { + if (!responseDone) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve({ + transcript: fullTranscript.trim() || "No response received.", + audio: "", + }); + } + }, 15000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" }); + } + }; + }); + + return Response.json(await wsPromise); + } while (true); + } + + // Session granted directly — connect WebSocket + const grant = sessionData; + const ws = new WebSocket(grant.connect_url); + + return new Promise((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Send session.update + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short, natural, and warm. Only respond directly to the user's message in text form. Do not ask questions or request audio input.", + audio: { output: { voice: "Dylan" } } + } + })); + }, 500); + + // Send user message via text + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect response + let fullTranscript = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") { + const delta = event.delta || ""; + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") { + const segment = event.transcript || ""; + if (segment) fullTranscript += segment; + } + if (type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + try { ws.close(1000, "done"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim(), + status: event.response?.status ?? "completed", + })); + } + } catch (e: any) { + console.warn("[chat] parse error:", e.message); + } + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "error"); } catch {} + resolve(Response.json({ + transcript: "Error connecting to chat service.", + status: "error", + })); + } + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve(Response.json({ + transcript: fullTranscript.trim() || "Session closed.", + status: "closed", + })); + } + }; + + // Timeout + const timeout = setTimeout(() => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim() || "No response received. The service may be busy.", + status: "timeout", + })); + } + }, 20000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + clearTimeout(timeout); + resolve(Response.json({ + transcript: "Connection closed.", + status: "closed", + })); + } + }; + }); + } catch (err: any) { + console.error("[/api/chat] Error:", err.message); + return Response.json({ error: "Chat service error: " + err.message }, { status: 500 }); + } +} + +const server = Bun.serve({ + port: PORT, + routes: { + "/": { GET: serveProductOG }, + "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) }, + "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) }, + "/api/vaix/products": { GET: async (req: Request) => { + try { + await loadVaixProducts(); + if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 }); + const url = new URL(req.url); + const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999); + const total = VAIX_PRODUCTS.length; + const formatted = VAIX_PRODUCTS.map((p, i) => formatProduct(p, i)).slice(0, Math.min(limit, total)); + return Response.json({ + products: formatted, + total, limit, page: 1, perPage: limit, + totalPages: 1, + }); + } catch { return Response.json({ products: [], total: 0 }); } + }}, + "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) }, + "/api/vaix/search": { GET: async (req: Request) => { + const url = new URL(req.url); + const q = url.searchParams.get("q"); + if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 }); + try { + await loadVaixProducts(); + if (!VAIX_PRODUCTS?.length) return Response.json({ results: [], aiAnswer: "No products." }); + const qNorm = norm(q); + const terms = qNorm.split(/\s+/).filter(t => t.length > 1); + const results: Array<{ product: any; score: number }> = []; + for (let i = 0; i < VAIX_PRODUCTS.length; i++) { + const p = VAIX_PRODUCTS[i]; + let score = 0; + const nm = norm(p.name || p.n || ""); + const sk = norm(p.sku || p.mod || p.model || ""); + const br = norm(p.brand || ""); + const ca = norm(p.c || p.cat || ""); + const de = norm(p.desc || ""); + const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : ""); + if (nm.includes(qNorm)) score += 20; + if (sk.includes(qNorm)) score += 15; + if (br.includes(qNorm)) score += 10; + if (ca.includes(qNorm)) score += 8; + for (const t of terms) { + if (nm.includes(t)) score += 3; + if (sk.includes(t)) score += 2; + if (br.includes(t)) score += 2; + if (ca.includes(t)) score += 1; + if (de.includes(t)) score += 1; + if (ft.includes(t)) score += 1; + } + if (score > 0) results.push({ product: formatProduct(p, i), score }); + } + results.sort((a, b) => b.score - a.score); + return Response.json({ results: results.slice(0, 50), aiAnswer: results.length > 0 ? "Found " + results.length + " products." : "No products found." }); + } catch (err: any) { return Response.json({ results: [], error: err.message }); } + }}, + "/api/vaix/sync": { POST: async () => { + VAIX_PRODUCTS = null; + VAIX_LOADED = false; + VAIX_LOADING = false; + await loadVaixProducts(); + return Response.json({ synced: true, count: VAIX_PRODUCTS?.length ?? 0 }); + }}, + "/api/wiki/search": { GET: async (req: Request) => { + const q = new URL(req.url).searchParams.get("q"); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const r = await fetch("https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + encodeURIComponent(q) + "&format=json&srlimit=5&origin=*"); + const d = await r.json(); + return Response.json({ results: (d.query?.search ?? []).map((r: any) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })) }); + } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } + }}, + "/api/wiki/summary": { GET: async (req: Request) => { + const t = new URL(req.url).searchParams.get("title"); + if (!t) return Response.json({ error: "Missing ?title=" }, { status: 400 }); + try { + const r = await fetch("https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(t) + "?origin=*"); + const d = await r.json(); + return Response.json({ title: d.title, extract: d.extract, url: d.content_urls?.desktop?.page }); + } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } + }}, + "/api/web/search": { GET: async (req: Request) => { + const q = new URL(req.url).searchParams.get("q"); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" } }); + const h = await r.text(); + const res: Array<{ title: string; snippet: string; url: string }> = []; + const lm = [...h.matchAll(/]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; + const sm = [...h.matchAll(/]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; + for (let i = 0; i < Math.min(lm.length, 5); i++) { + let href = lm[i][1]; + const ru = href.match(/uddg=(https?%3[^&]+)/i); + if (ru) href = decodeURIComponent(ru[1]); + const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); + const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; + if (title) { + // Extract source from URL + let source = ''; + try { + const urlObj = new URL(href); + source = urlObj.hostname.replace(/^www\./, ''); + } catch (_) { source = ''; } + res.push({ title, snippet, url: href, source }); + } + } + return Response.json({ results: res }); + } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); } + }}, + "/api/web/content": { GET: async (req: Request) => { + const u = new URL(req.url).searchParams.get("url"); + if (!u) return Response.json({ error: "Missing ?url=" }, { status: 400 }); + try { + const r = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(8000) }); + const h = await r.text(); + const t = h.replace(/]*>[\s\S]*?<\/script>/gi, "").replace(/]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim(); + return Response.json({ content: t.slice(0, 3000), url: u }); + } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); } + }}, + "/api/session": { POST: sessionHandler }, + "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler }, + "/api/chat": { POST: textChatHandler }, + "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!), + "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!), + "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!), + "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), + "/src/:name": (req: Request) => srcFile("src", req.params.name!), + "/src/s2s/:name": (req: Request) => srcFile("src/s2s", req.params.name!), + "/api/news/hot": { GET: async () => { + try { + const resp = await fetch("https://news.google.com/rss?hl=vi-VN&gl=VN&ceid=VN:vi"); + if (!resp.ok) throw new Error("RSS failed"); + const rssText = await resp.text(); + // Parse blocks: title, link, source + const itemBlocks = rssText.match(/[\s\S]*?<\/item>/gi) || []; + const titles: string[] = []; + const articles: Array<{ source: string; title: string; url: string }> = []; + for (const block of itemBlocks) { + const tm = block.match(/(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); + const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); + const sm = block.match(/<source[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*url="([^"]*)"[^>]*>/i); + if (!tm) continue; + const title = tm[1].replace(/\+|_/g, " ").trim(); + if (title) titles.push(title); + let url = ""; + if (lm) { + url = (lm[1] || "").trim(); + // Google News links are /articles/... redirects → keep them, they work in browser + } + let source = ""; + if (sm) source = (sm[1] || sm[0] || "").replace(/<[^>]+>/g, "").trim(); + if (title && url) { + articles.push({ source: source || "", title, url }); + } + if (titles.length >= 12) break; + } + return Response.json({ titles: titles.slice(0, 12), articles }); + } catch (e: any) { + return Response.json({ error: e.message, titles: [], articles: [] }, { status: 502 }); + } + }}, + "/api/news/articles": { GET: async (req: Request) => { + // Search Google News RSS for a specific topic and return SPECIFIC detailed articles + // with real clickable links (each card = one concrete news article, not a category page). + const url = new URL(req.url); + const q = (url.searchParams.get("q") || "").trim(); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const resp = await fetch( + "https://news.google.com/rss/search?q=" + encodeURIComponent(q) + + "&hl=vi-VN&gl=VN&ceid=VN:vi", + { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" } } + ); + if (!resp.ok) throw new Error("RSS failed " + resp.status); + const rssText = await resp.text(); + const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; + const articles: Array<{ source: string; title: string; url: string; desc: string }> = []; + for (const block of itemBlocks) { + const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); + const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); + const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i); + const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); + if (!tm) continue; + let title = tm[1].replace(/\+|_/g, " ").trim(); + // Strip trailing "- SourceName" that Google appends to titles + let source = ""; + if (sm) { + source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim(); + if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim(); + } + if (!title) continue; + let url = ""; + if (lm) url = (lm[1] || "").trim(); + let desc = ""; + if (dm) { + // Remove all HTML tags (including malformed Google News CDATA tags) + desc = dm[1] + .replace(/<[^>]*>/g, " ") + .replace(/\bhttps?:\/\/\S+/gi, "") + .replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">") + .replace(/ /g, " ") + .replace(/\s+/g, " ").trim().slice(0, 160); + } + if (title && url) { + articles.push({ source: source || "", title, url, desc }); + } + if (articles.length >= 8) break; + } + return Response.json({ articles, query: q }); + } catch (e: any) { + return Response.json({ error: e.message, articles: [] }, { status: 502 }); + } + }}, + "/api/news/images": { GET: async (req: Request) => { + // Return REAL per-article images for a topic using Vietnamese portal RSS feeds + // (VnExpress feeds include <enclosure>/<img> with real article thumbnails). + const url = new URL(req.url); + const q = (url.searchParams.get("q") || "").trim(); + const topicNorm = q.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); + const feeds: Array<{ cat: string; u: string }> = [ + { cat: "tin-moi-nhat", u: "https://vnexpress.net/rss/tin-moi-nhat.rss" }, + { cat: "thoi-su", u: "https://vnexpress.net/rss/thoi-su.rss" }, + { cat: "the-thao", u: "https://vnexpress.net/rss/the-thao.rss" }, + { cat: "kinh-doanh", u: "https://vnexpress.net/rss/kinh-doanh.rss" }, + { cat: "giai-tri", u: "https://vnexpress.net/rss/giai-tri.rss" }, + { cat: "the-gioi", u: "https://vnexpress.net/rss/the-gioi.rss" }, + { cat: "doi-song", u: "https://vnexpress.net/rss/doi-song.rss" }, + { cat: "suc-khoe", u: "https://vnexpress.net/rss/suc-khoe.rss" }, + { cat: "giao-duc", u: "https://vnexpress.net/rss/giao-duc.rss" }, + ]; + const normStr = (s: string) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); + const all: Array<{ title: string; url: string; image: string; source: string; desc: string }> = []; + try { + await Promise.all(feeds.map(async (f) => { + try { + const r = await fetch(f.u, { headers: { "User-Agent": "Mozilla/5.0" } }); + if (!r.ok) return; + const t = await r.text(); + const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; + for (const it of items) { + const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); + const lm = it.match(/<link>(.*?)<\/link>/i); + const em = it.match(/<enclosure[^>]*url="([^"]+)/i); + const im = it.match(/<img[^>]+src="([^"]+)/i); + if (!tm || !lm) continue; + const title = tm[1].trim(); + let image = (em && em[1]) ? em[1].replace(/&/g, "&") : ""; + if (!image && im) image = im[1].replace(/&/g, "&"); + if (!title || !image) continue; + const link = lm[1].trim(); + const dm = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); + let desc = ""; + if (dm) desc = dm[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); + // Filter by topic if a query was provided + if (q && !normStr(title + " " + link).includes(topicNorm)) continue; + all.push({ title, url: link, image, source: "VnExpress", desc }); + } + } catch (_) {} + })); + } catch (_) {} + + // If no exact topic match, fall back to the freshest topical items (always have images) + if (all.length === 0 && q) { + try { + const r = await fetch("https://vnexpress.net/rss/tin-moi-nhat.rss", { headers: { "User-Agent": "Mozilla/5.0" } }); + const t = await r.text(); + const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; + for (const it of items) { + const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); + const lm = it.match(/<link>(.*?)<\/link>/i); + const em = it.match(/<enclosure[^>]*url="([^"]+)/i); + if (tm && lm) { + const image = em ? em[1].replace(/&/g, "&") : ""; + if (image && tm[1].trim()) { + const dm2 = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); + let desc2 = ""; + if (dm2) desc2 = dm2[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); + all.push({ title: tm[1].trim(), url: lm[1].trim(), image, source: "VnExpress", desc: desc2 }); + } + } + } + } catch (_) {} + } + + return Response.json({ images: all.slice(0, 8), query: q }); + }}, + "/api/news/topic": { GET: async (req: Request) => { + // Search Vietnamese news for a specific topic and return concrete article cards with links. + const url = new URL(req.url); + const q = (url.searchParams.get("q") || "").trim(); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const portalQueries = [ + q + " site:vnexpress.net", + q + " site:tuoitre.vn", + q + " site:thanhnien.vn", + q + " site:cafef.vn", + q + " site:dantri.com.vn", + "tin tức " + q + " hôm nay", + ]; + const allResults: Array<{ title: string; snippet: string; url: string; source: string }> = []; + await Promise.all(portalQueries.map(nq => + fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(nq), { headers: { "User-Agent": "Mozilla/5.0" } }) + .then(r => r.text()) + .then(h => { + const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; + const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; + for (let i = 0; i < Math.min(lm.length, 4); i++) { + let href = lm[i][1]; + const ru = href.match(/uddg=(https?%3[^&]+)/i); + if (ru) href = decodeURIComponent(ru[1]); + const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); + if (!title) continue; + const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; + let source = ""; + try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {} + allResults.push({ title, snippet, url: href, source }); + } + }) + .catch(() => {}) + )); + // Deduplicate + drop empty + const seen = new Set<string>(); + const unique = allResults.filter(r => { + if (!r.url || seen.has(r.url)) return false; + seen.add(r.url); + return r.source && !/duckduckgo|google/i.test(r.source); + }); + return Response.json({ results: unique.slice(0, 8), query: q }); + } catch (e: any) { + return Response.json({ error: e.message, results: [] }, { status: 502 }); + } + }}, + "/api/logs/client": { POST: async (req: Request) => { + try { + const body = await req.json(); + console.error("[ClientError]", body?.message ?? String(body), body?.details ? JSON.stringify(body) : ""); + return Response.json({ ok: true }); + } catch(e: any) { return Response.json({ error: e.message }, { status: 400 }); } + }}, + "/api/vaix/json": async () => { + try { + const r = await fetch(JSON_URL); + if (!r.ok) throw new Error("HTTP "+r.status); + const data = await r.json(); + // Only return top 1000 to reduce server memory + const prods = Array.isArray(data) ? data.slice(0, 1000) : []; + return new Response(JSON.stringify(prods), { + headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } + }); + } catch(e) { + return Response.json({ error: "JSON fetch failed: " + e.message }, { status: 502 }); + } + }, + "/*": { GET: serveStatic }, + }, +}); +console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none")); diff --git a/index.ts.patched b/index.ts.patched new file mode 100644 index 0000000000000000000000000000000000000000..8263df0d341ea943801c31952f458c6be1594a9b --- /dev/null +++ b/index.ts.patched @@ -0,0 +1,684 @@ +/** + * Gemma Avatar — realtime voice/text chat with 3D talking-head avatar + V.AI STUDIO. + * Products: frontend loads DIRECTLY from dataset JSON URL (1 HTTP GET, no backend proxy). + * Backend keeps /api/vaix/products for backward compat / debugging. + * Fast, reliable, no WASM, no compiled deps. + * + * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access). + * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode. + */ +import index from "./index.html"; +import { readdir } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, ""); +const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://victor-gemma-avatar.hf.space/api").trim().replace(/\/$/, ""); +const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL; +const PORT = Number(Bun.env.PORT ?? 7860); + +// ── JSON file from dataset (33.5MB, 23.4K rows) — kept for backend API compat ── +const JSON_URL = + "https://huggingface.co/datasets/bep40/grob-products-updated" + + "/resolve/main/products_with_slugs.json"; + +let VAIX_PRODUCTS: any[] | null = null; +let VAIX_LOADING = false; +let VAIX_LOADED = false; +let VAIX_LAST_SYNC = Date.now(); + +async function loadVaixProducts(): Promise<void> { + if (VAIX_LOADED && VAIX_PRODUCTS?.length) return; + if (VAIX_LOADING) { + for (let i = 0; i < 120; i++) { + await new Promise(r => setTimeout(r, 500)); + if (VAIX_LOADED) return; + } + } + VAIX_LOADING = true; + const startMs = Date.now(); + console.log("[VAIX] Fetching JSON from bep40/grob-products-updated..."); + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 120_000); + const resp = await fetch(JSON_URL, { signal: ctrl.signal }); + clearTimeout(timer); + if (!resp.ok) throw new Error("HTTP " + resp.status); + const data = await resp.json(); + VAIX_PRODUCTS = Array.isArray(data) ? data : []; + VAIX_LAST_SYNC = Date.now(); + console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products in " + (Date.now() - startMs) + "ms"); + } catch (err: any) { + console.error("[VAIX] Error:", err.message); + setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000); + } finally { + VAIX_LOADING = false; + } +} +loadVaixProducts().catch(() => {}); +setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000); + +function norm(s: string): string { + return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); +} + +async function proxy(path: string, req: Request, init: any = {}): Promise<Response> { + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + const cookie = req.headers.get("cookie"); + if (cookie) headers.set("Cookie", cookie); + const resp = await fetch(UPSTREAM + path, { ...init, headers }); + 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")] : []); + for (const sc of setCookies) out.headers.append("Set-Cookie", sc.split(";").map((p: string) => p.trim()).filter((p: string) => !/^domain=/i.test(p)).join("; ")); + return out; +} + +function staticFile(dir: string, name: string) { + return new Response(Bun.file(import.meta.dir + "/public/" + dir + "/" + name)); +} + +async function listAvatars() { + const avatarsDir = join(import.meta.dir, "public/avatars"); + const names: string[] = []; + try { + const dir = await readdir(avatarsDir, { withFileTypes: true }); + for (const e of dir) { + if (e.isFile() && e.name.endsWith(".glb")) names.push(e.name); + } + } catch {} + return names.sort(); +} + +function formatProduct(p: any, idx: number) { + return { + name: p.name || p.n || "", + title_clean: p.name || p.n || "", + brand: p.brand || "", + price: p.p || p.price || "", + priceNum: Number(p.pn ?? 0), + category: p.c || p.cat || "", + category_slug: p.cs || "", + category_icon: p.ci || "fa-box", + sku: p.sku || "", + model: p.mod || p.model || "", + slug: p.slug || "", + description: p.desc || "", + summary: p.sum || p.summary || "", + features: Array.isArray(p.feats) ? p.feats : [], + specs: (typeof p.specs === "object" && p.specs !== null) ? p.specs : {}, + video: p.vid || "", + image: p.i || (Array.isArray(p.imgs) ? p.imgs[0] : "") || (Array.isArray(p.images) ? p.images[0] : ""), + images: p.imgs || p.images || [], + link: p.l || "", + _idx: p._idx ?? idx, + }; +} + +async function serveProductOG(req: Request): Promise<Response> { + const url = new URL(req.url); + const productSlug = url.searchParams.get("product"); + if (productSlug && VAIX_PRODUCTS?.length) { + const foundProduct = VAIX_PRODUCTS.find((p: any) => { + const slug = p.slug || p._source_alias || ""; + const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); + }); + if (foundProduct) { + const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); + const fpName = fp.name || fp.model || "Sản phẩm"; + const fpBrand = fp.brand || "V.AI STUDIO"; + const fpPriceNum = fp.priceNum || Number(fp.pn) || 0; + const fpDesc = (fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 200); + const fpImg = fp.image || (fp.images && fp.images[0]) || ""; + const fpCategory = fp.category || ""; + const fpModel = fp.model || ""; + const fpSpecs = fp.specs || {}; + let specsStr = ""; + const specKeys = Object.keys(fpSpecs).slice(0, 8); + for (const sk of specKeys) { + specsStr += `\n* ${sk}: ${fpSpecs[sk]}`; + } + const ogDescription = fpDesc ? fpDesc + specsStr : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; + const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"; + const ogImage = fpImg || fallbackImg; + const ogTitle = `${fpName} | ${fpBrand} - V.AI STUDIO`; + const ogTags = ` + <meta property="og:title" content="${ogTitle}" /> + <meta property="og:description" content="${ogDescription}" /> + <meta property="og:url" content="${url.href}" /> + <meta property="og:type" content="product" /> + <meta property="og:image" content="${ogImage}" /> + <meta property="og:image:secure_url" content="${ogImage}" /> + <meta property="og:image:type" content="image/jpeg" /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="630" /> + <meta property="product:price:amount" content="${fpPriceNum}" /> + <meta property="product:price:currency" content="VND" /> + <meta property="product:availability" content="in stock" /> + <meta property="product:condition" content="new" /> + <meta property="fb:app_id" content="1321688464574422" /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:site" content="@bep40" /> + <meta name="twitter:title" content="${ogTitle}" /> + <meta name="twitter:description" content="${ogDescription}" /> + <meta name="twitter:image" content="${ogImage}" /> + <meta name="description" content="${ogDescription}" />`; + return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + } + return new Response(index, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} + +async function serveProductOG(req: Request): Promise<Response> { + const url = new URL(req.url); + const productSlug = url.searchParams.get("product"); + + function escapeHTML(str: string): string { + return String(str || "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/\r\n/g, " ") + .replace(/\n/g, " ") + .replace(/\r/g, " ") + .slice(0, 350); + } + + if (productSlug && VAIX_PRODUCTS?.length) { + const foundProduct = VAIX_PRODUCTS.find((p: any) => { + const slug = p.slug || p._source_alias || ""; + const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); + return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); + }); + if (foundProduct) { + const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); + // HTML-escape ALL values before injecting + const ogTitle = escapeHTML(`${fp.name || fp.model || "Sản phẩm"} | ${fp.brand || "V.AI STUDIO"} - V.AI STUDIO`); + const fpDesc = escapeHTML((fp.summary || fp.description || "").trim().slice(0, 200)); + const fpImg = escapeHTML(fp.image || (fp.images && fp.images[0]) || ""); + const fpPriceNum = fp.priceNum || Number(fp.pn) || 0; + let specsStr = ""; + const specKeys = Object.keys(fp.specs || {}).slice(0, 8); + for (const sk of specKeys) { + const key = escapeHTML(sk || ""); + const val = escapeHTML(String(fp.specs[sk] || "")); + specsStr += `${key}: ${val}, `; + } + specsStr = specsStr.slice(0, -2); + const ogDescription = fpDesc ? `${fpDesc}${specsStr ? " -- " + specsStr : ""}` : "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; + const fallbackImg = escapeHTML("https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"); + const ogImage = fpImg || fallbackImg; + const ogTags = `<meta property="og:title" content="${ogTitle}" /> + <meta property="og:description" content="${ogDescription}" /> + <meta property="og:url" content="${escapeHTML(url.href)}" /> + <meta property="og:type" content="product" /> + <meta property="og:image" content="${ogImage}" /> + <meta property="og:image:secure_url" content="${ogImage}" /> + <meta property="og:image:type" content="image/jpeg" /> + <meta property="og:image:width" content="1200" /> + <meta property="og:image:height" content="630" /> + <meta property="product:price:amount" content="${fpPriceNum}" /> + <meta property="product:price:currency" content="VND" /> + <meta property="product:availability" content="in stock" /> + <meta property="product:condition" content="new" /> + <meta property="fb:app_id" content="1321688464574422" /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:site" content="@bep40" /> + <meta name="twitter:title" content="${ogTitle}" /> + <meta name="twitter:description" content="${ogDescription}" /> + <meta name="twitter:image" content="${ogImage}" /> + <meta name="description" content="${ogDescription}" />`; + return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + } + // No product param — serve default HTML with static OG fallback + const fallbackTitle = escapeHTML("Gemma Avatar + V.AI STUDIO - Chat & Voice AI"); + const fallbackDesc = escapeHTML("Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng."); + const fallbackOgTags = `<meta property="og:title" content="${fallbackTitle}" /> + <meta property="og:description" content="${fallbackDesc}" /> + <meta property="og:url" content="${escapeHTML(url.href)}" /> + <meta property="og:type" content="website" /> + <meta property="og:image" content="${fallbackImg}" /> + <meta name="twitter:card" content="summary_large_image" />`; + return new Response(index.replace(/<\/head>/, fallbackOgTags + "\n </head>"), { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +}` + +function serveStatic(req: Request): Response { + const url = new URL(req.url); + const fullPath = join("/app", url.pathname.slice(1)); + if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 }); + return new Response(Bun.file(fullPath)); +} + +async function queueHandler(req: Request): Promise<Response> { + if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); + const url = new URL(req.url); + const parts = url.pathname.split("/"); + const id = parts[parts.length - 1]; + if (!id) return Response.json({ error: "Missing id" }, { status: 400 }); + try { + if (req.method === "DELETE") { + return await proxy("/queue/" + encodeURIComponent(id), req, { method: "DELETE" }); + } + return await proxy("/queue/" + encodeURIComponent(id), req); + } catch { + return Response.json({ error: "Speech service unreachable." }, { status: 502 }); + } +} + +async function sessionHandler(req: Request): Promise<Response> { + if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 }); + try { + return await proxy("/session", req, { method: "POST", body: "{}" }); + } catch { + return Response.json({ error: "Speech service unreachable." }, { status: 502 }); + } +} + +// ── Text-only chat endpoint ── +// Uses S2S backend in text-only mode: POST /session → text chat via WebSocket +async function textChatHandler(req: Request): Promise<Response> { + if (!UPSTREAM) return Response.json({ error: "Chat service not configured." }, { status: 404 }); + try { + const reqBody = await req.json(); + const userMessage = reqBody.message || ""; + if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 }); + + // Create S2S session in text-only mode + const sessionResp = await fetch(UPSTREAM + "/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!sessionResp.ok) { + const errBody = await sessionResp.text().catch(() => ""); + return Response.json({ + error: "Failed to create S2S session", + status: sessionResp.status, + detail: errBody.slice(0, 500), + }, { status: 502 }); + } + const sessionData = await sessionResp.json(); + if (sessionData.state === "queued") { + // Poll the queue + let queueResp; + let attempts = 0; + do { + await new Promise(r => setTimeout(r, (sessionData.poll_interval_s || 2) * 1000)); + queueResp = await fetch(UPSTREAM + "/queue/" + encodeURIComponent(sessionData.queue_id), { + headers: { "Content-Type": "application/json" }, + }); + if (!queueResp.ok) continue; + const queueData = await queueResp.json(); + if (queueData.state === "queued") { + attempts++; + if (attempts > 30) { + return Response.json({ error: "Queue timed out after 60 seconds" }, { status: 504 }); + } + continue; + } + const grant = queueData; + // Connect WebSocket + const ws = new WebSocket(grant.connect_url); + + const wsPromise = new Promise<any>((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Session created — wait for session.updated then send text + const waitForSession = async () => { + while (!msgReceived) { + // Wait for session.created + // We'll use a timeout approach + } + }; + + // Send session.update then user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short and natural. Only respond to the user's last message.", + audio: { output: { voice: "Sohee" } } + } + })); + }, 500); + + // Send user message + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect responses + let fullTranscript = ""; + let audioBuffer = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.audio_transcript.done") { + const delta = (event.transcript || event.delta || ""); + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + resolve({ + transcript: fullTranscript.trim(), + audio: audioBuffer, + audioFormat: "pcm16", + }); + } + } catch {} + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => reject(new Error("WebSocket error")); + + // Timeout + const timeout = setTimeout(() => { + if (!responseDone) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve({ + transcript: fullTranscript.trim() || "No response received.", + audio: "", + }); + } + }, 15000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve({ transcript: fullTranscript || "Session closed unexpectedly", audio: "" }); + } + }; + }); + + return Response.json(await wsPromise); + } while (true); + } + + // Session granted directly — connect WebSocket + const grant = sessionData; + const ws = new WebSocket(grant.connect_url); + + return new Promise<Response>((resolve, reject) => { + ws.binaryType = "arraybuffer"; + let msgReceived = false; + + ws.onopen = () => { + // Send session.update + setTimeout(() => { + ws.send(JSON.stringify({ + type: "session.update", + session: { + type: "realtime", + instructions: "You are Gemma, a friendly assistant. Speak in Vietnamese. Keep responses short, natural, and warm. Only respond directly to the user's message in text form. Do not ask questions or request audio input.", + audio: { output: { voice: "Sohee" } } + } + })); + }, 500); + + // Send user message via text + setTimeout(() => { + ws.send(JSON.stringify({ + type: "conversation.item.create", + item: { + type: "message", + role: "user", + content: [{ type: "input_text", text: userMessage }] + } + })); + ws.send(JSON.stringify({ type: "response.create" })); + }, 1000); + + // Collect response + let fullTranscript = ""; + let responseDone = false; + + const handleMessage = (raw: string) => { + try { + const event = JSON.parse(raw); + const type = event?.type; + + if (type === "response.audio_transcript.delta" || type === "response.output_audio_transcript.delta") { + const delta = event.delta || ""; + if (delta) fullTranscript += delta; + } + if (type === "response.audio_transcript.done" || type === "response.output_audio_transcript.done") { + const segment = event.transcript || ""; + if (segment) fullTranscript += segment; + } + if (type === "response.done") { + responseDone = true; + clearTimeout(timeout); + msgReceived = true; + try { ws.close(1000, "done"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim(), + status: event.response?.status ?? "completed", + })); + } + } catch (e: any) { + console.warn("[chat] parse error:", e.message); + } + }; + + ws.onmessage = (e: any) => { + const data = typeof e.data === "string" ? e.data : new TextDecoder("utf-8").decode(e.data); + handleMessage(data); + }; + + ws.onerror = () => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "error"); } catch {} + resolve(Response.json({ + transcript: "Error connecting to chat service.", + status: "error", + })); + } + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + resolve(Response.json({ + transcript: fullTranscript.trim() || "Session closed.", + status: "closed", + })); + } + }; + + // Timeout + const timeout = setTimeout(() => { + if (!msgReceived) { + msgReceived = true; + try { ws.close(1000, "timeout"); } catch {} + resolve(Response.json({ + transcript: fullTranscript.trim() || "No response received. The service may be busy.", + status: "timeout", + })); + } + }, 20000); + }; + + ws.onclose = () => { + if (!msgReceived) { + msgReceived = true; + clearTimeout(timeout); + resolve(Response.json({ + transcript: "Connection closed.", + status: "closed", + })); + } + }; + }); + } catch (err: any) { + console.error("[/api/chat] Error:", err.message); + return Response.json({ error: "Chat service error: " + err.message }, { status: 500 }); + } +} + +const server = Bun.serve({ + port: PORT, + routes: { + "/": { GET: serveProductOG }, + "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) }, + "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) }, + "/api/vaix/products": { GET: async (req: Request) => { + try { + await loadVaixProducts(); + if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 }); + const url = new URL(req.url); + const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999); + const total = VAIX_PRODUCTS.length; + const formatted = VAIX_PRODUCTS.slice(0, Math.min(limit, total)).map((p, i) => formatProduct(p, i)); + return Response.json({ + products: formatted, + total, limit, page: 1, perPage: limit, + totalPages: 1, + }); + } catch { return Response.json({ products: [], total: 0 }); } + }}, + "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) }, + "/api/vaix/search": { GET: async (req: Request) => { + const url = new URL(req.url); + const q = url.searchParams.get("q"); + if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 }); + try { + await loadVaixProducts(); + if (!VAIX_PRODUCTS?.length) return Response.json({ results: [], aiAnswer: "No products." }); + const qNorm = norm(q); + const terms = qNorm.split(/\s+/).filter(t => t.length > 1); + const results: Array<{ product: any; score: number }> = []; + for (let i = 0; i < VAIX_PRODUCTS.length; i++) { + const p = VAIX_PRODUCTS[i]; + let score = 0; + const nm = norm(p.name || p.n || ""); + const sk = norm(p.sku || p.mod || p.model || ""); + const br = norm(p.brand || ""); + const ca = norm(p.c || p.cat || ""); + const de = norm(p.desc || ""); + const ft = norm(Array.isArray(p.feats) ? p.feats.join(" ") : ""); + if (nm.includes(qNorm)) score += 20; + if (sk.includes(qNorm)) score += 15; + if (br.includes(qNorm)) score += 10; + if (ca.includes(qNorm)) score += 8; + for (const t of terms) { + if (nm.includes(t)) score += 3; + if (sk.includes(t)) score += 2; + if (br.includes(t)) score += 2; + if (ca.includes(t)) score += 1; + if (de.includes(t)) score += 1; + if (ft.includes(t)) score += 1; + } + if (score > 0) results.push({ product: formatProduct(p, i), score }); + } + results.sort((a, b) => b.score - a.score); + return Response.json({ results: results.slice(0, 50), aiAnswer: results.length > 0 ? "Found " + results.length + " products." : "No products found." }); + } catch (err: any) { return Response.json({ results: [], error: err.message }); } + }}, + "/api/vaix/sync": { POST: async () => { + VAIX_PRODUCTS = null; + VAIX_LOADED = false; + VAIX_LOADING = false; + await loadVaixProducts(); + return Response.json({ synced: true, count: VAIX_PRODUCTS?.length ?? 0 }); + }}, + "/api/wiki/search": { GET: async (req: Request) => { + const q = new URL(req.url).searchParams.get("q"); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const r = await fetch("https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=" + encodeURIComponent(q) + "&format=json&srlimit=5&origin=*"); + const d = await r.json(); + return Response.json({ results: (d.query?.search ?? []).map((r: any) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") })) }); + } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } + }}, + "/api/wiki/summary": { GET: async (req: Request) => { + const t = new URL(req.url).searchParams.get("title"); + if (!t) return Response.json({ error: "Missing ?title=" }, { status: 400 }); + try { + const r = await fetch("https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(t) + "?origin=*"); + const d = await r.json(); + return Response.json({ title: d.title, extract: d.extract, url: d.content_urls?.desktop?.page }); + } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); } + }}, + "/api/web/search": { GET: async (req: Request) => { + const q = new URL(req.url).searchParams.get("q"); + if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); + try { + const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" } }); + const h = await r.text(); + const res: Array<{ title: string; snippet: string; url: string }> = []; + const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; + const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; + for (let i = 0; i < Math.min(lm.length, 5); i++) { + let href = lm[i][1]; + const ru = href.match(/uddg=(https?%3[^&]+)/i); + if (ru) href = decodeURIComponent(ru[1]); + const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); + const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; + if (title) res.push({ title, snippet, url: href }); + } + return Response.json({ results: res }); + } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); } + }}, + "/api/web/content": { GET: async (req: Request) => { + const u = new URL(req.url).searchParams.get("url"); + if (!u) return Response.json({ error: "Missing ?url=" }, { status: 400 }); + try { + const r = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(8000) }); + const h = await r.text(); + const t = h.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim(); + return Response.json({ content: t.slice(0, 3000), url: u }); + } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); } + }}, + "/api/session": { POST: sessionHandler }, + "/api/queue/:id": { GET: queueHandler, DELETE: queueHandler }, + "/api/chat": { POST: textChatHandler }, + "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!), + "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!), + "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), + "/*": { GET: serveStatic }, + }, +}); +console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none")); diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..bc3066b242b47d6828aeb72fb2a354a79155a908 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "gemma-avatar", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun --hot ./index.ts", + "start": "bun ./index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@met4citizen/talkinghead": "^1.7.0", + "three": "~0.180.0" + }, + "version": "1.5.1" +} \ No newline at end of file diff --git a/public/avatars/Ella.glb b/public/avatars/Ella.glb new file mode 100644 index 0000000000000000000000000000000000000000..adb97c166aa61de7f2d665bc90eb8ea1825cd592 --- /dev/null +++ b/public/avatars/Ella.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c084db10674b11d030014cabea42cd52323ac4f279024c59470408668197d38 +size 2926564 diff --git a/public/avatars/Lisa.glb b/public/avatars/Lisa.glb new file mode 100644 index 0000000000000000000000000000000000000000..3a1b8908d61d596a0d737d7dff0ae9705ec17695 --- /dev/null +++ b/public/avatars/Lisa.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24470df495fa6b848e842fb56d6ce56653db8bc7a0d10aff7bd6c1d0af57caea +size 3282620 diff --git a/public/avatars/avatarsdk.glb b/public/avatars/avatarsdk.glb new file mode 100644 index 0000000000000000000000000000000000000000..ae5449d9af910d131e90a5e30517fe7fa64afdeb --- /dev/null +++ b/public/avatars/avatarsdk.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:193c07c351f1f94b12b499ece3f8813432dbe902afe0b5dd6957e788a9a36605 +size 12284040 diff --git a/public/avatars/avaturn.glb b/public/avatars/avaturn.glb new file mode 100644 index 0000000000000000000000000000000000000000..7da83c885837cf31a47b0bf6fb2162a7da06c246 --- /dev/null +++ b/public/avatars/avaturn.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f56e7a34e249597cff08ba7ef7ce597b61b66b455a21023f186e769caca0cdda +size 13823336 diff --git a/public/avatars/bruce_avaturn.glb b/public/avatars/bruce_avaturn.glb new file mode 100644 index 0000000000000000000000000000000000000000..65df9b6de664855acea0e9a2fc0b0fc7964005ae --- /dev/null +++ b/public/avatars/bruce_avaturn.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db6821e81f7a6185cc9673d88b11875d8d57ccfe750ea1806926a0c305c5764d +size 13574312 diff --git a/public/avatars/brunette-t.glb b/public/avatars/brunette-t.glb new file mode 100644 index 0000000000000000000000000000000000000000..f658aa758fa248df2ac834a6dfd7b9797f255e84 --- /dev/null +++ b/public/avatars/brunette-t.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ca13a1e3ad8f57e50266294b823641680dd3f3f174eb4be908ddca7afc0c82a +size 2875236 diff --git a/public/avatars/brunette.glb b/public/avatars/brunette.glb new file mode 100644 index 0000000000000000000000000000000000000000..cefac7097103c0011038f490c91329f8fe2e8cf8 --- /dev/null +++ b/public/avatars/brunette.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8864c504b5c11daa2f0037afffdc0815bb0fa2e6062e37a584746116a3c2f538 +size 4721528 diff --git a/public/avatars/david.glb b/public/avatars/david.glb new file mode 100644 index 0000000000000000000000000000000000000000..29b932e49539306769df8f6ccaece895d04c78a3 --- /dev/null +++ b/public/avatars/david.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2875f23a2da5960fd860b0a1106e309a027226367e6c48761868426d16502b8e +size 4785200 diff --git a/public/avatars/fabio_avaturn.glb b/public/avatars/fabio_avaturn.glb new file mode 100644 index 0000000000000000000000000000000000000000..3ef3eaf8a9fa118643419a943c4da8eb0490f9c8 --- /dev/null +++ b/public/avatars/fabio_avaturn.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39c64e808cc4bcee03665653b877205f2c38ba730a977a9d7ae50ce325305b71 +size 13772844 diff --git a/public/avatars/grit-b6c6c4.glb b/public/avatars/grit-b6c6c4.glb new file mode 100644 index 0000000000000000000000000000000000000000..ea5bf6b7f380e31dc05486a8c819d4b03fe220a6 --- /dev/null +++ b/public/avatars/grit-b6c6c4.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e128f8a7263b679c94701b67ed6b9379e639d251cdf5129a92daef0b7a16876 +size 11247792 diff --git a/public/avatars/julia.glb b/public/avatars/julia.glb new file mode 100644 index 0000000000000000000000000000000000000000..cefac7097103c0011038f490c91329f8fe2e8cf8 --- /dev/null +++ b/public/avatars/julia.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8864c504b5c11daa2f0037afffdc0815bb0fa2e6062e37a584746116a3c2f538 +size 4721528 diff --git a/public/avatars/lisamy.glb b/public/avatars/lisamy.glb new file mode 100644 index 0000000000000000000000000000000000000000..4822de86abfefe27f7a4de40b0618f375fd8626b --- /dev/null +++ b/public/avatars/lisamy.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a92c208c498aca4c7ffe48d237ce1f3098df902631c8f9ca98bab5f7c104bd3f +size 8320540 diff --git a/public/avatars/lisamy.jpg b/public/avatars/lisamy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d53955116cbe347e5012e1c635eff8bd9372cbf4 Binary files /dev/null and b/public/avatars/lisamy.jpg differ diff --git a/public/avatars/model (6).glb b/public/avatars/model (6).glb new file mode 100644 index 0000000000000000000000000000000000000000..3bee575a6f4afc9aedd94eda6d093a0262b9a3bb --- /dev/null +++ b/public/avatars/model (6).glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0333fe126ab2dac578f6ec69482a8a397706d849ac3f71919f37d77f2f5bdcd +size 14048464 diff --git a/public/avatars/mpfb.glb b/public/avatars/mpfb.glb new file mode 100644 index 0000000000000000000000000000000000000000..3fecfa0c1bdcb5102570abb35c27b5df1c9608f5 --- /dev/null +++ b/public/avatars/mpfb.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63c645a2a863b9972e9a9c2ed576a1de4c390b8475508e1473e69c87a3ee299c +size 36815920 diff --git a/public/avatars/ready_player_me_1.glb b/public/avatars/ready_player_me_1.glb new file mode 100644 index 0000000000000000000000000000000000000000..26da397cb5dcfe5e78b2d0702d2292f5368abd03 --- /dev/null +++ b/public/avatars/ready_player_me_1.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50ca75ebbf13b133e4eedfc3af8121ccf0f02bd2f469d1ef680b762b7ee87632 +size 8810736 diff --git a/public/avatars/ready_player_me_2.glb b/public/avatars/ready_player_me_2.glb new file mode 100644 index 0000000000000000000000000000000000000000..4b354d079cf6680425ded6c7c9cc35d12fab8651 --- /dev/null +++ b/public/avatars/ready_player_me_2.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a30b59d6a0c1e23f214101d64a01092e09b3bfb9aafa9f399420685ec2486ff +size 9618120 diff --git a/public/avatars/vuong.glb b/public/avatars/vuong.glb new file mode 100644 index 0000000000000000000000000000000000000000..7a2bb3698a04db05625ea333f402e5863668e64a --- /dev/null +++ b/public/avatars/vuong.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aec85cb497aec853af78f6686b672fffe4fe2dd9bdab81bc43b39caf0d69926a +size 6964484 diff --git a/public/avatars/vuong.jpg b/public/avatars/vuong.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8d715ca77694ce5f8f5095425b2811431bf1f856 Binary files /dev/null and b/public/avatars/vuong.jpg differ diff --git a/public/avatars/vuong2.glb b/public/avatars/vuong2.glb new file mode 100644 index 0000000000000000000000000000000000000000..1f4e8985fc1690294eedd15ee56fa755909e57f9 --- /dev/null +++ b/public/avatars/vuong2.glb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b8bd12ea374b894fa6d7290dabea0fb785fb0f410087d8e1ac65585cc5a9d06 +size 7137424 diff --git a/public/vendor/headaudio.min.mjs b/public/vendor/headaudio.min.mjs new file mode 100644 index 0000000000000000000000000000000000000000..92226eacbbfce618fcc2c85c0a61211ad8deb882 --- /dev/null +++ b/public/vendor/headaudio.min.mjs @@ -0,0 +1 @@ +var e={d:(t,s)=>{for(var n in s)e.o(s,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:s[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=16e3,s=32,n=64,i=!0,o=.97,r=512,a=256,l=12,h=40,f=22,m=!1,c=!1,u=12,d=!0,v=1,p=15,g=14,b=0,y=2,w=8,E=u,A=56,M=78,_=368,O=92;class Training{constructor(e){const t=Object.assign({},e||{});this.vectorDiff=new Float32Array(u),this.matrixL=Array.from({length:u},()=>new Float32Array(u)),this.matrixLInv=Array.from({length:u},()=>new Float32Array(u)),this.matrixSigma=Array.from({length:u},()=>new Float32Array(u)),this.indexSigmaInvLower=this._buildSigmaInvLowerLookup()}_buildSigmaInvLowerLookup(){const e=Array.from({length:u},()=>new Uint16Array(u));let t=0;for(let s=0;s<u;s++)for(let n=0;n<=s;n++)e[s][n]=t,e[n][s]=t,t++;return e}computePrototype(e,t,s,n){const i=u,o=n.length;if(o<i)throw new Error(`Not enough samples (${o}) to compute full covariance.`);const r=new Float32Array(O),a=new DataView(r.buffer,0,8),l=undefined,h=undefined,f=(e?.codePointAt(0)||0)<<16|(e?.codePointAt(1)||0||0);a.setUint32(0,f),a.setUint8(5,t),a.setUint8(7,s);const m=new Float32Array(r.buffer,8,E),c=new Float32Array(r.buffer,A,M);for(let e=0;e<o;e++){const t=n[e];for(let e=0;e<i;e++)m[e]+=t[e]}const d=1/o;for(let e=0;e<i;e++)m[e]*=d;const v=this.matrixSigma;v.forEach(e=>e.fill(0));const p=1/(o-1);for(let e=0;e<o;e++){const t=n[e];for(let e=0;e<i;e++){const s=t[e]-m[e];for(let n=e;n<i;n++)v[e][n]+=s*(t[n]-m[n])}}for(let e=0;e<i;e++)for(let t=e;t<i;t++){const s=v[e][t]*p;v[e][t]=s,e!==t&&(v[t][e]=s)}let g=0;for(let e=0;e<i;e++)g+=v[e][e];const b=g/i*1e-5;for(let e=0;e<i;e++)v[e][e]+=b;const y=this.matrixL;y.forEach(e=>e.fill(0));for(let e=0;e<i;e++)for(let t=0;t<=e;t++){let s=v[e][t];for(let n=0;n<t;n++)s-=y[e][n]*y[t][n];if(e===t){if(s<=0)throw new Error("Matrix is not positive definite.");y[e][t]=Math.sqrt(s)}else y[e][t]=s/y[t][t]}const w=this.matrixLInv;w.forEach(e=>e.fill(0));for(let e=0;e<i;e++){w[e][e]=1/y[e][e];for(let t=0;t<e;t++){let s=0;for(let n=t;n<e;n++)s-=y[e][n]*w[n][t];w[e][t]=s/y[e][e]}}const _=Array.from({length:i},()=>new Float32Array(i));let x=0;for(let e=0;e<i;e++)for(let t=0;t<=e;t++){let s=0;for(let n=Math.max(e,t);n<i;n++)n>=e&&n>=t&&(s+=w[n][e]*w[n][t]);_[e][t]=s,_[t][e]=s,c[x]=s,x++}return{mu:m,sigmaInv:_,sigmaInvLower:c,bin:r.buffer}}decodeBinaryRecord(e){const t=new DataView(e.buffer,e.byteOffset,8),s=t.getUint32(0),n=s>>>16,i=65535&s,o=undefined,r=undefined,a=undefined,l=undefined,h=undefined;return{phoneme:0===i?String.fromCodePoint(n):String.fromCodePoint(n,i),group:t.getUint8(5),viseme:t.getUint8(7),mu:new Float32Array(e.buffer,e.byteOffset+8,E),sigmaInvLower:new Float32Array(e.buffer,e.byteOffset+A,M)}}async loadModel(e,t=null){const s=await fetch(e);if(!s.ok)throw new Error(`Failed to fetch model: ${s.status} ${s.statusText}`);const n=await s.arrayBuffer(),i=[],o=n.byteLength;let r=0;for(;r+_<=o;){const e=new Float32Array(n,r,O),s=this.decodeBinaryRecord(e);null!==t&&(s.group=t),i.push(s),r+=_}if(0===i.length)throw new Error("Fetched model is empty");return{model:i,buffer:n}}update(e){for(const t in e);}}class HeadAudio extends AudioWorkletNode{constructor(e,t=null){const s=Object.assign({},t||{});super(e,"headworklet",{numberOfInputs:1,numberOfOutputs:0,channelCount:1,channelCountMode:"explicit",channelInterpretation:"speakers",outputChannelCount:[],processorOptions:s?.processorOptions||{},parameterData:s?.parameterData||{}}),this.frameEventsEnabled=!!s.processorOptions?.frameEventsEnabled,this.vadEventsEnabled=!!s.processorOptions?.vadEventsEnabled,this.featureEventsEnabled=!!s.processorOptions?.featureEventsEnabled,this.visemeEventsEnabled=!!s.processorOptions?.visemeEventsEnabled,this.visemeNames=["viseme_aa","viseme_E","viseme_I","viseme_O","viseme_U","viseme_PP","viseme_SS","viseme_TH","viseme_DD","viseme_FF","viseme_kk","viseme_nn","viseme_RR","viseme_CH","viseme_sil"],this.nVisemes=this.visemeNames.length,this.visemeMaxs=[.65,.65,.65,.65,.65,.75,.65,.65,.65,.75,.65,.65,.65,.65,.65],this.visemeAlphas=new Array(this.nVisemes).fill(0),this.visemeActive=-1,this.easing=this.sigmoidFactory(5),this.onvalue=null,this.onframe=null,this.onsentence=null,this.onstarted=null,this.onended=null,this.onfeature=null,this.onviseme=null,this.training=new Training,this.timerCounter=0,this.isRunning=!0,this.port.onmessage=this._onmessage.bind(this)}sigmoidFactory(e){function t(t){return 1/(1+Math.exp(-e*t))-.5}var s=.5/t(1);return function(e){return s*t(2*Math.max(Math.min(e,1),0)-1)+.5}}_onmessage(e){const t=e.data;switch(t.event){case"frame":this.frameEventsEnabled&&this.onframe&&"function"==typeof this.onframe&&this.onframe(t);break;case"vad":this.vadEventsEnabled&&this.onvad&&"function"==typeof this.onvad&&this.onvad(t);break;case"feature":this.featureEventsEnabled&&this.onfeature&&"function"==typeof this.onfeature&&this.onfeature(t);break;case"viseme":const e=t.viseme;e&&(this.visemeActive=e===g?-1:e),this.visemeEventsEnabled&&this.onviseme&&"function"==typeof this.onviseme&&this.onviseme(t);break;case"started":this.onstarted&&"function"==typeof this.onstarted&&this.onstarted(t);break;case"ended":this.visemeActive=-1,this.lastEndEvent=performance.now(),this.onended&&"function"==typeof this.onended&&this.onended(t);break;case"calibrated":try{const{mu:e,sigmaInvLower:s,bin:n}=this.training.computePrototype("sc",255,g,t.vs);this.port.postMessage({event:"model",model:[{phoneme:"sc",group:255,viseme:g,mu:e,sigmaInvLower:s}]},[n])}catch(e){t.error=e,console.error(e)}this.oncalibrated&&"function"==typeof this.oncalibrated&&this.oncalibrated(t)}}async loadModel(e,t=!0){const{model:s,buffer:n}=await this.training.loadModel(e);t&&this.port.postMessage({event:"reset"}),this.port.postMessage({event:"model",model:s},[n])}update(e){const t=e/100;for(let e=0;e<this.nVisemes;e++){let s=this.visemeAlphas[e],n=!1;if(e===this.visemeActive?(n=!0,s<1&&(s+=t,s>.99&&(s=1))):s>0&&(n=!0,s-=t,s<.01&&(s=0)),n){let t=this.visemeMaxs[e]*this.easing(s);this.onvalue&&"function"==typeof this.onvalue&&this.onvalue(this.visemeNames[e],t),this.visemeAlphas[e]=s}}}start(){this.isRunning=!0,this.port.postMessage({event:"start"})}stop(){this.isRunning=!1,this.port.postMessage({event:"stop"})}calibrate(){this.port.postMessage({event:"calibrate"})}resetAll(){this.port.postMessage({event:"reset"})}resetTimer(){this.timerCounter++,this.parameters.get("timerReset").value=this.timerCounter}}export{HeadAudio}; \ No newline at end of file diff --git a/public/vendor/headworklet.min.mjs b/public/vendor/headworklet.min.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6457b2102acc32b771fda67170c1a196d1657e3f --- /dev/null +++ b/public/vendor/headworklet.min.mjs @@ -0,0 +1 @@ +var t={d:(e,s)=>{for(var i in s)t.o(s,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:s[i]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e)};const e=16e3,s=32,i=64,a=!0,n=.97,r=512,o=256,l=12,h=40,c=22,d=!1,u=!1,p=12,v=!0,m=1,f=15,g=14,b=0,M=2,w=8,y=p,k=56,F=78,A=368,E=92;class RingBuffer{constructor(t,e=void 0,s=!1){this.buf=Array.from({length:t},e),this.capacity=t,this.head=0,this.tail=0,this.count=s?t:0}add(t){const e=this.capacity;this.buf[this.tail]=t,this.tail=(this.tail+1)%e,this.count<e?this.count++:this.head=(this.head+1)%e}allocate(){const t=this.capacity,e=this.buf[this.tail];return this.tail=(this.tail+1)%t,this.count<t?this.count++:this.head=(this.head+1)%t,e}isAvailable(t){return this.count>=t}isFull(){return this.count===this.capacity}getHead(t=0){const e=this.capacity,s=(this.head+t%e+e)%e;return this.buf[s]}getTail(t=0){const e=this.capacity,s=(this.tail+(t-1)%e+e)%e;return this.buf[s]}getLatest(t,e=null){const s=t.length;if(s>this.count)return null;const i=this.capacity;this.head=(this.head+(this.count-s))%i;for(let e=0;e<s;e++){const s=(this.head+e)%i;t[e]=this.buf[s]}return null!==e&&e>=0&&e<=s&&(this.head=(this.head+e)%i,this.count=s-e),t}clear(t=0){this.count=t,this.tail=(this.head+t)%this.capacity}}class MFCC{constructor(t){const e=Object.assign({speakerMeanHz:150},t||{});this.cossin=this._buildCosSin(),this.bitrev=this._buildBitrev(),this.spectrum=new Float32Array(1024),this.powerSpec=new Float32Array(256),this.melFilters=Array.from({length:h},()=>new Float32Array(256)),this._buildMelFilterbank(e.speakerMeanHz),this.melEnergies=new Float32Array(h),this.hamming=this._buildWindowHamming(),this.dctMatrix=this._buildDCTMatrix(),this.lifter=this._buildLifter()}_buildCosSin(){const t=new Float32Array(1024),e=-2*Math.PI/r;for(let s=0;s<r;s++){const i=e*s;t[2*s]=Math.cos(i),t[2*s+1]=Math.sin(i)}return t}_buildBitrev(){const t=new Uint32Array(r),e=Math.log2(r);for(let s=0;s<r;s++){let i=0;for(let t=0;t<e;t++)i=i<<1|s>>t&1;t[s]=i}return t}_hammingAndFFT(t){const e=this.cossin,s=this.bitrev,i=this.hamming,a=this.spectrum;for(let e=0;e<r;e++){const n=s[e];a[2*n]=t[e]*i[e],a[2*n+1]=0}for(let t=1;t<r;t<<=1){const s=t<<1,i=r/s;for(let n=0;n<t;n++){const o=n*i*2,l=e[o],h=e[o+1];for(let e=n;e<r;e+=s){const s=e<<1,i=e+t<<1,n=a[s],r=a[s+1],o=a[i],c=a[i+1],d=o*l-c*h,u=o*h+c*l;a[s]=n+d,a[s+1]=r+u,a[i]=n-d,a[i+1]=r-u}}}}_buildWindowHamming(){return Float32Array.from({length:r},(t,e)=>.54-.46*Math.cos(2*Math.PI*e/511))}_buildMelFilterbank(t=150){const s=this.melFilters,i=150,a=Math.min(Math.max(t/i,.6),1.8),n=undefined,o=7800,l=t=>2595*Math.log10(1+t/700),c=t=>700*(Math.pow(10,t/2595)-1),d=l(30),u=undefined,p=l(o)-d,v=undefined,m=Float32Array.from({length:42},(t,e)=>{const s=undefined,i=undefined;return c(d+(d+p/41*e-d)*a)}).map(t=>Math.floor(t/e*r));for(let t=0;t<h;t++){const e=s[t];for(let s=m[t];s<m[t+1];s++)e[s]=(s-m[t])/(m[t+1]-m[t]);for(let s=m[t+1];s<m[t+2];s++)e[s]=(m[t+2]-s)/(m[t+2]-m[t+1])}}_buildDCTMatrix(){const t=[],e=Math.sqrt(.05);for(let s=0;s<=l;s++){const i=new Float32Array(h);for(let t=0;t<h;t++)i[t]=e*Math.cos(Math.PI*s*(t+.5)/h);t.push(i)}return t}_buildLifter(){const t=new Float32Array(l);for(let e=1;e<=l;e++)t[e-1]=1+11*Math.sin(Math.PI*e/c);return t}update(t){for(const e in t)if("speakerMeanHz"===e){const s=t[e];this._buildMelFilterbank(s)}}compute(t,e){const s=256;this._hammingAndFFT(t);const i=this.spectrum,a=this.powerSpec;let n=0;for(let t=0;t<s;t++){const e=i[2*t],s=i[2*t+1];a[t]=(e*e+s*s)/r,n+=a[t]}const o=Math.log10(n+1e-10),c=this.melFilters,d=this.melEnergies;for(let t=0;t<h;t++){const e=c[t],s=e.length;let i=0;for(let t=0;t<s;t++)i+=a[t]*e[t];d[t]=Math.log10(i+1e-10)}const u=this.dctMatrix,p=e||new Float32Array(l),v=this.lifter;for(let t=0;t<l;t++){const e=u[t+1];let s=0;for(let t=0;t<h;t++)s+=e[t]*d[t];p[t]=s*v[t]}return{le:o,mfcc:p}}}class VadGate{constructor(t){const e=Object.assign({vadGateActiveDb:-40,vadGateActiveMs:10,vadGateInactiveDb:-50,vadGateInactiveMs:10},t||{});this.activeLE=e.vadGateActiveDb/10,this.activeFrames=Math.max(1,Math.round(62.5*e.vadGateActiveMs/1e3)),this.inactiveLE=e.vadGateInactiveDb/10,this.inactiveFrames=Math.max(1,Math.round(62.5*e.vadGateInactiveMs/1e3)),this.active=0,this.inactive=1,this.preActive=0,this.preInactive=0}update(t){for(const e in t){const s=t[e];switch(e){case"vadGateActiveDb":this.activeLE=s/10;break;case"vadGateActiveMs":this.activeFrames=62.5*s/1e3;break;case"vadGateInactiveDb":this.inactiveLE=s/10;break;case"vadGateInactiveMs":this.inactiveFrames=62.5*s/1e3}}}process(t){return this.active?(this.active++,t<this.inactiveLE?(this.preInactive++,this.preInactive>=this.inactiveFrames&&(this.active=0,this.inactive=1,this.preActive=0)):this.preInactive>0&&this.preInactive--):(this.inactive++,t>this.activeLE?(this.preActive++,this.preActive>=this.activeFrames&&(this.active=1,this.inactive=0,this.preInactive=0)):this.preActive>0&&this.preActive--),{active:this.active,inactive:this.inactive}}}class Classifier{constructor(t){const e=Object.assign({silSensitivity:1.2},t||{});this.prototypes=[],this.prototypesN=0,this.vectorDiff=new Float32Array(p),this.indexSigmaInvLower=this._buildSigmaInvLowerLookup(),this.ringPredictions=new RingBuffer(6,()=>g,!0),this.counts=Array(f).fill(0),this.predictionLast=g,this.silSensitivity=e.silSensitivity}_buildSigmaInvLowerLookup(){const t=Array.from({length:p},()=>new Uint16Array(p));let e=0;for(let s=0;s<p;s++)for(let i=0;i<=s;i++)t[s][i]=e,t[i][s]=e,e++;return t}clear(){this.prototypes=[],this.prototypesN=0}import(t){t?.reset&&this.clear();const e=t?.model,s=e?.length||0,i=[];for(let t=0;t<s;t++){const s=e[t];s.hasOwnProperty("group")&&i.push(s.group)}i.length&&(this.prototypes=this.prototypes.filter(t=>!i.includes(t.group)));for(let t=0;t<s;t++){const s=e[t];this.prototypes.push(s)}this.prototypesN=this.prototypes.length,this.distances=Array(this.prototypesN).fill(1/0)}update(t){for(const e in t)if("silSentivity"===e){const s=t[e];this.silSensitivity=s}}distanceMahalanobis(t,e,s){let i=0;const a=this.indexSigmaInvLower,n=this.vectorDiff;for(let s=0;s<p;++s)n[s]=t[s]-e[s];for(let t=0;t<p;++t){const e=a[t][t],r=n[t];let o=s[e]*r*r;for(let e=0;e<t;++e){const i=undefined;o+=2*s[a[t][e]]*r*n[e]}i+=o}return i}predict(t){const e=this.prototypesN;if(0===e)return{viseme:null,distances:[]};const s=this.prototypes,i=this.distances;let a=1/0,n=0;for(let r=0;r<e;r++){const e=s[r];let o=this.distanceMahalanobis(t,e.mu,e.sigmaInvLower);e.viseme===g&&(o/=this.silSensitivity),i[r]=o,o<=a&&(a=o,n=r)}const r=this.ringPredictions,o=s[n].viseme;r.add(o);const l=r.capacity,h=this.counts.fill(0);for(let t=0;t<l;t++)h[r.buf[t]]++;let c=0,d=0;for(let t=0;t<f;t++){const e=h[t];e>=d&&(c=t,d=e)}return c===this.predictionLast&&c===g?c=null:this.predictionLast=c,{viseme:c,distances:i}}}class Processor{constructor(t,i=null){const a=Object.assign({vadEventsEnabled:!1,frameEventsEnabled:!1,featureEventsEnabled:!1,visemeEventsEnabled:!1},t?.processorOptions||{}),n=Object.assign({vadMode:1,vadGateActiveDb:-40,vadGateActiveMs:10,vadGateInactiveDb:-50,vadGateInactiveMs:10,silMode:1,silCalibrationWindowSec:3,silSensitivity:1.2},t?.parameterData||{});this.worklet=i||{port:{onmessage:null,postMessage:t=>{}}},this.sampleRate=t.sampleRate,this.samplesN=t.samplesN||r,this.samplesHop=t.samplesHop||o,this.preemphasisPrevValue=0,this.downsampleRatio=this.sampleRate/e,this.downsamplePhaseAccumulator=0,this.downsamplePolyFilter=this.designPolyphaseFilter(),this.ringPolyFilter=new RingBuffer(s,()=>0,!0),this.ringSamples=new RingBuffer(this.samplesN,()=>0),this.block=new Float32Array(this.samplesN),this.mfcc=new MFCC(t),this.ringVectors=new RingBuffer(3,()=>new Float32Array(p),!0),this.vectorDuration=this.samplesN/e,this.vadMode=n.vadMode,this.vadGate=new VadGate(n),this.isRunning=!0,this.isSpeaking=!1,this.silMode=n.silMode;const l=Math.max(100,Math.floor(n.silCalibrationWindowSec*o/e));this.ringCalibration=new RingBuffer(l),this.isCalibrationRunning=!1,this.frameEventsEnabled=!!a.frameEventsEnabled,this.vadEventsEnabled=!!a.vadEventsEnabled,this.featureEventsEnabled=!!a.featureEventsEnabled,this.visemeEventsEnabled=!!a.visemeEventsEnabled,this.frameEventsEnabled&&(this.ringFrame=new RingBuffer(128)),this.sampleCount=0,this.classifier=new Classifier(n)}_onmessage(t){const s=t.data;switch(s.event){case"stop":if(this.isRunning=!1,this.isSpeaking){this.isSpeaking=!1;const t=this.sampleCount/e;this.worklet.port.postMessage({event:"end",t})}this.ringVectors.clear();break;case"start":this.isRunning=!0;break;case"calibrate":this.ringCalibration.clear(),this.isCalibrationRunning=!0;break;case"model":this.classifier.import(s);break;case"reset":this.ringSamples.clear(),this.ringVectors.clear(),this.sampleCount=0;break;default:console.warn("Processor received an unknown message: '"+s.event+"'.")}}designPolyphaseFilter(){const t=[],e=15.5,a=.45;for(let n=0;n<i;n++){const r=n/i,o=new Float32Array(s);for(let t=0;t<s;t++){const s=t-e-r;o[t]=0===s?.9:Math.sin(2*Math.PI*a*s)/(Math.PI*s),o[t]*=.54-.46*Math.cos(2*Math.PI*t/31)}t.push(o)}return t}update(t){for(const s in t){const i=t[s];switch(s){case"vadMode":this.vadMode=i;break;case"silMode":this.silMode=i;break;case"silSensitivity":this.silSensitivity=i;break;case"silCalibrationWindowSec":const t=Math.max(100,Math.floor(i*o/e));this.ringCalibration=new RingBuffer(t);break;case"speakerMeanHz":this.mfcc.update({speakerMeanHz:i});break;case"timerReset":this.sampleCount=0;break;default:if(s.startsWith("vadGate")){const t={};t[s]=i,this.vadGate.update(t)}}}}process(t){const a=t.length;if(0!==a){this.frameEventsEnabled&&this.ringFrame.clear();for(let r=0;r<a;r++){let a=t[r];{const t=a;a-=n*this.preemphasisPrevValue,this.preemphasisPrevValue=t}for(this.ringPolyFilter.add(a),this.downsamplePhaseAccumulator+=1/this.downsampleRatio;this.downsamplePhaseAccumulator>=1;){const t=(this.downsamplePhaseAccumulator-1)*i,a=Math.floor(t),n=this.downsamplePolyFilter[a];let r=0;const o=this.ringPolyFilter;for(let t=0;t<s;t++)r+=o.getHead(t)*n[t];if(this.downsamplePhaseAccumulator-=1,this.frameEventsEnabled&&this.ringFrame.add(r),this.ringSamples.add(r),this.sampleCount++,this.isRunning&&this.ringSamples.isFull()){const t=this.sampleCount/e,s=this.block;this.ringSamples.getLatest(s,this.samplesHop);const i=this.ringVectors.allocate(),{le:a}=this.mfcc.compute(s,i);{const t=1;for(let e=0;e<l;++e){const s=i[e]*t;i[e]=1*Math.tanh(s)}}if(this.isCalibrationRunning&&1===this.silMode){const t=i.slice(),e=this.ringCalibration;e.add(t),e.isFull()&&(this.worklet.port.postMessage({event:"calibrated",vs:e.buf}),this.isCalibrationRunning=!1)}if(1===this.vadMode){const{active:e,inactive:s}=this.vadGate.process(a);if(this.vadEventsEnabled&&this.worklet.port.postMessage({event:"vad",active:e,inactive:s,db:10*a,t}),s){this.isSpeaking&&(this.isSpeaking=!1,this.worklet.port.postMessage({event:"ended",t}));continue}}this.featureEventsEnabled&&this.worklet.port.postMessage({event:"feature",vector:i,le:a,t:t-this.vectorDuration/2,d:this.vectorDuration});const{viseme:n,distances:r}=this.classifier.predict(i);if(this.isSpeaking?n===g&&(this.isSpeaking=!1,this.worklet.port.postMessage({event:"ended",t})):null!==n&&n!==g&&(this.isSpeaking=!0,this.worklet.port.postMessage({event:"started",t})),this.isSpeaking){let e={event:"viseme",viseme:n,t:t-this.vectorDuration/2,d:this.vectorDuration};this.visemeEventsEnabled&&(e.vector=i,e.distances=r),this.worklet.port.postMessage(e)}}}}if(this.frameEventsEnabled){const t=this.sampleCount/e,s=new Float32Array(this.ringFrame.count);this.ringFrame.getLatest(s),this.worklet.port.postMessage({event:"frame",frame:s,t})}}}}class HeadWorklet extends AudioWorkletProcessor{constructor(t){super(t),(t=t||{}).sampleRate=sampleRate,this.detectors={};for(const e of HeadWorklet.parameterDescriptors){const s=e.name,i=e.value;this.detectors[s]=i,t[s]=i}this.processor=new Processor(t,this),this.port.onmessage=this.processor._onmessage.bind(this.processor)}process(t,e,s){const i=t[0];if(!i)return!0;const a=i[0];if(!a)return!0;const n=undefined;if(!a.length)return!0;const r=this.detectors;let o=!1;const l={};for(const t in s){const e=undefined,i=s[t][0],a=undefined;i!==r[t]&&(o=!0,r[t]=l[t]=i)}return o&&this.processor.update(l),this.processor.process(a),!0}static get parameterDescriptors(){return[{name:"timerReset",defaultValue:-9999,automationRate:"k-rate"},{name:"vadMode",defaultValue:1,minValue:0,maxValue:1,automationRate:"k-rate"},{name:"vadGateActiveDb",defaultValue:-40,minValue:-100,maxValue:0,automationRate:"k-rate"},{name:"vadGateActiveMs",defaultValue:10,minValue:0,maxValue:1e3,automationRate:"k-rate"},{name:"vadGateInactiveDb",defaultValue:-50,minValue:-100,maxValue:0,automationRate:"k-rate"},{name:"vadGateInactiveMs",defaultValue:10,minValue:0,maxValue:1e3,automationRate:"k-rate"},{name:"silMode",defaultValue:1,minValue:0,maxValue:2,automationRate:"k-rate"},{name:"silCalibrationWindowSec",defaultValue:3,minValue:.1,maxValue:10,automationRate:"k-rate"},{name:"silSensitivity",defaultValue:1.2,minValue:0,maxValue:4,automationRate:"k-rate"},{name:"speakerMeanHz",defaultValue:150,minValue:50,maxValue:500,automationRate:"k-rate"}]}}registerProcessor("headworklet",HeadWorklet);export{HeadWorklet}; \ No newline at end of file diff --git a/public/vendor/model-en-mixed.bin b/public/vendor/model-en-mixed.bin new file mode 100644 index 0000000000000000000000000000000000000000..e11ee055482397ef6ac852b06d760c27ca7efd0c --- /dev/null +++ b/public/vendor/model-en-mixed.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0358f68989b5861f9b7d18871b010fa6cbf88a53bda4954a954d8c548bbcf251 +size 14352 diff --git a/public/worklets/audio-playback.js b/public/worklets/audio-playback.js new file mode 100644 index 0000000000000000000000000000000000000000..182deb531e08590fa195fc1a51c98ad5e80208b1 --- /dev/null +++ b/public/worklets/audio-playback.js @@ -0,0 +1,179 @@ +// @ts-check +/** + * AudioWorkletProcessor that plays back Float32 mono samples received from + * the main thread, upsampling whatever incoming rate the server uses + * (typically 24 kHz PCM16) to the AudioContext rate (typically 48 kHz). + * + * Lifecycle / messaging: + * + * main -> worklet: + * { kind: "config", inputRate: 24000 } one-shot at startup + * { kind: "audio", samples: Float32Array } (transferable) per chunk + * { kind: "clear" } wipe queue (barge-in) + * + * worklet -> main: + * { kind: "stats", queuedMs, played } every ~250 ms + * { kind: "underrun" } every time the queue + * runs dry mid-playback + * + * Underrun strategy: output silence. We do NOT hold the last sample (that + * tends to produce audible clicks/buzzes when long gaps appear between + * TTS chunks). A short ramp-out + ramp-in at boundaries would be nicer but + * the server's 30 ms cadence makes underruns visible only at end of turn. + */ + +const STATS_INTERVAL_FRAMES = 12000; +const FADE_FRAMES = 32; + +class AudioPlaybackProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this._inputRate = 24000; + this._stepRatio = this._inputRate / sampleRate; + this._queue = []; + this._readIdx = 0; + this._fracPos = 0; + this._playing = false; + this._framesSinceStats = 0; + this._totalPlayed = 0; + this._fadeIn = 0; + this._fadeOut = 0; + this._lastSample = 0; + this._muted = false; + + this.port.onmessage = (e) => { + const data = e.data; + if (!data || typeof data !== "object") return; + switch (data.kind) { + case "config": + if (typeof data.inputRate === "number" && data.inputRate > 0) { + this._inputRate = data.inputRate; + this._stepRatio = this._inputRate / sampleRate; + } + break; + case "audio": + if (data.samples instanceof Float32Array && data.samples.length > 0) { + this._queue.push(data.samples); + if (!this._playing) { + this._playing = true; + this._fadeIn = FADE_FRAMES; + this._fadeOut = 0; + } + } + break; + case "clear": + this._queue.length = 0; + this._readIdx = 0; + this._fracPos = 0; + this._fadeOut = FADE_FRAMES; + break; + case "mute": + this._muted = true; + break; + case "unmute": + this._muted = false; + break; + } + }; + } + + _queuedSamples() { + let total = -this._readIdx; + for (const buf of this._queue) total += buf.length; + return Math.max(0, total); + } + + /** Linear-interp read at the current fractional position. */ + _readInterpolated() { + if (this._queue.length === 0) return null; + const head = this._queue[0]; + const idx = this._readIdx; + const frac = this._fracPos; + + let a = head[idx]; + let b; + if (idx + 1 < head.length) { + b = head[idx + 1]; + } else if (this._queue.length > 1) { + b = this._queue[1][0]; + } else { + b = a; + } + return a + (b - a) * frac; + } + + /** Advance the read position by `stepRatio`; pop consumed buffers. */ + _advance() { + this._fracPos += this._stepRatio; + while (this._fracPos >= 1) { + this._fracPos -= 1; + this._readIdx += 1; + } + while (this._queue.length > 0 && this._readIdx >= this._queue[0].length) { + this._readIdx -= this._queue[0].length; + this._queue.shift(); + } + } + + process(_, outputs) { + const channels = outputs[0]; + if (!channels || channels.length === 0) return true; + const out = channels[0]; + const stereo = channels.length > 1 ? channels[1] : null; + + for (let i = 0; i < out.length; i++) { + let sample = 0; + + if (this._muted) { sample = 0; } + else if (this._playing) { + const v = this._readInterpolated(); + if (v === null) { + // Underrun: try to ramp out cleanly to avoid clicks. + sample = this._lastSample * Math.max(0, 1 - 1 / FADE_FRAMES); + this._lastSample = sample; + if (Math.abs(sample) < 1e-4) { + this._playing = false; + this._lastSample = 0; + this.port.postMessage({ kind: "underrun" }); + } + } else { + sample = v; + this._lastSample = v; + this._advance(); + } + + if (this._fadeIn > 0) { + const gain = 1 - this._fadeIn / FADE_FRAMES; + sample *= gain; + this._fadeIn -= 1; + } + if (this._fadeOut > 0) { + const gain = this._fadeOut / FADE_FRAMES; + sample *= gain; + this._fadeOut -= 1; + if (this._fadeOut === 0) { + this._playing = false; + this._lastSample = 0; + } + } + + this._totalPlayed += 1; + } + + out[i] = sample; + if (stereo) stereo[i] = sample; + } + + this._framesSinceStats += out.length; + if (this._framesSinceStats >= STATS_INTERVAL_FRAMES) { + this._framesSinceStats = 0; + const queuedSamples = this._queuedSamples(); + const queuedMs = (queuedSamples / this._inputRate) * 1000; + this.port.postMessage({ kind: "stats", queuedMs, played: this._totalPlayed }); + } + + return true; + } +} + +registerProcessor("audio-playback", AudioPlaybackProcessor); diff --git a/public/worklets/mic-capture.js b/public/worklets/mic-capture.js new file mode 100644 index 0000000000000000000000000000000000000000..a48860b2c079916442debe8a7a03c0f1babede90 --- /dev/null +++ b/public/worklets/mic-capture.js @@ -0,0 +1,159 @@ +// @ts-check +/** + * AudioWorkletProcessor that resamples the AudioContext rate (typically 48 kHz) + * down to 16 kHz, packs the result as little-endian Int16 PCM, and posts it + * back to the main thread in fixed-size chunks. + * + * The Hugging Face speech-to-speech WebSocket route expects the + * `input_audio_buffer.append` payload at 16 kHz PCM16 mono. + * + * Design notes: + * - 48 -> 16 is an exact 3:1 ratio so we use a 3-tap boxcar average as a + * cheap low-pass before decimating. Good enough for voice STT; we lose + * a tiny bit of >8 kHz content which the pipeline discards anyway. + * - Output frames are emitted at the cadence dictated by `chunkMs` + * (default 40 ms = 640 samples = 1280 bytes). The OpenAI Realtime + * server batches incoming audio so the cadence is flexible; 20-100 ms + * is the sweet spot. + * - Float -> Int16 saturates to [-1, 1] before scaling. + * - Optional noise gate: per-chunk RMS decides open/closed against a + * threshold; the gain ramps (fast attack, hold, slow release) so word + * onsets aren't clipped and quiet tails don't click. The gate only + * affects the audio we SEND; the main-thread visualiser taps the raw + * mic separately. We post the chunk RMS up every frame so the Settings + * mic meter can show the live level against the threshold. + */ + +const TARGET_RATE = 16000; +const DEFAULT_CHUNK_MS = 40; +// Gate envelope timing (fixed; only the threshold is user-tunable). +const GATE_ATTACK_MS = 5; // open almost instantly so word onsets survive +const GATE_HOLD_MS = 250; // stay open this long after the level drops back under +const GATE_RELEASE_MS = 80; // then fade closed over this long (no click) + +class MicCaptureProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + const chunkMs = options?.processorOptions?.chunkMs ?? DEFAULT_CHUNK_MS; + this._inputRate = sampleRate; + this._ratio = this._inputRate / TARGET_RATE; + this._chunkSamples16k = Math.round((TARGET_RATE * chunkMs) / 1000); + this._scratch = new Float32Array(0); + this._decimated = new Float32Array(this._chunkSamples16k); + this._enabled = true; + + // Noise gate state. Disabled by default (pure passthrough). + this._gateEnabled = false; + this._thresholdLin = 0; // linear amplitude; signal RMS must exceed this to open + this._gateGain = 1; // smoothed gain currently applied + this._holdRemaining = 0; // samples left before the gate may start closing + this._attackCoef = Math.exp(-1 / ((GATE_ATTACK_MS / 1000) * TARGET_RATE)); + this._releaseCoef = Math.exp(-1 / ((GATE_RELEASE_MS / 1000) * TARGET_RATE)); + this._holdSamples = Math.round((GATE_HOLD_MS / 1000) * TARGET_RATE); + + this.port.onmessage = (e) => { + const data = e.data; + if (data?.kind === "enable") this._enabled = !!data.value; + else if (data?.kind === "gate") { + this._gateEnabled = !!data.enabled; + // dB -> linear amplitude. When off, threshold 0 keeps the gate open. + this._thresholdLin = data.enabled ? Math.pow(10, data.thresholdDb / 20) : 0; + } + }; + } + + /** + * Append `incoming` to the internal scratch buffer, then emit as many + * full output chunks as we have material for. + * @param {Float32Array} incoming + */ + _ingest(incoming) { + if (incoming.length === 0) return; + const next = new Float32Array(this._scratch.length + incoming.length); + next.set(this._scratch, 0); + next.set(incoming, this._scratch.length); + this._scratch = next; + this._maybeEmit(); + } + + _maybeEmit() { + const r = this._ratio; + const n = this._chunkSamples16k; + const needIn = Math.ceil(n * r); + const dec = this._decimated; + while (this._scratch.length >= needIn) { + // 1. Decimate to 16 kHz floats and accumulate energy for the gate/meter. + let sumSq = 0; + if (Math.abs(r - 3) < 1e-6) { + // 48 kHz -> 16 kHz fast path with boxcar lowpass. + for (let i = 0; i < n; i++) { + const idx = i * 3; + const s = (this._scratch[idx] + this._scratch[idx + 1] + this._scratch[idx + 2]) / 3; + dec[i] = s; + sumSq += s * s; + } + } else { + // Generic path: linear interpolation. Slower but works at any rate + // (e.g. some Windows boxes report sampleRate=44100). + for (let i = 0; i < n; i++) { + const srcPos = i * r; + const idx = Math.floor(srcPos); + const frac = srcPos - idx; + const a = this._scratch[idx]; + const b = this._scratch[idx + 1] ?? a; + const s = a + (b - a) * frac; + dec[i] = s; + sumSq += s * s; + } + } + const rms = Math.sqrt(sumSq / n); + + // 2. Decide the gate target for this chunk, then ramp sample-by-sample. + let target = 1; + if (this._gateEnabled) { + if (rms >= this._thresholdLin) { + this._holdRemaining = this._holdSamples; // re-arm the hold + } else if (this._holdRemaining > 0) { + this._holdRemaining -= n; // coasting through the hold window + } else { + target = 0; + } + } + + // 3. Apply the (smoothed) gain and pack to Int16. + const out = new Int16Array(n); + let gain = this._gateGain; + for (let i = 0; i < n; i++) { + const coef = target > gain ? this._attackCoef : this._releaseCoef; + gain = target + (gain - target) * coef; + const s = dec[i] * gain; + const clamped = s < -1 ? -1 : s > 1 ? 1 : s; + out[i] = clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff; + } + this._gateGain = gain; + + // Shift the scratch buffer to keep only the trailing unused samples. + const consumed = Math.floor(n * r); + this._scratch = this._scratch.slice(consumed); + + // Live input level for the Settings meter (raw RMS, pre-gate). + this.port.postMessage({ kind: "level", rms }); + + if (this._enabled) { + this.port.postMessage(out.buffer, [out.buffer]); + } + // When disabled (mic muted) we silently consume input so the worklet + // stays alive and the buffer never grows unbounded. + } + } + + process(inputs) { + const input = inputs[0]; + if (!input || input.length === 0 || !input[0]) return true; + const mono = input[0]; + if (mono.length > 0) this._ingest(mono); + return true; + } +} + +registerProcessor("mic-capture", MicCaptureProcessor); diff --git a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html new file mode 100644 index 0000000000000000000000000000000000000000..749f52e21abaa40dfaabba8e222af96dc91dd4b5 --- /dev/null +++ b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/index.html @@ -0,0 +1,56 @@ +<!DOCTYPE html> +<html lang="vi"> +<head> +<meta charset="UTF-8"> +<title>MI593ES | Malloca | Bếp từ 3 vùng nấu 59cm | V.AI STUDIO + + + + + + + + + + + +Đang chuyển hướng... + diff --git a/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f10745ddd8a70ea30870b19f986dc8c3501f4738 --- /dev/null +++ b/san-pham/mi593es-malloca-bep-tu-3-vung-nau-59cm/thumb.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06d583e5543e95843dda84249ff7a73c7e849345453a77b429536fca1d307858 +size 568130 diff --git a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html new file mode 100644 index 0000000000000000000000000000000000000000..dfc2648cfb089d76b74df11109d6d865445cb657 --- /dev/null +++ b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/index.html @@ -0,0 +1,60 @@ + + + + +MI73TWES | Malloca | Bếp từ 2 vùng nấu 73cm EcoKitchen | V.AI STUDIO + + + + + + + + + + + +Đang chuyển hướng... + diff --git a/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4df599680d6cbeb510561dd14b17be40748b0ff5 Binary files /dev/null and b/san-pham/mi73twes-malloca-bep-tu-2-vung-nau-73cm-ecokitchen/thumb.jpg differ diff --git a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html new file mode 100644 index 0000000000000000000000000000000000000000..86de500481662fa82f5d92288ec72db54fcf4565 --- /dev/null +++ b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/index.html @@ -0,0 +1,56 @@ + + + + +MI7502ES | Malloca | Bếp từ 2 vùng nấu 75cm | V.AI STUDIO + + + + + + + + + + + +Đang chuyển hướng... + diff --git a/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9c96815a21c91d137fb2cae19fd3fa9c80f4f0bd --- /dev/null +++ b/san-pham/mi7502es-malloca-bep-tu-2-vung-nau-75cm/thumb.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee503ee49a04f82190607c93d63dd9b4f70a935be4e82e3dbd9129c525e03ef2 +size 466784 diff --git a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8ed1472ca246134c7c6869fa7cdfa681a15a9ce5 --- /dev/null +++ b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/index.html @@ -0,0 +1,56 @@ + + + + +MI803ES | Malloca | Bếp từ 3 vùng nấu 80cm | V.AI STUDIO + + + + + + + + + + + +Đang chuyển hướng... + diff --git a/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..00785d2f30f80015b49f7306b6905ae7bcf4f555 --- /dev/null +++ b/san-pham/mi803es-malloca-bep-tu-3-vung-nau-80cm/thumb.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4330cd3f85759168321f8aee17377e0e01517e9e153681ea4d30f1fbcf9de5ad +size 588416 diff --git a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a5528aca32cfd8415dcd0366ee5022d7df1ae5c2 --- /dev/null +++ b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/index.html @@ -0,0 +1,58 @@ + + + + +MIR9021ES | Malloca | Bếp từ kết hợp hồng ngoại 3 vùng nấu 80cm | V.AI STUDIO + + + + + + + + + + + +Đang chuyển hướng... + diff --git a/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..13416062c660cd45779cf20d0a59271e88397875 --- /dev/null +++ b/san-pham/mir9021es-malloca-bep-tu-ket-hop-hong-ngoai-3-vung-nau-80cm/thumb.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f91765d7a2e60183c695ffb7d045738766d11bb5344c7e73546acfc0955a6c1c +size 450717 diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000000000000000000000000000000000000..f8f27fd347891b8704ed087e579eba4ce09fe3ad --- /dev/null +++ b/src/app.js @@ -0,0 +1,1621 @@ +import { S2sWsRealtimeClient } from "./s2s/s2s-ws-client.js"; +import { AvatarStage, AVATAR_MOODS, AVATAR_GESTURES } from "./avatar.js"; +import { smartNormalize } from "./viNumberFix.js"; +import { VoiceTyper } from "./voiceTyper.js"; + +const VOICES = ["Aiden","Ryan","Dylan","Eric","Ono_Anna","Serena","Sohee","Uncle_Fu","Vivian"]; +const DEFAULT_VOICE = "Sohee"; +const _urlParams = new URLSearchParams(location.search); +const FAKEMIC_MODE = _urlParams.has("fakemic"); + +async function getHotNewsGreeting() { + let hotTitle = ""; + try { + const resp = await fetch("/api/news/hot"); + if (resp.ok) { const data = await resp.json(); if(data.titles?.length){for(const t of data.titles){const c = t.replace(/^[\d.]+[\s:]*/,"").trim(); if(c.length>10&&c.length<200){hotTitle=c;break;}}} } + } catch {} + return hotTitle ? `Hôm nay có tin: ${hotTitle}. Hỏi người dùng có muốn nghe không.` : ""; +} + +const DEFAULT_INSTRUCTIONS = [ + "You are Gemma, a friendly warm voice assistant with a 3D avatar of a young Vietnamese woman.", + "You manage V.AI STUDIO — 8000+ kitchen appliances & smart locks (Malloca, Eurogold, Grob, Canzy, Demax).", + "CRITICAL: Same language as user. VN numbers as words (ba mươi lăm not 35), dates as 'ngày 9 tháng 7 năm 2026', currency as 'năm mươi nghìn đồng'.", + "Keep replies short, natural, warm.", + "AVAILABLE TOOLS: query_catalog (search all products + show in panel), show_product (open detail + show similar products), open_catalog, search_catalog, combo_suggest (gợi ý combo gian bếp: bếp từ + máy hút mùi + chậu rửa + vòi rửa, lọc theo thương hiệu/giá/chất liệu/màu sắc), get_current_datetime, search_wikipedia, search_web, search_news (search real-time Vietnamese news), set_mood, make_hand_gesture, make_facial_expression.", + "PRODUCT RULES: When user asks about ANY product — FIRST call query_catalog(query). This searches ALL products AND shows them in the panel AND returns similar product suggestions. After query_catalog, ALWAYS mention similar products and ask if user wants to see them. If user wants details, call show_product(name/SKU) to open the detail modal which also shows similar products below.", + "COMBO RULES: When user asks for a combo / set / bộ / gói of 2 or more kitchen appliances (e.g. 'combo bếp từ + máy hút mùi', 'bộ nồi bếp', 'có combo bếp từ và máy hút mùi dưới 20 triệu không', 'combo chậu + vòi') — call combo_suggest and pass the criteria. IMPORTANT: put each requested appliance in the categories array (e.g. categories:['bếp từ','máy hút mùi'] for 2 products; a combo can be 2, 3 or 4 products). Convert price phrases to numbers in VND: 'dưới 20 triệu' → maxPrice:20000000, 'trên 30 triệu' → minPrice:30000000, 'khoảng 15 triệu' → minPrice:14000000,maxPrice:16000000. Pass brand like Malloca/Eurogold/Grob, material like inox/kính/gốm, color like đen/trắng/bạc if mentioned. combo_suggest will show product cards and a text summary.", + "SIMILAR PRODUCTS: query_catalog() and show_product() both automatically recommend related products by same category/brand/price range. Use this to cross-sell: 'Chị có muốn xem thêm sản phẩm tương tự không?'", + "Never mention product IDs, SKUs, or prices in tools to user — just describe them naturally.", + "NEVER guess facts. Use search_web/wikipedia. Get datetime first. Never mention tools.", + "RELIABILITY RULES (mUST follow 100%): Answer exactly what the user asks, in their language and requested format. When the user asks about news, a campaign, a promotion, a product, or any current event: ALWAYS call the most specific search tool (search_news for Vietnamese news/promos/campaigns, search_web for general/web, query_catalog for products) FIRST and use the returned results to answer. If the first search returns little, RETRY with a second, broader or different keyword (e.g. add the brand name, rename 'chiến dịch' to the brand, try English) before ever saying you couldn't find anything. NEVER reply with a vague 'Em xin lỗi, chưa tìm thấy' / 'không tìm thấy thông tin' refusal. If you genuinely find no exact match, still give a useful answer: summarize the closest info you did find, tell the user what you searched, and offer 2-3 concrete alternatives (e.g. search another keyword, check a specific product, or ask them to clarify). Always deliver a real, substantive answer — never stop at an apology. Follow the user's exact request (quantity, format, detail level, tone).", +].join(" "); + +const GREETING_INSTRUCTIONS = "You are Gemma. Say exactly: 'Xin chào! Em là Gemma, trợ lý AI thân thiện của Vương V AI STUDIO. Em ở đây để giúp anh chị — trò chuyện, trả lời câu hỏi, xem tin tức, hoặc tìm sản phẩm trong V.AI STUDIO. Rất vui được gặp anh chị!' Then end. No tools."; + +const STORAGE_KEYS = { voice:"avatar.voice", avatar:"avatar.model", instructions:"avatar.instructions", directUrl:"avatar.directUrl", subtitles:"avatar.subtitles" }; + +const TOOL_DEFS = [ + { type:"function", name:"set_mood", description:"Change avatar mood.", parameters:{type:"object", properties:{mood:{type:"string", enum:AVATAR_MOODS}}, required:["mood"]}}, + { type:"function", name:"make_hand_gesture", description:"Hand gesture.", parameters:{type:"object", properties:{gesture:{type:"string", enum:AVATAR_GESTURES}}, required:["gesture"]}}, + { type:"function", name:"make_facial_expression", description:"Face emoji.", parameters:{type:"object", properties:{emoji:{type:"string"}}, required:["emoji"]}}, + { type:"function", name:"get_current_datetime", description:"Get date/time.", parameters:{type:"object", properties:{}, required:[]}}, + { type:"function", name:"search_wikipedia", description:"Search Wikipedia.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, + { type:"function", name:"search_web", description:"Search the web.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, + { type:"function", name:"search_news", description:"Search real-time Vietnamese news on a specific topic. Use this when user asks about current events, hot topics, or news.", parameters:{type:"object", properties:{query:{type:"string", description:"News topic or keyword to search for"}}, required:["query"]}}, + { type:"function", name:"open_catalog", description:"Open V.AI STUDIO panel.", parameters:{type:"object", properties:{category:{type:"string"}}, required:["category"]}}, + { type:"function", name:"search_catalog", description:"Search catalog UI.", parameters:{type:"object", properties:{query:{type:"string"}}, required:["query"]}}, + { type:"function", name:"open_product", description:"Open product by SKU.", parameters:{type:"object", properties:{product_id:{type:"string"}}, required:["product_id"]}}, + { type:"function", name:"query_catalog", description:"SEARCH all products + show in panel + return details + suggest similar products. ALWAYS use FIRST for product questions.", parameters:{type:"object", properties:{query:{type:"string", description:"Product name, brand, SKU, category, or feature"}}, required:["query"]}}, + { type:"function", name:"show_product", description:"Open product detail modal + show similar products. Use AFTER query_catalog. Pass name/SKU.", parameters:{type:"object", properties:{product_name:{type:"string", description:"Product name or SKU"}}, required:["product_name"]}}, + { type:"function", name:"combo_suggest", description:"Suggest a kitchen COMBO of 2, 3 or 4 appliances (bếp từ, máy hút mùi, chậu rửa, vòi rửa — same brand ideally). Call when user wants a combo/set/bộ/gói of multiple products. Provide categories (which appliances, e.g. ['bếp từ','máy hút mùi']), brand, price range (minPrice/maxPrice in VND — convert 'dưới 20 triệu' to maxPrice:20000000), material (inox/kính/gốm), or color (đen/trắng/bạc).", parameters:{type:"object", properties:{brand:{type:"string", description:"Preferred brand e.g. Malloca, Eurogold, Grob"}, minPrice:{type:"number", description:"Minimum price in VND (e.g. 'trên 30 triệu' → 30000000)"}, maxPrice:{type:"number", description:"Maximum price in VND (e.g. 'dưới 20 triệu' → 20000000)"}, material:{type:"string", description:"Material e.g. inox, kính, gốm"}, color:{type:"string", description:"Color e.g. đen, trắng, bạc"}, categories:{type:"array", items:{type:"string"}, description:"Which appliance categories to include, e.g. ['bếp từ','máy hút mùi'] for 2 products, or all of ['bếp từ','máy hút mùi','chậu rửa','vòi rửa']"}}, required:[]}}, +]; + +const $ = s => document.querySelector(s); +const stageNode = $("#stage"), mainBtn = $("#main-btn"), mainBtnLabel = $("#main-btn-label"); +const muteBtn = $("#mute-btn"), textModeBtn = $("#text-mode-btn"), caption = $("#caption"); +const subtitles = $("#subtitles"), loading = $("#loading"), settingsBtn = $("#settings-btn"); +const settingsDialog = $("#settings"), inputVoice = $("#voice"), inputInstructions = $("#instructions"); +const inputDirectUrl = $("#direct-url"), inputSubtitles = $("#subtitles-toggle"), directUrlRow = $("#direct-url-row"); +const textChat = $("#text-chat"), chatHeader = $("#chat-header"), chatMessages = $("#chat-messages"); +const chatInput = $("#chat-input"), chatSendBtn = $("#chat-send-btn"), chatCloseBtn = $("#chat-close-btn"); +const chatResizeHandle = $("#chat-resize-handle"), chatAvatarSelect = $("#chat-avatar-select"), settingsAvatarSelect = $("#settings-avatar-select"); +const chatMicBtn = $("#chat-mic-btn"); + +// ✨ Welcome Modal elements +const welcomeModal = $("#welcome-modal"); +const welcomeChatBtn = $("#welcome-chat-btn"); +const welcomeVoiceBtn = $("#welcome-voice-btn"); +const welcomeQuickBtn = $("#welcome-quick-btn"); + +// ✨ Welcome mode tracking: null | 'text' | 'voice' | 'quick' +let welcomeMode = null; +let voiceTyper = null; +let voiceTyperMode = null; // "chat" | "subtitle" + +const stage = new AvatarStage(stageNode); +let client = null, muted = false, subtitleTimer = 0, textMode = false; +let config = { lb:false, allowDirect:true }, avatarList = [], sessionInProgress = false, autoGreetingSent = false, preFetchedGreeting = null, isGreetingSession = false; + +// Variable để theo dõi context cho gợi ý +var currentContextForSuggestions = null; + +function loadSettings() { + // Subtitles default ON: unless the user has explicitly set the toggle to "0", + // subtitles are enabled so the user always sees what they say + what she says. + var subVal = localStorage.getItem(STORAGE_KEYS.subtitles); + var subOn = subVal === null ? true : subVal === "1"; + return { voice:localStorage.getItem(STORAGE_KEYS.voice)||DEFAULT_VOICE, avatar:localStorage.getItem(STORAGE_KEYS.avatar)||"vuong.glb", instructions:localStorage.getItem(STORAGE_KEYS.instructions)||"", directUrl:localStorage.getItem(STORAGE_KEYS.directUrl)||"", subtitles:subOn }; +} +let settings = loadSettings(); +function saveSettings() { for(const[k,v]of Object.entries(settings)) localStorage.setItem(STORAGE_KEYS[k],String(v)); } + +function effectiveInstructions(newsHook) { + const n = new Date(); + return `${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}\n\n${DEFAULT_INSTRUCTIONS}${newsHook?`\n\nGreeting: "${newsHook}"`:""}${settings.instructions.trim()?"\n\nUser: "+settings.instructions.trim():""}`; +} + +// ✨ Welcome modal show/hide +function showWelcome() { + welcomeModal.classList.add("show"); + document.getElementById("topbar").style.display = "none"; + document.getElementById("controls").style.display = "none"; + document.getElementById("subtitles").style.display = "none"; + document.getElementById("vaistudio-toggle").style.display = "none"; +} +function hideWelcome() { + welcomeModal.classList.remove("show"); + welcomeModal.style.display = "none"; + document.getElementById("topbar").style.display = ""; + document.getElementById("controls").style.display = ""; + document.getElementById("subtitles").style.display = ""; + document.getElementById("vaistudio-toggle").style.display = ""; +} + +async function fetchAvatarList(){try{const r=await fetch("/api/avatars");if(r.ok)avatarList=(await r.json()).avatars||[]}catch{}} +function populateAvatarSelects(sn){ + for(const sel of[chatAvatarSelect,settingsAvatarSelect]){ + sel.innerHTML=""; const d=document.createElement("option"); d.value=""; d.textContent="(Default)"; sel.appendChild(d); + for(const n of avatarList){const o=document.createElement("option"); o.value=n; o.textContent=n.replace(/\.glb$/i,"").replace(/_/g," ")+(n.toLowerCase()==="vuong.glb"?" 🎙️":""); sel.appendChild(o);} + if(sn&&avatarList.includes(sn)) sel.value=sn; + } +} +function setAvatarFromSelect(v){settings.avatar=v||"";saveSettings()} +async function reloadAvatar(){if(!stage.head)return;loading.classList.remove("done");loading.textContent="Loading...";try{await Promise.race([stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}}),new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),15000))])}catch(e){console.error(e)}loading.classList.add("done")} + +// ✨ Ensure the avatar is initialized before starting a session. Called AFTER +// the user picks a character in the welcome modal, so the loading % bar only +// appears once a character is chosen (matches the new startup flow). +let __avatarReadyPromise = null; +function ensureAvatarReady(){ + if(__avatarReadyPromise) return __avatarReadyPromise; + __avatarReadyPromise=(async()=>{ + if(stage.head){ loading.classList.add("done"); return; } + loading.classList.remove("done"); + try{ + await Promise.race([ + stage.init({avatarUrl:settings.avatar?`/avatars/${settings.avatar}`:void 0,onprogress:e=>{if(e.lengthComputable)loading.textContent=`Loading avatar ${Math.min(100,Math.round(e.loaded/e.total*100))}%`}}), + new Promise((_,rej)=>setTimeout(()=>rej(new Error("avatar timeout")),45000)), + ]); + }catch(e){ console.error("[AvatarStage] init failed:", e?.message||e); } + loading.classList.add("done"); + if(stage.resume) try{ await stage.resume(); }catch(_){} + })(); + return __avatarReadyPromise; +} +function clampRect(){const vw=innerWidth,vh=innerHeight,r=textChat.getBoundingClientRect();let l=r.left,t=r.top;l=Math.max(10,Math.min(l,vw-r.width-10));t=Math.max(10,Math.min(t,vh-r.height-10));textChat.style.left=l+"px";textChat.style.top=t+"px"} +function makeDraggable(){ + let d=false,sx,sy,sl,st; + function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} + function os(e){if(e.target.closest("#chat-header-actions,#chat-avatar-select"))return;const p=gp(e);d=true;const r=textChat.getBoundingClientRect();sx=p.x;sy=p.y;sl=r.left;st=r.top;textChat.classList.add("dragging");e.preventDefault} + function om(e){if(!d)return;const p=gp(e);textChat.style.left=(sl+p.x-sx)+"px";textChat.style.top=(st+p.y-sy)+"px";textChat.style.right="auto";textChat.style.bottom="auto";e.preventDefault} + function oe(){if(!d)return;d=false;textChat.classList.remove("dragging");clampRect()} + chatHeader.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatHeader.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); +} +function makeResizable(){ + let r=false,sx,sy,sw,sh; + function gp(e){const p=e.changedTouches?e.changedTouches[0]:e;return{x:p.clientX,y:p.clientY}} + function os(e){r=true;const rc=textChat.getBoundingClientRect(),p=gp(e);sx=p.x;sy=p.y;sw=rc.width;sh=rc.height;textChat.classList.add("resizing");e.preventDefault;e.stopPropagation} + function om(e){if(!r)return;const p=gp(e);textChat.style.width=Math.max(260,sw+p.x-sx)+"px";textChat.style.height=Math.max(120,sh+p.y-sy)+"px";e.preventDefault} + function oe(){if(!r)return;r=false;textChat.classList.remove("resizing")} + chatResizeHandle.addEventListener("mousedown",os);document.addEventListener("mousemove",om);document.addEventListener("mouseup",oe);chatResizeHandle.addEventListener("touchstart",os,{passive:false});document.addEventListener("touchmove",om,{passive:false});document.addEventListener("touchend",oe); +} +function setCaption(t,k=""){caption.textContent=t;caption.className=k} +function showSubtitles(t){if(!settings.subtitles)return;clearTimeout(subtitleTimer);subtitles.textContent=t;subtitles.classList.add("visible")} +function fadeSubtitles(d=2600){clearTimeout(subtitleTimer);subtitleTimer=setTimeout(()=>subtitles.classList.remove("visible"),d)} + +// ═══════════════════════════════════════════════════════════ +// POST-MESSAGE CONTEXT (suggestions + hot tags + source cards) +// ═══════════════════════════════════════════════════════════ + +// ── Dynamic HOT topics extracted from Google News RSS in real-time ── +// No fixed topics — every fetch pulls fresh keywords from actual news headlines +let HOT_TOPIC_KEYWORDS = []; // Populated dynamically by fetchHotTags + +/** + * Extract trending keywords from Google News RSS titles. + * Returns array of {label, query, keyword} objects — fully dynamic, changes every fetch. + */ +/** + * Extract trending keywords from Google News RSS titles. + * Labels are derived from the actual RSS titles - fully dynamic. + * Returns array of {label, query, keyword} objects. + */ +function extractTrendingKeywords(titles) { + // Category patterns: each maps to a label generator that creates + // dynamic labels based on the actual RSS title content + const categoryPatterns = [ + // AI / Tech models - extract specific model name from title + { + regex: /\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i, + labelFromTitle: function(title) { + const m = title.match(/\b(kimi\s*k3|kimi|openai|gpt[-_]?4o|gpt[-_]?4|chatgpt|gemini|google\s+deepmind|google\s+ai|llama|meta\s+ai|meta)\b/i); + if (!m) return null; + const kw = m[1].replace(/\s+/g, ''); + return '#' + kw.charAt(0).toUpperCase() + kw.slice(1); + }, + queryFromLabel: function(label) { + return label + ' tin tức mới nhất hôm nay'; + } + }, + // Finance - extract specific topic + { + regex: /\b(chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán|gold|vàng|giá\s+vàng)\b/i, + labelFromTitle: function(title) { + if (/chứng\s+khoán|vn[-_]?index|cổ\s+phiếu|thị\s+trường\s+chứng\s+khoán/i.test(title)) + return '#ChứngKhoán'; + if (/gold|vàng|giá\s+vàng/i.test(title)) + return '#GiáVàng'; + return null; + }, + queryFromLabel: function(label) { + return label + ' Việt Nam hôm nay'; + } + }, + // Sports - extract specific event/team + { + regex: /\b(bóng\s+đá|football|premier\s+league|world\s+cup|chuyển\s+nhượng|transfer|tuyển\s+việt\s+nam|u23|asian\s+cup|đội\s+tuyển|fifa|world\s+cup)\b/i, + labelFromTitle: function(title) { + if (/fifa/i.test(title)) return '#Fifa'; + if (/tuyển\s+việt\s+nam|đội\s+tuyển\s+việt\s+nam/i.test(title)) return '#ĐộiTuyểnViệtNam'; + if (/chuyển\s+nhượng|transfer/i.test(title)) return '#ChuyểnNhượng'; + if (/asian\s+cup|u23/i.test(title)) return '#U23'; + if (/world\s+cup/i.test(title)) return '#WorldCup'; + if (/premier\s+league/i.test(title)) return '#PremierLeague'; + return '#BóngĐá'; + }, + queryFromLabel: function(label) { + return label + ' tin tức mới nhất'; + } + }, + // Mobile / Tech - extract specific device + { + regex: /\b(iphone|apple|galaxy|smartphone|pixel|xiaomi|oppo|vivo|samsong)\b/i, + labelFromTitle: function(title) { + const m = title.match(/\b(iphone|galaxy|pixel|xiaomi|oppo|vivo)\b/i); + if (m) return '#' + m[1].charAt(0).toUpperCase() + m[1].slice(1); + return '#iPhone'; + }, + queryFromLabel: function(label) { + return label + ' thông tin mới nhất 2026'; + } + }, + // Entertainment - extract specific content + { + regex: /\b(phim|movie|netflix|disney|box\s+office|phim\s+hay|phim\s+mới)\b/i, + labelFromTitle: function(title) { + if (/netflix/i.test(title)) return '#Netflix'; + if (/disney/i.test(title)) return '#Disney'; + if (/phim\s+mới|phim\s+hay/i.test(title)) return '#PhimMới'; + return '#Phim'; + }, + queryFromLabel: function(label) { + return label + ' hay nhất 2026'; + } + }, + // Weather + { + regex: /\b(bão|thời\s+tiết|weather|nắng\s+nóng|lũ\s+lụt|mưa\s+lớn|bão\s+số)\b/i, + labelFromTitle: function(title) { + if (/bão\s+số/i.test(title)) return '#BãoSố'; + if (/nắng\s+nóng/i.test(title)) return '#NắngNóng'; + if (/lũ\s+lụt|ngập\s+úng/i.test(title)) return '#LũLụt'; + return '#ThờiTiết'; + }, + queryFromLabel: function(label) { + return label + ' Việt Nam hôm nay'; + } + }, + // Economy + { + regex: /\b(nông\s+sản|giá\s+điện|inflation|gdp|kinh\s+tế)\b/i, + labelFromTitle: function(title) { + if (/nông\s+sản/i.test(title)) return '#NôngSản'; + if (/giá\s+điện/i.test(title)) return '#GiáĐiện'; + return '#KinhTế'; + }, + queryFromLabel: function(label) { + return label + ' Việt Nam tin tức mới nhất'; + } + }, + // Politics + { + regex: /\b(chính\s+trị|đảng|chính\s+phủ|pháp\s+luật|thời\s+sự)\b/i, + labelFromTitle: function(title) { + if (/thời\s+sự/i.test(title)) return '#ThờiSự'; + return '#ChínhTrị'; + }, + queryFromLabel: function(label) { + return label + ' nóng nhất hôm nay'; + } + }, + // Education + { + regex: /\b(giáo\s+dục|đại\s+học|tuyển\s+sinh|học\s+sinh|sinh\s+viên)\b/i, + labelFromTitle: function(title) { + if (/tuyển\s+sinh/i.test(title)) return '#TuyểnSinh'; + if (/đại\s+học/i.test(title)) return '#ĐạiHọc'; + return '#GiáoDục'; + }, + queryFromLabel: function(label) { + return label + ' mới nhất hôm nay'; + } + }, + // Health + { + regex: /\b(sức\s+khoẻ|bệnh|y\s+tế|epidemic|dịch|vaccine)\b/i, + labelFromTitle: function(title) { + if (/vaccine/i.test(title)) return '#Vaccine'; + if (/dịch|epidemic/i.test(title)) return '#DịchBệnh'; + return '#SứcKhỏe'; + }, + queryFromLabel: function(label) { + return label + ' nóng nhất hôm nay'; + } + }, + ]; + + const matched = []; + const seenLabels = new Set(); + + for (const title of titles) { + for (const cat of categoryPatterns) { + if (cat.regex.test(title)) { + const label = cat.labelFromTitle(title); + if (!label || seenLabels.has(label)) continue; + seenLabels.add(label); + matched.push({ + label: label, + query: cat.queryFromLabel(label), + keyword: cat.regex.source, + }); + if (matched.length >= 10) return matched; + break; // Only match one category per title + } + } + } + + return matched; +} + + +let currentHotTags = []; +let hotTagsLastFetch = 0; +const HOT_TAGS_TTL = 60 * 60 * 1000; // 1 hour cache + +async function fetchHotTags() { + const now = Date.now(); + if (currentHotTags.length > 0 && (now - hotTagsLastFetch) < HOT_TAGS_TTL) { + return currentHotTags; + } + + const realTitles = []; + + // ── Step 1: Fetch real-time news titles via the SERVER → /api/news/hot ── + // (Google News RSS does NOT send CORS headers, so fetching it directly from the + // browser always fails and yields an empty HOT list. The server proxies it.) + try { + const resp = await fetch('/api/news/hot'); + if (resp.ok) { + const data = await resp.json(); + if (Array.isArray(data.titles)) { + for (const t of data.titles) { + const c = t.replace(/^[\d.]+[\s:]*/, '').trim(); + if (c) realTitles.push(c); + if (realTitles.length >= 15) break; + } + } + } else { + console.warn('[HOT] /api/news/hot returned', resp.status); + } + } catch (e) { + console.warn('[HOT] /api/news/hot fetch failed:', e.message); + } + + // ── Step 2: Extract trending keywords dynamically from real news titles ── + HOT_TOPIC_KEYWORDS = extractTrendingKeywords(realTitles); + + // ── Step 3: Build hot tags from extracted keywords ── + const tags = HOT_TOPIC_KEYWORDS.map(kw => ({ + label: kw.label, + query: kw.query, + keyword: kw.keyword, + })); + + // If we have fewer than 10 tags, pad with topic keywords extracted from the + // headline titles so the HOT list always has at least 10 entries. These are + // created only from REAL trending titles (never hard-coded), so they stay HOT-relevant. + if (tags.length < 10 && realTitles.length > 0) { + for (const t of realTitles) { + if (tags.length >= 10) break; + let short = t.replace(/^[\d.]+[\s:]*/, '').trim(); + // Drop trailing source "- SourceName" + short = short.replace(/\s*-\s*[^-\s]+(?:\s+[^-\s]+)?\s*$/, '').trim(); + if (short.length < 6) continue; + // Pick 2-3 meaningful words (skip numbers & common connectors) to form a compact tag + const stopwords = ['của', 'và', 'với', 'cho', 'các', 'trong', 'một', 'những', 'đã', 'sẽ', 'đang', 'có', 'tại', 'theo', 'về', 'ngày', 'hôm']; + const words = short.split(/\s+/).filter(w => w.length > 2 && !/^[\d.,]+$/.test(w) && !stopwords.includes(w.toLowerCase().replace(/[.,]/g,'').toLowerCase())); + const picked = words.slice(0, 3); + if (picked.length === 0) continue; + const label = '#' + picked.join('').replace(/[^\wÀ-ỹà-ỹ]/gi, '').slice(0, 20); + if (label.length > 3 && !tags.find(t => t.label === label)) { + tags.push({ + label, + query: picked.length > 2 ? picked.slice(0, 2).join(' ') + ' hôm nay' : picked[0] + ' hôm nay', + keyword: picked.slice(0, 2).join(' '), + }); + } + } + } + + // Absolute last-resort safety net (should never trigger, but keeps UI non-empty) + if (tags.length === 0) { + tags.push({ label: '#TinTức', query: 'tin tức mới nhất hôm nay', keyword: 'tin tức' }); + tags.push({ label: '#ThờiSự', query: 'thời sự nóng nhất hôm nay', keyword: 'thời sự' }); + } + + currentHotTags = tags.slice(0, 10); + hotTagsLastFetch = now; + return currentHotTags; +} + +/** Match response text keywords to find the most relevant dynamic topic */ +function findBestTopicMatch(responseText) { + const lower = (responseText || "").toLowerCase(); + let bestScore = 0; + let bestMatch = null; + + for (const kw of HOT_TOPIC_KEYWORDS) { + if (lower.includes(kw.keyword.toLowerCase())) { + const score = kw.keyword.length; + if (score > bestScore) { + bestScore = score; + bestMatch = kw; + } + } + } + + return bestMatch; +} + +/** Generate source cards matching the SPECIFIC topic of the response. + * FIX: use VnExpress RSS items (title+image+url+desc are ONE unit) as the + * PRIMARY source, so every card's image ALWAYS matches its news content. + */ +async function generateSourceCards(responseText) { + const matchedTopic = findBestTopicMatch(responseText); + const query = matchedTopic ? matchedTopic.query : responseText; + // Prefer a clean topical keyword that Google News can search on + const searchQuery = query.replace(/^#/, '').replace(/ tin tức.*$| thông tin.*$| mới nhất.*$| hôm nay.*$| nóng nhất.*$| Việt Nam hôm nay.*$/, '').trim() || query; + + // ── PRIMARY: VnExpress RSS items carry title+image+url+desc together → no mismatch ── + let cards = []; + const catLabel = matchedTopic ? matchedTopic.label : 'mới nhất'; + try { + const resp = await fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`); + if (resp.ok) { + const data = await resp.json(); + const imgs = (data.images || []); + if (imgs.length > 0) { + // NEW: extend each card so "Hỏi AI" knows the exact article title too + cards = imgs.slice(0, 6).map(a => ({ + source: a.source || 'VnExpress', + title: a.title, + url: a.url, + icon: '📰', + category: catLabel, + desc: cleanText(a.desc || ''), + image: a.image || '', + articleTitle: a.title || '', + })); + } + } else { + console.warn('[SourceCards] /api/news/images returned', resp.status); + } + } catch (e) { + console.warn('[SourceCards] images fetch failed:', e.message); + } + + // ── SECONDARY (no VnExpress items matched): Google News article search, then + // match a VnExpress image to a card by content (title keyword overlap), + // NEVER by array index — this eliminates the image/content mismatch. ── + if (cards.length === 0) { + let imageMap = []; + const imagesPromise = fetch(`/api/news/images?q=${encodeURIComponent(searchQuery)}`) + .then(r => r.ok ? r.json() : { images: [] }) + .then(d => { imageMap = d.images || []; }) + .catch(() => {}); + try { + const resp = await fetch(`/api/news/articles?q=${encodeURIComponent(searchQuery)}`); + if (resp.ok) { + const data = await resp.json(); + const arts = (data.articles || []).slice(0, 6); + if (arts.length > 0) { + cards = arts.map((a) => ({ + source: a.source || 'Nguồn tin', + title: a.title, + url: a.url, + icon: '📰', + category: catLabel, + desc: cleanText(a.desc || ''), + image: '', + articleTitle: a.title || '', + })); + } + } + } catch (e) {} + await imagesPromise; + // Content-based image matching (not index-based) + if (imageMap.length > 0) { + const norm = (t) => String(t||'').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[đĐ]/g,'d'); + cards = cards.map(c => { + if (c.image) return c; + const cn = norm(c.title); + // find the VnExpress item whose title overlaps most with this card title + let best = '', bestScore = 0; + for (const m of imageMap) { + const mn = norm(m.title); + let score = 0; + const words = cn.split(/[^a-z0-9]+/).filter(w => w.length > 3); + for (const w of words) if (mn.includes(w)) score++; + if (score > bestScore) { bestScore = score; best = m.image; } + } + if (best) return { ...c, image: best }; + // last resort: reuse this VnExpress item's full card (image+title+url match by construction) + const mi = imageMap.find(m => norm(m.title).includes(cn.slice(0, 20)) || cn.includes(norm(m.title).slice(0, 20))); + if (mi) return { ...c, title: mi.title, url: mi.url, image: mi.image, desc: c.desc || cleanText(mi.desc||'') }; + return c; + }); + } + } + + if (cards.length > 0) return cards; + + // Fallback #2: topic search (category landing pages) — no index-based images + try { + const resp = await fetch(`/api/news/topic?q=${encodeURIComponent(searchQuery)}`); + if (resp.ok) { + const data = await resp.json(); + const results = (data.results || []).slice(0, 6); + if (results.length > 0) { + return results.map(r => ({ + source: r.source || 'Nguồn tin', + title: r.title, + url: r.url, + icon: '📰', + category: catLabel, + desc: r.snippet || '', + image: '', + })); + } + } + } catch (e) { + console.warn('[SourceCards] topic fetch failed:', e.message); + } + + // Fallback: general news portals + return [ + { source: 'VnExpress', title: 'Tin tổng hợp trong ngày cập nhật mới nhất', url: 'https://vnexpress.net/', icon: '📰', category: catLabel, image: '' }, + { source: 'Tuổi Trẻ', title: 'Thông tin đời sống xã hội và kinh tế', url: 'https://tuoitre.vn/', icon: '📰', category: catLabel, image: '' }, + { source: 'Thanh Niên', title: 'Nhìn đa chiều các vấn đề thời sự', url: 'https://thanhnien.vn/', icon: '📰', category: catLabel, image: '' }, + ]; +} + +/** Create DOM element for suggestion pill - black bg like chatbox */ +function createSuggestionPill(text, icon) { + const btn = document.createElement("button"); + btn.className = "chat-suggestion-pill"; + btn.style.background = "#1a1a2e"; + btn.style.color = "#e0e0e0"; + btn.style.border = "1px solid #333"; + btn.style.borderRadius = "16px"; + btn.style.padding = "6px 14px"; + btn.style.fontSize = "13px"; + btn.style.cursor = "pointer"; + btn.style.transition = "background 0.2s"; + btn.innerHTML = `${icon || '💡'}${text}`; + btn.addEventListener("click", function(e) { + e.stopPropagation(); + sendUserSuggestion(text); + }); + btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); + btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); + return btn; +} + +/** Create DOM element for hot tag — click triggers real-time news search */ +function createHotTag(tagObj) { + const btn = document.createElement("button"); + btn.className = "hot-tag"; + btn.textContent = tagObj.label; + btn.title = tagObj.query; + btn.style.background = "#1a1a2e"; + btn.style.color = "#e0e0e0"; + btn.style.border = "1px solid #333"; + btn.style.borderRadius = "16px"; + btn.style.padding = "6px 14px"; + btn.style.fontSize = "13px"; + btn.style.cursor = "pointer"; + btn.style.transition = "background 0.2s"; + btn.addEventListener("click", async function(e) { + e.stopPropagation(); + // Show loading state immediately + const origLabel = btn.textContent; + btn.textContent = '⏳ ' + tagObj.label.replace('#', ''); + btn.disabled = true; + // Send the query — the assistant will use search_news tool to fetch real articles + sendUserSuggestion(tagObj.query); + // Also render concrete news cards with real links right here in the chat + try { + const sources = await generateSourceCards(tagObj.query); + if (sources.length && chatMessages) { + const section = document.createElement("div"); + section.className = "news-section"; + const h = document.createElement("div"); + h.className = "news-section-title"; + h.innerHTML = ' Tin về ' + escHtml(tagObj.label) + ':'; + const row = document.createElement("div"); + row.className = "source-cards-row"; + sources.forEach(s => row.appendChild(createSourceCard(s))); + section.appendChild(h); + section.appendChild(row); + chatMessages.appendChild(section); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + } catch (_) {} + btn.textContent = origLabel; + btn.disabled = false; + }); + btn.addEventListener("mouseenter", function() { btn.style.background = "#2a2a4e"; }); + btn.addEventListener("mouseleave", function() { btn.style.background = "#1a1a2e"; }); + return btn; +} + +function escHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +/** Strip HTML tags & entities from a text string (for clean card descriptions) */ +function cleanText(s) { + return String(s == null ? '' : s) + .replace(/<[^>]*>/g, ' ') + .replace(/\bhttps?:\/\/\S+/gi, '') + .replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'") + .replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ') + .replace(/\s+/g, ' ').trim(); +} + +/** Build context-aware follow-up questions for a SPECIFIC news article so the + * suggested questions are always relevant to the news at hand (ngữ cảnh). */ +function buildNewsSuggestions(sourceObj) { + const title = (sourceObj && sourceObj.articleTitle) || (sourceObj && sourceObj.title) || ''; + if (!title) return ["Cho tôi xem tin tức mới nhất", "Tin nóng hôm nay là gì?", "Có tin gì thú vị không?"]; + // Extract the core subject for natural, contextual questions + const short = String(title).replace(/\s*-\s*[^\-]{2,}$/, '').replace(/^\d+[.:\s]*/, '').trim(); + const norm = String(short).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); + const subj = short.length > 60 ? short.slice(0, 57) + '…' : short; + return [ + 'Cho tôi biết thêm về "' + subj + '"', + 'Nội dung chi tiết của tin "' + subj + '" là gì?', + 'Có thông tin mới nhất nào về "' + subj + '" không?', + 'Bối cảnh và diễn biến của "' + subj + '" như thế nào?' + ]; +} + +/** Create DOM element for source news card. + * FIX: "Hỏi AI về tin này" sends the EXACT article title (+ fetches article content + * if possible) so the AI answers about the CORRECT story, and each card now appends + * its own suggested questions specific to that news item. */ +function createSourceCard(sourceObj) { + const card = document.createElement("div"); + card.className = "source-card"; + if (sourceObj.url) { + card.addEventListener("click", function(e) { + e.stopPropagation(); + window.open(sourceObj.url, '_blank'); + }); + card.style.cursor = "pointer"; + } + let sourceName = sourceObj.source || ''; + if (!sourceName && sourceObj.url) { + try { sourceName = new URL(sourceObj.url).hostname.replace(/^www\./, ''); } catch (_) {} + } + if (!sourceName) sourceName = 'Nguồn tin'; + const title = sourceObj.title || ''; + const articleTitle = sourceObj.articleTitle || title; + const imgHtml = sourceObj.image + ? `
` + : ''; + const newsQs = buildNewsSuggestions(sourceObj); + const qPills = newsQs.map(q => + `` + ).join(''); + card.innerHTML = ` + ${imgHtml} + ${sourceObj.icon || '📰'} ${escHtml(sourceName)} + ${escHtml(title)} + ${sourceObj.desc ? '' + escHtml(sourceObj.desc) + '' : ''} + +
${qPills}
+ `; + const askBtn = card.querySelector(".source-card-askai"); + if (askBtn) { + askBtn.addEventListener("click", function(e) { + e.stopPropagation(); + const artTitle = (this.getAttribute("data-title") || '').trim(); + const artUrl = (this.getAttribute("data-url") || '').trim(); + if (artTitle) { + // Ask AI about the EXACT article — prefer fetching its real content + sendUserSuggestion('Cho tôi biết thêm về tin: "' + artTitle + '". Hãy tìm hiểu và tóm tắt nội dung, diễn biến mới nhất liên quan tới tin này.'); + } else { + sendUserSuggestion("Cho tôi xem tin tức mới nhất"); + } + }); + } + const qPillsEls = card.querySelectorAll(".news-q-pill"); + qPillsEls.forEach(function(btn) { + btn.addEventListener("click", function(e) { + e.stopPropagation(); + sendUserSuggestion(this.getAttribute("data-q")); + }); + }); + return card; +} + +/** Build complete post-message context container using DOM elements */ +function buildPostMessageContextBlock(responseText, isGreeting) { + const container = document.createElement("div"); + container.className = "post-message-context"; + + // Don't show suggestions after product results + if (!isGreeting && responseText && (responseText.includes('Sản phẩm phù hợp') || responseText.includes('Tìm thấy ') || responseText.includes('Đã mở thông tin'))) { + return container; // Return empty container (won't be appended due to check in addChatMessage) + } + + // 1) Suggestions row + if (!isGreeting && responseText) { + const suggestionsRow = document.createElement("div"); + suggestionsRow.className = "suggestions-row"; + const questions = generateSuggestionsFromContext(responseText); + questions.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '💡'))); + container.appendChild(suggestionsRow); + } else if (isGreeting) { + const suggestionsRow = document.createElement("div"); + suggestionsRow.className = "suggestions-row"; + const greetQs = ["Tôi muốn tìm bếp từ Malloca", "Khóa thông minh nào tốt?", "Xem sản phẩm gia dụng"]; + greetQs.forEach(q => suggestionsRow.appendChild(createSuggestionPill(q, '🛒'))); + container.appendChild(suggestionsRow); + } + + // 2) HOT trending tags row + const tagsPromise = fetchHotTags(); + const hotTagsRow = document.createElement("div"); + hotTagsRow.className = "hot-tags-row"; + + const label = document.createElement("span"); + label.className = "hot-tags-label"; + label.innerHTML = ' HOT'; + hotTagsRow.appendChild(label); + + tagsPromise.then(tags => { + hotTagsRow.innerHTML = ''; + hotTagsRow.appendChild(label); + tags.forEach(tag => hotTagsRow.appendChild(createHotTag(tag))); + }).catch(err => { + console.warn('[HOT] Failed to update tags:', err); + }); + container.appendChild(hotTagsRow); + + // 3) Source news cards (non-greeting, lengthy responses) + if (!isGreeting && responseText && responseText.length > 30) { + generateSourceCards(responseText).then(sources => { + const sourceCardsRow = document.createElement("div"); + sourceCardsRow.className = "source-cards-row"; + sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src))); + container.appendChild(sourceCardsRow); + }); + } + + return container; +} + +// ── Enhanced addChatMessage with product card + post-response context support ── +let _pendingProductCards = null; +let _pendingSourceCards = null; +let _isGreetingMessage = false; +function addChatMessage(role, text, isGreeting) { + _isGreetingMessage = !!isGreeting; + const m = document.createElement("div"); + m.className = "chat-message " + role; + if (role === "assistant" && text) { + const mt = document.createElement("div"); + mt.textContent = text; + m.appendChild(mt); + + // Product cards + if (_pendingProductCards) { + const pc = document.createElement("div"); + pc.innerHTML = _pendingProductCards; + m.appendChild(pc); + if (window.vaix) window.vaix.attachChatCardHandlers(pc); + _pendingProductCards = null; + } + + // Post-message context: suggestions + hot tags + source cards + const ctxEl = buildPostMessageContextBlock(text, isGreeting); + if (ctxEl && ctxEl.style.display !== "none") { + m.appendChild(ctxEl); + } + + _pendingSourceCards = null; + } else { + m.textContent = text; + } + chatMessages.appendChild(m); + chatMessages.scrollTop = chatMessages.scrollHeight; + setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); +} +function setPendingProductCards(html) { _pendingProductCards = html; } +function setPendingSourceCards(list) { _pendingSourceCards = list; } + +// ── COMBO SUGGESTION CARDS ── +let _comboShownThisSession = false; +function showComboOnEntry(opts) { + try { + if (!window.vaix || !window.vaix.isLoaded || !window.vaix.isLoaded()) return; + if (_comboShownThisSession) return; + _comboShownThisSession = true; + const preferredBrand = (opts && opts.brand) || "Malloca"; + const combo = window.vaix.getRandomCombo(preferredBrand); + if (!combo || !combo.items || !combo.items.length) return; + + const m = document.createElement("div"); + m.className = "chat-message assistant"; + const intro = document.createElement("div"); + intro.textContent = "🛒 Gợi ý combo gian bếp " + (combo.brand ? "thương hiệu " + combo.brand : "") + " cho bạn:"; + m.appendChild(intro); + + const html = window.vaix.renderComboCardsBlock(combo); + const wrap = document.createElement("div"); + wrap.innerHTML = html; + m.appendChild(wrap); + if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(wrap); + + const randomBtn = wrap.querySelector(".chat-combo-random"); + if (randomBtn) { + randomBtn.addEventListener("click", function(e) { + e.stopPropagation(); + const fresh = window.vaix.getRandomCombo(preferredBrand); + if (!fresh) return; + const freshEl = document.createElement("div"); + freshEl.innerHTML = window.vaix.renderComboCardsBlock(fresh); + if (window.vaix.attachChatCardHandlers) window.vaix.attachChatCardHandlers(freshEl); + const oldCards = m.querySelector(".chat-combo-section"); + if (oldCards) oldCards.replaceWith(freshEl.querySelector(".chat-combo-section")); + chatMessages.scrollTop = chatMessages.scrollHeight; + }); + } + + chatMessages.appendChild(m); + chatMessages.scrollTop = chatMessages.scrollHeight; + setTimeout(() => { chatMessages.scrollTop = chatMessages.scrollHeight; }, 50); + } catch (e) { + console.warn('[Combo] error:', e.message); + } +} + +// Poll until products are loaded, then show the random combo once per session entry. +function startComboPoll() { + if (typeof showComboOnEntry !== "function") return; + let _i = 0; + const _iv = setInterval(function() { + _i++; + if (_i > 30) { clearInterval(_iv); return; } + showComboOnEntry(); + if (window.vaix && window.vaix.isLoaded && window.vaix.isLoaded() && _comboShownThisSession) clearInterval(_iv); + }, 350); +} + +function showTextChat(s){ + textChat.hidden=!s; + if(chatMicBtn) chatMicBtn.hidden=!s; + if(s)chatInput.focus(); + if(!s && voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } +} +function sendTextViaSession(t){if(!client)return false;const s=client._status;if(s!=="connected"&&s!=="ai-speaking"&&s!=="processing"&&s!=="user-speaking")return false;setCaption("SENDING…");client.sendUserText(t);client.requestResponse();return true} + +// Store callback for suggested question sends +window._triggerSuggestionSend = function(text) { + if (sendTextViaSession(text)) { + setCaption("GEMMA ĐANG VIẾT…", "live"); + return; + } + // S2S not connected — restart session + if (client) { try { client.close(); } catch(_) {} client = null; } + sessionInProgress = false; + startTextSession(text); +}; + +// ✨ Pure text chat client — uses S2S in text-only mode, so it has ALL same tools as voice +function sendTextMessage(t){ + const msg = t || chatInput.value.trim(); + if(!msg) return; + chatInput.value = ""; + addChatMessage("user", msg); + setCaption("GEMMA ĐANG VIẾT…", "live"); + + if (sendTextViaSession(msg)) { return; } + if (client) { try { client.close(); } catch(_) {} client = null; } + sessionInProgress = false; + startTextSession(msg); +} + +// Safe event binding helpers (null-safety) +function on(el,evt,fn){if(el)el.addEventListener(evt,fn)} +function toggleClass(el,cls,val){if(el)el.classList.toggle(cls,val)} + +on(chatCloseBtn,"click",e=>{ + e.stopPropagation(); + textMode=false; + if(voiceTyper){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } + showTextChat(false); + toggleClass(textModeBtn,"active",false); + // Clear suggested questions when closing chat + if(window.vaix && window.vaix.clearSuggestedQuestions) window.vaix.clearSuggestedQuestions(); + currentContextForSuggestions = null; +}); +let mainAction="start"; +function setMainButton(a,l){mainAction=a;mainBtnLabel.textContent=l;mainBtn.disabled=a==="busy";mainBtn.classList.toggle("live",a==="stop");if(muteBtn)muteBtn.hidden=a!=="stop";if(textModeBtn)textModeBtn.hidden=false} +const CAPTIONS={idle:"TAP TO TALK","creating-session":"REQUESTING A SLOT…",queued:"WAITING IN LINE…","your-turn":"YOUR TURN, TAP TO JOIN",connecting:"CONNECTING…",connected:"GO AHEAD, I'M LISTENING","user-speaking":"LISTENING",processing:"THINKING…","ai-speaking":"SPEAKING",closed:"TAP TO TALK",error:"SOMETHING BROKE, TAP TO RETRY"}; + +// Track last message content to show suggestions after responses +let lastAssistantMessage = ""; + +function onStatus(s){ + stage.setConversationState(s); + if(isGreetingSession){if(s==="ai-speaking")setCaption("");else if(s==="closed"||s==="idle")setCaption(CAPTIONS.idle);return} + setCaption(CAPTIONS[s]??s,s==="error"?"error":s==="idle"||s==="closed"?"":"live"); + switch(s){case"idle":case"closed":setMainButton("start","Start talking");break;case"error":setMainButton("start","Retry");break;case"creating-session":case"connecting":setMainButton("busy","Connecting…");break;case"queued":setMainButton("stop","Leave queue");break;case"your-turn":setMainButton("join","Join now");default:setMainButton("stop","End conversation");break} + if(s==="ai-speaking" && !textMode && client) client.setMuted(true); + if(s==="connected" && !textMode && client) client.setMuted(false); + if(s==="user-speaking" && !textMode && client) client.setMuted(false); + if(s==="user-speaking"){ + if(subtitles)subtitles.classList.remove("visible"); + showTextChat(textMode); + if(!textMode){ + if(voiceTyper && voiceTyper.active && voiceTyperMode==="chat"){ try{voiceTyper.stop();}catch(_){} voiceTyper=null; } + startVoiceSubtitle(); + } + } + if(s==="ai-speaking"){ + if(voiceTyper && voiceTyper.active && !textMode) try{voiceTyper.stop();}catch(_){} + } + updateChatAudioToggleBtn(); +} +function runTool(name,argsJson,callId){ + if(!client)return;if(isGreetingSession){client.sendToolOutput(callId,"OK");client.requestResponse();return} + let args={};try{args=JSON.parse(argsJson||"{}")}catch(e){} + const send=r=>{ client.sendToolOutput(callId,r); client.requestResponse(); }; + if(name==="get_current_datetime"){const n=new Date();send(`Date: ${n.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})} Time: ${n.toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit",second:"2-digit"})}.`);return} + if(name==="search_wikipedia"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(q.replace(/\s+/g,"_"))}`).then(r=>r.json()).then(d=>{if(d.extract){send(`Wikipedia (${d.title}): ${d.extract.slice(0,1000)}\n${d.url}`);return}fetch(`/api/wiki/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(sd=>{if(!sd.results?.length){send("No results.");return}fetch(`/api/wiki/summary?title=${encodeURIComponent(sd.results[0].title)}`).then(r=>r.json()).then(s=>{send(s.extract?`Wikipedia (${s.title}): ${s.extract.slice(0,1000)}`:`${sd.results.slice(0,3).map(r=>r.title+": "+r.snippet).join("\n")}`)})})}).catch(()=>send("Wikipedia failed."));return} + if(name==="search_web"){const q=args.query||"";if(!q){send("No query.");return}fetch(`/api/web/search?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(d=>{if(!d.results?.length){send("No results.");return}const lines=d.results.slice(0,5).map((r,i)=>`${i+1}. ${r.title}\n ${r.snippet}\n 🔗 ${r.url}`);send(lines.join("\n\n"))}).catch(()=>send("Web search failed."));return} + if(name==="search_news"){const q=args.query||"";if(!q){send("No query.");return}// Build search query with Vietnamese news sources + const newsQueries=[`${q} site:vnexpress.net`,`${q} site:tuoitre.vn`,`${q} site:thanhnien.vn`,`${q} site:cafef.vn`,`tin tức ${q} hôm nay`];const allResults=[];const fetchPromises=newsQueries.map(nq=>fetch(`/api/web/search?q=${encodeURIComponent(nq)}`).then(r=>r.json()).then(d=>{if(d.results)allResults.push(...d.results)}).catch(()=>{}));Promise.all(fetchPromises).then(()=>{if(!allResults.length){send("Không tìm thấy tin tức nào.");return}// Deduplicate by URL + const seen=new Set();const unique=allResults.filter(r=>{if(seen.has(r.url))return false;seen.add(r.url);return true});// Group by source + const bySource={};for(const r of unique.slice(0,8)){if(!bySource[r.source])bySource[r.source]=[];bySource[r.source].push(r);}let msg="";for(const[src,items]of Object.entries(bySource)){msg+=`\n${src}:\n`;for(const item of items.slice(0,2)){msg+=` • ${item.title}\n 🔗 ${item.url}\n`;}}if(!msg)msg="No news found.";send(msg)}).catch(()=>send("News search failed."));return} + if(name==="open_catalog"){const p=document.getElementById("vaistudio-panel"),t=document.getElementById("vaistudio-toggle");if(p){p.classList.add("open");p.style.display="flex"}if(t)t.classList.add("active");send("Catalog opened.");return} + if(name==="open_product"){const p=window.vaix?.findProduct(args.product_id||"");send(p?`Product: ${p.title_clean}\nPrice: ${(p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ")}\nBrand: ${p.brand||""}\nModel: ${p.model||p.sku||""}`:"Product not found.");return} + if(name==="search_catalog"){send("Searching...");return} + if(name==="query_catalog"){ + const result = window.vaix?.queryCatalog(args.query||"")||"Catalog not loaded yet."; + const results = window.vaix?.getLastSearchResults(); + if(results && results.length){ + let html = '
'; + const limit = Math.min(results.length, 5); + for(let i=0;i'; setPendingProductCards(html); } + send(result); + return; + } + if(name==="combo_suggest"){ + const combo = window.vaix?.comboByCriteria(args||{})||null; + if(combo && combo.items && combo.items.length){ + const html = '
' + window.vaix.renderComboCardsBlock(combo) + '
'; + setPendingProductCards(html); + const lines = combo.items.map(function(p){ return '• ' + p.title_clean + ' — ' + (p.brand||'') + ' — ' + (p.priceNum>0?p.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"); }); + const _tot = (combo.items||[]).reduce(function(s,p){ return s + ((p.priceNum||0)); }, 0); + send("Em gợi ý combo sau đây " + (combo.brand?("thương hiệu "+combo.brand+" "):"") + "cho anh chị:\n" + lines.join("\n") + "\n\nTổng combo (" + combo.items.length + " món): " + (_tot>0 ? (_tot.toLocaleString("vi-VN")+" ₫") : "Liên hệ") + " — đã tính đủ theo tổng các món. Anh chị có muốn tôi điều chỉnh theo giá/ chất liệu/ màu sắc/ thương hiệu không?"); + } else { + send("Xin lỗi, em chưa tìm được combo phù hợp với yêu cầu đó. Anh chị có thể thử với tiêu chí khác như thương hiệu Malloca, Eurogold hoặc khoảng giá nhé."); + } + return; + } + const r=stage.runTool(name,args)??`Unknown: ${name}`;send(r); +} +async function connectSession(c){try{await c.connect();return c}catch(e){const code=e?.code;if(isGreetingSession){sessionInProgress=false;return null}if(code==="limit")setCaption("DAILY LIMIT","error");else if(code==="queue-full")setCaption("ALL SEATS","error");else if(code==="join-expired")setCaption("EXPIRED","error");else if(code!=="aborted"){console.error(e);setCaption("NO CONNECTION","error")}sessionInProgress=false;return null}} + +// ✨ Quick Consult: text chat WITHOUT loading the 3D avatar. Builds its own +// AudioContext + playback so voice of the AI still plays, but no GLB/lip-sync +// is loaded — instant start. "showText" also opens the product panel at once. +let _quickAudioCtx = null; +function getQuickAudioCtx(){ + if(_quickAudioCtx && _quickAudioCtx.state!=="closed") return _quickAudioCtx; + try{ _quickAudioCtx = new (window.AudioContext||window.webkitAudioContext)({latencyHint:"interactive"}); }catch(e){ _quickAudioCtx = null; } + return _quickAudioCtx; +} +function openCatalogPanelNow(){ + const p=document.getElementById("vaistudio-panel"), t=document.getElementById("vaistudio-toggle"); + if(p){ p.classList.add("open"); p.style.display="flex"; } + if(t){ t.classList.add("active"); } +} +async function startQuickConsult(t){ + if(sessionInProgress){ if(client&&t){client.sendUserText(t);client.requestResponse()} return } + const ac=getQuickAudioCtx(); + if(ac&&ac.state==="suspended"){ try{ await ac.resume(); }catch(_){} } + if(!ac){ console.warn("[Quick] no AudioContext"); } + await startTextSession(t||"Xin chào! Tôi muốn tư vấn nhanh sản phẩm.", { quick:true }); + // Open the product panel immediately so a product page shows right away. + if(window.vaix && window.vaix.isLoaded && window.vaix.isLoaded()){ + openCatalogPanelNow(); + } else if(window.vaix && window.vaix.load){ + window.vaix.load().then(function(){ openCatalogPanelNow(); }).catch(function(){ openCatalogPanelNow(); }); + } else { + openCatalogPanelNow(); + } +} + +async function startTextSession(t, opt){ + if(sessionInProgress){ if(client&&t){client.sendUserText(t);client.requestResponse()} return } + const opts = opt || {}; + sessionInProgress=true; isGreetingSession=false; + let ac, vs; + if(opts.quick){ + ac = getQuickAudioCtx(); vs = ac ? ac.destination : null; + if(!ac){ sessionInProgress=false; return; } + if(ac.state==="suspended"){ try{ await ac.resume(); }catch(_){} } + } else { + await stage.resume(); + ac = stage.audioCtx; vs = stage.voiceSink; + if(!ac || !vs){ sessionInProgress=false; return; } + } + const nh=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(nh),audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:true});client=c;_a(c);textMode=true;showTextChat(true);toggleClass(textModeBtn,"active",true);const ok=await connectSession(c);if(!ok)return;if(!autoGreetingSent)c.requestResponse();startComboPoll();if(t){c.sendUserText(t);c.requestResponse()}} +async function startVoiceSession(){if(sessionInProgress)return;sessionInProgress=true;isGreetingSession=false;await stage.resume();let ms;if(FAKEMIC_MODE){ms=stage.audioCtx.createMediaStreamDestination().stream}else{try{ms=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}catch{setCaption("MIC BLOCKED","error");sessionInProgress=false;return}}const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;return}const g=preFetchedGreeting||(await getHotNewsGreeting());const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:effectiveInstructions(g),micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:TOOL_DEFS,_textOnly:false});client=c;_a(c);setCaption("REQUESTING A SLOT…");const ok=await connectSession(c);if(ok&&!autoGreetingSent)c.requestResponse();startComboPoll(); + // Voice-understanding fix: rely on correct BROWSER vi-VN STT text for what the + // avatar understands, and stop sending raw mic audio to the backend so it never + // produces a misrecognized (English/"mai yomui") audio transcript that the model + // would answer incorrectly. Correct browser text is injected via sendUserText. + if(ok){try{c.setUserAudioEnabled(false);}catch(_){} startVoiceRecognition();}} +async function startGreetingSession(){if(sessionInProgress)return;isGreetingSession=true;sessionInProgress=true;await stage.resume();const ac=stage.audioCtx,vs=stage.voiceSink;if(!ac||!vs){sessionInProgress=false;isGreetingSession=false;return}const fm=ac.createMediaStreamDestination(),ms=fm.stream;const c=new S2sWsRealtimeClient({...(config.lb?{sessionUrl:"api/session"}:{directUrl:settings.directUrl}),voice:settings.voice,instructions:GREETING_INSTRUCTIONS,micStream:ms,audioContext:ac,outputNode:vs,workletBaseUrl:"/worklets/",tools:[],_textOnly:false});client=c;_a(c);setCaption("");const ok=await connectSession(c);if(!ok){isGreetingSession=false;return}autoGreetingSent=true;c.requestResponse()} + +function _a(c){ + c.addEventListener("status",e=>onStatus(e.detail.status)); + c.addEventListener("queue",e=>{const{position}=e.detail;if(isGreetingSession)return;setCaption(position>0?`#${position} IN LINE…`:"ALMOST THERE…","live")}); + c.addEventListener("transcript",e=>{const{role,text}=e.detail;if(role==="assistant"&&text){lastAssistantMessage=text; const n=smartNormalize(text);showSubtitles(n);addChatMessage("assistant",n,isGreetingSession)}}); + // Server-side Vietnamese STT → user subtitles (the OFFICIAL recognition path). + // The backend runs whisper-1 with language "vi" (see _buildSessionUpdate) and + // streams the user's recognized speech here. Display it under subtitles so the + // user can SEE that their voice was understood, and mirror it into the chat + // input so the avatar is clearly working from the same recognized text. + c.addEventListener("user-transcript", e => { + const text = (e.detail && e.detail.text) || ""; + if (!text) return; + if (settings.subtitles) { + showSubtitles("🗣️ " + text); + if (chatInput) { chatInput.value = text; } + } else if (chatInput && text) { + chatInput.value = text; + } + }); + c.addEventListener("response-finished",()=>{ + autoGreetingSent=true; + fadeSubtitles(); + if(voiceTyper && !voiceTyper.active && voiceTyper._recognition){ try{ voiceTyper.stop(); }catch(_){} voiceTyper=null; } + + // Suggestions are now embedded directly in addChatMessage — no separate render needed + + if(isGreetingSession){ + // After greeting, show a "Tin nóng hôm nay" section with SPECIFIC article cards (with links) + (async () => { + try { + let data; + try { data = await (await fetch("/api/news/hot")).json(); } catch(e) { data = {}; } + const hotArticles = (data.articles || []).slice(0, 4); + const lastMsg = chatMessages.lastElementChild; + if (lastMsg && lastMsg.classList.contains("chat-message")) { + const section = document.createElement("div"); + section.className = "news-section"; + + // Generic hot-news heading + const h = document.createElement("div"); + h.className = "news-section-title"; + h.innerHTML = ' Tin nóng hôm nay:'; + section.appendChild(h); + + // FIX: always build cards with matching image+content via generateSourceCards + const titles = data.titles || []; + const topic = (titles[0] || "").replace(/^[\d.]+[\s:]*/,"").trim(); + let sources = []; + if (topic.length > 5) { + sources = await generateSourceCards(topic).catch(()=>[]); + } + // If topic-based raw cards are empty, fall back to the plain hot articles list + if (sources.length === 0 && hotArticles.length > 0) { + sources = hotArticles.map(a => ({ + source: a.source || 'Nguồn tin', + title: a.title, + url: a.url, + icon: '📰', + desc: '', + articleTitle: a.title || '', + })); + } + if (sources.length) { + const sourceCardsRow = document.createElement("div"); + sourceCardsRow.className = "source-cards-row"; + sources.forEach(src => sourceCardsRow.appendChild(createSourceCard(src))); + section.appendChild(sourceCardsRow); + } + + lastMsg.appendChild(section); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + } catch (_) {} + })(); + setTimeout(()=>{ isGreetingSession=false; sessionInProgress=false; setCaption(CAPTIONS.idle); setMainButton("start","Start talking"); updateChatAudioToggleBtn(); },1500); + } + }); + c.addEventListener("toolcall",e=>{const{name,arguments:a,callId}=e.detail;runTool(name,a,callId)}); + c.addEventListener("server-error",e=>console.warn("err:",e.detail.error)); + c.addEventListener("error",()=>void endSession()); +} + +/** Show suggested questions after avatar finishes a general response */ +function renderSuggestedQuestionsAfterResponse(responseText) { + // Don't show suggestions right after showing products or tool calls + if (responseText && (responseText.includes('Sản phẩm phù hợp') || responseText.includes('Tìm thấy ') || responseText.includes('Đã mở thông tin'))) { + return; // Already showing product suggestions, skip + } + + var container = document.getElementById("suggested-questions"); + if (!container) return; + clearSuggestedQuestions(); + + // Generate contextual suggestions based on what avatar just said + var questions = generateSuggestionsFromContext(responseText); + container.innerHTML = ""; + for (var i = 0; i < questions.length; i++) { + var pill = document.createElement("button"); + pill.className = "suggested-pill"; + pill.textContent = "💡 " + questions[i]; + (function(q) { + pill.addEventListener("click", function() { sendUserSuggestion(q); }); + })(questions[i]); + container.appendChild(pill); + } + container.style.display = "flex"; +} + +/** Generate contextual suggestions from the avatar's last response */ +function generateSuggestionsFromContext(responseText) { + var lower = (responseText || "").toLowerCase(); + + // If mentions time/datetime + if (lower.includes("thứ") || lower.includes("tháng") || lower.includes("năm")) { + return [ + "Thời tiết hôm nay thế nào?", + "Có sự kiện gì thú vị hôm nay không?", + "Cho tôi xem tin tức mới nhất" + ]; + } + + // If mentions news or weather + if (lower.includes("tin") || lower.includes("thời tiết") || lower.includes("tin tức") || lower.includes("news") || lower.includes("báo")) { + return [ + "Tin tức nóng nhất hôm nay là gì?", + "Cập nhật tin tức mới nhất", + "Có tin gì thú vị không?" + ]; + } + + // If mentions specific topics — use dynamic HOT_TOPIC_KEYWORDS for suggestions + var topicSuggestions = []; + for (const kw of HOT_TOPIC_KEYWORDS) { + if (lower.includes(kw.keyword.toLowerCase())) { + topicSuggestions.push(kw.query); + if (topicSuggestions.length >= 3) break; + } + } + if (topicSuggestions.length > 0) return topicSuggestions; + + // ── Topic-contextual suggestions (ngữ cảnh chủ đề) ── + // Build follow-up questions that stay on the SAME topic the user/assistant + // just talked about (Fifa/Infantino, Đội tuyển Việt Nam/Indonesia, ...). + if (lower.includes("infantino") || lower.includes("fifa") || lower.includes("giải vô địch bóng đá thế giới")) { + return [ + "Infantino là ai và vai trò của ông ở FIFA?", + "FIFA có những thay đổi gì mới nhất?", + "Cho tôi xem tin tức về bóng đá thế giới hôm nay" + ]; + } + if (lower.includes("đội tuyển việt nam") || lower.includes("việt nam") && (lower.includes("indonesia") || lower.includes("trận đấu"))) { + return [ + "Kết quả trận Việt Nam vs Indonesia thế nào?", + "Đội tuyển Việt Nam thi đấu khi nào?", + "Lịch thi đấu sắp tới của đội tuyển Việt Nam" + ]; + } + if (lower.includes("indonesia") && (lower.includes("việt nam") || lower.includes("trận") || lower.includes("bóng"))) { + return [ + "Trận đấu Việt Nam vs Indonesia diễn ra khi nào?", + "Đội hình ra sân của hai đội thế nào?", + "Kết quả trận đấu ra sao?" + ]; + } + + // Topic-specific fallbacks (Vietnamese keywords) + if (lower.includes("bóng đá") || lower.includes("football") || lower.includes("thể thao") || lower.includes("sport")) { + return [ + "Kết quả bóng đá hôm nay", + "Ngoại hạng Anh tuần này", + "Đội tuyển Việt Nam sắp thi đấu" + ]; + } + + if (lower.includes("chứng khoán") || lower.includes("stock") || lower.includes("vn-index") || lower.includes("cổ phiếu")) { + return [ + "Thị trường chứng khoán hôm nay", + "VN-Index biến động ra sao?", + "Cổ phiếu nào tăng mạnh?" + ]; + } + + if (lower.includes("phim") || lower.includes("movie") || lower.includes("netflix") || lower.includes("phim hay")) { + return [ + "Phim hay nhất tuần này", + "Phim Netflix đáng xem", + "Bom tấn phòng vé mới" + ]; + } + + if (lower.includes("iphone") || lower.includes("apple") || lower.includes("galaxy") || lower.includes("smartphone")) { + return [ + "iPhone 17 Pro Max có gì mới?", + "So sánh iPhone vs Samsung", + "Smartphone nào tốt nhất 2026?" + ]; + } + + // General fallback — pick random products + return getRandomSuggestions(); +} + +/** Handle Vietnamese voice recognition result: fill chat input */ +let _lastSentVoiceText = ""; +function onVoiceResult(e) { + if (!e || !e.transcript) return; + const text = e.transcript.trim(); + if (voiceTyperMode === "subtitle") { + // Voice mode (subtitle): browser vi-VN STT is the AUTHORITATIVE Vietnamese + // channel. Show the recognized text AND send it into the session so the + // avatar understands the user's Vietnamese even if backend Whisper fails. + if (text && settings.subtitles) showSubtitles("🗣️ " + text); + if (e.isFinal && text && sessionInProgress && !textMode) { + // avoid re-sending the same accumulated utterance in continuous mode + if (text === _lastSentVoiceText) return; + _lastSentVoiceText = text; + // Mirror into chat input for visibility + if (chatInput) chatInput.value = text; + try { sendTextViaSession(text); } catch (_) {} + } + return; + } + if (!chatInput || !sessionInProgress) return; + chatInput.value = text; + if (e.isFinal && sessionInProgress) { + const triggerRe = /(?:gửi đi|gửi|send|ok|đồng ý|đồng)\s*$/i; + if (triggerRe.test(text)) { + setCaption("Đang gửi…", "live"); + sendTextMessage(); + if (voiceTyper && voiceTyper.active) try{voiceTyper.stop();}catch(_){} + } else { + setCaption("Nhấp Send để gửi", "live"); + } + } +} + +function startVoiceTyping() { + try { + if (!sessionInProgress || !chatInput) return false; + if (voiceTyper && voiceTyper.active) return true; + if (voiceTyper) { try{ voiceTyper.stop(); }catch(_){} voiceTyper = null; } + voiceTyper = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); + if (!voiceTyper.init()) { + voiceTyper = new VoiceTyper({ lang: "en-US", continuous: true, interimResults: true, autoSubmit: false }); + if (!voiceTyper.init()) { console.warn("[voiceTyper] Speech Recognition not supported"); voiceTyper = null; return false; } + } + voiceTyperMode = "chat"; + voiceTyper.onResult = onVoiceResult; + voiceTyper.onError = (e) => { console.warn("[voiceTyper]", e); voiceTyper=null; }; + voiceTyper.onSoundStart = () => { try{setCaption("🎤 Đang nghe…", "live");}catch(_){} }; + voiceTyper.onSoundEnd = () => {}; + voiceTyper.start(); + try{setCaption("🎤 Đang nghe…", "live");}catch(_){} + return true; + } catch(e) { console.warn("[voiceTyper] start failed:", e); voiceTyper = null; return false; } +} + +function startVoiceSubtitle() { + try { + if (!sessionInProgress || textMode) return false; + // PREFER server-side Vietnamese STT (whisper-1, language "vi") which is wired + // to subtitles via the "user-transcript" event — this is the reliable VN path. + // If the browser supports vi-VN Web Speech, it only ADDS interim feedback for + // VN; it must NEVER fall back to en-US (that would show English when user + // speaks Vietnamese). + if (voiceTyper && voiceTyper.active && voiceTyperMode === "subtitle") return true; + if (voiceTyper) { try{voiceTyper.stop();}catch(_){} voiceTyper = null; } + var vt = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); + if (!vt.init()) { + // No vi-VN browser support → rely solely on server STT (no English fallback) + voiceTyper = null; + return false; + } + voiceTyper = vt; + voiceTyperMode = "subtitle"; + voiceTyper.onResult = onVoiceResult; + voiceTyper.onError = (e) => console.warn("[voiceTyper]", e); + voiceTyper.onSoundStart = () => {}; + voiceTyper.onSoundEnd = () => {}; + voiceTyper.start(); + return true; + } catch(e) { console.warn("[voiceTyper] subtitle start failed:", e); voiceTyper = null; return false; } +} + +// Voice-understanding fix: run the browser vi-VN recognizer as the authoritative +// Vietnamese input channel for voice mode. Because we disable backend mic audio +// (setUserAudioEnabled(false)), server VAD events (speech_started/user-speaking) +// no longer fire, so we start the recognizer here directly instead of waiting for +// them. On each final result onVoiceResult() sends the correct Vietnamese text +// into the session (sendTextViaSession), so the avatar understands Vietnamese. +function startVoiceRecognition() { + try { + if (!sessionInProgress || textMode) return; + if (voiceTyper && voiceTyper.active && voiceTyperMode === "subtitle") return; + if (voiceTyper) { try{voiceTyper.stop();}catch(_){} voiceTyper = null; } + var vt = new VoiceTyper({ lang: "vi-VN", continuous: true, interimResults: true, autoSubmit: false }); + if (!vt.init()) { console.warn("[voiceTyper] vi-VN SpeechRecognition unavailable"); voiceTyper = null; return; } + voiceTyper = vt; + voiceTyperMode = "subtitle"; + voiceTyper.onResult = onVoiceResult; + voiceTyper.onError = function () { + // auto-restart unless session ended + try { if (sessionInProgress && !textMode && voiceTyperMode === "subtitle") setTimeout(startVoiceRecognition, 600); } catch (_) {} + }; + voiceTyper.onSoundStart = function(){}; + voiceTyper.onSoundEnd = function(){}; + voiceTyper.onEnd = function(){ try { if (sessionInProgress && !textMode && voiceTyperMode === "subtitle") setTimeout(startVoiceRecognition, 400); } catch(_){} }; + voiceTyper.start(); + // Voice mode = always show subtitles ("talking" feedback), regardless of any + // previously stored toggle, so the user sees their Vietnamese transcribed live. + if (subtitles) { + settings.subtitles = true; + subtitles.textContent = "🎤 Đang nghe tiếng Việt…"; + subtitles.classList.add("visible"); + } + return true; + } catch(e) { console.warn("[voiceTyper] startVoiceRecognition failed:", e); voiceTyper = null; return false; } +} + +function toggleVoiceTyping() { + try { + if (!voiceTyper) { startVoiceTyping(); if (chatMicBtn) chatMicBtn.classList.add("active"); return; } + if (voiceTyper.active) { try{voiceTyper.stop();}catch(_){} voiceTyper=null; try{setCaption("Voice stopped", "live");}catch(_){} if (chatMicBtn) chatMicBtn.classList.remove("active"); } + else { startVoiceTyping(); if (chatMicBtn) chatMicBtn.classList.add("active"); } + } catch(e) { console.warn("[voiceTyper] toggle failed:", e); voiceTyper = null; } +} + +async function endSession(silent=false){ + _lastSentVoiceText = ""; + if(voiceTyper){try{voiceTyper.stop();}catch(_){}voiceTyper=null;voiceTyperMode=null;} + const c=client;client=null;sessionInProgress=false;autoGreetingSent=false;isGreetingSession=false;currentContextForSuggestions=null; + if(c){if(c.options.micStream)for(const t of c.options.micStream?.getTracks()??[])t.stop();await c.close().catch(()=>{})} + stage.setConversationState("idle");subtitles.classList.remove("visible"); + if(!silent)setCaption(CAPTIONS.idle);setMainButton("start","Start talking"); + clearSuggestedQuestions(); +} + +// ── Chat audio toggle (mute/unmute avatar voice in chat) ── +let avatarAudioMuted = false; +let chatAudioToggleBtn = null; +function initChatAudioToggle(){ + chatAudioToggleBtn = $("#chat-audio-toggle-btn"); + if(!chatAudioToggleBtn) return; + chatAudioToggleBtn.addEventListener("click",()=>{ + avatarAudioMuted=!avatarAudioMuted; + client?.setMuted(avatarAudioMuted); + var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted"); + if(snd) snd.style.display=avatarAudioMuted?"none":""; + if(mtc) mtc.style.display=avatarAudioMuted?"":"none"; + chatAudioToggleBtn.setAttribute("aria-label",avatarAudioMuted?"Unmute avatar audio":"Mute avatar audio"); + if(client&&client._playbackNode){ var n=client._playbackNode; if(n.port)n.port.postMessage({kind:avatarAudioMuted?"mute":"unmute"}); } + }); +} +function updateChatAudioToggleBtn(){ + if(!chatAudioToggleBtn) return; + var show=(client&&(client._playbackNode||client._status==="ai-speaking"||client._status==="connected"||client._status==="processing")); + chatAudioToggleBtn.style.display=show?"grid":"none"; +} + +// ✨ Welcome button handlers +if (welcomeChatBtn) { + welcomeChatBtn.addEventListener("click", async () => { + welcomeMode = "text"; + // Hide the picker FIRST so the avatar loading % bar can show right after. + hideWelcome(); + // Avatar (with loading %) initializes only after the user picks. + await ensureAvatarReady(); + startTextSession("Xin chào! Tôi muốn trò chuyện."); + }); +} + +if (welcomeVoiceBtn) { + welcomeVoiceBtn.addEventListener("click", async () => { + welcomeMode = "voice"; + hideWelcome(); + await ensureAvatarReady(); + startGreetingSession(); + }); +} + +// ✨ Quick Consult: no avatar load — instant text chat + product panel open. +if (welcomeQuickBtn) { + welcomeQuickBtn.addEventListener("click", async () => { + welcomeMode = "quick"; + hideWelcome(); + startQuickConsult(); + }); +} + +function g(s){try{return document.querySelector(s)}catch{return null}} + +/** Random general suggestions from catalog */ +function getRandomSuggestions() { + var prods = (window.vaix && window.vaix.allProducts) ? window.vaix.allProducts() : []; + if (prods.length < 5) return [ + "Tôi muốn tìm máy hút mùi âm tủ 700mm", + "Hãy cho tôi biết về bếp từ Malloca", + "Có khóa thông minh nào không?" + ]; + + var shuffled = prods.sort(function(){ return Math.random() - 0.5; }); + var selected = []; + var brandsSeen = new Set(); + + for (var i = 0; i < shuffled.length && selected.length < 3; i++) { + if (brandsSeen.has(shuffled[i].brand)) continue; + brandsSeen.add(shuffled[i].brand); + selected.push(shuffled[i]); + } + + while (selected.length < 3 && selected.length < shuffled.length) { + selected.push(shuffled[selected.length]); + } + + return selected.map(function(p) { + if (p.brand) { + return "Tôi muốn tìm " + (p.category ? p.category.toLowerCase() + " " : "") + p.title_clean.toLowerCase() + " của " + p.brand; + } + return "Cho tôi xem " + (p.category ? p.category.toLowerCase() + " " : "") + p.title_clean.toLowerCase(); + }); +} + +/** Clear suggestion pills */ +function clearSuggestedQuestions() { + var container = document.getElementById("suggested-questions"); + if (container) { container.innerHTML = ""; container.style.display = "none"; } +} + +/** Handle sending a suggested question */ +function sendUserSuggestion(text) { + if (window._triggerSuggestionSend) { + window._triggerSuggestionSend(text); + } +} + +on(mainBtn,"click",()=>{if(mainAction==="start"){if(sessionInProgress)return;if(FAKEMIC_MODE)void startTextSession();else void startVoiceSession()}else if(mainAction==="join"){stage.resume();client?.join()}else if(mainAction==="stop")void endSession()}); +on(muteBtn,"click",()=>{ + muted=!muted; + toggleClass(muteBtn,"active",muted); + if(client && client._playbackNode && client._playbackNode.port){ + client._playbackNode.port.postMessage({kind: muted ? "mute" : "unmute"}); + } + client?.setMuted(muted); + var chatToggle=$("#chat-audio-toggle-btn"); + if(chatToggle){ var snd=$("#audio-icon-sound"), mtc=$("#audio-icon-muted"); if(snd) snd.style.display=muted?"none":""; if(mtc) mtc.style.display=muted?"":"none"; } +}); +on(textModeBtn,"click",()=>{textMode=!textMode;showTextChat(textMode);toggleClass(textModeBtn,"active",textMode)}); +on(chatSendBtn,"click",()=>sendTextMessage()); +on(chatMicBtn,"click",()=>toggleVoiceTyping()); +on(chatInput,"keypress",e=>{if(e.key==="Enter")sendTextMessage()}); +on(chatAvatarSelect,"change",()=>{setAvatarFromSelect(chatAvatarSelect.value);void reloadAvatar()}); +on(settingsAvatarSelect,"change",()=>{setAvatarFromSelect(settingsAvatarSelect.value);if(chatAvatarSelect)chatAvatarSelect.value=settingsAvatarSelect.value||"";void reloadAvatar()}); +on(settingsBtn,"click",()=>{if(inputVoice)inputVoice.value=settings.voice;if(inputInstructions)inputInstructions.value=settings.instructions;if(inputDirectUrl)inputDirectUrl.value=settings.directUrl;if(inputSubtitles)inputSubtitles.checked=settings.subtitles;if(settingsAvatarSelect&&chatAvatarSelect)settingsAvatarSelect.value=chatAvatarSelect.value||"";if(settingsDialog)settingsDialog.showModal()}); +settingsDialog.addEventListener("close",()=>{ + var iv=(inputVoice&&(inputVoice.value||""))||DEFAULT_VOICE; + var sa=(settingsAvatarSelect&&settingsAvatarSelect.value)||""; + var ii=(inputInstructions&&inputInstructions.value)||""; + var du=inputDirectUrl?(inputDirectUrl.value||"").trim():settings.directUrl||""; + var cb=(inputSubtitles&&inputSubtitles.checked)||false; + settings={voice:iv,avatar:sa,instructions:ii,directUrl:du,subtitles:cb}; + if(settings.avatar!=="vuong.glb")autoGreetingSent=false; + saveSettings(); + if(chatAvatarSelect)chatAvatarSelect.value=settings.avatar; + if(subtitles&&!settings.subtitles)subtitles.classList.remove("visible"); + client?.updateSession({voice:settings.voice,instructions:effectiveInstructions(preFetchedGreeting||"")}); +}); +window.addEventListener("beforeunload",()=>client?.close()); +initChatAudioToggle(); + +async function boot(){ + for(const v of VOICES){const o=document.createElement("option");o.value=v;o.textContent=v.replaceAll("_"," ");inputVoice.append(o)} + try{const r=await fetch("api/config");if(r.ok)config={...config,...(await r.json())}}catch{} + directUrlRow.hidden=!config.allowDirect; + await fetchAvatarList();populateAvatarSelects(settings.avatar); + makeDraggable();makeResizable(); + setCaption("KHỞI ĐỘNG…");setMainButton("busy","Loading…"); + // ── Deferred avatar init ── + // Show the character-picker (welcome) modal FIRST. The avatar (and its + // loading %) is only initialized AFTER the user picks a character, so the + // picker is always visible immediately. ensureAvatarReady() is awaited by + // the session starters. + loading.classList.add("done"); // hide native loading text; picker modal shows now + setCaption(CAPTIONS.idle);setMainButton("start","Start talking"); + + const panel=document.getElementById("vaistudio-panel"); + const toggle=document.getElementById("vaistudio-toggle"); + const closeBtn=document.getElementById("vaistudio-close"); + const retryBtn=document.getElementById("vaistudio-retry-btn"); + // ── Fixed: toggle opens panel, loads + renders reliably ── + if(toggle)toggle.addEventListener("click",()=>{ + const o=!panel.classList.contains("open"); + panel.classList.toggle("open",o); + panel.style.display=o?"flex":"none"; + toggle.classList.toggle("active",o); + if(o){ window.vaix?.load().then(()=>{if(panel.classList.contains("open"))renderAll();}); } + }); + if(closeBtn)closeBtn.addEventListener("click",()=>{panel.classList.remove("open");panel.style.display="none";toggle?.classList.remove("active")}); + // ── Fixed: retry always renders (not dependent on load() returning Promise) ── + if(retryBtn)retryBtn.addEventListener("click",()=>{window.vaix?.load();renderAll()}); + // ── Fixed renderAll: guard against premature call + avoid double-render from ?product=xxx + auto-render when loaded ── + function renderAll(){ + const p=document.getElementById("vaistudio-panel"),l=document.getElementById("vaistudio-loading"),pe=document.getElementById("vaistudio-products"),ce=document.getElementById("vaistudio-count"),sc=document.getElementById("vaix-suggestions"); + if(!p)return; + if(!pe)return; + + // If panel is NOT open, we still try to render if products are loaded + // (handles case where products finish loading before user opens panel) + const isOpen = p.classList.contains("open"); + if(!isOpen && !window.vaix?.isLoaded()) return; // Not ready yet + + // Don't overwrite if ?product=xxx URL param already rendered panel content + if(isOpen && window.vaix?.hasProductUrlHandled())return; + + const prods=window.vaix?.allProducts()||[]; + if(!prods.length && !window.vaix?.isLoaded()){ + // Products still loading — keep loading indicator visible, retry shortly + if(l) l.hidden=false; + clearTimeout(renderAll._timer); + renderAll._timer=setTimeout(renderAll,500); + return; + } + + // Products are loaded (or explicitly failed) — clear loading and render + if(l)l.hidden=true; + if(sc)sc.style.display="none"; + pe.innerHTML=""; + pe.style.display="block"; + if(ce)ce.textContent=prods.length+" sản phẩm"; + for(let i=0;i`:'
📦
')+`

${item.title_clean||item.name}

${item.brand||""}

${item.priceNum>0?item.priceNum.toLocaleString("vi-VN")+"₫":"Liên hệ"}

`; + c.addEventListener("click",e=>{e.stopPropagation();window.vaix?.showProduct(item.title_clean)}); + pe.appendChild(c); + } + // If panel is open, make sure display is set + if(isOpen){ p.style.display="flex"; toggle?.classList.add("active"); } + } + renderAll._timer=null; + + // ── Poll until loaded or max attempts ── + let tries=0;(function wait(){if(window.vaix?.isLoaded()){clearTimeout(renderAll._timer);renderAll();return}if(++tries>50)return;setTimeout(wait,200)})(); + + // ── Show the character picker modal immediately (no waiting) ── + showWelcome(); + // Lightweight pre-fetch of greeting/news in the background (non-blocking). + Promise.race([ + (settings.avatar==="vuong.glb"||!settings.avatar)?getHotNewsGreeting().catch(()=>null):Promise.resolve(null), + new Promise(res=>setTimeout(()=>res(null),8000)) + ]).then(g=>{preFetchedGreeting=g;}).catch(()=>{}); +} + +function setLoading(show){ + if(show){ loading.style.display=""; loading.classList.remove("done"); } + else { loading.classList.add("done"); } +} +void boot(); +// HOT-TAGS-V3-DEPLOYED-804 + +// DEPLOY-1785728163 diff --git a/src/avatar-picker.js b/src/avatar-picker.js new file mode 100644 index 0000000000000000000000000000000000000000..c345cdb2965f56975ddca9c5e3a3eccfe3f6df89 --- /dev/null +++ b/src/avatar-picker.js @@ -0,0 +1,258 @@ +// avatar-picker.js +// ============================================================================ +// TWO additions for the V.AI AVATAR welcome modal: +// +// (A) Circular avatar picker on the "Chọn voice / text chat" modal: +// - vuong.glb → "Mr V" (avatar image: vuong.jpg) +// - lisamy.glb → "Lisamy" (avatar image: lisamy.jpg) +// Clicking a circle selects that avatar (persists via localStorage) and +// applies it live by driving the existing avatar reflects it + var vs = document.getElementById("voice"); + if (vs) { try { vs.value = localStorage.getItem(VOICE_KEY) || dv; } catch(e){} } + } + } catch (e) {} + // Drive the existing avatar ' + + '' + (lineTotal > 0 ? lineTotal.toLocaleString('vi-VN') + '₫' : '') + '' + + '' + + ''; + }).join(''); + updateQuoteTotal(); +}; + +window.updateQuoteTotal = function() { + var cart = loadCart(); + var total = 0; + cart.forEach(function(c, i) { + var discInput = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + var discVal = discInput ? (parseInt(discInput.value.replace(/\D/g, '')) || 0) : 0; + if (!discVal) discVal = c.priceNum || 0; + var lineTotal = discVal * (c.qty || 1); + var cell = document.querySelector('.qt-linetotal[data-idx="' + i + '"]'); + if (cell) cell.textContent = lineTotal > 0 ? lineTotal.toLocaleString('vi-VN') + '₫' : ''; + total += lineTotal; + }); + var tc = document.getElementById('quoteTotalCell'); + if (tc) tc.textContent = total.toLocaleString('vi-VN') + '₫'; + return total; +}; + +function getQuoteData() { + var cart = loadCart(); + var customer = { + name: (document.getElementById('qcName') || {}).value || '', + phone: (document.getElementById('qcPhone') || {}).value || '', + email: (document.getElementById('qcEmail') || {}).value || '', + addr: (document.getElementById('qcAddr') || {}).value || '', + orderCode: (document.getElementById('qcCompany') || {}).value || genOrderCode(), + date: (document.getElementById('qcDate') || {}).value || new Date().toISOString().slice(0, 10) + }; + var items = cart.map(function(c, i) { + var p = findCartProduct(c); + var model = (p && (p.model || p.sku)) || c.sku || ''; + var specs = ''; + if (p && p.specs && Object.keys(p.specs).length) { + specs = Object.entries(p.specs).slice(0, 6).map(function(e){ return e[0] + ': ' + e[1]; }).join('; '); + } else if (p && p.summary) { specs = String(p.summary).slice(0, 300); } + var discInput = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + var noteInput = document.querySelector('.qt-note[data-idx="' + i + '"]'); + var discPrice = discInput ? (parseInt(discInput.value.replace(/\D/g, '')) || 0) : 0; + if (!discPrice) discPrice = c.priceNum || 0; + return { + stt: i + 1, name: c.name, model: model, specs: specs, + image: c.image, qty: c.qty || 1, price: c.priceNum || 0, + discPrice: discPrice, total: discPrice * (c.qty || 1), + note: noteInput ? noteInput.value : '' + }; + }); + var grandTotal = items.reduce(function(s, it) { return s + it.total; }, 0); + return { customer: customer, items: items, grandTotal: grandTotal }; +} + +function fmtVND(n) { return n > 0 ? n.toLocaleString('vi-VN') + '₫' : 'Liên hệ'; } + +/* Parse AI discount prompt (CK %, fees, cọc, ghi chú) */ +window.applyAiDiscount = function() { + var inp = document.getElementById('qcAiPrompt'); + var txt = inp ? inp.value.trim() : ''; + var statusEl = document.getElementById('aiDiscStatus'); + if (!txt) { if (statusEl) statusEl.textContent = 'VD: Malloca ck 35%, giao hàng 200k, lắp đặt 500k, cọc 5tr, ghi chú: giao thứ 7'; return; } + + var commands = txt.split(/[,;\n]+/).map(function(c){ return c.trim(); }).filter(function(c){ return c.length > 1; }); + var results = [], fees = [], deposit = 0, depositPct = 0, noteText = ''; + + commands.forEach(function(cmd) { + var cmdLow = cmd.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); + if (cmdLow.match(/^(ghi chu|note|luu y)/)) { + noteText = cmd.replace(/^(ghi chú|ghi chu|note|lưu ý|luu y)[:\s]*/i, '').trim(); + results.push('📝 ' + noteText); return; + } + var brandCkMatch = cmdLow.match(/^(\w+)\s+(?:ck|chiet khau|giam|discount)\s*(\d+)\s*%?/); + if (brandCkMatch) { + var brandName = brandCkMatch[1], pct = parseFloat(brandCkMatch[2]); + if (pct > 0 && pct <= 90) { + var count = 0; + var cart = loadCart(); + cart.forEach(function(c, i) { + if (c.priceNum > 0) { + var pBrand = String(c.brand || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); + var pName = String(c.name || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[đĐ]/g, 'd'); + if (pBrand.indexOf(brandName) !== -1 || pName.indexOf(brandName) !== -1) { + var dp = Math.round(c.priceNum * (1 - pct / 100)); + var el = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + if (el) { el.value = dp.toLocaleString('vi-VN'); count++; } + } + } + }); + if (count > 0) { results.push(brandCkMatch[1] + ' CK ' + pct + '% (' + count + ' SP)'); } + } + return; + } + var globalCkMatch = cmdLow.match(/^(?:ck|chiet khau|giam|discount)\s*(\d+)\s*%/); + if (globalCkMatch) { + var gpct = parseFloat(globalCkMatch[1]); + if (gpct > 0 && gpct <= 90) { + var gcount = 0, cart2 = loadCart(); + cart2.forEach(function(c, i) { + if (c.priceNum > 0) { + var gdp = Math.round(c.priceNum * (1 - gpct / 100)); + var gel = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + if (gel) { gel.value = gdp.toLocaleString('vi-VN'); gcount++; } + } + }); + results.push('CK ' + gpct + '% tất cả (' + gcount + ' SP)'); + } + return; + } + var cocMatch = cmdLow.match(/(?:coc|dat coc|deposit)\s*([\d.,]+)\s*(k|tr|trieu|%|m)?/); + if (cocMatch) { + var cocVal = parseFloat(cocMatch[1].replace(/\./g, '').replace(',', '.')); + var cu = (cocMatch[2] || '').toLowerCase(); + if (cu === '%') { depositPct = cocVal; results.push('Cọc: ' + cocVal + '%'); } + else { + if (cu === 'k') cocVal *= 1000; else if (cu === 'tr' || cu === 'trieu' || cu === 'm') cocVal *= 1000000; + else if (cocVal > 0 && cocVal < 500) cocVal *= 1000000; + deposit = cocVal; results.push('Cọc: ' + cocVal.toLocaleString('vi-VN') + '₫'); + } + return; + } + var feeMatch = cmdLow.match(/^(giao hang|giao|lap dat|lap|van chuyen|ship|phi giao|phi lap|phi khac|phu phi|phi)\s*([\d.,]+)\s*(k|tr|trieu|m|nghin)?/); + if (feeMatch) { + var feeAmt = parseFloat(feeMatch[2].replace(/\./g, '').replace(',', '.')); + var u = (feeMatch[3] || '').toLowerCase(); + if (u === 'k' || u === 'nghin') feeAmt *= 1000; + else if (u === 'tr' || u === 'trieu' || u === 'm') feeAmt *= 1000000; + else if (feeAmt > 0 && feeAmt < 500) feeAmt *= 1000; + var labelMap = {'giao hang':'Phí giao hàng','giao':'Phí giao hàng','lap dat':'Phí lắp đặt','lap':'Phí lắp đặt','van chuyen':'Phí vận chuyển','ship':'Phí vận chuyển','phi giao':'Phí giao hàng','phi lap':'Phí lắp đặt','phi khac':'Phí khác','phu phi':'Phụ phí','phi':'Phụ phí'}; + fees.push({ label: labelMap[feeMatch[1]] || 'Phụ phí', amount: feeAmt }); + results.push((labelMap[feeMatch[1]] || 'Phụ phí') + ': +' + feeAmt.toLocaleString('vi-VN') + '₫'); + return; + } + var numMatch = cmdLow.match(/([\d.,]+)\s*(k|tr|trieu|m|nghin)?/); + if (numMatch) { + var amt = parseFloat(numMatch[1].replace(/\./g, '').replace(',', '.')); + var u2 = (numMatch[2] || '').toLowerCase(); + if (u2 === 'k' || u2 === 'nghin') amt *= 1000; + else if (u2 === 'tr' || u2 === 'trieu' || u2 === 'm') amt *= 1000000; + if (amt > 0) { + var lb = cmdLow.replace(numMatch[0], '').replace(/[^a-z\s]/g, '').trim(); + if (!lb || lb.length < 2) lb = 'Phụ phí'; + else lb = lb.charAt(0).toUpperCase() + lb.slice(1); + fees.push({ label: lb, amount: amt }); + results.push(lb + ': +' + amt.toLocaleString('vi-VN') + '₫'); + } + return; + } + }); + + updateQuoteTotal(); + + // Render fees + cọc into the footer + document.querySelectorAll('.qt-extra-row').forEach(function(r){ r.remove(); }); + var tfoot = document.querySelector('.quote-table tfoot'); + if (tfoot) { + var totalCell = document.getElementById('quoteTotalCell'); + var totalVal = totalCell ? (parseInt(totalCell.textContent.replace(/\D/g, '')) || 0) : 0; + var insertBefore = tfoot.querySelector('.quote-total-row'); + fees.forEach(function(f) { + var row = document.createElement('tr'); row.className = 'qt-extra-row'; + row.innerHTML = '' + esc(f.label) + ':+' + f.amount.toLocaleString('vi-VN') + '₫'; + if (insertBefore) tfoot.insertBefore(row, insertBefore); else tfoot.appendChild(row); + totalVal += f.amount; + }); + if (fees.length && totalCell) totalCell.textContent = totalVal.toLocaleString('vi-VN') + '₫'; + if (deposit > 0 || depositPct > 0) { + var cocAmt = deposit > 0 ? deposit : Math.round(totalVal * depositPct / 100); + var cocRow = document.createElement('tr'); cocRow.className = 'qt-extra-row'; + cocRow.innerHTML = 'Đặt cọc' + (depositPct > 0 ? ' (' + depositPct + '%)' : '') + ':' + cocAmt.toLocaleString('vi-VN') + '₫'; + tfoot.appendChild(cocRow); + } + } + + if (noteText) { + document.querySelectorAll('input.qt-note,textarea.qt-note').forEach(function(el){ el.value = noteText; }); + } + + if (statusEl) { + if (results.length) statusEl.innerHTML = '✅ ' + results.join(' | '); + else statusEl.textContent = '⚠️ Không nhận diện. VD: Malloca ck 35%, giao hàng 200k, lắp 500k, cọc 5tr, ghi chú: giao thứ 7'; + } +}; + +// Save order (đơn hàng / báo giá) — store locally +window.saveOrder = function() { + var cart = loadCart(); + if (!cart.length) { toast('Giỏ hàng trống!'); return; } + var orders = _ls(ORDER_KEY, '[]'); + var codeEl = document.getElementById('qcCompany'); + var code = (codeEl && codeEl.value) || genOrderCode(); + var qd = getQuoteData(); + var order = { + code: code, + customer: qd.customer.name, + phone: qd.customer.phone, + email: qd.customer.email, + addr: qd.customer.addr, + date: qd.customer.date, + items: qd.items, + grandTotal: qd.grandTotal, + status: 'pending', + savedAt: new Date().toISOString() + }; + var idx = orders.findIndex(function(o) { return o.code === code; }); + if (idx >= 0) orders[idx] = order; else orders.unshift(order); + _lss(ORDER_KEY, orders); + toast('✅ Đã lưu đơn ' + code); +}; + +// ── Đơn hàng (Order management) ── +function getAllOrders() { return _ls(ORDER_KEY, '[]'); } +window.getAllOrders = getAllOrders; + +window.deleteOrder = function(code) { + _lss(ORDER_KEY, getAllOrders().filter(function(o){ return o.code !== code; })); +}; + +window.openOrderModal = function() { + var old = document.getElementById('vai-order-modal'); + if (old) old.remove(); + var orders = getAllOrders(); + var ov = document.createElement('div'); + ov.id = 'vai-order-modal'; + ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:5000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto'; + ov.onclick = function(e){ if (e.target === ov) ov.remove(); }; + ov.innerHTML = '
' + + '
Đơn hàng (' + orders.length + ')
' + + '
' + + '
' + + '
'; + document.body.appendChild(ov); + document.getElementById('vai-ord-close').onclick = function(){ ov.remove(); }; + var re = document.getElementById('vai-ord-r'); + function render(list) { + if (!list.length) { re.innerHTML = '
Trống — hãy lưu báo giá trước
'; return; } + re.innerHTML = list.map(function(o) { + var total = o.grandTotal || 0; + return '
' + + '
' + esc(o.code) + '' + + '
' + + '
' + esc(o.customer) + ' • ' + (o.phone || '') + ' • ' + total.toLocaleString('vi-VN') + '₫
' + + '
' + (o.items || []).length + ' SP • ' + esc(o.date) + '
' + + '
'; + }).join(''); + } + function filter() { + var q = (document.getElementById('vai-ord-q').value || '').toLowerCase(); + var list = orders.filter(function(o){ + if (!q) return true; + return (o.code||'').toLowerCase().indexOf(q) !== -1 || (o.customer||'').toLowerCase().indexOf(q) !== -1 || (o.phone||'').indexOf(q) !== -1; + }); + render(list); + } + filter(); + document.getElementById('vai-ord-q').oninput = filter; + re.onclick = function(e) { + var del = e.target.closest('.voi-del'); + if (del) { e.stopPropagation(); if (confirm('Xóa đơn này?')) { deleteOrder(del.getAttribute('data-code')); orders = getAllOrders(); filter(); } return; } + var card = e.target.closest('.voi-card'); + if (card) openOrderDetail(card.getAttribute('data-c')); + }; +}; + +window.openOrderDetail = function(code) { + var order = getAllOrders().find(function(o){ return o.code === code; }); + if (!order) return; + var old = document.getElementById('vai-order-detail-modal'); + if (old) old.remove(); + var ov = document.createElement('div'); + ov.id = 'vai-order-detail-modal'; + ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:5100;display:flex;align-items:flex-start;justify-content:center;padding:16px;overflow-y:auto'; + ov.onclick = function(e){ if (e.target === ov) ov.remove(); }; + var rows = (order.items || []).map(function(it, i) { + return '' + + '' + (it.stt || i + 1) + '' + + '' + + '' + esc(it.name) + '' + + '' + esc(it.model) + '' + + '' + esc(it.specs || '') + '' + + '' + (it.qty || 1) + '' + + '' + fmtVND(it.price) + '' + + '' + fmtVND(it.discPrice) + '' + + '' + fmtVND(it.total) + '' + + '' + (it.note ? esc(it.note) : '-') + '' + + ''; + }).join(''); + ov.innerHTML = '
' + + '
Đơn ' + esc(order.code) + '
' + + '
' + + 'Khách hàng: ' + esc(order.customer) + '
' + + 'Điện thoại: ' + esc(order.phone) + '
' + + 'Email: ' + esc(order.email) + '
' + + 'Địa chỉ: ' + esc(order.addr) + '
' + + 'Ngày: ' + esc(order.date) + '
' + + '' + rows + '
STTHình ảnhTên SPThông tinSLĐơn giáĐơn giá CKThành tiềnGhi chú
' + + '
TỔNG CỘNG: ' + fmtVND(order.grandTotal ? order.grandTotal : (order.total || 0)) + '
' + + '
'; + document.body.appendChild(ov); +}; + +window.openOrderPage = function() { openOrderModal(); }; + +// Share quote as text (Zalo / clipboard) +window.shareQuoteLink = function() { + var cart = loadCart(); + if (!cart.length) { toast('Giỏ hàng trống!'); return; } + var lines = ['📋 BÁO GIÁ V.AI STUDIO', '----------------------------------']; + cart.forEach(function(c, i) { + var discInput = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + var disc = discInput ? (parseInt(discInput.value.replace(/\D/g, '')) || 0) : 0; + if (!disc) disc = c.priceNum || 0; + lines.push('• ' + c.name + ' x' + (c.qty || 1) + ' — ' + (disc * (c.qty || 1)).toLocaleString('vi-VN') + '₫'); + }); + lines.push('----------------------------------'); + var total = cart.reduce(function(s, c, i) { + var discInput = document.querySelector('.qt-disc[data-idx="' + i + '"]'); + var disc = discInput ? (parseInt(discInput.value.replace(/\D/g, '')) || 0) : 0; + if (!disc) disc = c.priceNum || 0; + return s + disc * (c.qty || 1); + }, 0); + lines.push('TỔNG CỘNG: ' + total.toLocaleString('vi-VN') + '₫'); + var customer = (document.getElementById('qcName') || {}).value || ''; + if (customer) lines.push('Khách hàng: ' + customer); + var text = lines.join('\n'); + if (navigator.share) { navigator.share({ title: 'Báo giá', text: text }).catch(function() {}); } + else { navigator.clipboard.writeText(text).then(function() { toast('✅ Đã sao chép báo giá'); }).catch(function() { prompt('Sao chép báo giá:', text); }); } +}; + +// ── Excel export (ExcelJS) with branded V.AISTUDIO layout ── +/* ── Excel export (ExcelJS) — canonical V.AISTUDIO layout v1046 ── + * Pros: embeds REAL product images by fetching each image URL (ArrayBuffer) with a + * wsrv.nl CORS-proxy fallback (so Eurogold/Hafele/Malloca images always appear), + * exact V.AISTUDIO column order (STT|Hình|Tên sản phẩm|Mã SP|Thông tin|SL|Đơn giá| + * Giá CK|Thành tiền|Ghi chú), branded header, customer grid, zebra rows, per-item + * discount formulas, TỔNG CỘNG row + bank/QR footer. + */ +var BANK_ID = 'vib', BANK_ACCOUNT = '918258385', BANK_NAME = 'TRAN QUOC VUONG', BANK_NAME_VN = 'Trần Quốc Vương'; +function __qGetQRUrl(a, c) { return 'https://img.vietqr.io/image/' + BANK_ID + '-' + BANK_ACCOUNT + '-compact2.png?amount=' + (Math.round(a) || 0) + '&addInfo=' + encodeURIComponent(c || 'VAISTUDIO') + '&accountName=' + encodeURIComponent(BANK_NAME); } +window.exportQuoteExcel = window.exportExcel = async function() { + var cart = loadCart(); + if (!cart.length) { toast('Giỏ hàng trống!'); return; } + if (typeof ExcelJS === 'undefined') { toast('⚠️ Thư viện Excel chưa tải xong.'); return; } + toast('⏳ Đang xuất Excel...'); + var qd = getQuoteData(); + var qdItems = qd.items; + var grandTotal = Number(qd.grandTotal || 0); + var code = qd.customer.orderCode || 'DH'; + var MONEY_FMT = '#,##0"đ"', NUM_FMT = '#,##0'; + var WHITE = { argb: 'FFFFFFFF' }; + var WB = { top:{style:'thin',color:WHITE}, bottom:{style:'thin',color:WHITE}, left:{style:'thin',color:WHITE}, right:{style:'thin',color:WHITE} }; + var wb = new ExcelJS.Workbook(), ws = wb.addWorksheet('Báo giá'); + ws.views = [{ showGridLines: false }]; + ws.columns = [{ width:5 },{ width:11 },{ width:28 },{ width:13 },{ width:20 },{ width:6 },{ width:13 },{ width:13 },{ width:15 },{ width:14 }]; + + // Row 1: logo + tagline + try { + var lr = await fetch('https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp', { mode:'cors' }); + if (!lr.ok) lr = await fetch('https://huggingface.co/spaces/bep40/V.AISTUDIO/resolve/main/logo/logo_200.png', { mode:'cors' }); + if (lr.ok) { + var lb = await lr.arrayBuffer(); + ws.addImage(wb.addImage({ buffer: lb, extension:'png' }), { tl:{ col:0, row:0 }, ext:{ width:40, height:40 } }); + } + } catch(e) {} + ws.mergeCells('B1:J1'); ws.getCell('B1').value = '❝ Niềm tin khách hàng là tài sản của chúng tôi ❞'; + ws.getCell('B1').font = { italic:true, size:9, color:{ argb:'FF64748B' } }; + ws.getCell('B1').alignment = { vertical:'middle' }; ws.getCell('B1').border = WB; + ws.getRow(1).height = 28; ws.getRow(2).height = 5; + + // Row 3: title + ws.mergeCells('A3:J3'); ws.getCell('A3').value = 'BẢNG BÁO GIÁ'; + ws.getCell('A3').font = { bold:true, size:18, color:{ argb:'FFDB9815' } }; + ws.getCell('A3').alignment = { horizontal:'center', vertical:'middle' }; ws.getCell('A3').border = WB; + ws.getRow(3).height = 30; ws.getRow(4).height = 5; + + // Rows 5-8: customer grid (exact V.AISTUDIO layout) + ws.getCell('A5').value = 'Khách hàng:'; ws.getCell('A5').font = { bold:true, size:9 }; ws.getCell('A5').border = WB; + ws.mergeCells('B5:E5'); ws.getCell('B5').value = qd.customer.name || ''; ws.getCell('B5').font = { bold:true, size:10 }; ws.getCell('B5').border = WB; + ws.getCell('H5').value = 'Mã đơn:'; ws.getCell('H5').font = { size:9 }; ws.getCell('H5').border = WB; + ws.mergeCells('I5:J5'); ws.getCell('I5').value = code; ws.getCell('I5').font = { bold:true, size:10, color:{ argb:'FF003F62' } }; ws.getCell('I5').border = WB; + ws.getCell('A6').value = 'SĐT:'; ws.getCell('A6').font = { size:9 }; ws.getCell('A6').border = WB; + ws.getCell('B6').value = qd.customer.phone || ''; ws.getCell('B6').border = WB; + ws.getCell('H6').value = 'Ngày:'; ws.getCell('H6').font = { size:9 }; ws.getCell('H6').border = WB; + ws.getCell('I6').value = qd.customer.date || ''; ws.getCell('I6').border = WB; + ws.getCell('A7').value = 'Email:'; ws.getCell('A7').font = { size:9 }; ws.getCell('A7').border = WB; + ws.getCell('B7').value = qd.customer.email || ''; ws.getCell('B7').border = WB; + ws.getCell('A8').value = 'Địa chỉ:'; ws.getCell('A8').font = { size:9 }; ws.getCell('A8').border = WB; + ws.mergeCells('B8:J8'); ws.getCell('B8').value = qd.customer.addr || ''; ws.getCell('B8').border = WB; + ws.getRow(9).height = 5; + + // Header row 10 + var HB = { top:{style:'thin',color:{argb:'FF003F62'}}, bottom:{style:'thin',color:{argb:'FF003F62'}}, left:{style:'thin',color:{argb:'FF003F62'}}, right:{style:'thin',color:{argb:'FF003F62'}} }; + var headerRow = ws.getRow(10); + ['STT','Hình','Tên sản phẩm','Mã SP','Thông tin','SL','Đơn giá','Giá CK','Thành tiền','Ghi chú'].forEach(function(h, i) { + var c = headerRow.getCell(i + 1); c.value = h; + c.font = { bold:true, color:{ argb:'FFFFFFFF' }, size:9 }; + c.fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FF003F62' } }; + c.alignment = { horizontal:'center', vertical:'middle', wrapText:true }; c.border = HB; + }); + headerRow.height = 20; + + var cr = 11, firstItemRow = cr; + for (var i = 0; i < qdItems.length; i++) { + var it = qdItems[i], row = ws.getRow(cr); row.height = 50; + var bgColor = i % 2 === 0 ? 'FFF8FAFC' : 'FFFFFFFF'; + var RB = { top:{style:'thin',color:{argb:bgColor}}, bottom:{style:'thin',color:{argb:bgColor}}, left:{style:'thin',color:{argb:bgColor}}, right:{style:'thin',color:{argb:bgColor}} }; + row.getCell(1).value = it.stt || i + 1; row.getCell(1).alignment = { horizontal:'center', vertical:'middle' }; + // Fetch REAL product image (ArrayBuffer) with wsrv.nl CORS fallback → images ALWAYS appear + if (it.image && it.image.indexOf('http') === 0) { + try { + var ir = await fetch(it.image, { mode:'cors' }); + if (!ir.ok) throw new Error('HTTP ' + ir.status); + var ib = await ir.arrayBuffer(); + var ext = it.image.match(/\.(png|jpe?g)/i); ext = ext ? (ext[1].indexOf('png') === 0 ? 'png' : 'jpeg') : 'jpeg'; + ws.addImage(wb.addImage({ buffer: ib, extension: ext }), { tl:{ col:1, row:cr - 1 }, ext:{ width:46, height:46 } }); + } catch(e1) { + try { + var proxyUrl = 'https://wsrv.nl/?url=' + encodeURIComponent(it.image) + '&w=200&output=' + (/\.png/i.test(it.image) ? 'png' : 'jpg'); + var ir2 = await fetch(proxyUrl); + if (ir2.ok) { + var ib2 = await ir2.arrayBuffer(); + var ext2 = it.image.match(/\.(png|jpe?g)/i); ext2 = ext2 ? (ext2[1].indexOf('png') === 0 ? 'png' : 'jpeg') : 'jpeg'; + ws.addImage(wb.addImage({ buffer: ib2, extension: ext2 }), { tl:{ col:1, row:cr - 1 }, ext:{ width:46, height:46 } }); + } + } catch(e2) {} + } + } + row.getCell(3).value = it.name || ''; row.getCell(3).font = { bold:true, size:9 }; row.getCell(3).alignment = { wrapText:true, vertical:'middle' }; + row.getCell(4).value = it.model || ''; row.getCell(4).alignment = { horizontal:'center', vertical:'middle' }; + row.getCell(5).value = it.specs || ''; row.getCell(5).font = { size:8, color:{ argb:'FF64748B' } }; row.getCell(5).alignment = { wrapText:true, vertical:'middle' }; + row.getCell(6).value = Number(it.qty || 1); row.getCell(6).numFmt = NUM_FMT; row.getCell(6).alignment = { horizontal:'center', vertical:'middle' }; + row.getCell(7).value = Number(it.price || 0); row.getCell(7).numFmt = MONEY_FMT; row.getCell(7).alignment = { horizontal:'right', vertical:'middle' }; + // Giá CK — formula = Giá * discountFactor when discounted + var discountFactor = 1.0; + if (Number(it.discPrice) > 0 && Number(it.price) > 0 && it.discPrice < it.price) { + discountFactor = Number(it.discPrice) / Number(it.price); + } + row.getCell(8).value = { formula:'G' + cr + '*' + discountFactor.toFixed(4), result: Number(it.discPrice || it.price || 0) }; + row.getCell(8).numFmt = MONEY_FMT; row.getCell(8).alignment = { horizontal:'right', vertical:'middle' }; + if ((it.discPrice || 0) && (it.price || 0) && it.discPrice < it.price) row.getCell(8).font = { bold:true, color:{ argb:'FFDC3545' } }; + row.getCell(9).value = { formula:'F' + cr + '*H' + cr, result: Number(it.total || 0) }; row.getCell(9).numFmt = MONEY_FMT; row.getCell(9).font = { bold:true }; row.getCell(9).alignment = { horizontal:'right', vertical:'middle' }; + row.getCell(10).value = it.note || ''; row.getCell(10).font = { size:8 }; row.getCell(10).alignment = { wrapText:true, vertical:'middle' }; + for (var ci = 1; ci <= 10; ci++) { row.getCell(ci).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:bgColor } }; row.getCell(ci).border = RB; } + cr++; + } + var lastItemRow = cr - 1; + var totalQtyFormula = lastItemRow >= firstItemRow ? 'SUM(F' + firstItemRow + ':F' + lastItemRow + ')' : '0'; + var totalQtyValue = qdItems.reduce(function(s, it) { return s + Number(it.qty || 1); }, 0); + var sumParts = []; + if (lastItemRow >= firstItemRow) sumParts.push('SUM(I' + firstItemRow + ':I' + lastItemRow + ')'); + + // Fees rows (Phí giao hàng / lắp đặt / cọc from quote footer) + var fees = []; + document.querySelectorAll('.qt-extra-row').forEach(function(rw) { /* already-rendered rows come from share/excel re-run */ }); + var feeRows = document.querySelectorAll('tr.qt-extra-row td.qt-price'); + feeRows.forEach(function(td) { var v = parseInt((td.textContent || '').replace(/[^\d]/g, ''), 10); if (v > 0) fees.push({ label: 'Phụ phí', amount: v }); }); + + if (fees.length) { + var FBG = 'FFFFFBEB', FB = { top:{style:'thin',color:{argb:FBG}}, bottom:{style:'thin',color:{argb:FBG}}, left:{style:'thin',color:{argb:FBG}}, right:{style:'thin',color:{argb:FBG}} }; + fees.forEach(function(f) { + var row = ws.getRow(cr); row.height = 20; + ws.mergeCells(cr, 1, cr, 8); row.getCell(1).value = ' ⊕ ' + f.label; + row.getCell(1).font = { size:9, color:{ argb:'FF92400E' } }; row.getCell(1).alignment = { vertical:'middle' }; + row.getCell(9).value = Number(f.amount || 0); row.getCell(9).numFmt = MONEY_FMT; + row.getCell(9).font = { bold:true, size:9, color:{ argb:'FF92400E' } }; row.getCell(9).alignment = { horizontal:'right', vertical:'middle' }; + for (var ci2 = 1; ci2 <= 10; ci2++) { row.getCell(ci2).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:FBG } }; row.getCell(ci2).border = FB; } + sumParts.push('I' + cr); cr++; + }); + } + + var totalRowNum = cr; + var TBG = 'FF003F62', TB = { top:{style:'thin',color:{argb:TBG}}, bottom:{style:'thin',color:{argb:TBG}}, left:{style:'thin',color:{argb:TBG}}, right:{style:'thin',color:{argb:TBG}} }; + ws.mergeCells(cr, 1, cr, 5); + var tRow = ws.getRow(cr); tRow.height = 28; + tRow.getCell(1).value = 'TỔNG CỘNG'; tRow.getCell(1).font = { bold:true, size:13, color:{ argb:'FFFFFFFF' } }; tRow.getCell(1).alignment = { horizontal:'left', vertical:'middle', indent:1 }; tRow.getCell(1).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(1).border = TB; + tRow.getCell(6).value = { formula: totalQtyFormula, result: totalQtyValue }; tRow.getCell(6).numFmt = NUM_FMT; tRow.getCell(6).font = { bold:true, size:12, color:{ argb:'FFFFFFFF' } }; tRow.getCell(6).alignment = { horizontal:'center', vertical:'middle' }; tRow.getCell(6).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(6).border = TB; + tRow.getCell(7).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(7).border = TB; + tRow.getCell(8).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(8).border = TB; + tRow.getCell(9).value = { formula: sumParts.length ? sumParts.join('+') : '0', result: Number(grandTotal) }; tRow.getCell(9).numFmt = MONEY_FMT; tRow.getCell(9).font = { bold:true, size:14, color:{ argb:'FFF0B840' } }; tRow.getCell(9).alignment = { horizontal:'right', vertical:'middle' }; tRow.getCell(9).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(9).border = TB; + tRow.getCell(10).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:TBG } }; tRow.getCell(10).border = TB; cr++; + + // Bank + QR footer (exact V.AISTUDIO) + cr++; + var QBG = 'FFF8FAFC', QB = { top:{style:'thin',color:{argb:QBG}}, bottom:{style:'thin',color:{argb:QBG}}, left:{style:'thin',color:{argb:QBG}}, right:{style:'thin',color:{argb:QBG}} }; + ws.mergeCells(cr, 1, cr, 5); ws.getCell('A' + cr).value = '💳 CHUYỂN KHOẢN'; ws.getCell('A' + cr).font = { bold:true, size:11, color:{ argb:'FF003F62' } }; ws.getCell('A' + cr).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:QBG } }; ws.getCell('A' + cr).border = QB; cr++; + ws.mergeCells(cr, 1, cr, 5); ws.getCell('A' + cr).value = '🏦 VIB | 👤 ' + BANK_NAME_VN; ws.getCell('A' + cr).font = { size:10 }; ws.getCell('A' + cr).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:QBG } }; ws.getCell('A' + cr).border = QB; cr++; + ws.mergeCells(cr, 1, cr, 5); ws.getCell('A' + cr).value = '🔢 STK: ' + BANK_ACCOUNT; ws.getCell('A' + cr).font = { bold:true, size:10 }; ws.getCell('A' + cr).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:QBG } }; ws.getCell('A' + cr).border = QB; cr++; + ws.mergeCells(cr, 1, cr, 5); ws.getCell('A' + cr).value = '💰 ' + fmtVND(grandTotal) + ' | 📝 ' + code; ws.getCell('A' + cr).font = { bold:true, size:10 }; ws.getCell('A' + cr).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:QBG } }; ws.getCell('A' + cr).border = QB; + try { + var qrR = await fetch(__qGetQRUrl(grandTotal, code), { mode:'cors' }); + if (qrR.ok) { var qb = await qrR.arrayBuffer(); ws.addImage(wb.addImage({ buffer: qb, extension:'png' }), { tl:{ col:6, row:totalRowNum + 1 }, ext:{ width:115, height:115 } }); } + } catch(e) {} + + ws.eachRow(function(row) { row.eachCell(function(cell) { if (!cell.border) cell.border = WB; }); }); + wb.calcProperties = { fullCalcOnLoad: true }; + try { + var buf = await wb.xlsx.writeBuffer(); + var blob = new Blob([buf], { type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); a.href = url; a.download = code + '.xlsx'; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(function(){ URL.revokeObjectURL(url); }, 60000); + toast('✅ Đã xuất file Excel!'); + } catch(e) { console.error('ExcelJS error:', e); toast('⚠️ Xuất Excel thất bại'); } +}; + +// ── PDF export (html2canvas) with branded layout ── +window.exportQuotePDF = window.exportPDF = async function() { + var cart = loadCart(); + if (!cart.length) { toast('Giỏ hàng trống!'); return; } + var qd = getQuoteData(); + var qdItems = qd.items; + var div = document.createElement('div'); + div.id = 'pdfRender'; + div.style.cssText = 'position:fixed;top:-9999px;left:0;width:1100px;background:#fff;padding:40px;font-family:Inter,Arial,sans-serif'; + var itemsHtml = qdItems.map(function(it) { + return '' + + '' + it.stt + '' + + '' + (it.image ? '' : '') + '' + + '' + esc(it.name) + '' + + '' + esc(it.model) + '' + + '' + esc(it.specs) + '' + + '' + it.qty + '' + + '' + fmtVND(it.price) + '' + + '' + fmtVND(it.discPrice) + '' + + '' + fmtVND(it.total) + '' + + '' + esc(it.note) + '' + + ''; + }).join(''); + + div.innerHTML = '
' + + '
V.AI STUDIO
' + + '
❝ Niềm tin khách hàng là tài sản của chúng tôi ❞
' + + '
BẢNG BÁO GIÁ
' + + '
' + + '
' + + '
Khách hàng: ' + esc(qd.customer.name) + '
SĐT: ' + esc(qd.customer.phone) + '
Email: ' + esc(qd.customer.email) + '
Địa chỉ: ' + esc(qd.customer.addr) + '
' + + '
Mã đơn hàng: ' + esc(qd.customer.orderCode) + '
Ngày: ' + esc(qd.customer.date) + '
' + + '
' + + '' + + '' + + '' + + '' + itemsHtml + '' + + '' + + '
STTHình ảnhTên sản phẩmMã SPThông tin SPSLĐơn giáĐơn giá CKThành tiềnGhi chú
TỔNG CỘNG:' + fmtVND(qd.grandTotal) + '
' + + '
Miễn phí giao hàng trong TPHCM. | Giá đã bao gồm VAT.
'; + + document.body.appendChild(div); + await new Promise(function(r){ setTimeout(r, 500); }); + try { + var canvas = await html2canvas(div, { scale:2, useCORS:true, allowTaint:true, logging:false, backgroundColor:'#fff' }); + var imgData = canvas.toDataURL('image/jpeg', 0.95); + if (!window.jspdf || !window.jspdf.jsPDF) { div.remove(); toast('⚠️ Thư viện PDF chưa tải.'); return; } + var jsPDF = window.jspdf.jsPDF; + var pdf = new jsPDF({ orientation:'landscape', unit:'mm', format:'a4' }); + var pdfW = pdf.internal.pageSize.getWidth(); + var pdfH = pdf.internal.pageSize.getHeight(); + var imgW = canvas.width, imgH = canvas.height; + var ratio = pdfW / imgW; + var pageH = pdfH - 10, y = 5, srcY = 0; + var sliceH = pageH / ratio; + while (srcY < imgH) { + if (srcY > 0) pdf.addPage(); + var clip = document.createElement('canvas'); + clip.width = imgW; clip.height = Math.min(sliceH, imgH - srcY); + clip.getContext('2d').drawImage(canvas, 0, srcY, imgW, clip.height, 0, 0, imgW, clip.height); + var sliceData = clip.toDataURL('image/jpeg', 0.95); + pdf.addImage(sliceData, 'JPEG', 0, y, pdfW, clip.height * ratio); + srcY += sliceH; + } + pdf.save((qd.customer.orderCode || 'BG') + '.pdf'); + toast('✅ Đã xuất file PDF!'); + } catch(e) { console.error('PDF error:', e); toast('❌ Lỗi xuất PDF'); } + div.remove(); +}; + +// ── Share quote image (đúng layout V.AISTUDIO) ── +window.shareQuoteImage = async function() { + var cart = loadCart(); + if (!cart.length) { toast('Giỏ hàng trống!'); return; } + var qd = getQuoteData(); + var qdItems = qd.items; + var div = document.createElement('div'); + div.style.cssText = 'position:fixed;top:-9999px;left:0;width:1100px;background:#fff;padding:40px;font-family:Inter,Arial,sans-serif'; + var itemsHtml = qdItems.map(function(it) { + return '' + + '' + it.stt + '' + + '' + (it.image ? '' : '') + '' + + '' + esc(it.name) + '' + + '' + esc(it.model) + '' + + '' + esc(it.specs) + '' + + '' + it.qty + '' + + '' + fmtVND(it.price) + '' + + '' + fmtVND(it.discPrice) + '' + + '' + fmtVND(it.total) + '' + + '' + esc(it.note) + '' + + ''; + }).join(''); + div.innerHTML = '
' + + '
V.AI STUDIO
' + + '
❝ Niềm tin khách hàng là tài sản của chúng tôi ❞
' + + '
BẢNG BÁO GIÁ
' + + '
' + + '
' + + '
Khách hàng: ' + esc(qd.customer.name) + '
SĐT: ' + esc(qd.customer.phone) + '
Email: ' + esc(qd.customer.email) + '
Địa chỉ: ' + esc(qd.customer.addr) + '
' + + '
Mã đơn hàng: ' + esc(qd.customer.orderCode) + '
Ngày: ' + esc(qd.customer.date) + '
' + + '
' + + '' + + '' + + '' + + '' + itemsHtml + '' + + '' + + '
STTHìnhTên SPThông tinSLĐơn giáĐơn giá CKThành tiềnGhi chú
TỔNG CỘNG:' + fmtVND(qd.grandTotal) + '
' + + '
Miễn phí giao hàng trong TPHCM. | Giá đã bao gồm VAT.
'; + document.body.appendChild(div); + await new Promise(function(r){ setTimeout(r, 500); }); + try { + var canvas = await html2canvas(div, { scale:2, useCORS:true, allowTaint:true, backgroundColor:'#fff', logging:false }); + div.remove(); + var blob = await new Promise(function(r){ canvas.toBlob(r, 'image/png'); }); + var file = new File([blob], (qd.customer.orderCode || 'BG') + '.png', { type:'image/png' }); + if (navigator.share && navigator.canShare && navigator.canShare({ files:[file] })) { + await navigator.share({ title:'Báo giá V.AI STUDIO - ' + qd.customer.orderCode, files:[file] }); + } else { + var url = URL.createObjectURL(blob); + var preview = document.createElement('div'); + preview.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(4px)'; + preview.innerHTML = ''; + document.body.appendChild(preview); + toast('✅ Ảnh báo giá đã sẵn sàng!'); + } + } catch(e) { div.remove(); toast('Lỗi: ' + e.message); } +}; + +// Print quote (đúng layout) +window.printQuotation = function() { + var qd = getQuoteData(); + var w = window.open('', '_blank', 'width=1100,height=800'); + if (!w) { exportQuotePDF(); return; } + var rows = qd.items.map(function(it) { + return '' + + '' + it.stt + '' + + '' + (it.image ? '' : '') + '' + + '' + esc(it.name) + '' + + '' + esc(it.model) + '' + + '' + esc(it.specs) + '' + + '' + it.qty + '' + + '' + fmtVND(it.price) + '' + + '' + fmtVND(it.discPrice) + '' + + '' + fmtVND(it.total) + '' + + '' + esc(it.note) + '' + + ''; + }).join(''); + w.document.write('Báo giá'); + w.document.write('

V.AI STUDIO

❝ Niềm tin khách hàng là tài sản của chúng tôi ❞
BẢNG BÁO GIÁ
'); + w.document.write('
Khách hàng: ' + esc(qd.customer.name) + '
Điện thoại: ' + esc(qd.customer.phone) + '
Email: ' + esc(qd.customer.email) + '
Địa chỉ: ' + esc(qd.customer.addr) + '
Mã đơn: ' + esc(qd.customer.orderCode) + '
Ngày: ' + esc(qd.customer.date) + '
'); + w.document.write('' + rows + ''); + w.document.write('
STTHìnhTên sản phẩmMã SPThông tinSLĐơn giáĐơn giá CKThành tiềnGhi chú
TỔNG CỘNG:' + fmtVND(qd.grandTotal) + '
'); + w.document.write('
Miễn phí giao hàng trong TPHCM. | Giá đã bao gồm VAT.
'); + w.document.write(''); + w.document.close(); + w.focus(); + setTimeout(function() { w.print(); }, 400); +}; + +function toast(msg) { + var el = document.getElementById('vai-cart-toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'vai-cart-toast'; + el.style.cssText = 'position:fixed;bottom:120px;left:50%;transform:translateX(-50%);background:#1e293b;color:#fff;padding:11px 20px;border-radius:10px;font-size:.82rem;z-index:9999;box-shadow:0 6px 20px rgba(0,0,0,.35);opacity:0;transition:opacity .3s;pointer-events:none;max-width:90vw;text-align:center'; + document.body.appendChild(el); + } + el.textContent = msg; + el.style.opacity = '1'; + clearTimeout(el._t); + el._t = setTimeout(function() { el.style.opacity = '0'; }, 2600); +} +window.showToast = toast; + +function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); } + +ready(function() { + // Robust cart-opening wiring — bind directly AND via document-level delegation + // so the cart is ALWAYS clickable regardless of when app.js re-renders the DOM. + function bindCartButton() { + var badgeBtn = document.getElementById('cart-badge-btn'); + if (badgeBtn && !badgeBtn._vaiCartBound) { + badgeBtn._vaiCartBound = true; + badgeBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (window.openCart) window.openCart(); + }); + badgeBtn.addEventListener('touchstart', function(e) { + if (e.target.closest && !e.target.closest('button')) return; + if (window.openCart) { e.preventDefault(); e.stopPropagation(); window.openCart(); } + }, { passive: false }); + } + } + // Document-level delegation catches clicks even if a re-render replaced the node + if (!document._vaiCartDelegated) { + document._vaiCartDelegated = true; + document.addEventListener('click', function(e) { + var t = e.target && e.target.closest ? e.target.closest('#cart-badge-btn, [data-open-cart]') : null; + if (t) { e.preventDefault(); e.stopPropagation(); if (window.openCart) window.openCart(); } + }); + } + bindCartButton(); + var overlay = document.getElementById('cartOverlay'); + if (overlay) overlay.addEventListener('click', function(e) { if (e.target === overlay) closeCart(); }); + // Re-bind in case the button is injected late + setTimeout(function(){ bindCartButton(); }, 500); + setTimeout(function(){ bindCartButton(); }, 2000); + if (_quoteUnlocked) { applyUnlockUI(); } + badgeUpdate(); +}); + +// expose API +window.CART_QUOTE = { + loadCart: loadCart, saveCart: saveCart, badgeUpdate: badgeUpdate, + cartTotals: cartTotals, genOrderCode: genOrderCode, toast: toast, + getQuoteData: getQuoteData, getAllOrders: getAllOrders +}; +console.log('[CartQuote] module loaded'); +})(); diff --git a/src/greeting-news.js b/src/greeting-news.js new file mode 100644 index 0000000000000000000000000000000000000000..9bfed8ffa7ab5286d3e73f8eb2c5103e69007858 --- /dev/null +++ b/src/greeting-news.js @@ -0,0 +1,810 @@ +/** + * V.AI AVATAR — All-in-one enhancement module. + * ---------------------------------------------------------------------------- + * Loaded from index.html BEFORE