Spaces:
Running
Running
| /** | |
| * Gemma Avatar — realtime voice/text chat with 3D talking-head avatar + V.AI STUDIO. | |
| * Products: frontend loads DIRECTLY from dataset JSON URL (1 HTTP GET, no backend proxy). | |
| * Backend keeps /api/vaix/products for backward compat / debugging. | |
| * Fast, reliable, no WASM, no compiled deps. | |
| * | |
| * S2S upstream: victor-gemma-avatar.hf.space S2S backend (smolagents now gates access). | |
| * Text-only chat: /api/chat — uses victor's S2S backend in text-only mode. | |
| */ | |
| import { readFileSync, writeFileSync } from "fs"; | |
| // Read index.html directly as text to avoid Bun HTMLBundle object | |
| const index = readFileSync(new URL("./index.html", import.meta.url), "utf-8"); | |
| import { readdir } from "fs/promises"; | |
| import { join } from "path"; | |
| import { existsSync } from "fs"; | |
| import { synthesize as edgeSynthesize } from "./edge-tts.mjs"; | |
| import { parseBulkFile, parseTextContent, normalizeCatalogProduct, buildCatalogRecord, CATALOG_DATASET, CATALOG_FILE } from "./bulk-import.ts"; | |
| // ── Edge TTS memory cache: { key: audioBytes } — bound to prevent growth ── | |
| const TTS_CACHE = new Map<string, Uint8Array>(); | |
| const TTS_CACHE_MAX = 400; | |
| // Small bounded memo cache for the /api/img image proxy (URL -> {body,type}). | |
| const IMAGE_CACHE = new Map<string, { body: Buffer; type: string }>(); | |
| // v55: catalogue page-image cache (URL -> {body, fetchedAt}) — /api/catpage hits | |
| // HF resolve for every page flip; a bounded in-memory cache makes paging instant | |
| // after the first visit and removes ~10 upstream requests per page turn. | |
| const CATPAGE_CACHE = new Map<string, { body: Buffer; at: number }>(); | |
| const CATPAGE_CACHE_MAX = 160; // 3 catalogues × ~38-50 pages ≈ fits; LRU-evict oldest | |
| const CATPAGE_TTL_MS = 1000 * 60 * 60 * 6; // 6h — page images are static on HF | |
| const CATAI_INDEX_CACHE_KEY = "catai-products-index"; | |
| const PROD_IDX_CACHE: { body: Buffer | null; at: number } = { body: null, at: 0 }; | |
| const CATAI_INDEX_CACHE: { body: Buffer; at: number }[] = []; // tiny (1 slot) helper | |
| function catpageKey(cat: string, page: string) { return cat + "|" + page; } | |
| function ttsCacheKey(text: string, voice: string) { | |
| return voice + "|" + text.trim().slice(0, 600); | |
| } | |
| 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(); | |
| // ── Catalogue AI: per-SKU image overrides + name-based fallback map ── | |
| // Sourced from the official Malloca webshop (bizweb.dktcdn.net) which matches | |
| // SKUs by variants[].barcode. Products absent there use a curated rename/alias | |
| // table (new catalogue models sold under different names) or a direct image URL. | |
| const CATAI_IMGMAP_URL = | |
| "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/catai_imgmap.json"; | |
| let CATAI_IMGMAP: Record<string, string> | null = null; | |
| const CATAI_IMGMAP_FALLBACK: Record<string, string> = { | |
| "SILVERMS7750SI": "https://bizweb.dktcdn.net/100/567/847/products/ms7750si.jpg?v=1786590594533", | |
| "SILVERMS7743SI": "https://bizweb.dktcdn.net/100/567/847/products/ms7743si.jpg?v=1786590968300", | |
| "MHO3IN": "https://bizweb.dktcdn.net/thumb/grande/100/567/847/products/hinh-san-pham-bep-gas-tu-hong-ngoai-malloca-mh03in-34f3aac4-662a-4b26-a8cb-8527dc3a6327.png?v=1767088090180", | |
| }; | |
| async function loadCataiImgmap(): Promise<Record<string, string>> { | |
| if (CATAI_IMGMAP) return CATAI_IMGMAP; | |
| try { | |
| const resp = await fetch(CATAI_IMGMAP_URL, { headers: { "User-Agent": "Mozilla/5.0 (compatible; VAI-Avatar2/1.0)" }, signal: AbortSignal.timeout(15000) }); | |
| if (resp.ok) { const d = await resp.json(); if (d && typeof d === "object" && !Array.isArray(d)) { CATAI_IMGMAP = d as Record<string, string>; return CATAI_IMGMAP; } } | |
| } catch (_e) {} | |
| CATAI_IMGMAP = CATAI_IMGMAP_FALLBACK; | |
| return CATAI_IMGMAP; | |
| } | |
| 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(); | |
| VAIX_PRODUCTS = VAIX_PRODUCTS.slice(0, 1000); // Limit to 1000 for memory | |
| console.log("[VAIX] Loaded " + VAIX_PRODUCTS.length + " products (capped from " + data.length + ") in " + (Date.now() - startMs) + "ms"); | |
| } catch (err: any) { | |
| console.error("[VAIX] Error:", err.message); | |
| setTimeout(() => { VAIX_LOADING = false; VAIX_LOADED = false; loadVaixProducts(); }, 10_000); | |
| } finally { | |
| VAIX_LOADING = false; | |
| } | |
| } | |
| loadVaixProducts().catch(() => {}); | |
| // ── Catalogue AI index (Malloca 2026 + Imudex 2026 + new H2 2026) ── | |
| // Loaded lazily for server-side product search fallback (text-chat tools): | |
| // VAIX (products_with_slugs.json) lacks Imudex products, so queries like | |
| // "khóa điện tử" must fall back to catalogue_ai_index.json to find them. | |
| const CATAI_INDEX_URL = | |
| "https://huggingface.co/datasets/bep40/grob-products-updated" + | |
| "/resolve/main/catalogue_ai_index.json"; | |
| let CATAI_INDEX: any[] | null = null; | |
| let CATAI_INDEX_LOADING = false; | |
| async function loadCataiIndex(): Promise<any[]> { | |
| if (CATAI_INDEX) return CATAI_INDEX; | |
| if (CATAI_INDEX_LOADING) { | |
| for (let i = 0; i < 120; i++) { | |
| await new Promise(r => setTimeout(r, 500)); | |
| if (CATAI_INDEX) return CATAI_INDEX; | |
| } | |
| return []; | |
| } | |
| CATAI_INDEX_LOADING = true; | |
| try { | |
| const resp = await fetch(CATAI_INDEX_URL, { headers: { "User-Agent": "Mozilla/5.0 (compatible; VAI-Avatar2/1.0)" }, signal: AbortSignal.timeout(60000) }); | |
| if (!resp.ok) throw new Error("HTTP " + resp.status); | |
| const d = await resp.json(); | |
| CATAI_INDEX = Array.isArray(d?.products) ? d.products : (Array.isArray(d) ? d : []); | |
| console.log("[CATAI-INDEX] Loaded " + (CATAI_INDEX?.length ?? 0) + " products"); | |
| } catch (e: any) { | |
| console.error("[CATAI-INDEX] load error:", e?.message); | |
| CATAI_INDEX = []; | |
| } finally { | |
| CATAI_INDEX_LOADING = false; | |
| } | |
| return CATAI_INDEX || []; | |
| } | |
| async function serverSearchCatai(q: string, limit: number): Promise<any[]> { | |
| const products = await loadCataiIndex(); | |
| if (!products.length) return []; | |
| const qNorm = norm(q); | |
| const rawTerms = String(q || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").split(/\s+/).filter((t: string) => t.length > 1); | |
| const terms = qNorm.length > 1 ? [qNorm, ...rawTerms] : rawTerms; | |
| const scored: Array<{ p: any; s: number }> = []; | |
| for (const p of products) { | |
| const nm = norm(p.name || ""); | |
| const sk = norm(p.sku || ""); | |
| const br = norm(p.brand || ""); | |
| const sm = norm(p.summary || ""); | |
| const sp = norm(Object.keys(p.specs || {}).map(k => k + " " + (p.specs?.[k] || "")).join(" ")); | |
| let s = 0; | |
| if (nm.includes(qNorm) || sk.includes(qNorm)) s += 30; | |
| for (const t of terms) { | |
| if (nm.includes(t)) s += 10; | |
| if (sk.includes(t)) s += 20; | |
| if (br.includes(t)) s += 3; | |
| if (sm.includes(t)) s += 6; | |
| if (sp.includes(t)) s += 8; | |
| } | |
| if (s > 0) scored.push({ p, s }); | |
| } | |
| scored.sort((a, b) => b.s - a.s); | |
| const srcLabel: Record<string, string> = { | |
| "catalogue-malloca-2026": "Malloca 2026", | |
| "imudex-2026": "Imudex 2026", | |
| "new-products-h2-2026": "Mới H2 2026", | |
| }; | |
| return scored.slice(0, limit).map(x => { | |
| const p = x.p; | |
| return { | |
| name: p.name || "", | |
| title_clean: p.name || "", | |
| brand: p.brand || "Imudex", | |
| price: p.price || "", | |
| priceNum: Number(String(p.price || "").replace(/[^0-9]/g, "")) || 0, | |
| category: srcLabel[p.source] || p.source || "", | |
| category_slug: p.source || "", | |
| category_icon: "fa-box", | |
| sku: p.sku || "", | |
| model: p.sku || "", | |
| slug: "", | |
| description: p.summary || "", | |
| summary: p.summary || "", | |
| features: [], | |
| specs: p.specs || {}, | |
| video: "", | |
| image: p.image || "", | |
| images: p.image ? [p.image] : [], | |
| link: "", | |
| _idx: 0, | |
| _catai: true, | |
| }; | |
| }); | |
| } | |
| setTimeout(() => { if (!VAIX_LOADED) loadVaixProducts().catch(() => {}); }, 3000); | |
| // ── Promo overlay store (product edits + BIGSALE + COMBO) ── | |
| // Durable truth lives in the dataset bep40/vaistudio-data/promos.json (never the | |
| // Space repo, so saving never triggers a rebuild). Shape: | |
| // { productEdits: {<sku>:{name,description,priceNum,image,images[],specs{},features[],deleted}}, | |
| // bigsale: [ {code,name,category,price,bigsale,discount,status,qty,image} ], | |
| // combo: [ {code,name,price,items:[{name,sku,price,image}]} ] } | |
| const PROMOS_DATASET = "bep40/vaistudio-data"; | |
| const PROMOS_FILE = "promos.json"; | |
| const PROMOS_MEMO: { body: any; at: number } = { body: null, at: 0 }; | |
| async function readPromos(token?: string): Promise<any> { | |
| try { | |
| const h = token ? { Authorization: "Bearer " + token } : {}; | |
| const r = await fetch("https://huggingface.co/datasets/" + PROMOS_DATASET + "/resolve/main/" + PROMOS_FILE, { headers: h, signal: AbortSignal.timeout(6000) }); | |
| if (r.ok) { const d = await r.json(); if (d && typeof d === "object") return d; } | |
| } catch (e: any) { /* ignore */ } | |
| return { productEdits: {}, bigsale: [], combo: [] }; | |
| } | |
| async function writePromos(token: string, data: any): Promise<void> { | |
| const content = JSON.stringify(data); | |
| const payload = [ | |
| { key: "header", value: { summary: "Update promos via api", repo: { type: "dataset", id: PROMOS_DATASET } } }, | |
| { key: "file", value: { path: PROMOS_FILE, content } }, | |
| ].map((x) => JSON.stringify(x)).join("\n"); | |
| const commit = await fetch("https://huggingface.co/api/datasets/" + PROMOS_DATASET + "/commit/main", { | |
| method: "POST", | |
| headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: payload, | |
| }); | |
| if (!commit.ok) throw new Error("Commit failed: HTTP " + commit.status + " " + (await commit.text().catch(() => "")).slice(0, 300)); | |
| } | |
| function norm(s: string): string { | |
| return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); | |
| } | |
| /* ── small HTML helpers for the product-URL parser ── */ | |
| function stripTags(h: string): string { | |
| return String(h || "").replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "); | |
| } | |
| function decodeHtmlEnt(s: string): string { | |
| return String(s || "").replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/g, "'").replace(/–/gi, "–").replace(/—/gi, "—"); | |
| } | |
| // Parse `<tr><td>label</td><td>value</td></tr>` tables (and td/td 2-col rows) into an | |
| // ordered label→value map. Works for thienkimhome.com (Thông số kỹ thuật) and | |
| // many other Vietnamese e-commerce CMSs. Label must be short; value may be long. | |
| function parseTrTable(html: string): Record<string, string> { | |
| const out: Record<string, string> = {}; | |
| const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi; | |
| let tm: RegExpExecArray | null; | |
| while ((tm = trRe.exec(html))) { | |
| const tds = (tm[1].match(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi) || []); | |
| if (tds.length < 2) continue; | |
| const cells = tds.map((td: string) => decodeHtmlEnt(stripTags(td)).replace(/\s+/g, " ").trim()); | |
| // two-column row: [label, value] | |
| if (cells.length === 2 && cells[0] && cells[1] && cells[0].length <= 80 && cells[1].length >= 1) { | |
| if (out[cells[0]] === undefined) out[cells[0]] = cells[1]; | |
| continue; | |
| } | |
| // three-column "label value unit" rows (some CMSs) | |
| if (cells.length === 3 && cells[0] && cells[1] && cells[0].length <= 80) { | |
| const v = (cells[1] + " " + (cells[2] || "")).trim(); | |
| if (v && out[cells[0]] === undefined) out[cells[0]] = v; | |
| } | |
| } | |
| return out; | |
| } | |
| // Extract the full content of a <div class="..."> block, balancing nested | |
| // <div> tags so the slice ends at the block's real closing tag. | |
| function extractDivBlock(html: string, classTokens: RegExp, maxLen = 60000): string { | |
| const re = new RegExp("<div[^>]*class=[\"'][^\"']*?(?<![\\w-])" + classTokens.source + "(?=[\\s\"']|$)" + "[^\"']*[\"'][^>]*>", "i"); | |
| const m0 = html.match(re); | |
| if (!m0 || typeof m0.index !== "number") return ""; | |
| const start = m0.index; | |
| let i = start + m0[0].length; | |
| let depth = 1; | |
| const limit = Math.min(html.length, start + maxLen); | |
| const opens: number[] = []; | |
| const closes: number[] = []; | |
| const openRe = /<div[\s>]/g; | |
| const closeRe = /<\/div\s*>/g; | |
| openRe.lastIndex = i; | |
| let o: RegExpExecArray | null; | |
| while ((o = openRe.exec(html)) && o.index < limit) opens.push(o.index); | |
| closeRe.lastIndex = i; | |
| let c: RegExpExecArray | null; | |
| while ((c = closeRe.exec(html)) && c.index < limit) closes.push(c.index); | |
| let oi = 0, ci = 0; | |
| while (oi < opens.length || ci < closes.length) { | |
| const po = oi < opens.length ? opens[oi] : Infinity; | |
| const pc = ci < closes.length ? closes[ci] : Infinity; | |
| if (pc < po) { | |
| depth--; | |
| if (depth === 0) { i = pc; break; } | |
| ci++; | |
| } else { depth++; oi++; } | |
| } | |
| if (depth !== 0) i = limit; | |
| return html.slice(start, i); | |
| } | |
| // Find the section that contains 'needle' (Vietnamese headings) and call fn with it. | |
| function findSection(html: string, needles: RegExp, maxLen: number): string { | |
| const m = html.match(needles); | |
| if (!m || typeof m.index !== "number") return ""; | |
| return html.slice(m.index, m.index + maxLen); | |
| } | |
| 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 getMimeType(name: string): string { | |
| const ext = name.split(".").pop()?.toLowerCase() ?? ""; | |
| const types: Record<string, string> = { | |
| "js": "application/javascript", | |
| "mjs": "application/javascript", | |
| "cjs": "application/javascript", | |
| "css": "text/css", | |
| "html": "text/html", | |
| "json": "application/json", | |
| "png": "image/png", | |
| "jpg": "image/jpeg", | |
| "gif": "image/gif", | |
| "svg": "image/svg+xml", | |
| "webp": "image/webp", | |
| "ico": "image/x-icon", | |
| "woff2": "font/woff2", | |
| "woff": "font/woff", | |
| "ttf": "font/ttf", | |
| "bin": "application/octet-stream", | |
| "mjs": "application/javascript", | |
| }; | |
| return types[ext] ?? "application/octet-stream"; | |
| } | |
| function staticFile(dir: string, name: string) { | |
| const path = import.meta.dir + "/public/" + dir + "/" + name; | |
| const mime = getMimeType(name); | |
| // no-cache: always revalidate so a deploy is picked up immediately (this Space | |
| // is edited/deployed frequently). CDN/browser won't serve stale JS/CSS. | |
| return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } }); | |
| } | |
| function srcFile(dir: string, name: string) { | |
| const path = import.meta.dir + "/" + dir + "/" + name; | |
| const mime = getMimeType(name); | |
| return new Response(Bun.file(path), { headers: { "Content-Type": mime, "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } }); | |
| } | |
| 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"); | |
| function esc(s: string): string { | |
| return (s || "") | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, ''') | |
| .replace(/\n/g, " ") | |
| .replace(/\r/g, " ") | |
| .trim() | |
| .slice(0, 350); | |
| } | |
| const fallbackImg = "https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/thumbnail.webp"; | |
| if (productSlug && VAIX_PRODUCTS?.length) { | |
| const foundProduct = VAIX_PRODUCTS.find((p: any) => { | |
| const slug = p.slug || p._source_alias || ""; | |
| const normSlug = slug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); | |
| const normTarget = productSlug.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "d"); | |
| return normSlug === normTarget || normSlug.includes(normTarget) || normTarget.includes(normSlug); | |
| }); | |
| if (foundProduct) { | |
| const fp = formatProduct(foundProduct, foundProduct._idx ?? 0); | |
| const fpName = esc(fp.name || fp.model || "Sản phẩm"); | |
| const fpBrand = esc(fp.brand || "V.AI STUDIO"); | |
| // ── Image URL: use escURL (not esc) because we need proper URL encoding for og:image ── | |
| const fpImageRaw = fp.image || (fp.images && fp.images[0]) || ""; | |
| const fpImg = fpImageRaw ? escURL(fpImageRaw) : fallbackImg; | |
| // ── Description: 600 chars to fit full summary + specs ── | |
| const fpDesc = esc((fp.summary || fp.description || "").replace(/<[^>]+>/g, "").trim().slice(0, 600)); | |
| // ── Specs: show all specs up to 12 keys ── | |
| const fpSpecs = fp.specs || {}; | |
| let specsStr = ""; | |
| const specKeys = Object.keys(fpSpecs).slice(0, 12); | |
| for (const sk of specKeys) { | |
| specsStr += (specsStr ? ", " : " | ") + esc(sk) + ": " + esc(String(fpSpecs[sk])); | |
| } | |
| const ogDescription = fpDesc + (specsStr ? " | " + specsStr.trim() : "") || "V.AI STUDIO - 8000+ sản phẩm gia dụng cao cấp"; | |
| // ── Price: formatted VND string for og:title ── | |
| const priceNum = fp.priceNum || Number(fp.pn) || 0; | |
| const ogPrice = priceNum > 0 ? " - " + Number(priceNum).toLocaleString("vi-VN") + "₫" : ""; | |
| const ogTitle = fpName + " | " + fpBrand + ogPrice + " - V.AI STUDIO"; | |
| const ogTags = buildOGTags(ogTitle, ogDescription, fpImg, url.href, true, priceNum); | |
| return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, | |
| }); | |
| } | |
| } | |
| // ── BIG SALE deep-link SEO (?bigsale=<code>) ── shows the discounted price. | |
| const bigsaleCodeRaw = url.searchParams.get("bigsale"); | |
| if (bigsaleCodeRaw && !productSlug) { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| const promos = await readPromos(token ? token : undefined); | |
| const normBs = (s: string) => String(s || "").trim().toUpperCase().replace(/\s+/g, " "); | |
| const target = normBs(decodeURIComponent(bigsaleCodeRaw)); | |
| const bsProd = (Array.isArray(promos.bigsale) ? promos.bigsale : []).find((b: any) => normBs(b.code) === target); | |
| if (bsProd) { | |
| const bsName = esc(bsProd.name || bsProd.code || "Sản phẩm BIG SALE"); | |
| const bsPrice = Number(String(bsProd.bigsale || "").replace(/\./g, "")) || 0; | |
| let bsImgRaw = bsProd.image || ""; | |
| if (!bsImgRaw) { | |
| const codeNorm = String(bsProd.code || "").trim().toUpperCase().replace(/\s+/g, " "); | |
| const codeNoSep = codeNorm.replace(/[^A-Z0-9]+/g, ""); | |
| const hayCandidates = [codeNorm, codeNoSep]; | |
| // 1) allProducts (catalog cached for og lookups) | |
| if (typeof allProducts !== "undefined" && Array.isArray(allProducts)) { | |
| for (let idx = 0; idx < allProducts.length && !bsImgRaw; idx++) { | |
| const x: any = allProducts[idx]; | |
| const sku = String(x.sku || x.model || x.slug || "").trim().toUpperCase().replace(/\s+/g, " "); | |
| const skuNoSep = sku.replace(/[^A-Z0-9]+/g, ""); | |
| const hay = String(x.title_clean || x.name || "").toUpperCase().replace(/\s+/g, " "); | |
| if (hayCandidates.indexOf(sku) >= 0 || hayCandidates.indexOf(skuNoSep) >= 0) { if (x.image) bsImgRaw = x.image; break; } | |
| } | |
| } | |
| } | |
| // 2) VAIX_PRODUCTS fallback (server catalog, capped at 1000) | |
| if (!bsImgRaw && Array.isArray(VAIX_PRODUCTS)) { | |
| const codeNorm2 = String(bsProd.code || "").trim().toUpperCase().replace(/\s+/g, " "); | |
| for (let vx = 0; vx < VAIX_PRODUCTS.length && !bsImgRaw; vx++) { | |
| const v: any = VAIX_PRODUCTS[vx]; | |
| const vs = String(v.sku || v.model || v.slug || "").trim().toUpperCase().replace(/\s+/g, " "); | |
| if (vs === codeNorm2 || vs.replace(/[^A-Z0-9]+/g, "") === codeNorm2.replace(/[^A-Z0-9]+/g, "")) { | |
| if (v.image || (v.images && v.images[0])) bsImgRaw = v.image || v.images[0]; | |
| } | |
| } | |
| } | |
| const bsImg = bsImgRaw ? escURL(bsImgRaw) : fallbackImg; | |
| const ogTitle = "🔥 BIG SALE: " + bsName + (bsPrice > 0 ? " - " + Number(bsPrice).toLocaleString("vi-VN") + "₫" : "") + " | V.AI STUDIO"; | |
| const ogDesc = (bsPrice > 0 ? "Giá BIG SALE " + bsName + " chỉ còn " + Number(bsPrice).toLocaleString("vi-VN") + "₫" : "Chương trình BIG SALE V.AI STUDIO") + " | Giảm " + esc("-" + (bsProd.discount || "0%")); | |
| const ogTags = buildOGTags(ogTitle, ogDesc, bsImg, url.href, true, bsPrice); | |
| return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, | |
| }); | |
| } | |
| } catch (e: any) { /* fall through to default og */ } | |
| } | |
| // Fallback OG tags — always set for any request (no product match or no ?product param) | |
| const fallbackTitle = "Gemma Avatar + V.AI STUDIO - Chat & Voice AI"; | |
| const fallbackDesc = "Trò chuyện voice/text với Gemma 4. Khám phá 8000+ sản phẩm gia dụng."; | |
| const ogTags = buildOGTags(fallbackTitle, fallbackDesc, fallbackImg, url.href, false); | |
| return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, | |
| }); | |
| } | |
| function buildOGTags(title: string, desc: string, image: string, href: string, isProduct: boolean, price = 0): string { | |
| return [ | |
| '<meta property="og:title" content="' + title + '" />', | |
| '<meta property="og:description" content="' + desc + '" />', | |
| '<meta property="og:url" content="' + escURL(href) + '" />', | |
| '<meta property="og:type" content="' + (isProduct ? 'product' : 'website') + '" />', | |
| '<meta property="og:image" content="' + image + '" />', | |
| '<meta property="og:image:secure_url" content="' + image + '" />', | |
| '<meta property="og:image:type" content="image/jpeg" />', | |
| '<meta property="og:image:width" content="1200" />', | |
| '<meta property="og:image:height" content="630" />', | |
| isProduct ? '<meta property="product:price:amount" content="' + price + '" />' : '', | |
| isProduct ? '<meta property="product:price:currency" content="VND" />' : '', | |
| isProduct ? '<meta property="product:availability" content="in stock" />' : '', | |
| isProduct ? '<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="' + title + '" />', | |
| '<meta name="twitter:description" content="' + desc + '" />', | |
| '<meta name="twitter:image" content="' + image + '" />', | |
| '<meta name="description" content="' + desc + '" />', | |
| ].filter(Boolean).join("\n "); | |
| } | |
| function escURL(s: string): string { | |
| // Only escape what's needed for HTML attribute — URL-safe | |
| return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| function serveStatic(req: Request): Response { | |
| const url = new URL(req.url); | |
| const fullPath = join("/app", url.pathname.slice(1)); | |
| if (!existsSync(fullPath)) return new Response("Not Found", { status: 404 }); | |
| // v51: force no-cache on EVERY static file (incl. /index.html) — the HF CDN | |
| // was caching index.html (zero cache headers = proxy heuristics), so clients | |
| // kept loading OLD script references -> "still broken" after fixes. | |
| return new Response(Bun.file(fullPath), { headers: { "Content-Type": getMimeType(url.pathname.split("/").pop() || ""), "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" } }); | |
| } | |
| 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 ───────────────────────────────────────────── | |
| // Chat + tư vấn nhanh dùng Google Gemma qua HF Inference Providers (router). | |
| // Streaming (SSE) + cascade gemma-4-31B -> gemma-3-12B -> gemma-3-4B for resilience. | |
| const CHAIN: Array<{ model: string }> = [ | |
| { model: "google/gemma-4-31B-it" }, | |
| { model: "google/gemma-3-12b-it" }, | |
| { model: "google/gemma-3-4b-it" }, | |
| ]; | |
| const ROUTER = "https://router.huggingface.co/v1/chat/completions"; | |
| const CHAT_TIMEOUT_MS = 45000; | |
| // Helper to present one model. Returns { reply, model } or throws. | |
| async function chatOnce(model: string, messages: any[], token: string) { | |
| const body = { | |
| model, | |
| messages, | |
| max_tokens: 700, | |
| temperature: 0.7, | |
| top_p: 0.9, | |
| stream: false, | |
| }; | |
| const resp = await fetch(ROUTER, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Authorization": `Bearer ${token}`, | |
| "User-Agent": "gemma-avatar", | |
| }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(CHAT_TIMEOUT_MS), | |
| }); | |
| const text = await resp.text().catch(() => ""); | |
| if (!resp.ok) { | |
| const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200)); | |
| err.status = resp.status; | |
| throw err; | |
| } | |
| try { | |
| const data = JSON.parse(text); | |
| const raw = data?.choices?.[0]?.message?.content; | |
| if (raw && String(raw).trim()) return { reply: String(raw).trim(), model }; | |
| } catch (_) {} | |
| const err: any = new Error("Empty response from " + model); | |
| err.status = 500; | |
| throw err; | |
| } | |
| // ── AI-assisted extraction from OCR text (PDF/ảnh đã OCR) ────────────────── | |
| // The deterministic line parser (`lineRowsToProducts`) struggles with ragged | |
| // tesseract OCR output. When the user uploads a PDF/ảnh and the OCR text is | |
| // noisy, send the raw OCR text to Gemma (same router as chat) and ask it to | |
| // return CLEAN structured products as JSON. Deterministic parse stays the | |
| // cheap/fast path for well-formed input; AI is the accuracy fallback. | |
| async function aiExtractProductsFromText(ocrText: string, opts: any = {}): Promise<any> { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return { ok: false, reason: "no HF token" }; | |
| const text = String(ocrText || "").slice(0, 24000); | |
| if (!text.trim()) return { ok: false, reason: "empty text" }; | |
| const model = opts.aiTextModel || "google/gemma-3-12b-it"; | |
| const sys = [ | |
| "Bạn là bộ phận trích xuất sản phẩm từ văn bản OCR (quét catalogue giá thị trường hoặc PDF thiết bị nhà bếp bằng tiếng Việt).", | |
| "OCR thường bị lẫn dòng, sai chỗ ngắt cột, dính chữ. Dựa vào NGỮ NGHĨA để bóc tách đúng từng sản phẩm.", | |
| "Mỗi dòng/sụm dòng là MỘT sản phẩm. Tách: name (tên SP đầy đủ), sku/mã (nếu có, VD 'EH-90','GL-400','357869'), brand (thương hiệu: Heatlock, Hafele, Eurogold, Malloca, Bosch, Malloca...), price (giá số nguyên, bỏ dấu chấm phẩy, VD 6290000 cho '6.290.000đ'; nếu '5tr5' -> 5500000), description (mô tả ngắn).", | |
| "BỎ các dòng không phải sản phẩm: 'TỔNG CỘNG', 'Trang 2', 'STT', 'Cộng tiền', 'Đơn vị tính', tiêu đề cột, dòng chỉ chứa số thứ tự.", | |
| "Trả về DUY NHẤT một JSON array, không markdown, không text thừa. Mỗi phần tử object chỉ dùng các key: name, sku, brand, price, description.", | |
| "Nếu không trích được sản phẩm nào, trả về [].", | |
| ]; | |
| const body = { | |
| model, | |
| messages: [ | |
| { role: "system", content: sys.join("\n") }, | |
| { role: "user", content: "Văn bản OCR catalogue:\n---\n" + text + "\n---\nHãy trả về JSON array các sản phẩm." }, | |
| ], | |
| max_tokens: 2048, | |
| temperature: 0, | |
| top_p: 0.95, | |
| stream: false, | |
| }; | |
| let resp: Response; | |
| try { | |
| resp = await fetch(ROUTER, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token, "User-Agent": "vai-avatar2-extract" }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(60000), | |
| }); | |
| } catch (e: any) { return { ok: false, reason: String(e?.message || e) }; } | |
| if (!resp.ok) { | |
| const t = await resp.text().catch(() => ""); | |
| return { ok: false, reason: "HTTP " + resp.status + " " + t.slice(0, 120), status: resp.status }; | |
| } | |
| let content = ""; | |
| try { const d = await resp.json(); content = String(d?.choices?.[0]?.message?.content || "").trim(); } | |
| catch (e: any) { return { ok: false, reason: "Bad AI response JSON" }; } | |
| let arr: any = null; | |
| try { arr = JSON.parse(content); } | |
| catch (e1: any) { | |
| const m = content.match(/\[[\s\S]*\]/); | |
| if (m) { try { arr = JSON.parse(m[0]); } catch (e2: any) {} } | |
| } | |
| if (!Array.isArray(arr)) return { ok: false, reason: "AI returned non-array JSON" }; | |
| const cleaned = arr | |
| .filter((p: any) => p && typeof p === "object" && (p.name || p.sku)) | |
| .map((p: any) => { | |
| const price = Number(String(p.price ?? "").replace(/[^\d]/g, "")); | |
| return { | |
| name: String(p.name || "").trim(), | |
| sku: String(p.sku || p.model || p.ma || "").trim(), | |
| brand: String(p.brand || "").trim(), | |
| price: price || undefined, | |
| description: String(p.description || p.desc || "").trim(), | |
| features: (Array.isArray(p.features) ? p.features : (p.features ? [p.features] : [])).map(String).map(s => s.trim()).filter(Boolean), | |
| }; | |
| }) | |
| .filter((p: any) => p.name || p.sku); | |
| if (!cleaned.length) return { ok: false, reason: "AI extracted 0 products" }; | |
| return { ok: true, products: cleaned, model }; | |
| } | |
| // ── Server-side tool execution (restores news/web/product search in /api/chat) ── | |
| const CHAT_TOOLS = [ | |
| { type: "function", function: { | |
| name: "search_news", | |
| description: "Tìm tin tức tiếng Việt mới nhất về một chủ đề. Dùng khi người dùng hỏi về tin tức, sự kiện, chương trình khuyến mãi, chiến dịch, hoặc thông tin thời sự.", | |
| parameters: { type: "object", properties: { query: { type: "string", description: "Chủ đề / từ khóa tin tức" } }, required: ["query"] }, | |
| }}, | |
| { type: "function", function: { | |
| name: "search_web", | |
| description: "Tìm kiếm thông tin trên web cho một câu hỏi hoặc chủ đề bất kỳ.", | |
| parameters: { type: "object", properties: { query: { type: "string", description: "Câu hỏi hoặc từ khóa tìm kiếm" } }, required: ["query"] }, | |
| }}, | |
| { type: "function", function: { | |
| name: "query_catalog", | |
| description: "Tìm kiếm sản phẩm trong catalog của V.AI STUDIO (thiết bị nhà bếp, phụ kiện tủ bếp, khóa cửa...). Dùng khi người dùng hỏi về sản phẩm, mua hàng, giá, model/mã sản phẩm. Trả về các sản phẩm khớp với tên/giá/thương hiệu. LUÔN dùng tool này trước khi trả lời câu hỏi về sản phẩm để tránh bịa đặt.", | |
| parameters: { type: "object", properties: { query: { type: "string", description: "Từ khóa tìm kiếm sản phẩm (tên, mã, model, thương hiệu...)" } }, required: ["query"] }, | |
| }}, | |
| { type: "function", function: { | |
| name: "show_product", | |
| description: "Mở chi tiết một sản phẩm cụ thể trong catalog V.AI STUDIO theo tên hoặc mã. Dùng khi người dùng muốn xem thông số, hình ảnh, giá của đúng 1 sản phẩm đã nêu tên.", | |
| parameters: { type: "object", properties: { product_name: { type: "string", description: "Tên hoặc mã sản phẩm" } }, required: ["product_name"] }, | |
| }}, | |
| { type: "function", function: { | |
| name: "combo_suggest", | |
| description: "Gợi ý COMBO 2-4 thiết bị nhà bếp / phụ kiện tủ bếp theo yêu cầu (bếp từ, máy hút mùi, chậu rửa, vòi rửa, kệ, khóa...). Dùng khi người dùng muốn combo/bộ/gói. Trả về các sản phẩm khớp; tổng giá là tổng của tất cả món.", | |
| parameters: { type: "object", properties: { categories: { type: "array", items: { type: "string" }, description: "Các hạng mục mong muốn, ví dụ ['bếp từ','máy hút mùi']" }, brand: { type: "string", description: "Thương hiệu ưu tiên" }, minPrice: { type: "number", description: "Tổng giá tối thiểu" }, maxPrice: { type: "number", description: "Tổng giá tối đa (TOÀN BỘ combo)" }, material: { type: "string", description: "Chất liệu yêu cầu (ví dụ: inox, kính, nan oval, nhôm, gốm...) — nằm trong chi tiết sản phẩm" }, color: { type: "string", description: "Màu sắc yêu cầu (ví dụ: đen, trắng, bạc, vàng gold...)" }, feature: { type: "string", description: "Tính năng yêu cầu (ví dụ: cảm ứng, remote, tự làm sạch, khử mùi, hẹn giờ, inverter...)" }, sizes: { type: "object", description: "Kích thước yêu cầu theo hạng mục, ví dụ {\"máy hút mùi\": [900], \"chậu rửa\": [700]}" } }, required: [] }, | |
| }}, | |
| ]; | |
| const sleepMs = (ms: number) => new Promise(r => setTimeout(r, ms)); | |
| async function ddgSearch(query: string) { | |
| const results: Array<{ title: string; snippet: string; url: string; source: string }> = []; | |
| const queries = [ | |
| query, | |
| query + " hôm nay", | |
| (query.length < 30 ? "tin tức " + query : query), | |
| ]; | |
| await Promise.all(queries.map(q => | |
| fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) }) | |
| .then(r => r.text()) | |
| .then(h => { | |
| const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; | |
| const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; | |
| for (let i = 0; i < Math.min(lm.length, 5); i++) { | |
| let href = lm[i][1].replace(/&/g, "&"); | |
| const ru = href.match(/uddg=(https?%3[^&]+)/i); | |
| if (ru) { try { href = decodeURIComponent(ru[1]); } catch (_) {} } | |
| const title = dec(lm[i][2].replace(/<[^>]+>/g, "")).trim(); | |
| if (!title) continue; | |
| const snippet = sm[i] ? dec(sm[i][1].replace(/<[^>]+>/g, "")).trim() : ""; | |
| let source = ""; | |
| try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {} | |
| results.push({ title, snippet, url: href, source }); | |
| } | |
| }) | |
| .catch(() => {}) | |
| )); | |
| const seen = new Set<string>(); | |
| const unique = results.filter(r => { | |
| if (!r.url || seen.has(r.url)) return false; | |
| seen.add(r.url); | |
| return r.source && !/duckduckgo|google/i.test(r.source); | |
| }); | |
| return unique.slice(0, 8); | |
| } | |
| // Reliable Vietnamese news search via Google News RSS (proven reachable from | |
| // HF Spaces — the duckduckgo html endpoint is NOT reliably reachable). | |
| async function searchNewsGoogle(query: string) { | |
| try { | |
| const resp = await fetch( | |
| "https://news.google.com/rss/search?q=" + encodeURIComponent(query) + | |
| "&hl=vi-VN&gl=VN&ceid=VN:vi", | |
| { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" }, signal: AbortSignal.timeout(15000) } | |
| ); | |
| if (!resp.ok) throw new Error("RSS failed " + resp.status); | |
| const rssText = await resp.text(); | |
| const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; | |
| const articles: Array<{ title: string; url: string; source: string; desc: string }> = []; | |
| for (const block of itemBlocks) { | |
| const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); | |
| const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); | |
| const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i); | |
| const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); | |
| if (!tm) continue; | |
| let title = tm[1].replace(/\+|_/g, " ").trim(); | |
| let source = ""; | |
| if (sm) { | |
| source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim(); | |
| if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim(); | |
| } | |
| if (!title) continue; | |
| let url = ""; | |
| if (lm) url = (lm[1] || "").trim(); | |
| let desc = ""; | |
| if (dm) { | |
| desc = dm[1].replace(/<[^>]*>/g, " ").replace(/\bhttps?:\/\/\S+/gi, "") | |
| .replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'") | |
| .replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ") | |
| .replace(/\s+/g, " ").trim().slice(0, 160); | |
| } | |
| if (title && url) articles.push({ title, url, source: source || "", desc }); | |
| if (articles.length >= 8) break; | |
| } | |
| return articles; | |
| } catch (e) { | |
| return []; | |
| } | |
| } | |
| async function executeChatTool(name: string, args: any): Promise<string> { | |
| const clean = (s: any) => String(s || "").trim(); | |
| try { | |
| if (name === "search_news") { | |
| const q = clean(args?.query); | |
| if (!q) return "Thiếu từ khóa tìm kiếm tin tức."; | |
| const arts = await searchNewsGoogle(q); | |
| if (!arts.length) return "Không tìm thấy tin tức cho '" + q + "'."; | |
| return arts.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.desc ? "\n " + r.desc : "") + "\n " + r.url).join("\n"); | |
| } | |
| if (name === "search_web") { | |
| const q = clean(args?.query); | |
| if (!q) return "Thiếu từ khóa tìm kiếm."; | |
| const res = await ddgSearch(q); | |
| if (!res.length) return "Không tìm thấy kết quả cho '" + q + "'."; | |
| return res.map(r => "• " + r.title + (r.source ? " (" + r.source + ")" : "") + (r.snippet ? "\n " + r.snippet : "") + "\n " + r.url).join("\n"); | |
| } | |
| if (name === "query_catalog") { | |
| await loadVaixProducts(); | |
| const q = clean(args?.query); | |
| if (!q) return "Thiếu từ khóa tìm kiếm sản phẩm."; | |
| const results = await serverSearchProducts(q, 6); | |
| if (!results.length) return "Không tìm thấy sản phẩm nào trong catalog V.AI STUDIO cho '" + q + "'. Hãy thử từ khóa khác (tên/brand/mã)."; | |
| return formatProductLines(results).join("\n"); | |
| } | |
| if (name === "show_product") { | |
| await loadVaixProducts(); | |
| const q = clean(args?.product_name); | |
| if (!q) return "Thiếu tên sản phẩm."; | |
| const results = await serverSearchProducts(q, 3); | |
| if (!results.length) return "Không tìm thấy sản phẩm '" + q + "' trong catalog."; | |
| const p = results[0]; | |
| const specs = (p && p.specs && typeof p.specs === "object") ? Object.entries(p.specs).slice(0, 8).map(([k, v]) => k + ": " + String(v)).join(", ") : (p.summary || ""); | |
| return [ | |
| (p.title_clean || p.name || "") + (p.brand ? " (" + p.brand + ")" : "") + " — mã " + (p.model || p.sku || "?"), | |
| "Giá: " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ"), | |
| specs ? ("Thông tin: " + specs) : "", | |
| p.link ? ("Link: " + p.link) : "", | |
| ].filter(Boolean).join("\n"); | |
| } | |
| if (name === "combo_suggest") { | |
| await loadVaixProducts(); | |
| const categories: string[] = Array.isArray(args?.categories) ? args.categories.map((c: any) => String(c)) : []; | |
| if (!categories.length) return "Thiếu danh sách hạng mục combo (categories). Ví dụ ['bếp từ','máy hút mùi']."; | |
| const brand = clean(args?.brand); | |
| const style: any = {}; | |
| if (clean(args?.material)) style.material = clean(args?.material); | |
| if (clean(args?.color)) style.color = clean(args?.color); | |
| if (clean(args?.feature)) style.feature = clean(args?.feature); | |
| const items = serverBuildCombo(categories, brand, Number(args?.minPrice) || 0, Number(args?.maxPrice) || 0, style, args?.sizes); | |
| if (!items.length) return "Không tìm thấy combo phù hợp trong catalog V.AI STUDIO cho các hạng mục: " + categories.join(", ") + "."; | |
| const note = [ | |
| brand ? "thương hiệu " + brand : "", | |
| style.material ? "chất liệu " + style.material : "", | |
| style.feature ? "tính năng " + style.feature : "", | |
| style.color ? "màu " + style.color : "", | |
| ].filter(Boolean).join(", "); | |
| const head = "Combo " + categories.join(" + ") + (note ? " (theo yêu cầu: " + note + ")" : "") + ":"; | |
| const total = items.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0); | |
| return head + "\n" + formatProductLines(items).join("\n") + "\nTổng combo (" + items.length + " món): " + (total > 0 ? total.toLocaleString("vi-VN") + " ₫" : "Liên hệ"); | |
| } | |
| return "Tool không hỗ trợ: " + name; | |
| } catch (e: any) { | |
| return "Lỗi khi chạy tool " + name + ": " + String(e?.message || e); | |
| } | |
| } | |
| // ── Server-side catalog search + combo (same logic the client panel uses) ── | |
| // Grounds the text-chat model on REAL V.AI STUDIO products so it never answers | |
| // off-space / invents product info, and keeps product context across follow-ups. | |
| const CAT_HINTS: string[] = [ | |
| "khóa","khoá","bếp","máy hút mùi","hút khói","hút mùi","chậu","vòi","lò nướng","lò vi sóng", | |
| "nồi chiên","máy rửa chén","máy xay","máy ép","tủ lạnh","kệ","giá ","xoong","bản lề","tay nắm","ray", | |
| "thùng rác","gia vị","phụ kiện","combo","nồi","lẩu","chảo", | |
| ]; | |
| function isGenericCatQuery(q: string): boolean { | |
| const s = String(q || "").toLowerCase(); | |
| if (!s) return true; | |
| for (const k of CAT_HINTS) if (s.includes(k)) return false; | |
| return true; | |
| } | |
| async function serverSearchProducts(q: string, limit: number): Promise<any[]> { | |
| if (!VAIX_PRODUCTS?.length) { | |
| // No VAIX catalog -> fall back to Catalogue AI index (Malloca/Imudex/H2) | |
| return serverSearchCatai(q, limit); | |
| } | |
| const qNorm = norm(q); | |
| const terms = qNorm.split(/\s+/).filter((t: string) => t.length > 1); | |
| const scored: Array<{ p: any; s: number }> = []; | |
| for (let i = 0; i < VAIX_PRODUCTS.length; i++) { | |
| const p = VAIX_PRODUCTS[i]; | |
| 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(" ") : ""); | |
| let score = 0; | |
| 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) scored.push({ p, s: score }); | |
| } | |
| scored.sort((a, b) => b.s - a.s); | |
| const ranked = scored.slice(0, limit).map(x => formatProduct(x.p, x.p._idx ?? 0)); | |
| // Catalogue AI (Malloca/Imudex/H2) fallback: VAIX lacks Imudex products, so | |
| // queries like "khóa điện tử" / "tay nắm" / "thùng rác" must surface Imudex | |
| // items. If the query matches an Imudex-family keyword or VAIX had no strong | |
| // hit, lead with the Catalogue AI matches (deduped) and keep VAIX as tail. | |
| const cat = await serverSearchCatai(q, limit); | |
| if (cat.length) { | |
| const seen = new Set(ranked.map(r => String(r.sku || "") + String(r.name || ""))); | |
| const added: any[] = []; | |
| for (const c of cat) { | |
| const k = String(c.sku || "") + String(c.name || ""); | |
| if (seen.has(k)) continue; | |
| added.push(c); | |
| seen.add(k); | |
| if (added.length >= limit) break; | |
| } | |
| if (added.length) { | |
| // Prefer dedicated Imudex-family matches for cabinet-hardware queries. | |
| const fam = /khoa|khoá|taynam|banle|ray|thungrac|giagiavi|giatri|kegia|kexi|kedao|kechen|kebat|kexoong|chot|moc|lock|smart|nangha|nang hạ/i.test(qNorm) | |
| || /khoa|khóa|kệ|tay nắm|bản lề|thùng rác|giá gia vị|chốt|móc|ray|nâng hạ|ổ khóa|khóa điện tử/i.test(q); | |
| // When the query is clearly cabinet hardware, Imudex (specific) should | |
| // come before generic Malloca/Grob matches. | |
| const familia = fam || cat.length >= Math.min(limit, 3); | |
| const final = familia ? [...added, ...ranked] : [...ranked, ...added]; | |
| return final.slice(0, limit).map((p: any, i: number) => ({ ...p, _idx: i })); | |
| } | |
| } | |
| return ranked.slice(0, limit); | |
| } | |
| // Derive a coarse product family from the product NAME for diverse combos. | |
| function serverItemFamily(p: any): string { | |
| const nm = norm(p.name || p.n || ""); | |
| const pairs: Array<[string[], string]> = [ | |
| [[ "khoacuathongminh","khoadientu","khoathongminh","vantay","faceid","smartlock" ], "khóa cửa thông minh"], | |
| [[ "khoacua","khoatu","okhoa","khoa" ], "khóa"], | |
| [[ "beptu","bephongngoai","bepgas","bepdien","bept" ], "bếp từ"], | |
| [[ "mayhutmui","mayhutkhoi","hutmui","hutkhoi" ], "máy hút mùi"], | |
| [[ "chau ruach","chau rua","bonrua","bonchen" ], "chậu rửa"], | |
| [[ "voirua","voinuoc" ], "vòi rửa"], | |
| [[ "lonuong","lohap","giunong" ], "lò nướng"], | |
| [[ "lovisong","loveda","visong" ], "lò vi sóng"], | |
| [[ "noichien","noi chien" ], "nồi chiên"], | |
| [[ "mayruachen","mayruabat","may sấy chen" ], "máy rửa chén"], | |
| [[ "mayxay","mayep","sinhto" ], "máy xay/ép"], | |
| [[ "tulanh","turuou" ], "tủ lạnh"], | |
| [[ "kexoongnoi","giaxoongnoi","kexoong","giaxoong","xoongnoi" ], "kệ xoong nồi"], | |
| [[ "kechendia","giachendia","giabatdia","kebatdia","kechend","kebechendia" ], "kệ chén dĩa"], | |
| [[ "kedaothot","giadaothot","giadao","kedao" ], "kệ dao thớt"], | |
| [[ "giagoc","kegoc","giagoclienhoan","goclienhoan" ], "giá góc"], | |
| [[ "thungrac","thung rac","thungracamtu" ], "thùng rác"], | |
| [[ "giagiavi","kegiavi","giachailo","kechailo","chailo" ], "giá gia vị"], | |
| [[ "khaychia","khay chiat","khaynhao","tha dian","thiadia","thia nia" ], "khay chia"], | |
| ]; | |
| for (const [keys, label] of pairs) for (const k of keys) if (nm.includes(k)) return label; | |
| return "khác"; | |
| } | |
| // Check whether a product matches a style requirement (material/color/feature) | |
| // against its title/brand/category/summary/features/specs/description. | |
| function serverStyleMatch(p: any, key: string, term: string): boolean { | |
| if (!term) return true; | |
| const nd = norm(p.name || p.n || "") + " " + norm(p.brand || "") + " " + norm(p.c || p.cat || "") + " " + | |
| norm(p.sum || p.summary || "") + " " + norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " + | |
| norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " + | |
| norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || ""); | |
| return nd.includes(norm(term)); | |
| } | |
| function serverBuildCombo(categories: string[], brand: string, minPrice: number, maxPrice: number, style?: any, sizes?: any): any[] { | |
| if (!VAIX_PRODUCTS?.length || !categories || !categories.length) return []; | |
| const st = style || {}; | |
| const sz = sizes || {}; | |
| const hasBudget = (Number(minPrice) > 0) || (Number(maxPrice) > 0); | |
| const picked: any[] = []; | |
| const used = new Set<number>(); | |
| // Product must match at least one requested mm per category (dimension token in | |
| // name/specs/description) — STRICT like the client, so "chén dĩa 700mm" never | |
| // returns an 800mm/900mm rack. | |
| function sizeOk(p: any, cat: string): boolean { | |
| const req = sz[cat]; | |
| if (!req || !Array.isArray(req) || !req.length) return true; | |
| const hay = norm(p.name || p.n || "") + " " + norm(p.c || p.cat || "") + " " + | |
| norm(Array.isArray(p.f) ? p.f.join(" ") : "") + " " + | |
| norm(Array.isArray(p.feats) ? p.feats.join(" ") : "") + " " + | |
| norm(Object.values(p.specs || {}).join(" ")) + " " + norm(p.desc || ""); | |
| return req.some((mm: number) => hay.includes(String(mm) + "mm") || hay.includes(String(mm) + " mm")); | |
| } | |
| for (const cat of categories) { | |
| const catNorm = norm(cat); | |
| // Prefer products whose family matches the requested category AND style. | |
| let best: any = null; let bestScore = -1; | |
| for (let i = 0; i < VAIX_PRODUCTS.length; i++) { | |
| const p = VAIX_PRODUCTS[i]; | |
| if (used.has(i)) continue; | |
| if (brand && norm(p.brand || "") !== norm(brand)) continue; | |
| if (!sizeOk(p, cat)) continue; | |
| const fam = serverItemFamily(p); | |
| let sc = 0; | |
| if (fam === catNorm) sc += 10; | |
| if (norm(p.c || p.cat || "").includes(catNorm)) sc += 6; | |
| if (norm(p.name || p.n || "").includes(catNorm)) sc += 5; | |
| // Style requirements (soft, preference-weighted). | |
| if (st.material && serverStyleMatch(p, "material", st.material)) sc += 20; | |
| if (st.color && serverStyleMatch(p, "color", st.color)) sc += 20; | |
| if (st.feature && serverStyleMatch(p, "feature", st.feature)) sc += 20; | |
| if (sc > bestScore) { bestScore = sc; best = p; best._i = i; } | |
| } | |
| // Only keep the pick if it satisfies every style requirement; otherwise this | |
| // category can't be matched under the requested style. Prefer a match, but | |
| // don't hard-fail — fall back to the best category match if none satisfies. | |
| if (best && st && Object.keys(st).length) { | |
| const okStyle = (!st.material || serverStyleMatch(best, "material", st.material)) && | |
| (!st.color || serverStyleMatch(best, "color", st.color)) && | |
| (!st.feature || serverStyleMatch(best, "feature", st.feature)); | |
| if (!okStyle) { | |
| // Try to find a styled alternative; if none, relax to the best match. | |
| let styledBest: any = null; let styledScore = -1; | |
| for (let i = 0; i < VAIX_PRODUCTS.length; i++) { | |
| const p = VAIX_PRODUCTS[i]; | |
| if (used.has(i)) continue; | |
| if (brand && norm(p.brand || "") !== norm(brand)) continue; | |
| if (serverItemFamily(p) !== catNorm && !norm(p.c || p.cat || "").includes(catNorm)) continue; | |
| if (!sizeOk(p, cat)) continue; | |
| if (!serverStyleMatch(p, "material", st.material)) continue; | |
| if (!serverStyleMatch(p, "color", st.color)) continue; | |
| if (!serverStyleMatch(p, "feature", st.feature)) continue; | |
| const ps = 10 + (norm(p.c || p.cat || "").includes(catNorm) ? 6 : 0) + (norm(p.name || p.n || "").includes(catNorm) ? 5 : 0); | |
| if (ps > styledScore) { styledScore = ps; styledBest = p; styledBest._i = i; } | |
| } | |
| if (styledBest) best = styledBest; | |
| } | |
| } | |
| // Track index on best for used-set (formatProduct doesn't carry original idx). | |
| if (best) { | |
| const idx = best._i ?? VAIX_PRODUCTS.indexOf(best); | |
| used.add(idx); | |
| picked.push(formatProduct(best, best._idx ?? idx)); | |
| } | |
| } | |
| if (hasBudget && picked.length) { | |
| const sum = picked.reduce((s: number, p: any) => s + (Number(p.priceNum) || 0), 0); | |
| const mn = Number(minPrice) || 0, mx = Number(maxPrice) || Infinity; | |
| if (sum < mn || sum > mx) { | |
| // Budget mismatch: relax brand + style constraints and retry. | |
| return serverBuildCombo(categories, "", 0, 0, undefined, sizes); | |
| } | |
| } | |
| return picked.slice(0, 4); | |
| } | |
| function formatProductLines(products: any[]): string[] { | |
| return products.map((p: any) => | |
| "• " + (p.title_clean || p.name || "") + | |
| (p.brand ? " (" + p.brand + ")" : "") + | |
| (p.model ? " — mã " + p.model : "") + | |
| " — " + (p.priceNum > 0 ? p.priceNum.toLocaleString("vi-VN") + "₫" : "Liên hệ") | |
| ); | |
| } | |
| // One chat completion round that supports tool calls. Returns the message content | |
| // and any tool_calls (name + args + id). | |
| async function chatOnceWithTools(model: string, messages: any[], token: string, withTools: boolean) { | |
| const body: any = { | |
| model, | |
| messages, | |
| max_tokens: 800, | |
| temperature: 0.7, | |
| top_p: 0.9, | |
| stream: false, | |
| tools: withTools ? CHAT_TOOLS : undefined, | |
| tool_choice: withTools ? "auto" : undefined, | |
| }; | |
| const resp = await fetch(ROUTER, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": "gemma-avatar" }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(CHAT_TIMEOUT_MS), | |
| }); | |
| const text = await resp.text().catch(() => ""); | |
| if (!resp.ok) { | |
| const err: any = new Error("HTTP " + resp.status + ": " + text.slice(0, 200)); | |
| err.status = resp.status; | |
| throw err; | |
| } | |
| const data = JSON.parse(text); | |
| const msg = data?.choices?.[0]?.message; | |
| const toolCalls: any[] = Array.isArray(msg?.tool_calls) ? msg.tool_calls : []; | |
| return { content: String(msg?.content || "").trim(), toolCalls, model }; | |
| } | |
| async function textChatHandler(req: Request): Promise<Response> { | |
| try { | |
| const reqBody = await req.json(); | |
| const userMessage = (reqBody.message || "").toString(); | |
| if (!userMessage) return Response.json({ error: "Missing 'message' field" }, { status: 400 }); | |
| let conversation = Array.isArray(reqBody.history) ? reqBody.history : []; | |
| conversation = conversation.slice(-12).map((m: any) => ({ | |
| role: (m.role === "assistant" || m.role === "user") ? m.role : "user", | |
| content: String(m.content || ""), | |
| })); | |
| const system = [ | |
| "Bạn là trợ lý của V.AI STUDIO. Bạn giỏi tư vấn thiết bị nhà bếp và khóa cửa (Grob, Hafele, Eurogold, Malloca...), NHƯNG bạn cũng là trợ lý tổng quát thân thiện có thể trả lời tin tức và câu hỏi thông thường.", | |
| "RẤT QUAN TRỌNG — TRẢ LỜI NGẮN GỌN: Trả lời ngắn gọn, xúc tích (tối đa 2-3 câu cho câu hỏi thường; tối đa 3-4 dòng cho câu hỏi phức tạp), tự nhiên, thân thiện bằng tiếng Việt như người trò chuyện thật. KHÔNG lan man, KHÔNG liệt kê dài dòng, không lặp lại ý. Người dùng đang đọc trên màn hình nhỏ nên câu ngắn gọn đủ ý là tốt nhất.", | |
| "KHÔNG dùng các nhãn đoạn kiểu 'Diễn biến chính:', 'Hành động:', 'Nội dung:', 'Tóm tắt:' hay bất kỳ tiêu đề máy móc nào. Viết thành lời văn liền mạch, xúc tích cho dễ đọc.", | |
| "Khi được hỏi về sản phẩm, hãy tư vấn theo nhu cầu (bếp từ, máy hút mùi, chậu rửa, vòi rửa, lò nướng, nồi chiên, máy rửa chén, khóa...) và kể tên sản phẩm cụ thể (kèm mã/model nếu biết) khi phù hợp.", | |
| "RẤT QUAN TRỌNG — SẢN PHẨM PHẢI LẤY TỪ CATALOG: Khi người dùng hỏi về sản phẩm, giá, mua hàng, hoặc muốn xem combo/bộ sản phẩm, bạn PHẢI gọi query_catalog / show_product / combo_suggest (tools có sẵn) để lấy dữ liệu sản phẩm THẬT từ catalog V.AI STUDIO trước khi trả lời. TUYỆT ĐỐI không bịa giá, mã, tên sản phẩm. Chỉ trả lời dựa trên kết quả tool trả về. Khi người dùng hỏi tiếp (follow-up) về combo/sản phẩm đã nói trước đó, hãy dựa vào lịch sử hội thoại + gọi lại tool nếu cần để giữ đúng ngữ cảnh và đúng sản phẩm.", | |
| "KHI TƯ VẤN COMBO: Nếu người dùng hỏi combo/bộ sản phẩm, gọi combo_suggest với categories (các hạng mục), trả về combo gồm đúng các sản phẩm họ yêu cầu kèm tổng giá. Câu trả lời ngắn gọn, liệt kê từng món trên 1 dòng (tên + mã + giá), không mô tả dài dòng từng món.", | |
| "VIẾT SỐ BẰNG CHỮ SỐ (DIGITS): Mọi con số (mã sản phẩm, giá tiền, kích thước mm, tổng combo) PHẢI được viết bằng chữ số Ả Rập, ví dụ mã 7806922, giá 276.000 đ, tổng 792.000 đ. TUYỆT ĐỐI KHÔNG viết số thành chữ tiếng Việt ('không trăm lẻ bảy triệu...', 'hai trăm bảy mươi sáu...'). Sao chép y nguyên mã sản phẩm và giá mà tool trả về, KHÔNG tự đổi, KHÔNG tự tính toán lại.", | |
| "KHI HIỂN THỊ SẢN PHẨM/COMBO: Sao chép CHÍNH XÁC danh sách sản phẩm, mã và giá do tool (query_catalog/show_product/combo_suggest) trả về. KHÔNG thay đổi mã, không thay sản phẩm khác, không bịa. Nếu người dùng yêu cầu hạng mục cụ thể (ví dụ kệ xoong nồi 800mm, kệ dao thớt 400mm), chỉ liệt kê đúng các sản phẩm khớp đúng yêu cầu đó do tool trả về.", | |
| "QUAN TRỌNG — TIN TỨC & SỰ KIỆN HIỆN TẠI: Khi người dùng hỏi về tin tức, sự kiện mới, chương trình khuyến mãi, chiến dịch, hoặc bất kỳ thông tin thời sự/gần đây nào, bạn PHẢI gọi tool search_news (có sẵn) để tìm tin mới nhất, rồi dựa vào kết quả trả về để trả lời. Đừng từ chối hay nói 'mình không cập nhật tin tức'. Hãy tóm tắt các bài báo tìm được một cách ngắn gọn, kèm nguồn.", | |
| "Khi cần thông tin chung không phải tin tức, bạn có thể gọi search_web. Nếu chưa đủ thông tin, hãy hỏi thêm thương hiệu, kích thước, chất liệu hoặc ngân sách một cách tự nhiên.", | |
| ].join("\n"); | |
| const messages = [{ role: "system", content: system }, ...conversation, { role: "user", content: userMessage }]; | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) console.error("[chat] No inference token configured (HF_INFERENCE_TOKEN missing)."); | |
| let lastErr: any = null; | |
| // Decide whether to offer tools: news/current-event/product questions benefit | |
| // from search; plain chit-chat doesn't need it. Offer tools always except very | |
| // short greetings — the model decides via tool_choice:"auto". | |
| const withTools = userMessage.trim().length >= 2 && !/^(chao|hello|hi|xin chao|cam on|oke|ok|bye)[.\s]*$/i.test(userMessage.trim()); | |
| for (const step of CHAIN) { | |
| let retries = 3; | |
| for (let attempt = 0; attempt <= retries; attempt++) { | |
| try { | |
| // Tool-calling loop: keep asking with tools, execute any tool_calls the | |
| // model requests, and feed results back until it gives a final answer. | |
| let msgs = [...messages]; | |
| let finalReply = ""; | |
| let finalModel = step.model; | |
| for (let round = 0; round < 3; round++) { | |
| const { content, toolCalls, model } = await chatOnceWithTools(step.model, msgs, token, withTools); | |
| finalModel = model; | |
| if (toolCalls && toolCalls.length) { | |
| msgs.push({ role: "assistant", content: content || null, tool_calls: toolCalls.map((tc: any) => ({ id: tc.id, type: "function", function: { name: tc.function?.name, arguments: tc.function?.arguments } })) }); | |
| for (const tc of toolCalls) { | |
| let argsObj: any = {}; | |
| try { argsObj = JSON.parse(tc.function?.arguments || "{}"); } catch (_) {} | |
| const resultText = await executeChatTool(tc.function?.name || "", argsObj); | |
| msgs.push({ role: "tool", tool_call_id: tc.id, content: resultText }); | |
| } | |
| continue; | |
| } | |
| finalReply = content; | |
| break; | |
| } | |
| if (!finalReply) { | |
| const e: any = new Error("Empty response after tool loop"); | |
| e.status = 500; | |
| throw e; | |
| } | |
| const clean = String(finalReply) | |
| .replace(/\*\*([^*]+)\*\*/g, "$1") | |
| .replace(/\*([^*]+)\*/g, "$1") | |
| .replace(/__([^_]+)__/g, "$1") | |
| .replace(/`([^`]+)`/g, "$1") | |
| .replace(/^#{1,6}\s+/gm, "") | |
| .replace(/[ \t]+\n/g, "\n") | |
| .replace(/\n{3,}/g, "\n\n") | |
| .trim(); | |
| return Response.json({ transcript: clean, raw: String(finalReply).slice(0, 40), model: finalModel, status: "completed" }); | |
| } catch (e: any) { | |
| lastErr = e; | |
| const code = e?.status; | |
| console.error("[chat] model " + step.model + " attempt " + attempt + " failed:", code, e?.message); | |
| if (code === 401) { | |
| return Response.json({ error: "Chat chưa được cấu hình token inference hợp lệ. Vui lòng thêm Secret HF_INFERENCE_TOKEN (User Access Token có quyền inference.serverless.write) trong Settings của Space.", status: 401, detail: e?.message }, { status: 401 }); | |
| } | |
| if (code === 403) { | |
| return Response.json({ error: "Token inference không có quyền gọi model này. Hãy kiểm tra quyền inference.serverless.write của token.", status: 403, detail: e?.message }, { status: 403 }); | |
| } | |
| if (code === 402) { | |
| return Response.json({ error: "Tài khoản đã hết credits Inference Providers. Vui lòng nạp credits tại https://huggingface.co/settings/billing để dùng Google Gemma cho chat.", status: 402, detail: e?.message }, { status: 402 }); | |
| } | |
| // Retry transient failures with exponential backoff (429/5xx/timeout). | |
| const wait = 250 * Math.pow(2, attempt); | |
| await new Promise(r => setTimeout(r, wait)); | |
| } | |
| } | |
| // Cascade to next (smaller) model. | |
| } | |
| console.error("[chat] All models failed:", lastErr?.status, lastErr?.message); | |
| return Response.json({ error: "Chat service error", status: (lastErr?.status || 502), detail: String(lastErr?.message || "") }, { status: lastErr?.status || 502 }); | |
| } catch (err: any) { | |
| console.error("[/api/chat] Error:", err.message); | |
| return Response.json({ error: "Chat service error: " + err.message }, { status: 500 }); | |
| } | |
| } | |
| // ── AI đọc bản vẽ kỹ thuật (VLM qua HF Inference Providers router) ──────── | |
| // Bao-gia.js gửi ảnh bản vẽ (mặt đứng/mặt bằng) dạng data URL → server gọi | |
| // vision-language model (Qwen3-VL / Qwen2.5-VL / GLM-4.6V...) để hiểu bản vẽ, | |
| // tách hạng mục nội thất + kích thước (W×D×H mm) → chuẩn hoá về đúng danh mục | |
| // + đơn vị (m² / mét dài / cái) → trả JSON array để đổ thẳng vào bảng báo giá. | |
| const HM_CATALOG: Array<{ id: string; label: string; mode: "m2" | "m" | "pc"; kw: string[] }> = [ | |
| { id: "tu-quan-ao", label: "Tủ quần áo", mode: "m2", kw: ["tu quan ao", "tu quan", "wardrobe", "tủ áo"] }, | |
| { id: "tu-giay", label: "Tủ giày", mode: "m2", kw: ["tu giay", "shoe cabinet"] }, | |
| { id: "lam-trang-tri", label: "Lam trang trí", mode: "m2", kw: ["lam trang tri", "lam", "decorative slat", "slat"] }, | |
| { id: "tu-trang-tri", label: "Tủ trang trí", mode: "m2", kw: ["tu trang tri", "display cabinet", "tu kính"] }, | |
| { id: "tu-bep", label: "Tủ bếp", mode: "m", kw: ["tu bep", "tu bep tren", "tu bep duoi", "kitchen cabinet", "tu bep treo"] }, | |
| { id: "ke-tivi", label: "Kệ tivi", mode: "m", kw: ["ke tivi", "ke tv", "tv shelf", "ke tv"] }, | |
| { id: "ke-trang-tri", label: "Kệ trang trí", mode: "m", kw: ["ke trang tri", "decorative shelf", "ke trang"] }, | |
| { id: "dau-giuong", label: "Đầu giường", mode: "pc", kw: ["dau giuong", "dau tuong", "headboard"] }, | |
| { id: "giuong", label: "Giường", mode: "pc", kw: ["giuong", "bed", "giuong ngu"] }, | |
| { id: "ban-hoc", label: "Bàn học", mode: "pc", kw: ["ban hoc", "desk", "ban viet"] }, | |
| { id: "keo-hoc", label: "Kéo hộc", mode: "pc", kw: ["keo hoc", "drawer", "keo", "hoc keo"] }, | |
| ]; | |
| const HM_BY_ID = new Map(HM_CATALOG.map(h => [h.id, h])); | |
| function hmNorm(s: string): string { | |
| return String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[\s_\-–—]+/g, " ").trim(); | |
| } | |
| function detectHmServer(name: string): { id: string; label: string; mode: "m2" | "m" | "pc" } | null { | |
| const n = hmNorm(name); | |
| if (!n) return null; | |
| // exact id match first | |
| if (HM_BY_ID.has(n)) return HM_BY_ID.get(n)!; | |
| for (const h of HM_CATALOG) { | |
| if (h.kw.some(k => n.includes(hmNorm(k)))) return h; | |
| } | |
| return null; | |
| } | |
| function cleanInt(v: any): number | null { | |
| const n = Number(String(v ?? "").replace(/[^\d.-]/g, "")); | |
| if (!isFinite(n) || n <= 0) return null; | |
| return Math.round(n); | |
| } | |
| async function aiReadDrawing(imageDataUrl: string, model: string): Promise<any> { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return { ok: false, reason: "no HF token" }; | |
| const sys = [ | |
| "Bạn là kỹ sư nội thất đọc bản vẽ kỹ thuật kiến trúc (mặt bằng, mặt đứng, chi tiết) tiếng Việt.", | |
| "Liệt kê TẤT CẢ hạng mục nội thất CẦN BÁO GIÁ. BỎ qua sofa, bàn ghế rời, đèn, rèm, đồ trang trí không thuộc danh mục, thiết bị điện (máy hút mùi, bếp từ, chậu rửa, tủ lạnh...).", | |
| "Danh mục hạng mục và đơn vị tính:", | |
| " m2 (diện tích W×Cao, đơn vị mm): tủ quần áo, tủ giày, lam trang trí, tủ trang trí", | |
| " mét dài (chiều rộng W, mm): tủ bếp, kệ tivi, kệ trang trí", | |
| " cái: giường, bàn học, đầu giường, kéo hộc", | |
| "Đọc kích thước thật từ dòng ghi chú/dimension line/chú thích trên bản vẽ. Nếu bản vẽ ghi '3200' cạnh tủ bếp thì w_mm=3200, d_mm=600 mặc định nếu có ghi, h_mm theo mặt đứng.", | |
| "Trả về DUY NHẤT một JSON array, không markdown, không text thừa. Mỗi phần tử: {\"hm\":\"tu-quan-ao\",\"name\":\"Tủ quần áo\",\"w_mm\":600,\"d_mm\":600,\"h_mm\":2400,\"qty\":1,\"unit\":\"m2\"}", | |
| "Nếu không chắc chắn kích thước thì ghi null. TUYỆT ĐỐI không bịa số. Nếu không có hạng mục nào trả về [].", | |
| ]; | |
| const body = { | |
| model, | |
| messages: [{ | |
| role: "user", | |
| content: [ | |
| { type: "image_url", image_url: { url: imageDataUrl } }, | |
| { type: "text", text: sys.join("\n") }, | |
| ], | |
| }], | |
| max_tokens: 3000, | |
| temperature: 0.1, | |
| top_p: 0.95, | |
| stream: false, | |
| }; | |
| let resp: Response; | |
| try { | |
| resp = await fetch(ROUTER, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token, "User-Agent": "vai-avatar2-read-drawing" }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(120000), | |
| }); | |
| } catch (e: any) { return { ok: false, reason: String(e?.message || e) }; } | |
| if (!resp.ok) { | |
| const t = await resp.text().catch(() => ""); | |
| return { ok: false, reason: "HTTP " + resp.status + " " + t.slice(0, 200), status: resp.status }; | |
| } | |
| let content = ""; | |
| try { const d: any = await resp.json(); content = String(d?.choices?.[0]?.message?.content || "").trim(); } | |
| catch (e: any) { return { ok: false, reason: "Bad AI response JSON" }; } | |
| let arr: any = null; | |
| try { arr = JSON.parse(content); } | |
| catch (e1: any) { | |
| const m = content.match(/\[[\s\S]*\]/); | |
| if (m) { try { arr = JSON.parse(m[0]); } catch (e2: any) {} } | |
| } | |
| if (!Array.isArray(arr)) return { ok: false, reason: "AI returned non-array JSON" }; | |
| // Chuẩn hoá: ép hạng mục về đúng id + đơn vị | |
| const cleaned = arr | |
| .filter((p: any) => p && typeof p === "object") | |
| .map((p: any) => { | |
| const rawName = String(p.name || p.ten || p.item || "").trim() || String(p.hm || "").trim(); | |
| let hm: any = HM_BY_ID.get(hmNorm(p.hm)) || null; | |
| if (!hm) hm = detectHmServer(rawName); | |
| if (!hm) hm = detectHmServer(String(p.hm || "")); | |
| if (!hm) return null; | |
| const w = cleanInt(p.w_mm ?? p.w ?? p.width); | |
| const d = cleanInt(p.d_mm ?? p.d ?? p.depth); | |
| const h = cleanInt(p.h_mm ?? p.h ?? p.height); | |
| const qty = Math.max(1, Math.round(Number(p.qty ?? p.soluong ?? p.quantity ?? 1)) || 1); | |
| return { | |
| hm: hm.id, name: hm.label, | |
| w_mm: w, d_mm: d, h_mm: h, | |
| qty, unit: hm.mode === "m2" ? "m2" : (hm.mode === "m" ? "m" : "pc"), | |
| note: rawName !== hm.label ? rawName : "", | |
| }; | |
| }) | |
| .filter((x: any) => x !== null); | |
| if (!cleaned.length) return { ok: false, reason: "AI returned 0 items (có thể bản vẽ không rõ hoặc ngoài danh mục)" }; | |
| return { ok: true, items: cleaned, model }; | |
| } | |
| const server = Bun.serve({ | |
| port: PORT, | |
| routes: { | |
| "/": { GET: serveProductOG }, | |
| "/catalogue/:cat/:page": { GET: async (req: Request) => { | |
| // Catalogue AI deep-link with full OpenGraph/Twitter SEO | |
| try { | |
| const url = new URL(req.url); | |
| const rawCat = String((req.params as any)?.cat || "catalogue-malloca-2026"); | |
| const pageNum = Math.max(1, parseInt(String((req.params as any)?.page || "1"), 10) || 1); | |
| const TITLES: Record<string, string> = { | |
| "catalogue-malloca-2026": "Catalogue Malloca 2026", | |
| "new-products-h2-2026": "Sản phẩm mới H2 2026", | |
| "imudex-2026": "Catalogue Imudex 2026" | |
| }; | |
| const catName = TITLES[rawCat] || rawCat; | |
| const pageImg = escURL("https://" + url.host + "/api/catpage?cat=" + encodeURIComponent(rawCat) + "&page=" + pageNum); | |
| const ogTitle = "Trang " + pageNum + " \u2014 " + catName + " | V.AI STUDIO"; | |
| const ogDesc = "Xem trang " + pageNum + " " + catName + ": đầy đủ sản phẩm bếp Malloca. Xem toàn màn hình, zoom rõ từng sản phẩm và thêm vào giỏ báo giá ngay."; | |
| const ogTags = buildOGTags(ogTitle, ogDesc, pageImg, "https://" + url.host + url.pathname + (url.search||""), true, 0); | |
| return new Response(index.replace(/<\/head>/, ogTags + "\n </head>"), { | |
| status: 200, | |
| headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0" }, | |
| }); | |
| } catch (eAny: any) { | |
| return new Response(index, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" } }); | |
| } | |
| }}, | |
| "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }, { headers: { "Cache-Control": "public, max-age=60", "Access-Control-Allow-Origin": "*" } }) }, | |
| "/api/avatars": { GET: async () => Response.json({ avatars: await listAvatars() }) }, | |
| "/api/vaix/products": { GET: async (req: Request) => { | |
| try { | |
| await loadVaixProducts(); | |
| if (!VAIX_PRODUCTS?.length) return Response.json({ products: [], total: 0 }); | |
| const url = new URL(req.url); | |
| const limit = Math.min(Number(url.searchParams.get("limit") || "20"), 99999); | |
| const total = VAIX_PRODUCTS.length; | |
| const formatted = VAIX_PRODUCTS.map((p, i) => formatProduct(p, i)).slice(0, Math.min(limit, total)); | |
| return Response.json({ | |
| products: formatted, | |
| total, limit, page: 1, perPage: limit, | |
| totalPages: 1, | |
| }); | |
| } catch { return Response.json({ products: [], total: 0 }); } | |
| }}, | |
| "/api/vaix/status": { GET: () => Response.json({ loaded: VAIX_LOADED, count: VAIX_PRODUCTS?.length ?? 0, lastSyncMs: VAIX_LAST_SYNC }) }, | |
| "/api/vaix/search": { GET: async (req: Request) => { | |
| const url = new URL(req.url); | |
| const q = url.searchParams.get("q"); | |
| if (!q) return Response.json({ results: [], aiAnswer: "Missing ?q=" }, { status: 400 }); | |
| try { | |
| await loadVaixProducts(); | |
| // Unified search: VAIX products first, then Catalogue AI index fallback | |
| // (Malloca/Imudex/H2) so Imudex-only queries like "khóa điện tử" return | |
| // real products instead of nothing/unrelated items. | |
| const products = await serverSearchProducts(q, 50); | |
| return Response.json({ | |
| results: products.map((p: any, i: number) => ({ product: p, score: Math.max(50 - i, 1) })), | |
| aiAnswer: products.length > 0 ? "Found " + products.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) => { | |
| // Product-link suggestions: DuckDuckGo primary, Google HTML fallback. | |
| // Returns up to 10 {title, snippet, url, source} — used by the | |
| // "Thêm SP bằng URL" modal to suggest 10 product links per keyword. | |
| const q = new URL(req.url).searchParams.get("q"); | |
| const limit = Math.min(parseInt(new URL(req.url).searchParams.get("limit") || "10", 10) || 10, 20); | |
| if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); | |
| const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; | |
| const dec = (s: string) => String(s || "").replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "); | |
| const dedupeFn = (arr: Array<any>) => { | |
| const seen = new Set<string>(); | |
| const out: Array<any> = []; | |
| for (const r of arr) { | |
| if (!r || !r.url) continue; | |
| const key = r.url.replace(/[?#].*$/, ""); | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| out.push(r); | |
| if (out.length >= limit) break; | |
| } | |
| return out; | |
| }; | |
| // 1) DuckDuckGo HTML | |
| const ddg = async (): Promise<Array<any>> => { | |
| const r = await fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(q), { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(12000) }); | |
| const h = await r.text(); | |
| const res: Array<any> = []; | |
| 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, 15); 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 && href) { | |
| let source = ''; | |
| try { source = new URL(href).hostname.replace(/^www\./, ''); } catch (_) {} | |
| res.push({ title, snippet, url: href, source }); | |
| } | |
| } | |
| return res; | |
| }; | |
| // 2) Bing RSS fallback (used when DDG returns nothing / is unreachable). | |
| // RSS endpoints are far more permissive than HTML scraping from HF egress. | |
| const bingRss = async (): Promise<Array<any>> => { | |
| const r = await fetch("https://www.bing.com/search?format=rss&q=" + encodeURIComponent(q), { headers: { "User-Agent": UA, "Accept-Language": "vi-VN,vi;q=0.9" }, signal: AbortSignal.timeout(12000) }); | |
| const h = await r.text(); | |
| const res: Array<any> = []; | |
| const items = [...h.matchAll(/<item>([\s\S]*?)<\/item>/gi)]; | |
| for (const it of items) { | |
| const body = it[1]; | |
| const tm = body.match(/<title>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i); | |
| const lm2 = body.match(/<link>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/link>/i); | |
| const dm = body.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); | |
| const title = tm ? dec(tm[1]).trim() : ''; | |
| let href = lm2 ? lm2[1].trim() : ''; | |
| const snippet = dm ? dec(dm[1]).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 160) : ''; | |
| if (!title || !href || !/^https?:\/\//i.test(href)) continue; | |
| let source = ''; | |
| try { source = new URL(href).hostname.replace(/^www\./, ''); } catch (_) {} | |
| res.push({ title, snippet, url: href, source }); | |
| } | |
| return res; | |
| }; | |
| // 3) Google HTML fallback (last resort — often blocked from HF egress) | |
| const ggl = async (): Promise<Array<any>> => { | |
| const r = await fetch("https://www.google.com/search?q=" + encodeURIComponent(q) + "&num=10&hl=vi", { headers: { "User-Agent": UA, "Accept-Language": "vi-VN,vi;q=0.9" }, signal: AbortSignal.timeout(12000) }); | |
| const h = await r.text(); | |
| const res: Array<any> = []; | |
| // Google organic results | |
| const lm2 = [...h.matchAll(/<a[^>]+href="(\/url\?q=([^"&]+)[^"]*)"[^>]*>(?:<h3[^>]*>)?([\s\S]*?)(?:<\/h3>)?<\/a>/gi)]; | |
| const seen2 = new Set<string>(); | |
| for (const m of lm2) { | |
| if (res.length >= limit) break; | |
| let href = ""; | |
| try { href = decodeURIComponent(m[2] || ""); } catch (_) {} | |
| if (!href || !/^https?:\/\//i.test(href)) continue; | |
| const host = new URL(href).hostname.replace(/^www\./, ''); | |
| if (host === 'google.com' || host === 'www.google.com' || host.endsWith('.google.com')) continue; | |
| if (seen2.has(href)) continue; | |
| seen2.add(href); | |
| const title = dec((m[3] || "").replace(/<[^>]+>/g, "")).trim(); | |
| // snippet: grab text around the anchor if present (rough) | |
| let snippet = ""; | |
| try { | |
| const anchorStart = h.indexOf(m[0]); | |
| if (anchorStart >= 0) { | |
| const chunk = h.slice(anchorStart, anchorStart + 900).replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); | |
| snippet = chunk.slice(0, 160); | |
| } | |
| } catch (_e) {} | |
| if (title) res.push({ title, snippet, url: href, source: host }); | |
| } | |
| // Fallback: parse /url?q= links without title via divs (simple) | |
| if (!res.length) { | |
| const urls = [...h.matchAll(/href="\/url\?q=(https?[^"&]+)[^"]*"/gi)]; | |
| for (const mu of urls) { | |
| if (res.length >= limit) break; | |
| let href = ""; | |
| try { href = decodeURIComponent(mu[1]); } catch (_) {} | |
| if (!href || !/^https?:\/\//i.test(href)) continue; | |
| const host = new URL(href).hostname.replace(/^www\./, ''); | |
| if (host === 'google.com' || host.endsWith('.google.com')) continue; | |
| if (seen2.has(href)) continue; | |
| seen2.add(href); | |
| res.push({ title: host, snippet: '', url: href, source: host }); | |
| } | |
| } | |
| return res; | |
| }; | |
| try { | |
| let results: Array<any> = []; | |
| try { results = await ddg(); } catch (_e) { results = []; } | |
| if (!results.length) { | |
| try { results = await bingRss(); } catch (_e) { results = []; } | |
| } | |
| if (!results.length) { | |
| try { results = await ggl(); } catch (_e) { results = []; } | |
| } | |
| if (!results.length) return Response.json({ error: "Web search unreachable." }, { status: 502 }); | |
| return Response.json({ results: dedupeFn(results), engine: "ddg+google+bing" }); | |
| } 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 }, | |
| "/api/read-drawing": { POST: async (req: Request) => { | |
| try { | |
| const body: any = await req.json().catch(() => ({})); | |
| const image = String(body?.image || "").trim(); | |
| const model = String(body?.model || "Qwen/Qwen3-VL-30B-A3B-Instruct").trim(); | |
| if (!image) return Response.json({ ok: false, reason: "Missing image data URL" }, { status: 400 }); | |
| if (!/^data:image\/[a-z+]+;base64,/i.test(image)) return Response.json({ ok: false, reason: "image must be base64 data URL" }, { status: 400 }); | |
| const out = await aiReadDrawing(image, model); | |
| if (!out.ok) return Response.json(out, { status: 502 }); | |
| return Response.json(out, { headers: { "Cache-Control": "no-store" } }); | |
| } catch (e: any) { | |
| console.error("[/api/read-drawing] Error:", e?.message || e); | |
| return Response.json({ ok: false, reason: String(e?.message || e) }, { status: 500 }); | |
| } | |
| }}, | |
| "/api/tts": { GET: async (req: Request) => { | |
| try { | |
| const url2 = new URL(req.url); | |
| const text = (url2.searchParams.get("text") || "").trim().replace(/\s+/g, " ").slice(0, 2000); | |
| const voice = url2.searchParams.get("voice") || "vi-VN-HoaiMyNeural"; | |
| const allowed = voice === "vi-VN-NamMinhNeural" ? voice : "vi-VN-HoaiMyNeural"; | |
| if (!text) return Response.json({ error: "Missing ?text=" }, { status: 400 }); | |
| const key = ttsCacheKey(text, allowed); | |
| let audio = TTS_CACHE.get(key); | |
| if (!audio) { | |
| try { | |
| // Synthesize in a separate Bun subprocess to isolate WebSocket crashes. | |
| const proc = Bun.spawn( | |
| [process.execPath, join("/app", "tts-worker.mjs"), allowed, text], | |
| { stdout: "pipe", stderr: "pipe" }, | |
| ); | |
| const exitCode = await proc.exited; | |
| const out = await new Response(proc.stdout).arrayBuffer(); | |
| const errText = await new Response(proc.stderr).text(); | |
| if (exitCode !== 0) { | |
| console.error("[/api/tts] worker failed code="+exitCode+" err="+errText); | |
| return Response.json({ error: "TTS failed", code: exitCode, detail: errText }, { status: 502 }); | |
| } | |
| audio = new Uint8Array(out); | |
| } catch (e: any) { | |
| console.error("[/api/tts] Error:", e?.message || e, e?.stack || ""); | |
| return Response.json({ error: "TTS failed", detail: String(e?.message || e), stack: String(e?.stack || "") }, { status: 502 }); | |
| } | |
| if (!audio || !audio.length) { | |
| return Response.json({ error: "Empty audio" }, { status: 502 }); | |
| } | |
| TTS_CACHE.set(key, audio); | |
| if (TTS_CACHE.size > TTS_CACHE_MAX) { | |
| const firstKey = TTS_CACHE.keys().next().value; | |
| if (firstKey) TTS_CACHE.delete(firstKey); | |
| } | |
| } | |
| return new Response(audio, { | |
| headers: { | |
| "Content-Type": "audio/mpeg", | |
| "Content-Length": String(audio.length), | |
| "Cache-Control": "public, max-age=3600", | |
| }, | |
| }); | |
| } catch (e2: any) { | |
| return Response.json({ error: "route error", detail: String(e2?.message || e2), stack: String(e2?.stack || "") }, { status: 500 }); | |
| } | |
| }}, | |
| "/api/catindex": { GET: async (req: Request) => { | |
| // Same-origin proxy for the Catalogue AI index (avoids cross-origin CORS | |
| // failures on the HF resolve->xethub redirect from the browser). | |
| // v7: fills missing per-product images from catai_imgmap.json (SKU image | |
| // overrides collected from the official Malloca webshop) BEFORE serving, | |
| // so card thumbs + cart lines get real photos even for brand-new PDF-only | |
| // models that have no webshop/interior-furniture entry. | |
| try { | |
| const u = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/catalogue_ai_index.json"; | |
| const resp = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0 (compatible; VAI-Avatar2/1.0)" }, signal: AbortSignal.timeout(30000) }); | |
| if (!resp.ok) return new Response("upstream " + resp.status, { status: 502 }); | |
| const idx = await resp.json(); | |
| if (idx && Array.isArray(idx.products)) { | |
| const imgmap = await loadCataiImgmap(); | |
| const normSku = (s: any) => String(s || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); | |
| let filled = 0; | |
| for (const p of idx.products) { | |
| if (!p || p.image) continue; | |
| let hit = imgmap[normSku(p.sku)]; | |
| if (!hit && Array.isArray(p.altSkus)) { | |
| for (const alt of p.altSkus) { hit = imgmap[normSku(alt)]; if (hit) break; } | |
| } | |
| if (hit) { p.image = hit; filled++; } | |
| } | |
| console.log("[catindex] images filled for " + filled + "/" + idx.products.length + " products"); | |
| } | |
| return new Response(JSON.stringify(idx), { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=300", "Access-Control-Allow-Origin": "*" } }); | |
| } catch (e: any) { | |
| return new Response("proxy error: " + String(e?.message || e), { status: 502 }); | |
| } | |
| }}, | |
| "/api/catpdf": { GET: async (req: Request) => { | |
| // Same-origin PDF proxy with Range passthrough so the embedded catalogue | |
| // viewer (built-in PDF render) renders pages without downloading 189MB. | |
| const PDFS: Record<string, string> = { | |
| "catalogue-malloca-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/pdfs/catalogue_malloca_2026_newest.pdf", | |
| "new-products-h2-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/pdfs/catalogue_new_products_h2_2026.pdf", | |
| "imudex-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/pdfs/imudex_2026.pdf", | |
| }; | |
| try { | |
| const name = new URL(req.url).searchParams.get("name") || "catalogue-malloca-2026"; | |
| const upstream = PDFS[name] || PDFS["catalogue-malloca-2026"]; | |
| const range = req.headers.get("range"); | |
| const dl = new URL(req.url).searchParams.get("dl"); | |
| const upstreamHeaders: Record<string, string> = { "User-Agent": "Mozilla/5.0 (compatible; VAI-Avatar2/1.0)" }; | |
| if (range) upstreamHeaders["Range"] = range; | |
| const resp = await fetch(upstream, { headers: upstreamHeaders, signal: AbortSignal.timeout(120000) }); | |
| const hdrs = new Headers(); | |
| hdrs.set("Content-Type", resp.headers.get("content-type") || "application/pdf"); | |
| hdrs.set("Cache-Control", "public, max-age=3600"); | |
| if (dl === "1") hdrs.set("Content-Disposition", "attachment; filename=\"catalogue.pdf\""); | |
| hdrs.set("Access-Control-Allow-Origin", "*"); | |
| // passthrough range response (206) so the PDF viewer can page | |
| if (resp.status === 206) { | |
| if (resp.headers.get("content-range")) hdrs.set("Content-Range", resp.headers.get("content-range")!); | |
| return new Response(resp.body as any, { status: 206, headers: hdrs }); | |
| } | |
| return new Response(resp.body as any, { status: 200, headers: hdrs }); | |
| } catch (e: any) { | |
| return new Response("proxy error: " + String(e?.message || e), { status: 502 }); | |
| } | |
| }}, | |
| "/api/products-index": { GET: async (req: Request) => { | |
| // Same-origin proxy for the full products_index.json (VAIX RAG engine), | |
| // avoiding CORS on the HF resolve->xethub redirect from the browser. | |
| // v55: in-memory TTL cache — this file is ~10 MB and every page load fired | |
| // 3+ identical upstream fetches (RAG + catalogue + panel). Serve from memory | |
| // for 10 min; the dataset is updated rarely. | |
| try { | |
| const PROD_IDX_TTL = 1000 * 60 * 10; | |
| const now = Date.now(); | |
| if (PROD_IDX_CACHE.body && (now - PROD_IDX_CACHE.at) < PROD_IDX_TTL) { | |
| return new Response(PROD_IDX_CACHE.body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=600", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" } }); | |
| } | |
| const u = "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/products_index.json"; | |
| const resp = await fetch(u, { headers: { "User-Agent": "Mozilla/5.0 (compatible; VAI-Avatar2/1.0)" }, signal: AbortSignal.timeout(60000) }); | |
| if (!resp.ok) return new Response("upstream " + resp.status, { status: 502 }); | |
| const buf = Buffer.from(await resp.arrayBuffer()); | |
| PROD_IDX_CACHE.body = buf; PROD_IDX_CACHE.at = now; | |
| return new Response(buf, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=600", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" } }); | |
| } catch (e: any) { | |
| return new Response("proxy error: " + String(e?.message || e), { status: 502 }); | |
| } | |
| }}, | |
| "/api/catpage": { GET: async (req: Request) => { | |
| // Same-origin proxy for one rasterised catalogue page image (JPEG). This | |
| // replaces the <iframe application/pdf> approach which fails to render in | |
| // embedded WebViews (e.g. Zalo in-app) that lack a built-in PDF viewer. | |
| const MAP: Record<string,string> = { | |
| "catalogue-malloca-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/catpages/catalogue-malloca-2026/", | |
| "new-products-h2-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/catpages/new-products-h2-2026/", | |
| "imudex-2026": "https://huggingface.co/datasets/bep40/grob-products-updated/resolve/main/catpages/imudex-2026/" | |
| }; | |
| try { | |
| const sp=new URL(req.url).searchParams; | |
| const cat=sp.get("cat")||"catalogue-malloca-2026"; | |
| var page=sp.get("page")||"0001"; | |
| let base=MAP[cat]||MAP["catalogue-malloca-2026"]; | |
| // Ghép H2 (3 trang) vào Malloca 2026: trang 36-38 = catpages/new-products-h2-2026 (1-3). | |
| if(cat==="catalogue-malloca-2026" && parseInt(page,10)>=36){ | |
| base=MAP["new-products-h2-2026"]||base; page=String(parseInt(page,10)-35); | |
| } | |
| const upstream=base+"page_"+String(page).padStart(4,"0")+".jpg"; | |
| // v55: serve from the in-memory cache when fresh — no upstream round trip. | |
| const ck = catpageKey(cat, page); | |
| const hit = CATPAGE_CACHE.get(ck); | |
| if (hit && (Date.now() - hit.at) < CATPAGE_TTL_MS) { | |
| return new Response(hit.body,{headers:{"Content-Type":"image/jpeg","Cache-Control":"public, max-age=2592000, immutable","Access-Control-Allow-Origin":"*","X-Cache":"hit"}}); | |
| } | |
| const resp=await fetch(upstream,{headers:{"User-Agent":"Mozilla/5.0 (compatible; VAI-Avatar2/1.0)"},signal:AbortSignal.timeout(30000)}); | |
| if(!resp.ok) return new Response("upstream "+resp.status,{status:502}); | |
| const buf=Buffer.from(await resp.arrayBuffer()); | |
| if (CATPAGE_CACHE.size >= CATPAGE_CACHE_MAX) { const firstKey = CATPAGE_CACHE.keys().next().value; if (firstKey) CATPAGE_CACHE.delete(firstKey); } | |
| CATPAGE_CACHE.set(ck, { body: buf, at: Date.now() }); | |
| return new Response(buf,{headers:{"Content-Type":"image/jpeg","Cache-Control":"public, max-age=2592000, immutable","Access-Control-Allow-Origin":"*","X-Cache":"miss"}}); | |
| } catch(e:any){ return new Response("proxy error: "+String(e?.message||e),{status:502}); } | |
| }}, | |
| "/api/imgsku": { GET: async (req: Request) => { | |
| // Catalogue AI per-SKU image lookup: name/sku -> webshop image URL (raw). | |
| // v7 backstop for cards whose index image stayed empty after enrichment | |
| // and whose SKU is missing from the interior-furniture VAIX products file. | |
| try { | |
| const q = new URL(req.url).searchParams.get("q") || ""; | |
| if (!q) return Response.json({ image: "" }); | |
| const imgmap = await loadCataiImgmap(); | |
| const normSku = (s: any) => String(s || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); | |
| const Q = normSku(q); | |
| let image = ""; | |
| if (Q && imgmap[Q]) image = imgmap[Q]; | |
| if (!image) { | |
| // substring match: "MI302FZ" should hit a "MDI302FZ" key etc. | |
| for (const k of Object.keys(imgmap)) { | |
| if ((Q.length >= 5 && k.indexOf(Q) >= 0) || (k.length >= 5 && Q.indexOf(k) >= 0)) { image = imgmap[k]; break; } | |
| } | |
| } | |
| if (!image) { | |
| // last resort: official Malloca webshop JSON by barcode/SKU | |
| try { | |
| for (let pg = 1; pg <= 3 && !image; pg++) { | |
| const wr = await fetch("https://malloca.com/products.json?limit=250&page=" + pg, { headers: { "User-Agent": "Mozilla/5.0" }, signal: AbortSignal.timeout(12000) }); | |
| if (!wr.ok) break; | |
| const wd = await wr.json(); | |
| for (const p of (wd.products || [])) { | |
| const hit = (p.variants || []).some((v: any) => normSku(v.barcode) === Q || normSku(v.sku) === Q); | |
| if (hit) { image = String(p.featured_image || (p.images && p.images[0]) || ""); break; } | |
| } | |
| } | |
| } catch (_e2) {} | |
| } | |
| return Response.json({ sku: q, image }, { headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=86400" } }); | |
| } catch (e: any) { return Response.json({ image: "", error: String(e?.message || e) }); } | |
| }}, | |
| "/api/url-product": { GET: async (req: Request) => { | |
| // Server-side product-URL parser: fetch the merchant page directly from Bun | |
| // (no CORS, no external proxies) and extract full product info. Supports | |
| // malloca.com (primary) + generic JSON-LD/meta fallback for any e-commerce URL. | |
| // Returns: { name, sku, model, brand, price, priceText, currency, image, images, | |
| // description, summary, specs, features, category, link } | |
| try { | |
| const raw = new URL(req.url).searchParams.get("url") || ""; | |
| if (!raw || !/^https?:\/\//i.test(raw)) return new Response("missing url", { status: 400 }); | |
| const u = new URL(raw); | |
| if (["http:", "https:"].indexOf(u.protocol) < 0) return new Response("bad proto", { status: 400 }); | |
| const resp = await fetch(u.href, { | |
| headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9" }, | |
| signal: AbortSignal.timeout(40000), | |
| redirect: "follow", | |
| }); | |
| if (!resp.ok) return new Response("upstream " + resp.status, { status: 502 }); | |
| const html = await resp.text(); | |
| const out: Record<string, any> = { link: u.href }; | |
| // ── JSON-LD (Product blocks) ── | |
| let ldProduct: any = null, ldDesc = "", ldPrice = 0, skuLD = ""; | |
| const ldRe = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/g; | |
| let lm: RegExpExecArray | null; | |
| while ((lm = ldRe.exec(html))) { | |
| try { | |
| const data = JSON.parse(lm[1]); | |
| const walk = (n: any) => { | |
| if (!n) return; | |
| if (Array.isArray(n)) { n.forEach(walk); return; } | |
| if (typeof n === "object") { | |
| if (n["@type"] && String(n["@type"]).toLowerCase() === "product" && !ldProduct) ldProduct = n; | |
| if (Array.isArray(n["@graph"])) n["@graph"].forEach(walk); | |
| walk(n["@type"] === "Product" ? n : n["itemListElement"]); | |
| } | |
| }; | |
| walk(data); | |
| } catch (e) { /* skip malformed */ } | |
| } | |
| if (ldProduct) { | |
| if (!ldDesc && ldProduct.description) ldDesc = String(ldProduct.description).replace(/&[a-z]+;/g, " ").trim(); | |
| if (!ldPrice && ldProduct.offers && ldProduct.offers.price) ldPrice = Number(ldProduct.offers.price) || 0; | |
| if (!skuLD && ldProduct.sku) skuLD = String(ldProduct.sku).trim().slice(0, 30); | |
| } | |
| // ── <title> / og / meta ── | |
| const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1]?.replace(/\s+/g, " ").trim() || ""; | |
| const meta = (name: string) => { | |
| const m = html.match(new RegExp(`<meta[^>]+(?:name|property)=["']${name}["'][^>]+content=["']([^"']*)["']`, "i")) || | |
| html.match(new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+(?:name|property)=["']${name}["']`, "i")); | |
| return m ? m[1] : ""; | |
| }; | |
| const ogTitle = meta("og:title") || "", ogDesc = meta("og:description") || "", ogImage = meta("og:image") || meta("twitter:image"); | |
| // ── name ── | |
| let name = ""; | |
| if (ldProduct && ldProduct.name) name = String(ldProduct.name).trim(); | |
| if (!name && ogTitle) name = ogTitle.split(/[|–—-]/)[0].trim(); | |
| if (!name && title) name = title.split(/[|–—-]/)[0].trim(); | |
| // ── sku / model: prefer URL slug token (MS7743SI) then HTML "Mã sản phẩm" then JSON-LD sku ── | |
| // skuLD is declared above next to the other JSON-LD lets. | |
| const slugToken = (u.pathname.split("/").filter(Boolean).pop() || "").match(/[A-Z]{2,}\d{3,}[A-Z]*/i); | |
| let sku = slugToken ? slugToken[0].toUpperCase() : ""; | |
| const msku = html.match(/Mã (?:sản phẩm|SP)[^>]*>?\s*([^<]{2,30})</i) || html.match(/(?:data-sku|itemprop="sku")[^>]*["']([A-Z0-9-]{3,30})["']/i); | |
| if (!sku && msku) sku = msku[1].trim(); | |
| if (!sku && skuLD) sku = skuLD; | |
| // Fallback: read "Mã sản phẩm" from the specs table (thienkimhome keeps the | |
| // real code there even when the URL slug has none). | |
| if (!sku) { | |
| const specRows = parseTrTable(html); | |
| for (const k of Object.keys(specRows)) { | |
| if (/mã\s*sản\s*phẩm|mã\s*sp|model|product\s*code|sku/i.test(k)) { | |
| const mm2 = specRows[k].match(/[A-Z0-9][A-Z0-9 .\-/]{2,30}/); | |
| if (mm2) { sku = mm2[0].trim().slice(0, 24); break; } | |
| } | |
| } | |
| } | |
| // ── brand ── | |
| let brand = ""; | |
| if (ldProduct && ldProduct.brand) brand = (typeof ldProduct.brand === "string" ? ldProduct.brand : (ldProduct.brand.name || "")).trim(); | |
| if (!brand) { const mb = html.match(/Malloca/i); if (mb) brand = "Malloca"; } | |
| // ── price ── | |
| let price = 0, priceText = "", listPrice = 0, listPriceText = "", currency = "VND"; | |
| const ogPrice = meta("og:price:amount"); | |
| if (ogPrice) price = Number(String(ogPrice).replace(/[^\d]/g, "")) || 0; | |
| if (!price && ldPrice) price = ldPrice; | |
| if (!price) { | |
| const m = html.match(/(\d{1,3}(?:[.,]\d{3}){1,3})\s*(?:đ|₫|vnd)/i); | |
| if (m) price = Number(m[1].replace(/[.,](?=\d{3}\b)/g, "").replace(/\./g, "")) || 0; | |
| } | |
| if (price) priceText = price.toLocaleString("vi-VN") + "đ"; | |
| const ogCur = meta("og:price:currency"); | |
| if (ogCur) currency = ogCur.trim().toUpperCase(); | |
| // Old / list price: common patterns — class "old-price", "price-old", and | |
| // `<strike>` right after the current price. Parsed so the avatar's | |
| // add-product preview can show "Giá niêm yết — Giá KM" (priceMode both). | |
| { | |
| const oldM = html.match(/<p[^>]*class=["'][^"']*special-price[^"']*["'][^>]*>\s*[^<]*<\/p>\s*<p[^>]*class=["'][^"']*old-price[^"']*["'][^>]*>\s*([^<]{2,40})</i) || | |
| html.match(/<p[^>]*class=["'][^"']*old-price[^"']*["'][^>]*>\s*([^<]{2,40})</i) || | |
| html.match(/class=["'][^"']*(?:old-price|price-old|old_price)[^"']*["'][^>]*>\s*([^<]{2,40})</i) || | |
| html.match(/<strike[^>]*>\s*([^<]{2,40})<\/strike>/i); | |
| if (oldM) { | |
| const num = Number(String(oldM[1]).replace(/[^\d]/g, "")) || 0; | |
| if (num && num > 0 && num !== price) { listPrice = num; listPriceText = num.toLocaleString("vi-VN") + "đ"; } | |
| } | |
| } | |
| // ── images: only real product images. Path heuristics: | |
| // /products/ (malloca), /application/upload/products (thienkimhome), | |
| // /upload/product(s)/, /images/ (generic). thumbnails → full image. ── | |
| const imgs: string[] = []; | |
| const allImgs = html.match(/https?:\/\/[^"'\\\s]+\.(?:jpg|jpeg|png|webp|avif|gif)/gi) || []; | |
| const seen = new Set<string>(); | |
| for (const im of allImgs) { | |
| let c = im.replace(/\\\//g, "/"); | |
| if (!(/\/products\//i.test(c) || /\/application\/upload\/products\//i.test(c) || /\/upload\/product/i.test(c) || /\/images\//i.test(c) || /\/upload\/images\//i.test(c))) continue; | |
| c = c.split("?")[0]; | |
| // Normalize common thumbnail suffixes to the full image URL. | |
| c = c.replace(/\/thumbs?\//i, "/"); | |
| c = c.replace(/\/thumb\/(?:grande|large|medium|small|compact)\//i, "/"); | |
| c = c.replace(/(?:\/|_)(?:thumb|small|medium|compact|home|base)\.(jpg|jpeg|png|webp)$/i, ".$1"); | |
| if (seen.has(c)) continue; | |
| seen.add(c); imgs.push(c); | |
| } | |
| // Keep only the actual product photos: drop og:image duplicates and known | |
| // "related product" thumbnails by favouring /products/ & large variants. | |
| const primary = imgs.filter((u) => /\/products\//i.test(u) || /\/application\/upload\/products\//i.test(u)); | |
| const dedupPrimary = primary.length ? primary : imgs; | |
| const finalImgs = dedupPrimary.slice(0, 20); | |
| if (!finalImgs.length && ogImage) finalImgs.push(ogImage.split("?")[0]); | |
| const image = finalImgs[0] || ogImage || ""; | |
| // ── description: prefer the long article body (Mô tả / Chi tiết / Nội dung), | |
| // fall back to JSON-LD / og:description. thienkimhome.com keeps the full | |
| // description in `.blog-content` — much richer than the 1-line og:desc. ── | |
| let description = ""; | |
| const descCandidates: string[] = []; | |
| { | |
| const art = { 1: extractDivBlock(html, /(?:blog-content__box-content|blog-content|product-content|product-description|product-detail|content-detail|description-detail|entry-content|ck-content)/, 60000) }; | |
| if (art && art[1]) { | |
| const inner = art[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<table[\s\S]*?<\/table>/gi, " ").replace(/<img[^>]*>/gi, " \n ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li|tr|div)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/ /gi, " "); | |
| const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim(); | |
| if (t.length > 80) descCandidates.push(t); | |
| } | |
| const main = { 1: extractDivBlock(html, /(?:main-content|content-wrapper|single-content)/, 60000) }; | |
| if (main && main[1]) { | |
| const inner = main[1].replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<table[\s\S]*?<\/table>/gi, " ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|h[1-6]|li)>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/ /gi, " "); | |
| const t = decodeHtmlEnt(inner).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim(); | |
| if (t.length > 200) descCandidates.push(t); | |
| } | |
| } | |
| if (descCandidates.length) { | |
| descCandidates.sort((a: string, b: string) => b.length - a.length); | |
| description = descCandidates[0]; | |
| } | |
| if (!description || description.length < 60) { | |
| const ld = (ldDesc || ogDesc || meta("description") || "").replace(/\s+/g, " ").trim(); | |
| if (ld.length >= (description ? description.length : 0)) description = ld; | |
| } | |
| description = description.trim(); | |
| // Strip leftover UI fragments that can leak into the extracted block: | |
| // review-form labels ("Bình thường / Tốt / Rất tốt / Gửi đánh giá"), | |
| // "Thông số kỹ thuật" tabs, "Đóng" buttons, "Xem thêm" etc. | |
| { | |
| const cutters = [ | |
| /Bình thường\s+Tốt\s+Rất tốt\s*$/i, | |
| /\s+Gửi đánh giá\s*$/i, | |
| /\s+Hiện chưa có nhận xét nào.*$/i, | |
| /\s+Thông số kỹ thuật\s+Thông số kỹ thuật.*$/i, | |
| /\s+Đóng\s+Thông số kỹ thuật.*$/i, | |
| /\s+Xem thêm\s*$/i, | |
| ]; | |
| for (const c of cutters) { | |
| let nm: RegExpMatchArray | null; | |
| while ((nm = description.match(c))) description = description.slice(0, nm.index || 0).trim(); | |
| } | |
| description = description.replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{2,}/g, "\n").trim(); | |
| } | |
| // ── specs: try <tr><td> tables first (thienkimhome and most Vietnamese | |
| // CMSs), then dt/dd, then label:value rows. ── | |
| const specs: Record<string, string> = {}; | |
| { | |
| // Parse every <tr><td> table on the page, preferring the largest one: | |
| // Vietnamese CMSs keep the spec sheet in a plain table that may sit far | |
| // from any "Thông số" heading (a section heuristic can match the phrase | |
| // "tính năng đa dạng" inside the description instead). Duplicate | |
| // desktop/mobile tables collapse because the first label wins. | |
| const tblRe = /<table[^>]*>([\s\S]*?)<\/table>/gi; | |
| let tm2: RegExpExecArray | null; | |
| const tables: Array<{ n: number; s: string }> = []; | |
| while ((tm2 = tblRe.exec(html))) { | |
| const rows = (tm2[1].match(/<tr[\s>]/gi) || []).length; | |
| if (rows >= 4) tables.push({ n: rows, s: tm2[1] }); | |
| } | |
| tables.sort((a, b) => b.n - a.n); | |
| const seenLabels = new Set<string>(); | |
| for (const tb of tables) { | |
| const parsed = parseTrTable("<table>" + tb.s + "</table>"); | |
| for (const k of Object.keys(parsed)) { | |
| if (!seenLabels.has(k)) { seenLabels.add(k); specs[k] = parsed[k]; } | |
| } | |
| if (Object.keys(specs).length >= 12) break; | |
| } | |
| } | |
| if (!Object.keys(specs).length) { | |
| const ddRe = (findSection(html, /(?:Thông số kỹ thuật|Thông số|Thông tin sản phẩm|Chi tiết sản phẩm|Đặc điểm nổi bật)/i, 6000) || html).match(/<dt[^>]*>([\s\S]*?)<\/dt>\s*<dd[^>]*>([\s\S]*?)<\/dd>/gi) || []; | |
| for (const blk of ddRe.slice(0, 40)) { | |
| const c = decodeHtmlEnt(stripTags(blk)).replace(/\s+/g, " ").trim(); | |
| const mm = c.match(/^(.{2,60}?)\s*[::]?\s*(.{1,180})$/); | |
| if (mm && specs[mm[1].trim()] === undefined) specs[mm[1].trim()] = mm[2].trim(); | |
| } | |
| } | |
| if (!Object.keys(specs).length) { | |
| const specSection = html.match(/(?:Thông số kỹ thuật|Thông số|Thông tin sản phẩm|Chi tiết sản phẩm|Đặc điểm nổi bật)[\s\S]{0,6000}/i); | |
| if (specSection) { | |
| const seg = specSection[0]; | |
| const rows = seg.match(/<[^>]{0,80}?>(?:<[^>]+>)*?\s*([A-ZÀ-Ỹ][^<>]{2,60}?)\s*(?:<\/[^>]+>)?\s*:?\s*<\/[^>]+>\s*(?:<[^>]+>)*?\s*([^<>]{1,200}?)\s*<\/[^>]+>/g) || []; | |
| for (const rw of rows) { | |
| const clean = rw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); | |
| const mm = clean.match(/^(.{2,60}?)\s*[::]\s*(.{1,180})$/); | |
| if (mm && mm[1].length <= 60 && specs[mm[1].trim()] === undefined) specs[mm[1].trim()] = mm[2].trim(); | |
| } | |
| } | |
| } | |
| // ── features: bullets under "Tính năng" / "Đặc điểm nổi bật", and h3/subsection | |
| // headings inside the description body (enriched feature list). ── | |
| const features: string[] = []; | |
| { | |
| const featSec = html.match(/(?:Tính năng|Đặc điểm nổi bật|Điểm nổi bật|Ưu điểm)[\s\S]{0,4000}/i); | |
| if (featSec) { | |
| const lis = featSec[0].match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || []; | |
| for (const b of lis.slice(0, 12)) { | |
| const t = decodeHtmlEnt(stripTags(b)).replace(/\s+/g, " ").trim(); | |
| if (t.length > 6 && t.length < 220 && features.indexOf(t) < 0) features.push(t); | |
| } | |
| if (!features.length) { | |
| // <p> paragraphs only if they look like short feature phrases | |
| // (at most 1 sentence) — multi-sentence body paragraphs are description. | |
| const ps = featSec[0].match(/<p[^>]*>([\s\S]*?)<\/p>/gi) || []; | |
| for (const b of ps.slice(0, 12)) { | |
| const t = decodeHtmlEnt(stripTags(b)).replace(/\s+/g, " ").trim(); | |
| const dots = (t.match(/\./g) || []).length; | |
| if (t.length > 6 && t.length <= 160 && dots <= 1 && features.indexOf(t) < 0) features.push(t); | |
| } | |
| } | |
| } | |
| if (!features.length && descCandidates.length) { | |
| const lines = descCandidates[0].split(/\n/).map((l: string) => l.trim()).filter(Boolean); | |
| // Prefer heading-like lines (short, no sentence-ending periods, no | |
| // digits-only), then short paragraphs that read like feature bullets. | |
| const heads = lines.filter((l: string) => l.length > 5 && l.length < 90 && !/\.\s*$/.test(l) && !/^[\d\s\-•]+$/.test(l) && !/^\d+[.,]\d/.test(l)); | |
| const shorts = lines.filter((l: string) => l.length > 10 && l.length <= 140 && !/^\d+[.,]\d/.test(l)); | |
| const picks = heads.length >= 2 ? heads : (shorts.length >= 2 ? shorts : heads); | |
| for (const h of picks.slice(0, 10)) if (features.indexOf(h) < 0) features.push(h); | |
| } | |
| } | |
| // ── category: breadcrumb (nav or div), fall back to JSON-LD breadcrumb name, | |
| // then og:site_name / path token. ── | |
| let category = ""; | |
| const bc = html.match(/<nav[^>]*aria-label=["']breadcrumb["'][\s\S]*?<\/nav>/i) || html.match(/<div[^>]*class=["'][^"']*breadcrumb[^"']*["'][\s\S]{0,3000}?<\/div>/i); | |
| if (bc) { | |
| const links = bc[0].match(/<a[^>]*>([\s\S]*?)<\/a>/gi) || []; | |
| for (const lk of links) { | |
| const t = decodeHtmlEnt(stripTags(lk)).replace(/\s+/g, " ").trim(); | |
| if (t && t.length <= 60 && t.toLowerCase().indexOf("trang chủ") < 0 && t.toLowerCase().indexOf("malloca") < 0) category = t; | |
| } | |
| } | |
| if (!category) { | |
| const bcJson = html.match(/["']BreadcrumbList["'][\s\S]{0,2000}?["']name["']\s*:\s*["']([^"']{3,60})["']/); | |
| if (bcJson) category = bcJson[1]; | |
| } | |
| if (!category && ogTitle) { | |
| const firstTok = ogTitle.split(/[|–—-]/)[0].trim(); | |
| if (firstTok.length <= 60) category = firstTok; | |
| } | |
| if (!category) { | |
| const pathTok = (u.pathname.split("/").filter(Boolean).pop() || "").replace(/-/g, " ").trim(); | |
| if (pathTok && pathTok.length <= 60) category = pathTok; | |
| } | |
| // Never keep generic placeholder categories. | |
| if (/^(sản phẩm|san pham|product|home|trang chủ|thiên kim home|thien kim home)$/i.test(category)) category = ""; | |
| out.name = name || "Sản phẩm"; | |
| out.sku = sku; out.model = sku; out.brand = brand; | |
| out.price = price; out.priceText = priceText; out.currency = currency || "VND"; | |
| out.listPrice = listPrice; out.listPriceText = listPriceText; out.oldPrice = listPrice; | |
| out.image = image; out.images = finalImgs; | |
| out.description = description; out.summary = (description || "").slice(0, 300); | |
| out.specs = specs; out.features = features; out.category = category; out.features = features; out.category = category; | |
| const body = JSON.stringify(out); | |
| return new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=600", "Access-Control-Allow-Origin": "*" } }); | |
| } catch (e: any) { | |
| return new Response("proxy error: " + String(e?.message || e), { status: 502 }); | |
| } | |
| }}, | |
| "/api/img": { GET: async (req: Request) => { | |
| // Image proxy: fetch a remote image server-side and return it with | |
| // permissive CORS, so hotlinked CDN images (bizweb.dktcdn.net etc.) that | |
| // block cross-origin browser loads always render in the app regardless of | |
| // the CDN's referer/CORS policy. A tiny memo cache bound by size avoids | |
| // hammering the upstream on every card/detail render. | |
| try { | |
| const u = new URL(req.url).searchParams.get("url") || ""; | |
| if (!u || !/^https?:\/\//i.test(u)) return new Response("missing url", { status: 400 }); | |
| const upstream = new URL(u); | |
| if (["http:", "https:"].indexOf(upstream.protocol) < 0) return new Response("bad proto", { status: 400 }); | |
| const key = "img|" + upstream.href; | |
| const cached = IMAGE_CACHE.get(key); | |
| if (cached) return new Response(cached.body, { headers: { "Content-Type": cached.type, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" } }); | |
| const resp = await fetch(upstream.href, { | |
| headers: { | |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", | |
| "Referer": upstream.origin + "/", | |
| "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", | |
| }, | |
| signal: AbortSignal.timeout(15000), | |
| redirect: "follow", | |
| }); | |
| if (!resp.ok) return new Response("upstream " + resp.status, { status: 502 }); | |
| const type = (resp.headers.get("content-type") || "image/jpeg").split(";")[0].trim().toLowerCase(); | |
| // Only serve actual image content — a CDN that returns HTML (bot | |
| // protection page, 404 page) must NOT be cached/served as an image. | |
| // 404 lets the frontend onerror hide the broken <img> cleanly. | |
| if (!/^image\//.test(type)) return new Response("not an image (" + type + ")", { status: 404 }); | |
| const buf = Buffer.from(await resp.arrayBuffer()); | |
| if (buf.length > 8 * 1024 * 1024) return new Response("too large", { status: 413 }); | |
| IMAGE_CACHE.set(key, { body: buf, type }); | |
| if (IMAGE_CACHE.size > 300) { const first = IMAGE_CACHE.keys().next().value; if (first) IMAGE_CACHE.delete(first); } | |
| return new Response(buf, { headers: { "Content-Type": type, "Cache-Control": "public, max-age=86400", "Access-Control-Allow-Origin": "*" } }); | |
| } catch (e: any) { | |
| return new Response("proxy error: " + String(e?.message || e), { status: 502 }); | |
| } | |
| }}, | |
| "/worklets/:name": (req: Request) => staticFile("worklets", req.params.name!), | |
| "/vendor/:name": (req: Request) => staticFile("vendor", req.params.name!), | |
| "/src/vendor/:name": (req: Request) => staticFile("src/vendor", req.params.name!), | |
| "/avatars/:name": (req: Request) => staticFile("avatars", req.params.name!), | |
| "/src/:name": (req: Request) => srcFile("src", req.params.name!), | |
| "/src/s2s/:name": (req: Request) => srcFile("src/s2s", req.params.name!), | |
| "/api/news/hot": { GET: async () => { | |
| try { | |
| const resp = await fetch("https://news.google.com/rss?hl=vi-VN&gl=VN&ceid=VN:vi"); | |
| if (!resp.ok) throw new Error("RSS failed"); | |
| const rssText = await resp.text(); | |
| // Parse <item> blocks: title, link, source | |
| const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; | |
| const titles: string[] = []; | |
| const articles: Array<{ source: string; title: string; url: string }> = []; | |
| for (const block of itemBlocks) { | |
| const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); | |
| const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); | |
| const sm = block.match(/<source[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*url="([^"]*)"[^>]*>/i); | |
| if (!tm) continue; | |
| const title = tm[1].replace(/\+|_/g, " ").trim(); | |
| if (title) titles.push(title); | |
| let url = ""; | |
| if (lm) { | |
| url = (lm[1] || "").trim(); | |
| // Google News links are /articles/... redirects → keep them, they work in browser | |
| } | |
| let source = ""; | |
| if (sm) source = (sm[1] || sm[0] || "").replace(/<[^>]+>/g, "").trim(); | |
| if (title && url) { | |
| articles.push({ source: source || "", title, url }); | |
| } | |
| if (titles.length >= 12) break; | |
| } | |
| return Response.json({ titles: titles.slice(0, 12), articles }); | |
| } catch (e: any) { | |
| return Response.json({ error: e.message, titles: [], articles: [] }, { status: 502 }); | |
| } | |
| }}, | |
| "/api/news/articles": { GET: async (req: Request) => { | |
| // Search Google News RSS for a specific topic and return SPECIFIC detailed articles | |
| // with real clickable links (each card = one concrete news article, not a category page). | |
| const url = new URL(req.url); | |
| const q = (url.searchParams.get("q") || "").trim(); | |
| if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); | |
| try { | |
| const resp = await fetch( | |
| "https://news.google.com/rss/search?q=" + encodeURIComponent(q) + | |
| "&hl=vi-VN&gl=VN&ceid=VN:vi", | |
| { headers: { "User-Agent": "Mozilla/5.0 (compatible; GemmaAvatar/1.0)" } } | |
| ); | |
| if (!resp.ok) throw new Error("RSS failed " + resp.status); | |
| const rssText = await resp.text(); | |
| const itemBlocks = rssText.match(/<item>[\s\S]*?<\/item>/gi) || []; | |
| const articles: Array<{ source: string; title: string; url: string; desc: string }> = []; | |
| for (const block of itemBlocks) { | |
| const tm = block.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); | |
| const lm = block.match(/<link>\s*<\!\[CDATA\[(.*?)\]\]>\s*<\/link>/i) || block.match(/<link>(.*?)<\/link>/i); | |
| const sm = block.match(/<source[^>]*url="([^"]*)"[^>]*>(.*?)<\/source>/i) || block.match(/<source[^>]*>(.*?)<\/source>/i); | |
| const dm = block.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); | |
| if (!tm) continue; | |
| let title = tm[1].replace(/\+|_/g, " ").trim(); | |
| // Strip trailing "- SourceName" that Google appends to titles | |
| let source = ""; | |
| if (sm) { | |
| source = (sm[2] || sm[1] || "").replace(/<[^>]+>/g, "").trim(); | |
| if (source && title.endsWith(" - " + source)) title = title.slice(0, -(source.length + 3)).trim(); | |
| } | |
| if (!title) continue; | |
| let url = ""; | |
| if (lm) url = (lm[1] || "").trim(); | |
| let desc = ""; | |
| if (dm) { | |
| // Remove all HTML tags (including malformed Google News CDATA tags) | |
| desc = dm[1] | |
| .replace(/<[^>]*>/g, " ") | |
| .replace(/\bhttps?:\/\/\S+/gi, "") | |
| .replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">") | |
| .replace(/ /g, " ") | |
| .replace(/\s+/g, " ").trim().slice(0, 160); | |
| } | |
| if (title && url) { | |
| articles.push({ source: source || "", title, url, desc }); | |
| } | |
| if (articles.length >= 8) break; | |
| } | |
| return Response.json({ articles, query: q }); | |
| } catch (e: any) { | |
| return Response.json({ error: e.message, articles: [] }, { status: 502 }); | |
| } | |
| }}, | |
| "/api/news/images": { GET: async (req: Request) => { | |
| // Return REAL per-article images for a topic using Vietnamese portal RSS feeds | |
| // (VnExpress feeds include <enclosure>/<img> with real article thumbnails). | |
| const url = new URL(req.url); | |
| const q = (url.searchParams.get("q") || "").trim(); | |
| const topicNorm = q.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); | |
| const feeds: Array<{ cat: string; u: string }> = [ | |
| { cat: "tin-moi-nhat", u: "https://vnexpress.net/rss/tin-moi-nhat.rss" }, | |
| { cat: "thoi-su", u: "https://vnexpress.net/rss/thoi-su.rss" }, | |
| { cat: "the-thao", u: "https://vnexpress.net/rss/the-thao.rss" }, | |
| { cat: "kinh-doanh", u: "https://vnexpress.net/rss/kinh-doanh.rss" }, | |
| { cat: "giai-tri", u: "https://vnexpress.net/rss/giai-tri.rss" }, | |
| { cat: "the-gioi", u: "https://vnexpress.net/rss/the-gioi.rss" }, | |
| { cat: "doi-song", u: "https://vnexpress.net/rss/doi-song.rss" }, | |
| { cat: "suc-khoe", u: "https://vnexpress.net/rss/suc-khoe.rss" }, | |
| { cat: "giao-duc", u: "https://vnexpress.net/rss/giao-duc.rss" }, | |
| ]; | |
| const normStr = (s: string) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d"); | |
| const all: Array<{ title: string; url: string; image: string; source: string; desc: string }> = []; | |
| try { | |
| await Promise.all(feeds.map(async (f) => { | |
| try { | |
| const r = await fetch(f.u, { headers: { "User-Agent": "Mozilla/5.0" } }); | |
| if (!r.ok) return; | |
| const t = await r.text(); | |
| const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; | |
| for (const it of items) { | |
| const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); | |
| const lm = it.match(/<link>(.*?)<\/link>/i); | |
| const em = it.match(/<enclosure[^>]*url="([^"]+)/i); | |
| const im = it.match(/<img[^>]+src="([^"]+)/i); | |
| if (!tm || !lm) continue; | |
| const title = tm[1].trim(); | |
| let image = (em && em[1]) ? em[1].replace(/&/g, "&") : ""; | |
| if (!image && im) image = im[1].replace(/&/g, "&"); | |
| if (!title || !image) continue; | |
| const link = lm[1].trim(); | |
| const dm = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); | |
| let desc = ""; | |
| if (dm) desc = dm[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); | |
| // Filter by topic if a query was provided | |
| if (q && !normStr(title + " " + link).includes(topicNorm)) continue; | |
| all.push({ title, url: link, image, source: "VnExpress", desc }); | |
| } | |
| } catch (_) {} | |
| })); | |
| } catch (_) {} | |
| // If no exact topic match, fall back to the freshest topical items (always have images) | |
| if (all.length === 0 && q) { | |
| try { | |
| const r = await fetch("https://vnexpress.net/rss/tin-moi-nhat.rss", { headers: { "User-Agent": "Mozilla/5.0" } }); | |
| const t = await r.text(); | |
| const items = t.match(/<item>[\s\S]*?<\/item>/gi) || []; | |
| for (const it of items) { | |
| const tm = it.match(/<title>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/title>/i); | |
| const lm = it.match(/<link>(.*?)<\/link>/i); | |
| const em = it.match(/<enclosure[^>]*url="([^"]+)/i); | |
| if (tm && lm) { | |
| const image = em ? em[1].replace(/&/g, "&") : ""; | |
| if (image && tm[1].trim()) { | |
| const dm2 = it.match(/<description>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/description>/i); | |
| let desc2 = ""; | |
| if (dm2) desc2 = dm2[1].replace(/<[^>]*>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim().slice(0, 200); | |
| all.push({ title: tm[1].trim(), url: lm[1].trim(), image, source: "VnExpress", desc: desc2 }); | |
| } | |
| } | |
| } | |
| } catch (_) {} | |
| } | |
| return Response.json({ images: all.slice(0, 8), query: q }); | |
| }}, | |
| "/api/news/topic": { GET: async (req: Request) => { | |
| // Search Vietnamese news for a specific topic and return concrete article cards with links. | |
| const url = new URL(req.url); | |
| const q = (url.searchParams.get("q") || "").trim(); | |
| if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 }); | |
| try { | |
| const portalQueries = [ | |
| q + " site:vnexpress.net", | |
| q + " site:tuoitre.vn", | |
| q + " site:thanhnien.vn", | |
| q + " site:cafef.vn", | |
| q + " site:dantri.com.vn", | |
| "tin tức " + q + " hôm nay", | |
| ]; | |
| const allResults: Array<{ title: string; snippet: string; url: string; source: string }> = []; | |
| await Promise.all(portalQueries.map(nq => | |
| fetch("https://html.duckduckgo.com/html/?q=" + encodeURIComponent(nq), { headers: { "User-Agent": "Mozilla/5.0" } }) | |
| .then(r => r.text()) | |
| .then(h => { | |
| const lm = [...h.matchAll(/<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi)]; | |
| const sm = [...h.matchAll(/<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi)]; | |
| for (let i = 0; i < Math.min(lm.length, 4); i++) { | |
| let href = lm[i][1]; | |
| const ru = href.match(/uddg=(https?%3[^&]+)/i); | |
| if (ru) href = decodeURIComponent(ru[1]); | |
| const title = lm[i][2].replace(/<[^>]+>/g, "").trim(); | |
| if (!title) continue; | |
| const snippet = sm[i] ? sm[i][1].replace(/<[^>]+>/g, "").trim() : ""; | |
| let source = ""; | |
| try { source = new URL(href).hostname.replace(/^www\./, ""); } catch (_) {} | |
| allResults.push({ title, snippet, url: href, source }); | |
| } | |
| }) | |
| .catch(() => {}) | |
| )); | |
| // Deduplicate + drop empty | |
| const seen = new Set<string>(); | |
| const unique = allResults.filter(r => { | |
| if (!r.url || seen.has(r.url)) return false; | |
| seen.add(r.url); | |
| return r.source && !/duckduckgo|google/i.test(r.source); | |
| }); | |
| return Response.json({ results: unique.slice(0, 8), query: q }); | |
| } catch (e: any) { | |
| return Response.json({ error: e.message, results: [] }, { status: 502 }); | |
| } | |
| }}, | |
| "/api/logs/client": { POST: async (req: Request) => { | |
| try { | |
| const body = await req.json(); | |
| console.error("[ClientError]", body?.message ?? String(body), body?.details ? JSON.stringify(body) : ""); | |
| return Response.json({ ok: true }); | |
| } catch(e: any) { return Response.json({ error: e.message }, { status: 400 }); } | |
| }}, | |
| "/api/vaix/json": async () => { | |
| try { | |
| const r = await fetch(JSON_URL); | |
| if (!r.ok) throw new Error("HTTP "+r.status); | |
| const data = await r.json(); | |
| // Only return top 1000 to reduce server memory | |
| const prods = Array.isArray(data) ? data.slice(0, 1000) : []; | |
| return new Response(JSON.stringify(prods), { | |
| headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| } catch(e) { | |
| return Response.json({ error: "JSON fetch failed: " + e.message }, { status: 502 }); | |
| } | |
| }, | |
| // ── Time-limited discount link validation ── | |
| // The Zalo bot signs ?kh=<ma_kh>&tk=<hmac>&exp=<unix-ts> with a shared | |
| // secret; links expire after 5 minutes. This endpoint verifies the token and | |
| // expiry server-side so a stale link stops applying discounts (the customer | |
| // must message the bot again to receive a fresh link). | |
| "/api/verify-kh": { | |
| GET: async (req: Request) => { | |
| try { | |
| const url = new URL(req.url); | |
| const kh = (url.searchParams.get("kh") || "").trim(); | |
| const tk = (url.searchParams.get("tk") || "").trim(); | |
| const expRaw = (url.searchParams.get("exp") || "").trim(); | |
| if (!kh || !tk || !expRaw) { | |
| return Response.json({ ok: false, error: "missing kh/tk/exp" }, { status: 400 }); | |
| } | |
| const secret = (process.env.DISCOUNT_SECRET || "vai2026ck"); | |
| const exp = Number(expRaw); | |
| if (!Number.isFinite(exp)) return Response.json({ ok: false, error: "bad exp" }, { status: 400 }); | |
| const crypto = await import("node:crypto"); | |
| const expect = crypto.createHmac("sha256", secret) | |
| .update(kh + ":" + expRaw) | |
| .digest("hex").slice(0, 24); | |
| const nowSec = Math.floor(Date.now() / 1000); | |
| // Link is valid only within its 60-minute window: exp must be in the | |
| // future (allow 30s clock skew) AND not beyond now+60min+skew — | |
| // otherwise a stale link's exp could be extended indefinitely. | |
| const maxExp = nowSec + 3600 + 30; | |
| const expired = exp < nowSec - 30; | |
| const tooFar = exp > maxExp; | |
| const ok = (tk === expect) && !expired && !tooFar; | |
| if (!ok) { | |
| return Response.json({ ok: false, expired: !!expired, error: expired ? "link hết hạn" : "sai token" }, { status: 200 }); | |
| } | |
| // Load customer ck (server-side, own data) so the client can apply it. | |
| // Source of truth = bep40/vaistudio-data/customers.json (durable dataset, | |
| // no Space rebuild on write). | |
| let ck: any = null; let maKh = kh; let custName = ""; | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| let data: any = {}; | |
| try { | |
| const r = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/vaistudio_customers.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (r.ok) { | |
| const raw = await r.text(); | |
| if (raw && raw.trim()) { try { data = JSON.parse(raw); } catch (_e) {} } | |
| } | |
| } catch (_e) {} | |
| if (!data || typeof data !== "object" || !Object.keys(data).length) { | |
| const f = join("/app", "customers.json"); | |
| if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_e2) { data = {}; } } | |
| } | |
| if (data && typeof data === "object") { | |
| // If not found locally, also check the ketoan khachhang directory | |
| // (keyed by ma_kh) so ketoan customers resolve even if the web/zalo | |
| // store was clobbered down to a subset. | |
| let found = false; | |
| for (const k in data) { | |
| const c = data[k]; | |
| if (c && String(c.ma_kh || "").trim().toUpperCase() === kh.toUpperCase()) { | |
| // Several records can share the same mã KH (web/zalo + ketoan | |
| // duplicates). The FIRST match may have an empty ck while a | |
| // later record carries the actual per-user chiết khấu. So we | |
| // remember the first match as a fallback but keep scanning: | |
| // a match with a non-empty ck always wins. | |
| if (!found) { | |
| ck = (c.ck != null ? String(c.ck) : null); | |
| maKh = String(c.ma_kh || kh); | |
| custName = c.name || ""; | |
| found = true; | |
| } | |
| if (c.ck != null && String(c.ck).trim() !== "") { | |
| ck = String(c.ck); | |
| maKh = String(c.ma_kh || kh); | |
| custName = c.name || ""; | |
| break; | |
| } | |
| } | |
| } | |
| if (!found) { | |
| try { | |
| const kr = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/khachhang.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (kr.ok) { | |
| const kk = JSON.parse(await kr.text()); | |
| if (Array.isArray(kk)) { | |
| const row = kk.find((x: any) => String(x && x.ma_kh || "").trim().toUpperCase() === kh.toUpperCase()); | |
| if (row) { | |
| const ckC = (Array.isArray(row.ck_codes) ? row.ck_codes : []) | |
| .map((cd: any) => String(cd).replace(/\s*ck\s*/i, " ").trim()).join(", "); | |
| ck = ckC || null; | |
| maKh = String(row.ma_kh || kh); | |
| custName = String(row.ten || maKh); | |
| } | |
| } | |
| } | |
| } catch (_e) {} | |
| } | |
| } | |
| } catch (_e) {} | |
| return Response.json({ ok: true, kh: maKh, ck: ck, customer: custName }); | |
| } catch (e: any) { | |
| return Response.json({ ok: false, error: e.message }, { status: 500 }); | |
| } | |
| }, | |
| }, | |
| // ── Issue a mã KH + fresh time-limited discount link for the web visitor ── | |
| // The Zalo bot only sends ?kh links in Zalo chat. Web visitors who open the | |
| // site directly (no ?kh in URL) still need to SEE their mã KH + a valid | |
| // chiết khấu link on every greeting / non-product reply. The client sends a | |
| // stable visitor id (localStorage vas_cid) + display name; the server mints | |
| // a customer code (persisted to customers.json) and a signed link — so the | |
| // greeting always has a working link, not a dead one. | |
| "/api/kh-link": { | |
| GET: async (req: Request) => { | |
| try { | |
| const url = new URL(req.url); | |
| const cid = (url.searchParams.get("cid") || "").trim().slice(0, 40); | |
| const name = (url.searchParams.get("name") || "").trim().slice(0, 60); | |
| if (!cid) return Response.json({ ok: false, error: "missing cid" }, { status: 400 }); | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| // Source of truth = bep40/vaistudio-data/customers.json (dataset). | |
| let data: any = {}; | |
| try { | |
| const r = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/vaistudio_customers.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (r.ok) { | |
| const raw = await r.text(); | |
| if (raw && raw.trim()) { try { data = JSON.parse(raw); } catch (_e) {} } | |
| } | |
| } catch (_e) {} | |
| const f = join("/app", "customers.json"); | |
| if (!data || typeof data !== "object" || !Object.keys(data).length) { | |
| if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")) || {}; } catch (_e2) { data = {}; } } | |
| } | |
| if (!data || typeof data !== "object") data = {}; | |
| // Merge the ketoan khachhang directory so a kh-link write never drops | |
| // ketoan customers from the write-back payload. | |
| try { | |
| const kr = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/khachhang.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (kr.ok) { | |
| const kk = JSON.parse(await kr.text()); | |
| if (Array.isArray(kk)) { | |
| const byMk = new Set<string>(); | |
| for (const k in data) if (data[k] && data[k].ma_kh) byMk.add(String(data[k].ma_kh).toUpperCase()); | |
| for (const row of kk) { | |
| const mk = String(row && row.ma_kh || "").trim(); | |
| if (!mk || byMk.has(mk.toUpperCase())) continue; | |
| const ck = (Array.isArray(row.ck_codes) ? row.ck_codes : []).map((cd: any) => String(cd).replace(/\s*ck\s*/i, " ").trim()).join(", "); | |
| data[mk] = { name: String(row.ten || mk), ma_kh: mk, cid: "", ck: ck }; | |
| byMk.add(mk.toUpperCase()); | |
| } | |
| } | |
| } | |
| } catch (_e) {} | |
| let rec: any = null; | |
| for (const k in data) { | |
| const c = data[k]; | |
| if (c && String(c.cid || "").trim() === cid) { rec = c; break; } | |
| } | |
| if (!rec) { | |
| // Mint a new code: initials + last 3 of cid (mirror bot's rule), fallback "X". | |
| const base = (name || "KH").replace(/[^A-Za-zÀ-ỹ\s]/g, " ").trim().split(/\s+/) | |
| .map((w: string) => w.charAt(0)).join("").toUpperCase().replace(/[^A-Z]/g, "") || "X"; | |
| const ma_kh = base + cid.slice(-3).toUpperCase(); | |
| rec = { cid: cid, name: name || "", ma_kh: ma_kh, created_at: new Date().toISOString() }; | |
| data[ma_kh] = rec; | |
| // Persist locally (best-effort; mirrors the bot's own persistence). | |
| try { writeFileSync(f, JSON.stringify(data, null, 2), "utf-8"); } catch (_e) {} | |
| // Also push to the durable DATASET so the Zalo bot/KETOAN see it — | |
| // dataset commits do NOT trigger a Space rebuild (the old commit to | |
| // the vai-avatar2 Space repo rebuilt the Space on every new visitor). | |
| try { | |
| if (token) { | |
| const payload = [ | |
| { key: "header", value: { summary: "Add web visitor " + ma_kh + " via /api/kh-link", repo: { type: "dataset", id: "bep40/vaistudio-data" } } }, | |
| { key: "file", value: { path: "vaistudio_customers.json", content: JSON.stringify(data, null, 2) } }, | |
| ].map((o) => JSON.stringify(o)).join("\n") + "\n"; | |
| await fetch("https://huggingface.co/api/datasets/bep40/vaistudio-data/commit/main", { | |
| method: "POST", headers: { "Content-Type": "application/x-ndjson", Authorization: "Bearer " + token }, | |
| body: payload, signal: AbortSignal.timeout(15000), | |
| }).catch(() => {}); | |
| } | |
| } catch (_e2) {} | |
| } | |
| const maKh = String(rec.ma_kh || ""); | |
| const secret = (process.env.DISCOUNT_SECRET || "vai2026ck"); | |
| const exp = Math.floor(Date.now() / 1000) + 3600; | |
| const crypto = await import("node:crypto"); | |
| const tk = crypto.createHmac("sha256", secret).update(maKh + ":" + exp).digest("hex").slice(0, 24); | |
| const link = "https://bep40-vai-avatar.hf.space/?kh=" + encodeURIComponent(maKh) + "&tk=" + tk + "&exp=" + exp; | |
| const ck = rec.ck != null ? String(rec.ck) : null; | |
| return Response.json({ ok: true, kh: maKh, ck: ck, customer: rec.name || "", link: link }); | |
| } catch (e: any) { | |
| return Response.json({ ok: false, error: e.message }, { status: 500 }); | |
| } | |
| }, | |
| }, | |
| // ── Durable "Thêm SP" (Add Product) — writes the product into the shared | |
| // catalog dataset bep40/grob-products-updated/products_url_added.json. | |
| // That file is loaded by BOTH avatar2 (see vaix-rag.js) and the Zalo bot | |
| // (vaistudio-zalo-bot load_products()) — so a product added from the | |
| // avatar2 admin panel becomes permanent and appears in the Zalo bot's | |
| // catalog after its next reload. Format matches the bot's _scrape_product_url. | |
| "/api/add-product": { | |
| POST: async (req: Request) => { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const prod = body?.product; | |
| if (!prod || typeof prod !== "object") return Response.json({ error: "Missing product" }, { status: 400 }); | |
| // Normalize to the zalobot products_url_added format. | |
| const sku = String(prod.sku || prod.mod || prod.model || ""); | |
| if (!sku) return Response.json({ error: "Missing SKU/model" }, { status: 400 }); | |
| const now = new Date().toISOString(); | |
| const rec: any = { | |
| n: prod.n || prod.name || prod.title_clean || "", | |
| l: prod.l || prod.link || prod.url || "", | |
| i: prod.i || prod.image || (Array.isArray(prod.images) ? prod.images[0] : "") || "", | |
| p: prod.p || (prod.priceNum ? String(prod.priceNum) : "") || "Liên hệ", | |
| pn: Number(prod.pn != null ? prod.pn : (prod.priceNum || 0)), | |
| listPn: Number(prod.listPn != null ? prod.listPn : 0) || 0, | |
| salePn: Number(prod.salePn != null ? prod.salePn : (prod.pn || prod.priceNum || 0)) || 0, | |
| priceMode: prod.priceMode || "sale", | |
| c: prod.c || prod.category || "", | |
| cs: prod.cs || prod.category_slug || "san-pham-them-moi", | |
| ci: prod.ci || prod.category_icon || "fa-box", | |
| imgs: Array.isArray(prod.imgs) ? prod.imgs : (Array.isArray(prod.images) ? prod.images : []), | |
| sum: prod.sum || prod.summary || "", | |
| desc: prod.desc || prod.description || prod.summary || "", | |
| specs: (prod.specs && typeof prod.specs === "object") ? prod.specs : {}, | |
| feats: Array.isArray(prod.feats) ? prod.feats : (Array.isArray(prod.features) ? prod.features : []), | |
| sku: sku, | |
| vid: prod.vid || "", | |
| mod: prod.mod || prod.model || sku, | |
| brand: prod.brand || "", | |
| slug: prod.slug || String(sku).toLowerCase(), | |
| _source: "avatar2-add-product", | |
| _source_url: prod.url || prod.link || "", | |
| _added_at: now, | |
| }; | |
| if (rec.imgs.length && !rec.i) rec.i = rec.imgs[0]; | |
| if (rec.i && rec.imgs.indexOf(rec.i) < 0) rec.imgs.unshift(rec.i); | |
| // Read current products_url_added.json (may not exist yet). | |
| let list: any[] = []; | |
| const CAT_DATASET = "bep40/grob-products-updated"; | |
| const CAT_FILE = "products_url_added.json"; | |
| try { | |
| const ra = await fetch("https://huggingface.co/datasets/" + CAT_DATASET + "/resolve/main/" + CAT_FILE, { signal: AbortSignal.timeout(8000) }); | |
| if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; } | |
| } catch (_e) {} | |
| // Upsert by slug or by link, mirroring zalobot add_product_from_url(). | |
| // CRITICAL FIX: only match an existing record on `x.l === rec.l` when | |
| // BOTH links are non-empty. Previously an empty rec.l ("") matched the | |
| // first record with an empty l in the list, so adding a SECOND product | |
| // via URL silently overwrote the FIRST product's full record (images, | |
| // description, specs all wiped → product kept only a name in promos). | |
| const key = (rec.slug || "").toLowerCase(); | |
| const keyL = (rec.l || "").toLowerCase(); | |
| let replaced = false; | |
| for (let i = 0; i < list.length; i++) { | |
| const x = list[i]; | |
| const xSlug = (x && (x.slug || "")).toLowerCase(); | |
| const xLink = (x && (x.l || "")).toLowerCase(); | |
| const slugMatch = !!key && xSlug === key; | |
| const linkMatch = !!keyL && !!xLink && xLink === keyL; | |
| if (slugMatch || linkMatch) { | |
| list[i] = rec; replaced = true; break; | |
| } | |
| } | |
| if (!replaced) list.unshift(rec); | |
| const content = JSON.stringify(list); | |
| const payload = [ | |
| { key: "header", value: { summary: "Add product from avatar2 (V.AISTUDIO code)", repo: { type: "dataset", id: CAT_DATASET } } }, | |
| { key: "file", value: { path: CAT_FILE, content } }, | |
| ].map((s) => JSON.stringify(s)).join("\n"); | |
| const commit = await fetch("https://huggingface.co/api/datasets/" + CAT_DATASET + "/commit/main", { | |
| method: "POST", | |
| headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: payload, signal: AbortSignal.timeout(20000), | |
| }); | |
| if (!commit.ok) { | |
| const errTxt = (await commit.text().catch(() => "")).slice(0, 300); | |
| return Response.json({ error: "Dataset commit failed: HTTP " + commit.status + " " + errTxt }, { status: 502 }); | |
| } | |
| // Also mirror into promos.json so the avatar2 panel/search sees it immediately. | |
| try { | |
| const cur = await readPromos(token); | |
| if (!cur.productEdits || typeof cur.productEdits !== "object") cur.productEdits = {}; | |
| cur.productEdits[String(sku)] = { | |
| name: rec.n, description: rec.desc || rec.n, priceNum: rec.pn, | |
| listPn: rec.listPn || 0, salePn: rec.salePn || rec.pn || 0, priceMode: rec.priceMode || "sale", | |
| image: rec.i, images: rec.imgs, specs: rec.specs, features: rec.feats, | |
| model: sku, brand: rec.brand, category: rec.c, category_slug: rec.cs, | |
| category_icon: rec.ci, slug: rec.slug, _addedBy: "vai-add-product" | |
| }; | |
| await writePromos(token, cur); | |
| } catch (e2: any) { console.error("[add-product] promos mirror failed", e2?.message); } | |
| // ── Auto-promo: FLASHSALE (<40%) / BIGSALE (>=40%) ── | |
| // When the product has BOTH prices (listPn > salePn), compute the | |
| // discount %. If >= 40% the product is pushed into the durable | |
| // BIGSALE list (promos.json bigsale[]) so it shows under the BIG SALE | |
| // panel; if < 40% it is picked up automatically by the FLASHSALE | |
| // panel (which uses catalog products that have both prices, list>sale, | |
| // discount < 40%). The discount% is stored on the record so the panel | |
| // can label it (-XX%). | |
| try { | |
| const _l = Number(rec.listPn || 0), _s = Number(rec.salePn || rec.pn || 0); | |
| if (_l > 0 && _s > 0 && _l > _s) { | |
| const _disc = Math.round((_l - _s) / _l * 100); | |
| if (_disc >= 40) { | |
| const cur2 = await readPromos(token); | |
| const arr = Array.isArray(cur2.bigsale) ? cur2.bigsale : []; | |
| const idx = arr.findIndex((b: any) => !!b && norm(String(b.code || "")) === norm(String(sku))); | |
| const bsItem = { | |
| code: sku, name: rec.n, category: rec.c || "", price: _l.toLocaleString("vi-VN"), | |
| bigsale: _s.toLocaleString("vi-VN"), discount: _disc + "%", status: "TỰ ĐỘNG", | |
| qty: 1, image: rec.i || (Array.isArray(rec.imgs) ? rec.imgs[0] : "") || "", | |
| }; | |
| if (idx >= 0) arr[idx] = bsItem; else arr.unshift(bsItem); | |
| cur2.bigsale = arr; | |
| await writePromos(token, cur2); | |
| } | |
| } | |
| } catch (e3: any) { console.error("[add-product] auto-bigsale failed", e3?.message); } | |
| return Response.json({ ok: true, replaced, product: rec, file: CAT_DATASET + "/" + CAT_FILE }); | |
| } catch (e: any) { | |
| console.error("[add-product]", e?.message); | |
| return Response.json({ error: e.message || String(e) }, { status: 502 }); | |
| } | |
| }, | |
| }, | |
| // ── Bulk parse: admin uploads a catalogue file (Excel/CSV/PDF/image) and | |
| // the server auto-detects columns + rows → returns structured products | |
| // for an editable preview. Never commits; the admin confirms via | |
| // /api/bulk-add-products. Parsing is deterministic (Excel/CSV/PDF-text); | |
| // images/scanned-PDFs use best-effort AI vision and degrade gracefully. | |
| "/api/bulk-parse": { | |
| POST: async (req: Request) => { | |
| try { | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const data = body?.data; // base64 data-URL of the file | |
| const filename = String(body?.filename || body?.name || "catalogue.xlsx"); | |
| const opts: any = { defaultCategory: String(body?.defaultCategory || "") || undefined }; | |
| const res: any = await parseBulkFile(data, filename, opts); | |
| return Response.json(res); | |
| } catch (e: any) { | |
| return Response.json({ ok: false, products: [], ready: false, reason: String(e?.message || e) }, { status: 200 }); | |
| } | |
| }, | |
| }, | |
| // ── Parse raw OCR text (from the in-browser tesseract.js pipeline) into | |
| // structured products. The frontend OCRs an image/PDF page into plain | |
| // text client-side (no AI credits), then sends it here for column/row | |
| // detection — deterministic, mirrors the CSV/PDF-text parser. | |
| "/api/parse-text": { | |
| POST: async (req: Request) => { | |
| try { | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const text = String(body?.text || ""); | |
| const opts: any = { defaultCategory: String(body?.defaultCategory || "") || undefined }; | |
| // First try the fast deterministic parser. | |
| const res: any = await parseTextContent(text, opts); | |
| const detProds = (Array.isArray(res?.products) ? res.products : []); | |
| const detTrustworthy = detProds.length >= 1 && (!res.reason || /product rows/i.test(String(res.reason))); | |
| // If deterministic gave nothing (ragged OCR, merged columns), fall back | |
| // to the Gemma structured extractor for accuracy — exactly the "PDF/ảnh | |
| // trích xuất tầm bậy" case the admin reported. | |
| if (!detTrustworthy || body?.forceAI) { | |
| try { | |
| const ai = await aiExtractProductsFromText(text, opts); | |
| if (ai.ok && Array.isArray(ai.products) && ai.products.length) { | |
| const aiProds = ai.products.map((p: any) => normalizeCatalogProduct(p, opts)).filter((p: any) => p && (p.name || p.sku)); | |
| const merged = detProds.length && aiProds.length ? aiProds : (aiProds.length ? aiProds : detProds); | |
| const finalRes: any = { ok: true, products: merged, ready: true, count: merged.length, aiExtracted: aiProds.length > 0, model: ai.model }; | |
| if (detProds.length && !aiProds.length) finalRes.warnings = res.warnings || []; | |
| return Response.json(finalRes); | |
| } | |
| } catch (_e) { /* fall through to deterministic result */ } | |
| } | |
| return Response.json(res); | |
| } catch (e: any) { | |
| return Response.json({ ok: false, products: [], ready: false, reason: String(e?.message || e) }, { status: 200 }); | |
| } | |
| }, | |
| }, | |
| // ── Bulk add: commit an array of confirmed products (from the bulk-import | |
| // preview) into the shared catalog dataset + promos overlay, all at once. | |
| "/api/bulk-add-products": { | |
| POST: async (req: Request) => { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const items: any[] = Array.isArray(body?.products) ? body.products : []; | |
| if (!items.length) return Response.json({ error: "No products to add" }, { status: 400 }); | |
| const now = new Date().toISOString(); | |
| // Normalize + build catalog records (skip empties / missing sku+name). | |
| const recs = items | |
| .map((p) => normalizeCatalogProduct(p)) | |
| .filter((p: any) => p && (p.name || p.sku) && (p.sku || p.name)) | |
| .map(normalizeCatalogProduct) | |
| .map((p: any) => buildCatalogRecord(p)) | |
| .map((r: any) => { if (!r.sku) r.sku = String(r.n || "sp").toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "san-pham-bulk"; return r; }); | |
| if (!recs.length) return Response.json({ error: "No valid products (need name or SKU)" }, { status: 400 }); | |
| // Read current products_url_added.json and upsert all. | |
| let list: any[] = []; | |
| try { | |
| const ra = await fetch("https://huggingface.co/datasets/" + CATALOG_DATASET + "/resolve/main/" + CATALOG_FILE, { signal: AbortSignal.timeout(8000) }); | |
| if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; } | |
| } catch (_e) {} | |
| const existing = new Set(list.map((x: any) => String((x.slug || x.sku || "").toString().toLowerCase())).concat(list.map((x: any) => String((x.l || "").toLowerCase()))).filter(Boolean)); | |
| const added: any[] = []; const duplicates: string[] = []; | |
| for (const r of recs) { | |
| const key = String((r.slug || r.sku || "")).toLowerCase(); | |
| const lkey = String(r.l || "").toLowerCase(); | |
| if ((key && existing.has(key)) || (lkey && existing.has(lkey))) { duplicates.push(r.n || r.sku || ""); continue; } | |
| list.push(r); existing.add(key); existing.add(lkey); | |
| added.push(r); | |
| } | |
| if (!added.length) return Response.json({ ok: true, added: [], duplicates, addedCount: 0, message: "No new products (all duplicates)" }); | |
| const content = JSON.stringify(list); | |
| const payload = [ | |
| { key: "header", value: { summary: "Bulk add products from avatar2 (V.AISTUDIO code)", repo: { type: "dataset", id: CATALOG_DATASET } } }, | |
| { key: "file", value: { path: CATALOG_FILE, content } }, | |
| ].map((s) => JSON.stringify(s)).join("\n"); | |
| const commit = await fetch("https://huggingface.co/api/datasets/" + CATALOG_DATASET + "/commit/main", { | |
| method: "POST", | |
| headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: payload, signal: AbortSignal.timeout(30000), | |
| }); | |
| if (!commit.ok) { | |
| const errTxt = (await commit.text().catch(() => "")).slice(0, 300); | |
| return Response.json({ error: "Dataset commit failed: HTTP " + commit.status + " " + errTxt }, { status: 502 }); | |
| } | |
| // Mirror into promos.json so the avatar2 panel/search sees them immediately. | |
| try { | |
| const cur = await readPromos(token); | |
| if (!cur.productEdits || typeof cur.productEdits !== "object") cur.productEdits = {}; | |
| let changed = false; | |
| for (const r of added) { | |
| cur.productEdits[String(r.sku)] = { | |
| name: r.n, description: r.desc || r.n, priceNum: r.pn, | |
| listPn: r.listPn || 0, salePn: r.salePn || r.pn || 0, priceMode: r.priceMode || "sale", | |
| image: r.i, images: r.imgs, specs: r.specs, features: r.feats, | |
| model: r.sku, brand: r.brand, category: r.c, category_slug: r.cs, | |
| category_icon: r.ci, slug: r.slug, _addedBy: "vai-add-product", _bulk: true | |
| }; | |
| changed = true; | |
| } | |
| if (changed) await writePromos(token, cur); | |
| } catch (e2: any) { console.error("[bulk-add] promos mirror failed", e2?.message); } | |
| return Response.json({ ok: true, addedCount: added.length, added: added.map(a => ({ name: a.n, sku: a.sku })), duplicates, file: CATALOG_DATASET + "/" + CATALOG_FILE }); | |
| } catch (e: any) { | |
| console.error("[bulk-add-products]", e?.message); | |
| return Response.json({ error: e.message || String(e) }, { status: 502 }); | |
| } | |
| }, | |
| }, | |
| "/api/delete-product": { | |
| POST: async (req: Request) => { | |
| // Admin-only: permanently remove a product the admin added (via URL/OCR). | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const sku = String(body?.sku || "").trim(); | |
| const slug = String(body?.slug || "").trim(); | |
| if (!sku && !slug) return Response.json({ error: "Missing sku/slug" }, { status: 400 }); | |
| const CAT_DATASET = "bep40/grob-products-updated"; | |
| const CAT_FILE = "products_url_added.json"; | |
| // 1) Remove from products_url_added.json (the permanent shared file). | |
| let list: any[] = []; | |
| let hadAdded = false; | |
| try { | |
| const ra = await fetch("https://huggingface.co/datasets/" + CAT_DATASET + "/resolve/main/" + CAT_FILE, { signal: AbortSignal.timeout(10000) }); | |
| if (ra.ok) { const d = await ra.json(); if (Array.isArray(d)) list = d; } | |
| } catch (_e) {} | |
| const SKU = sku.toUpperCase(); | |
| const prevCount = list.length; | |
| list = list.filter((x) => { | |
| if (!x) return false; | |
| const mSku = String(x.sku || "").toUpperCase(); | |
| const mSlug = String(x.slug || "").toLowerCase(); | |
| return !((SKU && mSku === SKU) || (slug && mSlug === slug.toLowerCase())); | |
| }); | |
| hadAdded = list.length !== prevCount; | |
| if (hadAdded) { | |
| const content = JSON.stringify(list); | |
| const payload = [ | |
| { key: "header", value: { summary: "Delete added product from avatar2 admin", repo: { type: "dataset", id: CAT_DATASET } } }, | |
| { key: "file", value: { path: CAT_FILE, content } }, | |
| ].map((s) => JSON.stringify(s)).join("\n"); | |
| const commit = await fetch("https://huggingface.co/api/datasets/" + CAT_DATASET + "/commit/main", { | |
| method: "POST", | |
| headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: payload, signal: AbortSignal.timeout(20000), | |
| }); | |
| if (!commit.ok) return Response.json({ error: "Dataset commit failed (" + commit.status + ")" }, { status: 502 }); | |
| } | |
| // 2) Mirror-remove from promos.json productEdits so the panel updates fast. | |
| let removedFromPromos = false; | |
| try { | |
| const cur = await readPromos(token); | |
| if (cur.productEdits && typeof cur.productEdits === "object") { | |
| let changed = false; | |
| if (sku && cur.productEdits[sku] != null) { delete cur.productEdits[sku]; changed = true; } | |
| if (slug) { | |
| for (const k of Object.keys(cur.productEdits)) { | |
| const ed = cur.productEdits[k]; | |
| if (ed && (ed.slug === slug || String(ed.model || ed.sku || k).toUpperCase() === SKU)) { | |
| delete cur.productEdits[k]; changed = true; | |
| } | |
| } | |
| } | |
| if (changed) { await writePromos(token, cur); removedFromPromos = true; } | |
| } | |
| } catch (e2: any) { console.error("[delete-product] promos mirror failed", e2?.message); } | |
| return Response.json({ ok: true, removed: hadAdded, removedFromPromos, file: CAT_DATASET + "/" + CAT_FILE }); | |
| } catch (e: any) { | |
| console.error("[delete-product]", e?.message); | |
| return Response.json({ error: e.message || String(e) }, { status: 502 }); | |
| } | |
| }, | |
| }, | |
| "/api/delete-promo": { | |
| POST: async (req: Request) => { | |
| // Admin-only: permanently remove a BIGSALE item (by code) or a COMBO | |
| // (by code/name/slug) from promos.json. Same x-vai-admin gate as | |
| // /api/delete-product so discount-link visitors stay locked out. | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| const isAdmin = (req.headers.get("x-vai-admin") || "") === "1"; | |
| if (!isAdmin) return Response.json({ error: "Admin unlock required" }, { status: 403 }); | |
| const body: any = await req.json().catch(() => ({})); | |
| const type = String(body?.type || "").trim(); | |
| const key = String(body?.key || "").trim(); | |
| if (!["bigsale", "combo"].includes(type)) return Response.json({ error: "type must be bigsale|combo" }, { status: 400 }); | |
| if (!key) return Response.json({ error: "Missing key" }, { status: 400 }); | |
| const cur = await readPromos(token); | |
| const normK = (s: any) => | |
| String(s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[đĐ]/g, "d").replace(/[.\-\s]/g, ""); | |
| const normKey = normK(key); | |
| let removed = 0; | |
| if (type === "bigsale") { | |
| const arr = Array.isArray(cur.bigsale) ? cur.bigsale : []; | |
| const next = arr.filter((it: any) => normK(it?.code) !== normKey); | |
| removed = arr.length - next.length; | |
| cur.bigsale = next; | |
| } else { | |
| const arr = Array.isArray(cur.combo) ? cur.combo : []; | |
| const next = arr.filter((it: any) => normK(it?.code || it?.name || it?.slug) !== normKey); | |
| removed = arr.length - next.length; | |
| cur.combo = next; | |
| } | |
| if (!removed) return Response.json({ ok: true, removed: 0 }); | |
| await writePromos(token, cur); | |
| return Response.json({ ok: true, removed, type }); | |
| } catch (e: any) { | |
| console.error("[delete-promo]", e?.message); | |
| return Response.json({ error: e.message || String(e) }, { status: 502 }); | |
| } | |
| }, | |
| }, | |
| "/api/promos": { | |
| GET: async () => { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| // v55: memoize GET 30s — the page fires 2 identical GETs at boot. | |
| const nowP = Date.now(); | |
| if (PROMOS_MEMO.body && (nowP - PROMOS_MEMO.at) < 30000) { | |
| return Response.json(PROMOS_MEMO.body, { headers: { "Cache-Control": "public, max-age=30" } }); | |
| } | |
| const d = await readPromos(token || undefined); | |
| PROMOS_MEMO.body = d; PROMOS_MEMO.at = nowP; | |
| return Response.json(d, { headers: { "Cache-Control": "public, max-age=30" } }); | |
| } catch (e: any) { return Response.json({ error: e.message }, { status: 500 }); } | |
| }, | |
| POST: async (req: Request) => { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| let body: any = {}; | |
| try { body = await req.json(); } catch (_) {} | |
| if (!body || typeof body !== "object") return Response.json({ error: "Bad body" }, { status: 400 }); | |
| const cur = await readPromos(token); | |
| const merge = cur; | |
| if (!merge.productEdits || typeof merge.productEdits !== "object") merge.productEdits = {}; | |
| if (body.resetProductEdits) { merge.productEdits = {}; } | |
| if (body.productEdits && typeof body.productEdits === "object" && !body.resetProductEdits) { | |
| for (const k in body.productEdits) merge.productEdits[k] = body.productEdits[k]; | |
| } | |
| if (body.resetBigsale === true) { merge.bigsale = Array.isArray(body.bigsale) ? body.bigsale : []; } | |
| else if (Array.isArray(body.bigsale)) { merge.bigsale = body.bigsale; } | |
| if (body.resetCombo === true) { merge.combo = Array.isArray(body.combo) ? body.combo : []; } | |
| else if (Array.isArray(body.combo)) { merge.combo = body.combo; } | |
| await writePromos(token, merge); | |
| return Response.json({ ok: true, promos: merge }); | |
| } catch (e: any) { return Response.json({ error: e.message }, { status: 502 }); } | |
| }, | |
| }, | |
| "/api/customers": { | |
| GET: async () => { | |
| try { | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| const authH = token ? { Authorization: "Bearer " + token } : {}; | |
| // 1) ALWAYS start from the local mirror (fast, never blocks) so the list | |
| // renders instantly and NEVER 500s even if the Hub is slow/unreachable. | |
| let data: any = {}; | |
| const f = join("/app", "customers.json"); | |
| if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_n) {} } | |
| if (!data || typeof data !== "object") data = {}; | |
| // 2) Fetch the remote sources IN PARALLEL with a tight budget. Whoever | |
| // wins is merged (space mirror > ketoan > dataset) so we always return | |
| // the FULL union of {dataset customers, ketoan khachhang, space mirror}. | |
| // Any fetch that is slow or fails is simply skipped — it can never make | |
| // the whole request hang or 500. | |
| const src = (u: string) => | |
| fetch(u, { headers: authH, signal: AbortSignal.timeout(2500) }) | |
| .then((r) => (r.ok ? r.text() : null)) | |
| .catch(() => null); | |
| try { | |
| const [mrRaw, krRaw, cRaw] = await Promise.all([ | |
| src("https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/customers.json"), | |
| src("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/khachhang.json"), | |
| src("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/vaistudio_customers.json"), | |
| ]); | |
| // dataset customers (keyed by cid) — the base | |
| if (cRaw && cRaw.trim()) { | |
| try { const cd: any = JSON.parse(cRaw); if (cd && typeof cd === "object") { | |
| for (const k in cd) if (cd[k] && typeof cd[k] === "object") data[k] = cd[k]; | |
| } } catch (_n) {} | |
| } | |
| // ketoan khachhang (by ma_kh), fills customers with ck codes | |
| if (krRaw && krRaw.trim()) { | |
| try { | |
| const kk: any = JSON.parse(krRaw); | |
| if (Array.isArray(kk)) { | |
| const byMk = new Set<string>(); | |
| for (const k in data) if (data[k] && data[k].ma_kh) byMk.add(String(data[k].ma_kh).toUpperCase()); | |
| for (const row of kk) { | |
| const mk = String(row && row.ma_kh || "").trim(); | |
| if (!mk) continue; | |
| if (byMk.has(mk.toUpperCase())) { | |
| // keep existing ma_kh entry; backfill ck/name if richer | |
| for (const k in data) { | |
| if (data[k] && String(data[k].ma_kh || "").toUpperCase() === mk.toUpperCase()) { | |
| if (!data[k].ck && Array.isArray(row.ck_codes)) { | |
| data[k].ck = row.ck_codes.map((cd: any) => String(cd).replace(/\s*ck\s*/i, " ").trim()).join(", "); | |
| } | |
| if (!data[k].name && row.ten) data[k].name = row.ten; | |
| } | |
| } | |
| continue; | |
| } | |
| const ck = (Array.isArray(row.ck_codes) ? row.ck_codes : []).map((cd: any) => String(cd).replace(/\s*ck\s*/i, " ").trim()).join(", "); | |
| data[mk] = { name: String(row.ten || mk), ma_kh: mk, cid: "", ck: ck }; | |
| byMk.add(mk.toUpperCase()); | |
| } | |
| } | |
| } catch (_n) {} | |
| } | |
| // space mirror (bep40/vai-avatar2/customers.json — the web/zalo | |
| // backup the ketoan sync never touches) — keeps web/zalo customers | |
| // (e.g. NBV2fc, TBTM0a7) even if a concurrent writer clobbered the | |
| // dataset down to a subset. | |
| if (mrRaw && mrRaw.trim()) { | |
| try { | |
| const sp: any = JSON.parse(mrRaw); | |
| if (sp && typeof sp === "object") { | |
| for (const k in sp) { | |
| const v = sp[k]; | |
| if (!v || typeof v !== "object") continue; | |
| if (!data[k]) data[k] = v; | |
| else if (!data[k].ck && v.ck) data[k].ck = v.ck; | |
| } | |
| } | |
| } catch (_n) {} | |
| } | |
| // Best-effort: write this merged union back to the local mirror so it | |
| // boots faster next time and survives a Hub outage. | |
| try { if (Object.keys(data).length) writeFileSync(f, JSON.stringify(data, null, 2)); } catch (_n) {} | |
| } catch (_e) {} | |
| return new Response(JSON.stringify(data), { | |
| headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| } catch (e: any) { | |
| // NEVER 500 on a slow/failed Hub fetch — return whatever we have. | |
| try { | |
| const f = join("/app", "customers.json"); | |
| let local: any = {}; | |
| if (existsSync(f)) { try { local = JSON.parse(readFileSync(f, "utf-8")); } catch (_n) {} } | |
| if (local && typeof local === "object") return new Response(JSON.stringify(local), { | |
| headers: { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| } catch (_n) {} | |
| return new Response(JSON.stringify({ error: String(e && e.message || e), customers: {} }), { | |
| status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } | |
| }); | |
| } | |
| }, | |
| POST: async (req: Request) => { | |
| try { | |
| const body = await req.json().catch(() => ({})); | |
| if (!body || typeof body !== "object") return Response.json({ error: "Invalid body" }, { status: 400 }); | |
| if (String(body.accessCode || "") !== "V.AISTUDIO") { | |
| return Response.json({ error: "Unauthorized" }, { status: 401 }); | |
| } | |
| const candidates = typeof body.customer === "object" && body.customer !== null ? [body.customer] : (Array.isArray(body.customers) ? body.customers : []); | |
| if (!candidates.length) return Response.json({ error: "No customers provided" }, { status: 400 }); | |
| const f = join("/app", "customers.json"); | |
| let data: any = {}; | |
| // Merge base = LIVE durable dataset (not the possibly-stale local file), | |
| // so concurrent writes from the bot / other sessions are preserved. | |
| const token = (process.env.HF_INFERENCE_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| try { | |
| const r = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/vaistudio_customers.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (r.ok) { | |
| const raw = await r.text(); | |
| if (raw && raw.trim()) { try { data = JSON.parse(raw); } catch (_e) {} } | |
| } | |
| } catch (_e) {} | |
| if (!data || typeof data !== "object" || !Object.keys(data).length) { | |
| if (existsSync(f)) { try { data = JSON.parse(readFileSync(f, "utf-8")); } catch (_e) { data = {}; } } | |
| } | |
| if (!data || typeof data !== "object") data = {}; | |
| // Merge the ketoan khachhang directory into the write base too, so a | |
| // POST never drops ketoan customers that a concurrent clobber removed | |
| // from vaistudio_customers.json. | |
| try { | |
| const kr = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/khachhang.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (kr.ok) { | |
| const kk = JSON.parse(await kr.text()); | |
| if (Array.isArray(kk)) { | |
| const byMk = new Set<string>(); | |
| for (const k in data) if (data[k] && data[k].ma_kh) byMk.add(String(data[k].ma_kh).toUpperCase()); | |
| for (const row of kk) { | |
| const mk = String(row && row.ma_kh || "").trim(); | |
| if (!mk || byMk.has(mk.toUpperCase())) continue; | |
| const ck = (Array.isArray(row.ck_codes) ? row.ck_codes : []).map((cd: any) => String(cd).replace(/\s*ck\s*/i, " ").trim()).join(", "); | |
| data[mk] = { name: String(row.ten || mk), ma_kh: mk, cid: "", ck: ck }; | |
| byMk.add(mk.toUpperCase()); | |
| } | |
| } | |
| } | |
| } catch (_e) {} | |
| // Merge the SPACE mirror (bep40/vai-avatar2/customers.json — the | |
| // durable web/zalo backup that the ketoan sync never touches) so a web | |
| // POST never drops web/zalo customers (e.g. NBV2fc) that a concurrent | |
| // ketoan sync removed from the dataset. The bot's write path already | |
| // merges this mirror; this extends the same self-healing to web writes. | |
| try { | |
| const mr = await fetch("https://huggingface.co/spaces/bep40/vai-avatar2/resolve/main/customers.json", { | |
| headers: token ? { Authorization: "Bearer " + token } : {}, | |
| signal: AbortSignal.timeout(6000), | |
| }); | |
| if (mr.ok) { | |
| const sp: any = JSON.parse(await mr.text()); | |
| if (sp && typeof sp === "object") { | |
| for (const k in sp) { | |
| const v = sp[k]; | |
| if (!v || !v.cid) continue; | |
| if (data[k]) { | |
| if (!data[k].ck && v.ck) data[k].ck = v.ck; | |
| } else { | |
| data[k] = Object.assign({}, v); | |
| } | |
| } | |
| } | |
| } | |
| } catch (_e) {} | |
| let changed = 0; | |
| candidates.forEach((c: any) => { | |
| const cid = String(c && c.cid || ""); | |
| if (!cid) return; | |
| data[cid] = Object.assign({}, data[cid] || {}, c); | |
| changed++; | |
| }); | |
| if (!changed) return Response.json({ error: "No valid customers" }, { status: 400 }); | |
| const content = JSON.stringify(data, null, 2); | |
| if (!token) return Response.json({ error: "No write token on server" }, { status: 500 }); | |
| // Commit to the durable DATASET — NOT the Space repo — so saving a | |
| // customer / CK never triggers a vai-avatar2 rebuild (the old commit | |
| // rebuilt the Space on every save and concurrent writers lost data). | |
| const payload = [ | |
| { key: "header", value: { summary: "Update customers via api", repo: { type: "dataset", id: "bep40/vaistudio-data" } } }, | |
| { key: "file", value: { path: "vaistudio_customers.json", content } }, | |
| ].map((x) => JSON.stringify(x)).join("\n"); | |
| const commit = await fetch("https://huggingface.co/api/datasets/bep40/vaistudio-data/commit/main", { | |
| method: "POST", | |
| headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" }, | |
| body: payload, | |
| }); | |
| if (!commit.ok) { | |
| const errText = await commit.text().catch(() => ""); | |
| return Response.json({ error: "Commit failed: HTTP " + commit.status + " " + errText.slice(0, 300) }, { status: 502 }); | |
| } | |
| // Persist locally too so the live GET keeps reflecting the save | |
| // immediately (no rebuild dependency). | |
| try { writeFileSync(f, content); } catch (_e) {} | |
| // NOTE: we do NOT write-through to the vai-avatar2 Space mirror here — | |
| // committing to the Space repo triggers a rebuild on every save (the | |
| // exact data-loss race we removed). Instead the mirror is seeded with | |
| // the durable web/zalo backup and merged on every read/write, so it | |
| // self-heals (restores customers like NBV2fc after a ketoan sync | |
| // clobbers the dataset) without any rebuild. | |
| return Response.json({ ok: true, updated: changed, customers: data }); | |
| } catch (e: any) { | |
| return Response.json({ error: e.message }, { status: 500 }); | |
| } | |
| }, | |
| }, | |
| "/api/zalo-imports": { | |
| GET: async () => { | |
| try { | |
| const token = String(Bun.env.HF_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| const headers: Record<string, string> = {}; | |
| if (token) headers.Authorization = "Bearer " + token; | |
| const r = await fetch("https://huggingface.co/api/datasets/bep40/vaistudio-data/tree/main/zalo-imports", { headers }); | |
| if (!r.ok) return Response.json({ ok: false, files: [], error: "List failed HTTP " + r.status }, { status: 502 }); | |
| const tree: any = await r.json(); | |
| const files = (Array.isArray(tree) ? tree : []).filter((x: any) => x && x.type === "file" && x.path && String(x.path).indexOf("zalo-imports/") === 0).map((x: any) => ({ name: String(x.path.split("/").pop() || ""), path: x.path, size: x.size || 0, lfs: !!x.lfs })); | |
| return Response.json({ ok: true, files }); | |
| } catch (e: any) { return Response.json({ ok: false, files: [], error: String((e && e.message) || e) }, { status: 500 }); } | |
| } | |
| }, | |
| "/api/zalo-import-file": { | |
| GET: async (req: Request) => { | |
| try { | |
| const url = new URL(req.url); | |
| const name = String(url.searchParams.get("name") || "").trim(); | |
| if (!name) return Response.json({ ok: false, error: "Missing name" }, { status: 400 }); | |
| const safeName = String(name.split("/").pop() || name).replace(/[?#&]/g, ""); | |
| const token = String(Bun.env.HF_TOKEN || process.env.HF_TOKEN || "").trim(); | |
| const headers: Record<string, string> = {}; | |
| if (token) headers.Authorization = "Bearer " + token; | |
| const r = await fetch("https://huggingface.co/datasets/bep40/vaistudio-data/resolve/main/zalo-imports/" + encodeURIComponent(safeName), { headers }); | |
| if (!r.ok) return Response.json({ ok: false, error: "Fetch failed HTTP " + r.status }, { status: 502 }); | |
| const buf = await r.arrayBuffer(); | |
| return new Response(buf, { headers: { "Content-Type": r.headers.get("content-type") || "application/octet-stream", "Content-Disposition": 'attachment; filename="' + safeName.replace(/"/g, "") + '"', "Cache-Control": "no-store" } }); | |
| } catch (e: any) { return Response.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 }); } | |
| } | |
| }, | |
| "/*": { GET: serveStatic }, | |
| }, | |
| // Bun <1.2 defaults to `fetch` being required; on the Space image the `fetch` | |
| // handler is unused (all routes are explicit) so pass a no-op to satisfy it. | |
| fetch() { return new Response("Not Found", { status: 404 }); }, | |
| }); | |
| console.log("Gemma Avatar listening on port " + PORT + " | Upstream: " + (UPSTREAM || "none")); | |