Spaces:
Running
Running
restore to c1013cc7ebfdc93cbb55645441528a84d871a052: 100% restore of target commit tree
9b44afa verified | /** | |
| * 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")); | |