vai-market / index.ts.patched
bep40's picture
restore to c1013cc7ebfdc93cbb55645441528a84d871a052: 100% restore of target commit tree
9b44afa verified
Raw
History Blame
30 kB
/**
* 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.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"));