FIX: serve app.js directly (no concat with broken app_part2.js), remove app_part2.js
Browse files
index.ts
CHANGED
|
@@ -1,358 +1,7 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Gemma Avatar + Product RAG — realtime voice/text chat with product knowledge base.
|
| 3 |
-
* Built from gemma-multi-avatar + V.AISTUDIO product catalogue.
|
| 4 |
-
*/
|
| 5 |
-
import { readFile, readdir, writeFile, mkdir } from "fs/promises";
|
| 6 |
-
import { join } from "path";
|
| 7 |
-
|
| 8 |
-
const htmlPath = join(process.cwd(), "index.html");
|
| 9 |
-
let _htmlCache: string | null = null;
|
| 10 |
-
async function getHTML(): Promise<string> {
|
| 11 |
-
if (!_htmlCache) _htmlCache = await readFile(htmlPath, "utf-8");
|
| 12 |
-
return _htmlCache;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
|
| 16 |
-
const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://smolagents-hf-realtime-voice.hf.space/api").trim().replace(/\/$/, "");
|
| 17 |
-
const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
|
| 18 |
-
const PORT = Number(Bun.env.PORT ?? 7860);
|
| 19 |
-
const HF_TOKEN = Bun.env.HF_TOKEN || "";
|
| 20 |
-
const RAG_MODEL = Bun.env.RAG_MODEL || "BAAI/bge-small-en-v1.5";
|
| 21 |
-
const PRODUCTS_PATH = join(process.cwd(), "products_rag.json");
|
| 22 |
-
const TOP_K = 5;
|
| 23 |
-
|
| 24 |
-
function sanitizeSetCookie(sc: string) {
|
| 25 |
-
return sc.split(";").map((p) => p.trim()).filter((p) => !/^domain=/i.test(p)).join("; ");
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
async function proxy(path: string, req: Request, init: RequestInit = {}) {
|
| 29 |
-
const headers = new Headers(init.headers);
|
| 30 |
-
headers.set("Content-Type", "application/json");
|
| 31 |
-
const cookie = req.headers.get("cookie");
|
| 32 |
-
if (cookie) headers.set("Cookie", cookie);
|
| 33 |
-
const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers });
|
| 34 |
-
if (!resp.ok) {
|
| 35 |
-
const ct = resp.headers.get("content-type") || "";
|
| 36 |
-
if (ct.includes("text/html")) {
|
| 37 |
-
if (resp.status === 429) return Response.json({ error: "Dịch vụ giọng nói đang quá tải, thử lại sau", state: "rate_limited" }, { status: 429 });
|
| 38 |
-
return Response.json({ error: "Speech service error", state: "unavailable" }, { status: resp.status });
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
const body = await resp.text();
|
| 42 |
-
const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
|
| 43 |
-
const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
|
| 44 |
-
for (const sc of setCookies) out.headers.append("Set-Cookie", sanitizeSetCookie(sc));
|
| 45 |
-
return out;
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
function staticFile(dir: string, name: string) {
|
| 49 |
-
return new Response(Bun.file(join(process.cwd(), "public", dir, name)));
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
async function listAvatars() {
|
| 53 |
-
const avatarsDir = join(process.cwd(), "public/avatars");
|
| 54 |
-
const names: string[] = [];
|
| 55 |
-
try {
|
| 56 |
-
const dir = await readdir(avatarsDir, { withFileTypes: true });
|
| 57 |
-
for (const entry of dir) { if (entry.isFile() && entry.name.endsWith(".glb")) names.push(entry.name); }
|
| 58 |
-
} catch {}
|
| 59 |
-
return names.sort();
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
async function avatarHasThumbnail(avatarName: string) {
|
| 63 |
-
try { return await Bun.file(join(process.cwd(), "public/avatars", `${avatarName}.thumb.png`)).exists(); } catch { return false; }
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
let products: any[] = [];
|
| 67 |
-
let ragReady = false;
|
| 68 |
-
|
| 69 |
-
function nVN(s: string) {
|
| 70 |
-
return s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d");
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
const SYN: Record<string, string[]> = { ke: ["gia"], gia: ["ke"], bat: ["chen"], chen: ["bat"], xoong: ["noi"], noi: ["xoong"], dao: ["thot"], thot: ["dao"], ray: ["truot"], truot: ["ray"], khoa: ["lock"], lock: ["khoa"], bon: ["chau"], chau: ["bon"], ro: ["gia", "ke"] };
|
| 74 |
-
|
| 75 |
-
function smartSearch(query: string, text: string): boolean {
|
| 76 |
-
const cleanQ = query.replace(/[.\- ]/g, "");
|
| 77 |
-
const cleanT = text.replace(/[.\- ]/g, "");
|
| 78 |
-
if (cleanT.includes(cleanQ)) return true;
|
| 79 |
-
if (text.includes(query)) return true;
|
| 80 |
-
const words = query.split(/\s+/).filter((w) => w);
|
| 81 |
-
if (!words.length) return true;
|
| 82 |
-
let matched = 0;
|
| 83 |
-
for (const w of words) {
|
| 84 |
-
const cw = w.replace(/[.\-]/g, "");
|
| 85 |
-
if (text.includes(w) || cleanT.includes(cw)) { matched++; continue; }
|
| 86 |
-
let hit = false;
|
| 87 |
-
if (SYN[w]) { for (const s of SYN[w]) { if (text.includes(s)) { hit = true; break; } } }
|
| 88 |
-
if (hit) matched++;
|
| 89 |
-
}
|
| 90 |
-
return matched === words.length;
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
async function loadProducts() {
|
| 94 |
-
console.log("[RAG] Loading product catalogue...");
|
| 95 |
-
try { products = JSON.parse(await readFile(PRODUCTS_PATH, "utf-8")); ragReady = true; console.log(`[RAG] Ready — ${products.length} products`); }
|
| 96 |
-
catch (e) { console.error(`[RAG] Failed to load products: ${e}`); }
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
async function getEmbedding(text: string): Promise<number[] | null> {
|
| 100 |
-
if (!HF_TOKEN) return null;
|
| 101 |
-
try {
|
| 102 |
-
const resp = await fetch(`https://api-inference.huggingface.co/models/${RAG_MODEL}`, {
|
| 103 |
-
method: "POST", headers: { Authorization: `Bearer ${HF_TOKEN}`, "Content-Type": "application/json" },
|
| 104 |
-
body: JSON.stringify({ inputs: text }), signal: AbortSignal.timeout(15000),
|
| 105 |
-
});
|
| 106 |
-
if (!resp.ok) return null;
|
| 107 |
-
const data = await resp.json();
|
| 108 |
-
const vec = Array.isArray(data) ? (Array.isArray(data[0]) ? data[0] : data) : null;
|
| 109 |
-
return vec ? Array.from(vec) : null;
|
| 110 |
-
} catch { return null; }
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
function cosineSim(a: number[], b: number[]): number {
|
| 114 |
-
if (!a || !b || a.length === 0 || b.length === 0) return 0;
|
| 115 |
-
let dot = 0, na = 0, nb = 0;
|
| 116 |
-
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
| 117 |
-
const n = Math.sqrt(na) * Math.sqrt(nb);
|
| 118 |
-
return n === 0 ? 0 : dot / n;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
const FEEDS = ["https://vnexpress.net/rss/tin-moi-nhat.rss","https://dantri.com.vn/trangchu.rss","https://tuoitre.vn/rss/tin-moi-nhat.rss","https://thanhnien.vn/rss/home.rss","https://vietnamnet.vn/rss/home.rss","https://zingnews.vn/rss/home.rss","https://kenh14.vn/rss/home.rss","https://cafef.vn/rss/trang-chu.rss","https://genk.vn/rss/home.rss","https://afamily.vn/rss/home.rss","https://plo.vn/rss/home.rss","https://vtc.vn/rss/home.rss","https://laodong.vn/rss/home.rss","https://www.nguoiduatin.vn/rss/home.rss","https://tienphong.vn/rss/home.rss","https://vov.vn/rss/home.rss","https://vietnamnet.vn/rss/the-thao.rss","https://vtc.vn/rss/the-thao.rss","https://thanhnien.vn/rss/the-thao.rss","https://tuoitre.vn/rss/the-thao.rss","https://dantri.com.vn/the-thao.rss","https://www.bongda.com.vn/rss-bong-da.html","https://bongdoanhnghia.vn/feed","https://vietnamnet.vn/rss/kinh-te.rss","https://cafef.vn/rss/thi-truong.rss","https://vtc.vn/rss/kinh-te.rss","https://thanhnien.vn/rss/kinh-te.rss","https://laodong.vn/kinh-te-doanh-nghiep.rss","https://vietnamnet.vn/rss/cong-nghe.rss","https://genk.vn/rss/cong-nghe.rss","https://cafef.vn/rss/cong-nghe.rss","https://vtc.vn/rss/cong-nghe.rss","https://thanhnien.vn/rss/cong-nghe.rss","https://vietnamnet.vn/rss/thoi-su.rss","https://vtc.vn/rss/thoi-su.rss","https://thanhnien.vn/rss/thoi-su.rss","https://tuoitre.vn/rss/thoi-su.rss","https://vietnamnet.vn/rss/giao-duc.rss","https://vtc.vn/rss/giao-duc.rss","https://thanhnien.vn/rss/giao-duc.rss"];
|
| 122 |
-
|
| 123 |
-
function extractImage(itemXml: string) {
|
| 124 |
-
const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/i);
|
| 125 |
-
if (media) return media[1];
|
| 126 |
-
const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/i);
|
| 127 |
-
if (enc) { const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i); if (urlMatch) return urlMatch[1]; }
|
| 128 |
-
const imgTag = itemXml.match(/<image>\s*<url>([\s\S]*?)<\/url>/i);
|
| 129 |
-
if (imgTag) return imgTag[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
|
| 130 |
-
const imgSrc = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/i);
|
| 131 |
-
if (imgSrc) return imgSrc[1];
|
| 132 |
-
return "";
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
function extractDescription(itemXml: string) {
|
| 136 |
-
const dm = itemXml.match(/<description[^>]*>([\s\S]*?)<\/description>/i);
|
| 137 |
-
if (!dm) return "";
|
| 138 |
-
let txt = dm[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
|
| 139 |
-
return txt.length > 300 ? txt.slice(0, 300) + "..." : txt;
|
| 140 |
-
}
|
| 141 |
-
|
| 142 |
-
let newsCache: { items: any[]; fetchedAt: number } = { items: [], fetchedAt: 0 };
|
| 143 |
-
const NEWS_TTL = 5 * 60 * 1000;
|
| 144 |
-
|
| 145 |
-
async function fetchAllNews(): Promise<any[]> {
|
| 146 |
-
const now = Date.now();
|
| 147 |
-
if (newsCache.items.length > 0 && now - newsCache.fetchedAt < NEWS_TTL) return newsCache.items;
|
| 148 |
-
const items: any[] = [];
|
| 149 |
-
const parser = new (await import("xml2js")).Parser({ explicitArray: false, mergeAttrs: true });
|
| 150 |
-
const results = await Promise.allSettled(FEEDS.slice(0, 3).map(async (url) => {
|
| 151 |
-
try {
|
| 152 |
-
const resp = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
| 153 |
-
if (!resp.ok) return [];
|
| 154 |
-
const result = await parser.parseStringPromise(await resp.text());
|
| 155 |
-
const channel = result.rss?.channel || result.feed || {};
|
| 156 |
-
const entries = channel.item || channel.entry || [];
|
| 157 |
-
return (Array.isArray(entries) ? entries : [entries]).slice(0, 8).map((item: any) => ({
|
| 158 |
-
title: (item.title?._ || item.title || "").toString().replace(/<!\[CDATA\[|\]\]>/g, "").trim(),
|
| 159 |
-
link: (item.link?._ || item.link || item.link?.href || "").toString().trim(),
|
| 160 |
-
image: extractImage(JSON.stringify(item)),
|
| 161 |
-
description: extractDescription(JSON.stringify(item)),
|
| 162 |
-
source: new URL(url).hostname.replace(/^www\./, ""),
|
| 163 |
-
sourceUrl: url,
|
| 164 |
-
})).filter((i: any) => i.title && i.link);
|
| 165 |
-
} catch { return []; }
|
| 166 |
-
}));
|
| 167 |
-
for (const r of results) { if (r.status === "fulfilled") items.push(...r.value); }
|
| 168 |
-
const seen = new Set<string>();
|
| 169 |
-
const deduped = items.filter((i) => { const key = i.title.toLowerCase().slice(0, 60); if (seen.has(key)) return false; seen.add(key); return true; });
|
| 170 |
-
newsCache = { items: deduped, fetchedAt: Date.now() };
|
| 171 |
-
return deduped;
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
const server = Bun.serve({
|
| 175 |
-
port: PORT,
|
| 176 |
-
routes: {
|
| 177 |
-
"/": { GET: async () => new Response(await getHTML(), { headers: { "Content-Type": "text/html; charset=utf-8" } }) },
|
| 178 |
-
|
| 179 |
-
"/products_rag.json": async () => { try { const f = Bun.file(PRODUCTS_PATH); if (await f.exists()) return new Response(f, { headers: { "Content-Type": "application/json" } }); } catch {} return new Response("Not Found", { status: 404 }); },
|
| 180 |
-
|
| 181 |
-
"/api/products/search": {
|
| 182 |
-
GET: async (req) => {
|
| 183 |
-
const url = new URL(req.url);
|
| 184 |
-
const q = (url.searchParams.get("q") || "").trim();
|
| 185 |
-
const limit = Math.min(20, Math.max(1, Number(url.searchParams.get("limit") ?? "10") || 10));
|
| 186 |
-
const cat = url.searchParams.get("cat") || "";
|
| 187 |
-
const brand = url.searchParams.get("brand") || "";
|
| 188 |
-
const sort = url.searchParams.get("sort") || "";
|
| 189 |
-
if (!q && !cat && !brand) return Response.json({ query: q, total: products.length, items: products.slice(0, limit) });
|
| 190 |
-
let results = [...products];
|
| 191 |
-
if (cat && cat !== "all") results = results.filter((p) => p.c === cat || p.catSlug === cat);
|
| 192 |
-
if (brand && brand !== "all") results = results.filter((p) => p.brand === brand);
|
| 193 |
-
if (q) {
|
| 194 |
-
const nq = nVN(q);
|
| 195 |
-
results = results.filter((p) => { const specText = (p.specs ? Object.entries(p.specs).map(([k, v]) => k + " " + v).join(" ") : ""); const featText = (p.feats || []).join(" "); return smartSearch(nq, nVN((p.n || "") + " " + (p.brand || "") + " " + (p.c || "") + " " + specText + " " + featText + " " + (p.sum || ""))); });
|
| 196 |
-
}
|
| 197 |
-
if (sort === "pa") results.sort((a, b) => (a.pn || 0) - (b.pn || 0));
|
| 198 |
-
else if (sort === "pd") results.sort((a, b) => (b.pn || 0) - (a.pn || 0));
|
| 199 |
-
else if (sort === "na") results.sort((a, b) => (a.n || "").localeCompare(b.n || "", "vi"));
|
| 200 |
-
return Response.json({ query: q, total: results.length, items: results.slice(0, limit), brands: [...new Set(products.map((p) => p.brand))].sort() });
|
| 201 |
-
},
|
| 202 |
-
},
|
| 203 |
-
|
| 204 |
-
"/api/products/rag": {
|
| 205 |
-
POST: async (req) => {
|
| 206 |
-
try {
|
| 207 |
-
const body = await req.json();
|
| 208 |
-
const query = (body.query || "").trim();
|
| 209 |
-
const topK = Math.min(10, Math.max(1, Number(body.topK ?? TOP_K) || TOP_K));
|
| 210 |
-
if (!query) return Response.json({ error: "Missing query" }, { status: 400 });
|
| 211 |
-
if (!HF_TOKEN) return Response.json({ error: "HF_TOKEN not configured" }, { status: 503 });
|
| 212 |
-
if (!ragReady) return Response.json({ error: "Product catalogue not loaded yet" }, { status: 503 });
|
| 213 |
-
const queryVec = await getEmbedding(query);
|
| 214 |
-
if (!queryVec) return Response.json({ error: "Embedding failed", fallback: true, items: products.slice(0, topK) });
|
| 215 |
-
const scored = products.map((p) => {
|
| 216 |
-
const pVec = p._embedding;
|
| 217 |
-
if (!pVec) return { ...p, score: 0 };
|
| 218 |
-
const nq = nVN(query);
|
| 219 |
-
const specText2 = (p.specs ? Object.entries(p.specs).map(([k, v]) => k + " " + v).join(" ") : "");
|
| 220 |
-
const featText2 = (p.feats || []).join(" ");
|
| 221 |
-
const haystack = nVN((p.n || "") + " " + (p.brand || "") + " " + (p.c || "") + " " + featText2 + " " + specText2);
|
| 222 |
-
let keywordScore = 0;
|
| 223 |
-
if (smartSearch(nq, haystack)) keywordScore = 0.5;
|
| 224 |
-
return { ...p, score: cosineSim(queryVec, pVec) + keywordScore };
|
| 225 |
-
});
|
| 226 |
-
scored.sort((a, b) => b.score - a.score);
|
| 227 |
-
return Response.json({ query, total: scored.length, items: scored.slice(0, topK), ragModel: RAG_MODEL });
|
| 228 |
-
} catch (e) { return Response.json({ error: "Search failed" }, { status: 500 }); }
|
| 229 |
-
},
|
| 230 |
-
},
|
| 231 |
-
|
| 232 |
-
"/api/products/embed-all": {
|
| 233 |
-
POST: async (req) => {
|
| 234 |
-
try {
|
| 235 |
-
const body = await req.json();
|
| 236 |
-
const action = body.action || "status";
|
| 237 |
-
if (action === "status") return Response.json({ ready: ragReady, totalProducts: products.length, embeddedCount: products.filter((p) => p._embedding).length });
|
| 238 |
-
if (action === "embed") {
|
| 239 |
-
if (!HF_TOKEN) return Response.json({ error: "HF_TOKEN required" }, { status: 400 });
|
| 240 |
-
let embedded = 0;
|
| 241 |
-
for (let i = 0; i < products.length; i++) {
|
| 242 |
-
if (products[i]._embedding) continue;
|
| 243 |
-
const vec = await getEmbedding(products[i].text || products[i].n || "");
|
| 244 |
-
if (vec) { products[i]._embedding = vec; embedded++; }
|
| 245 |
-
}
|
| 246 |
-
return Response.json({ embedded, total: products.length });
|
| 247 |
-
}
|
| 248 |
-
return Response.json({ error: "Unknown action" }, { status: 400 });
|
| 249 |
-
} catch (e) { return Response.json({ error: String(e) }, { status: 500 }); }
|
| 250 |
-
},
|
| 251 |
-
},
|
| 252 |
-
|
| 253 |
-
"/api/products/list": { GET: async (req) => { const url = new URL(req.url); const cat = url.searchParams.get("cat") || "all"; const brand = url.searchParams.get("brand") || "all"; const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") ?? "50") || 50)); let result = [...products]; if (cat && cat !== "all") result = result.filter((p) => p.c === cat || p.catSlug === cat); if (brand && brand !== "all") result = result.filter((p) => p.brand === brand); return Response.json({ total: result.length, items: result.slice(0, limit) }); } },
|
| 254 |
-
"/api/products/suggest": { GET: async (req) => { const url = new URL(req.url); const q = (url.searchParams.get("q") || "").trim(); if (!q || !ragReady) return Response.json({ suggestions: [] }); const nq = nVN(q); const matches = products.filter((p) => smartSearch(nq, nVN(p.n || "")) || smartSearch(nq, nVN((p.brand || "")))).slice(0, 10).map((p) => ({ name: p.n, brand: p.brand, slug: p.slug, price: p.p, cat: p.c, image: p.i, summary: p.sum })); return Response.json({ query: q, suggestions: matches }); } },
|
| 255 |
-
"/api/products/detail": { GET: async (req) => { const url = new URL(req.url); const slug = url.searchParams.get("slug") || ""; const product = products.find((p) => p.slug === slug); if (!product) return Response.json({ error: "Not found" }, { status: 404 }); return Response.json(product); } },
|
| 256 |
-
"/api/products/categories": { GET: async () => { const cats: Record<string, any> = {}; const brands: Record<string, number> = {}; products.forEach((p) => { if (!cats[p.catSlug]) cats[p.catSlug] = { n: p.c || p.catSlug, i: p.catIcon || "fa-box", c: 0 }; cats[p.catSlug].c++; brands[p.brand || "Other"] = (brands[p.brand || "Other"] || 0) + 1; }); return Response.json({ categories: Object.entries(cats).map(([s, c]) => ({ slug: s, ...c })), brands: Object.entries(brands).sort((a, b) => b[1] - a[1]) }); } },
|
| 257 |
-
|
| 258 |
-
"/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
|
| 259 |
-
"/api/news/hot": { GET: async () => { const items = await fetchAllNews(); return Response.json({ items: items.slice(0, 10) }); } },
|
| 260 |
-
"/api/news/more": { GET: async (req) => { const url = new URL(req.url); const offset = parseInt(url.searchParams.get("offset") || "0", 10); const limit = parseInt(url.searchParams.get("limit") || "10", 10); const all = await fetchAllNews(); const items = all.slice(offset, offset + limit); return Response.json({ items, offset: offset + items.length, hasMore: offset + items.length < all.length, total: all.length }); } },
|
| 261 |
-
"/api/news/filter": { GET: async (req) => { const url = new URL(req.url); const q = (url.searchParams.get("q") || "").trim().toLowerCase(); const limit = parseInt(url.searchParams.get("limit") || "10", 10); const all = await fetchAllNews(); const filtered = q ? all.filter((i) => i.title.toLowerCase().includes(q) || (i.description || "").toLowerCase().includes(q)) : all; return Response.json({ query: q, items: filtered.slice(0, limit) }); } },
|
| 262 |
-
"/api/topics/trending": { GET: async () => { const all = await fetchAllNews(); const keywords = ["AI", "công nghệ", "thể thao", "bóng đá", "kinh tế", "giáo dục", "thời sự", "sức khỏe", "đời sống", "chính trị"]; const topics = keywords.map((kw) => ({ slug: kw.toLowerCase().replace(/[^a-z0-9]/g, ""), label: kw, count: all.filter((i) => i.title.toLowerCase().includes(kw.toLowerCase())).length })).filter((t) => t.count > 0).sort((a, b) => b.count - a.count).slice(0, 8); return Response.json({ topics }); } },
|
| 263 |
-
|
| 264 |
-
"/api/summarize": {
|
| 265 |
-
POST: async (req) => {
|
| 266 |
-
try {
|
| 267 |
-
const body = await req.json();
|
| 268 |
-
const text = (body.text || "").trim();
|
| 269 |
-
if (!text) return Response.json({ error: "Missing text" }, { status: 400 });
|
| 270 |
-
if (!HF_TOKEN) return Response.json({ title: text.slice(0, 80), body: text.slice(0, 500), fallback: true });
|
| 271 |
-
const resp = await fetch("https://router.huggingface.co/v1/chat/completions", {
|
| 272 |
-
method: "POST", headers: { Authorization: `Bearer ${HF_TOKEN}`, "Content-Type": "application/json" },
|
| 273 |
-
body: JSON.stringify({ model: "Qwen/Qwen2.5-7B-Instruct", messages: [{ role: "system", content: "You are a helpful assistant. Summarize the following text in Vietnamese." }, { role: "user", content: `Summarize:\n${text}` }], max_tokens: 500, temperature: 0.7 }),
|
| 274 |
-
signal: AbortSignal.timeout(30000),
|
| 275 |
-
});
|
| 276 |
-
if (resp.ok) { const data = await resp.json(); const out = data?.choices?.[0]?.message?.content || ""; return Response.json({ title: text.slice(0, 80), body: out || text.slice(0, 500) }); }
|
| 277 |
-
return Response.json({ error: "Summarize failed" }, { status: 502 });
|
| 278 |
-
} catch (e) { return Response.json({ error: String(e) }, { status: 500 }); }
|
| 279 |
-
},
|
| 280 |
-
},
|
| 281 |
-
|
| 282 |
-
"/api/debug/avatars": { GET: async () => { const avatarsDir = join(process.cwd(), "public/avatars"); let files: string[] = []; try { const dir = await readdir(avatarsDir, { withFileTypes: true }); for (const entry of dir) if (entry.isFile()) files.push(entry.name); } catch {} return Response.json({ files, avatars: files.filter((f) => f.endsWith(".glb")) }); } },
|
| 283 |
-
|
| 284 |
-
"/api/avatars": { GET: async () => { const names = await listAvatars(); const avatars = await Promise.all(names.map(async (name) => ({ name, thumbnail: (await avatarHasThumbnail(name)) ? `/avatars/${name}.thumb.png` : null }))); return Response.json({ avatars }); } },
|
| 285 |
-
|
| 286 |
-
"/api/avatar-thumbnail": {
|
| 287 |
-
POST: async (req) => {
|
| 288 |
-
try {
|
| 289 |
-
const body = await req.json();
|
| 290 |
-
const avatar = (body.avatar || "").toString();
|
| 291 |
-
const dataUrl = (body.dataUrl || "").toString();
|
| 292 |
-
if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 });
|
| 293 |
-
const m = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/);
|
| 294 |
-
if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
|
| 295 |
-
let buf; try { buf = Buffer.from(m[2], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); }
|
| 296 |
-
if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
|
| 297 |
-
if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
|
| 298 |
-
try { await writeFile(`/tmp/${avatar}.thumb.png`, buf); } catch {}
|
| 299 |
-
return Response.json({ ok: true, url: `/avatars/${avatar}.thumb.png`, size: buf.length });
|
| 300 |
-
} catch (e) { return Response.json({ ok: false, error: "Internal server error" }, { status: 500 }); }
|
| 301 |
-
},
|
| 302 |
-
},
|
| 303 |
-
|
| 304 |
-
/* ── Vendor + worklet routes with fallback to src/vendor/ ── */
|
| 305 |
-
"/worklets/:name": async (req) => {
|
| 306 |
-
const p1 = join(process.cwd(), "public", "worklets", req.params.name);
|
| 307 |
-
const p2 = join(process.cwd(), "src", "vendor", req.params.name);
|
| 308 |
-
const f1 = Bun.file(p1), f2 = Bun.file(p2);
|
| 309 |
-
const [e1, e2] = await Promise.all([f1.exists(), f2.exists()]);
|
| 310 |
-
return new Response(e1 ? f1 : f2);
|
| 311 |
-
},
|
| 312 |
-
"/vendor/:name": async (req) => {
|
| 313 |
-
const p1 = join(process.cwd(), "public", "vendor", req.params.name);
|
| 314 |
-
const p2 = join(process.cwd(), "src", "vendor", req.params.name);
|
| 315 |
-
const f1 = Bun.file(p1), f2 = Bun.file(p2);
|
| 316 |
-
const [e1, e2] = await Promise.all([f1.exists(), f2.exists()]);
|
| 317 |
-
return new Response(e1 ? f1 : f2);
|
| 318 |
-
},
|
| 319 |
-
|
| 320 |
-
/* ─── app.js: concat app.js + app_part2.js at runtime ─── */
|
| 321 |
"/src/app.js": async () => {
|
| 322 |
-
const
|
| 323 |
-
const p2 = join(process.cwd(), "src", "app_part2.js");
|
| 324 |
-
const f1 = Bun.file(p1), f2 = Bun.file(p2);
|
| 325 |
-
const [e1, e2] = await Promise.all([f1.exists(), f2.exists()]);
|
| 326 |
-
if (!e1) return new Response("Not Found", { status: 404 });
|
| 327 |
-
let content = await f1.text();
|
| 328 |
-
if (e2) {
|
| 329 |
-
content += await f2.text();
|
| 330 |
-
content = content.replace(/try\s*\{\s*updateAvatarThumbPreview\s*\(\s*\)\s*;?\s*\}\s*catch\s*\(\s*e\s*\)\s*\{[^}]*\}\s*/g, "");
|
| 331 |
-
}
|
| 332 |
-
return new Response(content, { headers: { "Content-Type": "application/javascript" } });
|
| 333 |
-
},
|
| 334 |
-
|
| 335 |
-
/* ─── Other src files ─── */
|
| 336 |
-
"/src/:name": async (req) => {
|
| 337 |
-
const filePath = join(process.cwd(), "src", req.params.name);
|
| 338 |
const file = Bun.file(filePath);
|
| 339 |
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
| 340 |
return new Response(file);
|
| 341 |
-
},
|
| 342 |
-
},
|
| 343 |
-
|
| 344 |
-
async fetch(req) {
|
| 345 |
-
const url = new URL(req.url);
|
| 346 |
-
if (url.pathname.startsWith("/avatars/")) {
|
| 347 |
-
const tmpFile = Bun.file(join("/tmp", url.pathname.split("/").pop() || ""));
|
| 348 |
-
if (await tmpFile.exists()) return new Response(tmpFile);
|
| 349 |
-
}
|
| 350 |
-
const filePath = join(process.cwd(), "public", url.pathname);
|
| 351 |
-
const file = Bun.file(filePath);
|
| 352 |
-
if (await file.exists()) return new Response(file);
|
| 353 |
-
return new Response("Not Found", { status: 404 });
|
| 354 |
-
},
|
| 355 |
-
});
|
| 356 |
-
|
| 357 |
-
console.log(`[RAG+Avatar] server running on port ${PORT}`);
|
| 358 |
-
loadProducts();
|
|
|
|
| 1 |
+
/* ─── app.js: serve the full app.js directly (no concat needed) ─── */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
"/src/app.js": async () => {
|
| 3 |
+
const filePath = join(process.cwd(), "src", "app.js");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
const file = Bun.file(filePath);
|
| 5 |
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
| 6 |
return new Response(file);
|
| 7 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|