bep40 commited on
Commit
bf00212
·
verified ·
1 Parent(s): 4e47540

FIX: serve app.js directly (no concat with broken app_part2.js), remove app_part2.js

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