/** * 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 { 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 { 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 { 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 = ` `; return new Response(index.replace(/<\/head>/, ogTags + "\n "), { 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 { 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(/\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 = ` `; return new Response(index.replace(/<\/head>/, ogTags + "\n "), { 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 = ` `; return new Response(index.replace(/<\/head>/, fallbackOgTags + "\n "), { 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 { 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: "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((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(/]+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) 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(/]*>[\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!), "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), "/*": { GET: serveStatic }, }, }); console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none"));