Add backend AI topic keywords and direct AI filter support

#9
by bep40 - opened
Files changed (1) hide show
  1. index.ts +0 -841
index.ts CHANGED
@@ -1,841 +0,0 @@
1
- /**
2
- * Gemma Avatar — realtime voice/text chat with a 3D talking-head avatar.
3
- */
4
- import index from "./index.html";
5
- import { readdir, writeFile } from "fs/promises";
6
- import { join } from "path";
7
-
8
- const LOAD_BALANCER_URL = (Bun.env.LOAD_BALANCER_URL ?? "").trim().replace(/\/$/, "");
9
- const SESSION_PROXY_URL = (Bun.env.SESSION_PROXY_URL ?? "https://smolagents-hf-realtime-voice.hf.space/api").trim().replace(/\/$/, "");
10
- const UPSTREAM = LOAD_BALANCER_URL || SESSION_PROXY_URL;
11
- const PORT = Number(Bun.env.PORT ?? 7860);
12
- const REPO_ID = (Bun.env.SPACE_ID || "bep40/gemma-multi-avatar");
13
-
14
- function sanitizeSetCookie(sc) {
15
- return sc.split(";").map((p) => p.trim()).filter((p) => !/^domain=/i.test(p)).join("; ");
16
- }
17
-
18
- async function proxy(path, req, init = {}) {
19
- const headers = new Headers(init.headers);
20
- headers.set("Content-Type", "application/json");
21
- const cookie = req.headers.get("cookie");
22
- if (cookie) headers.set("Cookie", cookie);
23
- const resp = await fetch(`${UPSTREAM}${path}`, { ...init, headers });
24
- const body = await resp.text();
25
- const out = new Response(body, { status: resp.status, headers: { "Content-Type": "application/json" } });
26
- const setCookies = resp.headers.getSetCookie?.() ?? (resp.headers.get("set-cookie") ? [resp.headers.get("set-cookie")] : []);
27
- for (const sc of setCookies) out.headers.append("Set-Cookie", sanitizeSetCookie(sc));
28
- return out;
29
- }
30
-
31
- function staticFile(dir, name) {
32
- const filePath = join(process.cwd(), "public", dir, name);
33
- const file = Bun.file(filePath);
34
- return new Response(file);
35
- }
36
-
37
- /** List .glb files in public/avatars/ and check for corresponding thumbnails. */
38
- async function listAvatars() {
39
- const avatarsDir = join(process.cwd(), "public/avatars");
40
- const names = [];
41
- try {
42
- const dir = await readdir(avatarsDir, { withFileTypes: true });
43
- for (const entry of dir) {
44
- if (entry.isFile() && entry.name.endsWith(".glb")) {
45
- names.push(entry.name);
46
- }
47
- }
48
- } catch {}
49
- return names.sort();
50
- }
51
-
52
- /** List ALL files in public/avatars/ (for debugging). */
53
- async function listAllAvatarFiles() {
54
- const avatarsDir = join(process.cwd(), "public/avatars");
55
- const files = [];
56
- try {
57
- const dir = await readdir(avatarsDir, { withFileTypes: true });
58
- for (const entry of dir) {
59
- if (entry.isFile()) {
60
- files.push(entry.name);
61
- }
62
- }
63
- } catch {}
64
- return files.sort();
65
- }
66
-
67
- /** Check if a thumbnail exists for a given avatar name. */
68
- async function avatarHasThumbnail(avatarName) {
69
- const thumbName = `${avatarName}.thumb.png`;
70
- const thumbPath = join(process.cwd(), "public/avatars", thumbName);
71
- try {
72
- const file = Bun.file(thumbPath);
73
- const exists = await file.exists();
74
- return exists;
75
- } catch {
76
- return false;
77
- }
78
- }
79
-
80
- const SOURCE_MAP = {
81
- "vnexpress.net": "VnExpress",
82
- "dantri.com.vn": "Dân trí",
83
- "tuoitre.vn": "Tuổi Trẻ",
84
- "thanhnien.vn": "Thanh Niên",
85
- "vietnamnet.vn": "VietNamNet",
86
- "zingnews.vn": "ZingNews",
87
- "kenh14.vn": "Kênh 14",
88
- "cafef.vn": "CafeF",
89
- "genk.vn": "GenK",
90
- "afamily.vn": "Afamily",
91
- "plo.vn": "PLO",
92
- "vtc.vn": "VTC",
93
- "laodong.vn": "Lao Động",
94
- "nguoiduatin.vn": "Người Đưa Tin",
95
- "tienphong.vn": "Tiền Phong",
96
- "vov.vn": "VOV",
97
- "baohatinh.vn": "Báo Hà Tĩnh",
98
- };
99
-
100
- function sourceNameFromUrl(feedUrl) {
101
- try {
102
- const hostname = new URL(feedUrl).hostname.replace(/^www\./, "");
103
- return SOURCE_MAP[hostname] || hostname;
104
- } catch {
105
- return "Unknown";
106
- }
107
- }
108
-
109
- /** Extract the first usable image URL from an RSS <item> block. */
110
- function extractImage(itemXml) {
111
- const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/i);
112
- if (media) return media[1];
113
- const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/i);
114
- if (enc) {
115
- const urlMatch = enc[0].match(/\burl=["']([^"']+)["']/i);
116
- if (urlMatch) return urlMatch[1];
117
- }
118
- const imgTag = itemXml.match(/<image>\s*<url>([\s\S]*?)<\/url>/i);
119
- if (imgTag) return imgTag[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim();
120
- const imgSrc = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/i);
121
- if (imgSrc) return imgSrc[1];
122
- return "";
123
- }
124
-
125
- function extractAllImages(itemXml) {
126
- const imgs = [];
127
- const seen = new Set();
128
- const push = (u) => { if (u && !seen.has(u) && !u.startsWith("data:")) { seen.add(u); imgs.push(u); } };
129
- const media = itemXml.match(/<media:(?:thumbnail|content)[^>]*\burl=["']([^"']+)["']/gi) || [];
130
- for (const m of media) { const u = m.match(/\burl=["']([^"']+)["']/i); if (u) push(u[1]); }
131
- const enc = itemXml.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*>/gi) || [];
132
- for (const e of enc) { const u = e.match(/\burl=["']([^"']+)["']/i); if (u) push(u[1]); }
133
- const descImgs = itemXml.match(/<img[^>]*\bsrc=["']([^"']+)["']/gi) || [];
134
- for (const d of descImgs) { const u = d.match(/\bsrc=["']([^"']+)["']/i); if (u) push(u[1]); }
135
- return imgs;
136
- }
137
-
138
- function extractDescription(itemXml) {
139
- const dm = itemXml.match(/<description[^>]*>([\s\S]*?)<\/description>/i);
140
- if (!dm) return "";
141
- let txt = dm[1].replace(/<!\[CDATA\[|\]\]>/g, "");
142
- txt = txt.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "")
143
- .replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
144
- return txt.length > 300 ? txt.slice(0, 300) + "…" : txt;
145
- }
146
-
147
- const FEEDS = [
148
- "https://vnexpress.net/rss/tin-moi-nhat.rss",
149
- "https://dantri.com.vn/trangchu.rss",
150
- "https://tuoitre.vn/rss/tin-moi-nhat.rss",
151
- "https://thanhnien.vn/rss/home.rss",
152
- "https://vietnamnet.vn/rss/home.rss",
153
- "https://zingnews.vn/rss/home.rss",
154
- "https://kenh14.vn/rss/home.rss",
155
- "https://cafef.vn/rss/trang-chu.rss",
156
- "https://genk.vn/rss/home.rss",
157
- "https://afamily.vn/rss/home.rss",
158
- "https://plo.vn/rss/home.rss",
159
- "https://vtc.vn/rss/home.rss",
160
- "https://laodong.vn/rss/home.rss",
161
- "https://www.nguoiduatin.vn/rss/home.rss",
162
- "https://tienphong.vn/rss/home.rss",
163
- "https://vov.vn/rss/home.rss",
164
- "https://vietnamnet.vn/rss/the-thao.rss",
165
- "https://vtc.vn/rss/the-thao.rss",
166
- "https://thanhnien.vn/rss/the-thao.rss",
167
- "https://tuoitre.vn/rss/the-thao.rss",
168
- "https://dantri.com.vn/the-thao.rss",
169
- "https://www.bongda.com.vn/rss-bong-da.html",
170
- "https://bongdoanhnghia.vn/feed",
171
- "https://vietnamnet.vn/rss/kinh-te.rss",
172
- "https://cafef.vn/rss/thi-truong.rss",
173
- "https://vtc.vn/rss/kinh-te.rss",
174
- "https://thanhnien.vn/rss/kinh-te.rss",
175
- "https://laodong.vn/kinh-te-doanh-nghiep.rss",
176
- "https://vietnamnet.vn/rss/cong-nghe.rss",
177
- "https://genk.vn/rss/cong-nghe.rss",
178
- "https://cafef.vn/rss/cong-nghe.rss",
179
- "https://vtc.vn/rss/cong-nghe.rss",
180
- "https://thanhnien.vn/rss/cong-nghe.rss",
181
- "https://vietnamnet.vn/rss/thoi-su.rss",
182
- "https://vtc.vn/rss/thoi-su.rss",
183
- "https://thanhnien.vn/rss/thoi-su.rss",
184
- "https://tuoitre.vn/rss/thoi-su.rss",
185
- "https://vietnamnet.vn/rss/giao-duc.rss",
186
- "https://vtc.vn/rss/giao-duc.rss",
187
- "https://thanhnien.vn/rss/giao-duc.rss",
188
- "https://dantri.com.vn/giao-duc.rss",
189
- "https://vietnamnet.vn/rss/suc-khoe.rss",
190
- "https://vtc.vn/rss/suc-khoe.rss",
191
- "https://thanhnien.vn/rss/suc-khoe.rss",
192
- "https://vietnamnet.vn/rss/du-lich.rss",
193
- "https://vtc.vn/rss/du-lich.rss",
194
- "https://thanhnien.vn/rss/du-lich.rss",
195
- "https://vietnamnet.vn/rss/o-to-xe-xem.rss",
196
- "https://vtc.vn/rss/o-to.rss",
197
- "https://vietnamnet.vn/rss/giai-tri.rss",
198
- "https://vtc.vn/rss/giai-tri.rss",
199
- "https://thanhnien.vn/rss/giai-tri.rss",
200
- "https://vietnamnet.vn/rss/phap-luat.rss",
201
- "https://vtc.vn/rss/phap-luat.rss",
202
- "https://thanhnien.vn/rss/phap-luat.rss",
203
- ];
204
-
205
- function decodeHtmlEntities(str) {
206
- if (!str) return "";
207
- let decoded = str.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(Number(dec)));
208
- decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
209
- const namedEntities = {
210
- "&Ocirc;": "Ô", "&ograve;": "ò", "&Ograve;": "Ò", "&ocirc;": "ô",
211
- "&agrave;": "à", "&Agrave;": "À", "&eacute;": "é", "&Eacute;": "É",
212
- "&ugrave;": "ù", "&Ugrave;": "Ù", "&acirc;": "â", "&Acirc;": "Â",
213
- "&ecirc;": "ê", "&Ecirc;": "Ê", "&icirc;": "î", "&Icirc;": "Î",
214
- "&ocirc;": "ô", "&Ocirc;": "Ô", "&ucirc;": "û", "&Ucirc;": "Û",
215
- "&ntilde;": "ñ", "&Ntilde;": "Ñ", "&amp;": "&", "&lt;": "<", "&gt;": ">",
216
- "&quot;": '"', "&#039;": "'", "&apos;": "'", "&nbsp;": " ",
217
- "&yacute;": "ý", "&Yacute;": "Ý", "&aacute;": "á", "&Aacute;": "Á",
218
- "&eacute;": "é", "&Eacute;": "É", "&iacute;": "í", "&Iacute;": "Í",
219
- "&oacute;": "ó", "&Oacute;": "Ó", "&uacute;": "ú", "&Uacute;": "Ú",
220
- "&uuml;": "ü", "&Uuml;": "Ü", "&ouml;": "ö", "&Ouml;": "Ö",
221
- "&auml;": "ä", "&Auml;": "Ä", "&ntilde;": "ñ", "&Ntilde;": "Ñ",
222
- "&ccedil;": "ç", "&Ccedil;": "Ç", "&ntilde;": "ñ", "&Ntilde;": "Ñ",
223
- };
224
- for (const [entity, char] of Object.entries(namedEntities)) {
225
- decoded = decoded.split(entity).join(char);
226
- }
227
- return decoded;
228
- }
229
-
230
- async function fetchHotNews() {
231
- const parsed = [];
232
- const seenLinks = new Set();
233
- await Promise.all(
234
- FEEDS.map(async (feedUrl) => {
235
- try {
236
- const resp = await fetch(feedUrl, {
237
- headers: { "User-Agent": "Mozilla/5.0" },
238
- signal: AbortSignal.timeout(6000),
239
- });
240
- const xml = await resp.text();
241
- const sourceName = sourceNameFromUrl(feedUrl);
242
- const allItems = xml.match(/<item>[\s\S]*?<\/item>/gi) || [];
243
- const items = [];
244
- for (const item of allItems) {
245
- const tm = item.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
246
- const lm = item.match(/<link[^>]*>([\s\S]*?)<\/link>/i);
247
- if (!tm || !tm[1]) continue;
248
- const cleanTitle = decodeHtmlEntities(tm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim());
249
- const cleanLink = lm ? lm[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
250
- if (cleanTitle && cleanTitle.length > 10 && cleanLink && !seenLinks.has(cleanLink)) {
251
- seenLinks.add(cleanLink);
252
- const images = extractAllImages(item);
253
- const firstImg = extractImage(item) || (images[0] ?? "");
254
- items.push({ title: cleanTitle, link: cleanLink, image: firstImg, images, description: extractDescription(item) });
255
- }
256
- }
257
- if (items.length) parsed.push({ source: sourceName, items });
258
- } catch (e) {
259
- console.warn(`[RSS] ${feedUrl}: ${e}`);
260
- }
261
- })
262
- );
263
- const queue = parsed.map((p) => ({ source: p.source, items: p.items.slice() }));
264
- const items = [];
265
- let added = true;
266
- const MAX = 150;
267
- while (added && items.length < MAX) {
268
- added = false;
269
- for (const q of queue) {
270
- if (items.length >= MAX) break;
271
- const it = q.items.shift();
272
- if (it) {
273
- items.push({ ...it, source: q.source });
274
- added = true;
275
- }
276
- }
277
- }
278
- const shuffled = items.sort(() => Math.random() - 0.5);
279
- return shuffled;
280
- }
281
-
282
- let newsPool = [];
283
- let newsPoolLoadedAt = 0;
284
-
285
- async function refreshNewsPool() {
286
- newsPool = await fetchHotNews();
287
- newsPoolLoadedAt = Date.now();
288
- console.log(`[news] pool refreshed with ${newsPool.length} items`);
289
- }
290
-
291
- async function getNewsPool() {
292
- if (newsPool.length === 0 || Date.now() - newsPoolLoadedAt > 5 * 60 * 1000) {
293
- await refreshNewsPool();
294
- }
295
- return newsPool;
296
- }
297
-
298
- const ALL_TOPICS = [
299
- { slug: "worldcup", label: "World Cup", keywords: ["world cup", "worldcup", "cúp thế giới", "fifa"] },
300
- { slug: "aseancup", label: "ASEAN Cup", keywords: ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"] },
301
- { slug: "bongda", label: "Bóng đá", keywords: ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"] },
302
- { slug: "thethao", label: "Thể thao", keywords: ["thể thao", "olympics", "olympic", "sea games", "asiad"] },
303
- { slug: "ai", label: "AI", keywords: ["trí tuệ nhân tạo", "ai ", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "kimik3", "kimi k3", "kimi"] },
304
- { slug: "congngang", label: "Công nghệ", keywords: ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud", "edge computing"] },
305
- { slug: "kinhte", label: "Kinh tế", keywords: ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"] },
306
- { slug: "thoisu", label: "Thời sự", keywords: ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"] },
307
- { slug: "giao-duc", label: "Giáo dục", keywords: ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"] },
308
- { slug: "suc-khoe", label: "Sức khỏe", keywords: ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"] },
309
- { slug: "du-lich", label: "Du lịch", keywords: ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"] },
310
- { slug: "oto", label: "Ô tô", keywords: ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"] },
311
- { slug: "thegioi", label: "Thế giới", keywords: ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"] },
312
- { slug: "doisong", label: "Đời sống", keywords: ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"] },
313
- { slug: "giai-tri", label: "Giải trí", keywords: ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"] },
314
- { slug: "am-nhac", label: "Âm nhạc", keywords: ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"] },
315
- { slug: "phap-luat", label: "Pháp luật", keywords: ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"] },
316
- ];
317
-
318
- const VI_STOPWORDS = new Set([
319
- "và", "của", "các", "là", "được", "trong", "cho", "tại", "với", "để", "khi", "nếu", "như", "đó", "này", "kia",
320
- "tôi", "bạn", "anh", "chị", "cô", "chú", "bác", "ông", "bà", "nó", "họ", "ta", "chúng", "mình", "tôi",
321
- "có", "không", "đã", "sẽ", "đang", "để", "về", "từ", "trên", "dưới", "trong", "ngoài", "trước", "sau",
322
- "một", "hai", "ba", "bốn", "năm", "sáu", "bảy", "tám", "chín", "mười", "nhiều", "ít", "cả", "các",
323
- "hôm", "nay", "qua", "đến", "đi", "lên", "xuống", "ra", "vào", "ở", "tại", "thì", "mà", "nhưng", "hoặc",
324
- "vừa", "cũng", "chỉ", "đúng", "tất", "cả", "mỗi", "khác", "mới", "cũ", "lớn", "nhỏ", "cao", "thấp",
325
- "vừa", "rồi", "thì", "mới", "đã", "sẽ", "đang", "được", "để", "về", "từ", "trên", "dưới", "trong",
326
- "ngày", "giờ", "phút", "giây", "năm", "tháng", "tuần", "giờ", "phút", "giây",
327
- "ng", "nh", "nc", "nd", "nt", "nl", "nm", "np", "nk", "nj", "ni", "no", "nr", "ns", "nv", "nz",
328
- "ch", "tr", "ph", "th", "kh", "gh", "nh", "ng", "cn", "cv", "đc", "đk", "đt", "đv",
329
- ]);
330
-
331
- async function getTrendingTopics() {
332
- const pool = await getNewsPool();
333
- const allText = pool
334
- .map((i) => ((i.title || "") + " " + (i.description || "")).replace(/&[a-z]+;/gi, " "))
335
- .join(" ")
336
- .toLowerCase();
337
- const tokens = allText
338
- .replace(/[0-9]+/g, " ")
339
- .replace(/[^\p{L}\s]/gu, " ")
340
- .split(/\s+/)
341
- .filter((t) => t.length >= 2 && !VI_STOPWORDS.has(t));
342
- const bigramFreq = new Map();
343
- for (let i = 0; i < tokens.length - 1; i++) {
344
- const bigram = tokens[i] + " " + tokens[i + 1];
345
- if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1])) {
346
- bigramFreq.set(bigram, (bigramFreq.get(bigram) || 0) + 1);
347
- }
348
- }
349
- const trigramFreq = new Map();
350
- for (let i = 0; i < tokens.length - 2; i++) {
351
- const trigram = tokens[i] + " " + tokens[i + 1] + " " + tokens[i + 2];
352
- if (!VI_STOPWORDS.has(tokens[i]) && !VI_STOPWORDS.has(tokens[i + 1]) && !VI_STOPWORDS.has(tokens[i + 2])) {
353
- trigramFreq.set(trigram, (trigramFreq.get(trigram) || 0) + 1);
354
- }
355
- }
356
- const topicScores = [];
357
- for (const topic of ALL_TOPICS) {
358
- let count = 0;
359
- for (const kw of topic.keywords) {
360
- const kwLower = kw.toLowerCase();
361
- count += (allText.split(kwLower).length - 1);
362
- }
363
- if (count > 0) topicScores.push({ slug: topic.slug, label: topic.label, count, type: "fixed" });
364
- }
365
- const knownKeywords = new Set();
366
- for (const t of ALL_TOPICS) { for (const kw of t.keywords) knownKeywords.add(kw.toLowerCase()); }
367
- const topTrigrams = [...trigramFreq.entries()]
368
- .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 6) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
369
- .sort((a, b) => b[1] - a[1]).slice(0, 10);
370
- const topBigrams = [...bigramFreq.entries()]
371
- .filter(([phrase]) => { if (knownKeywords.has(phrase) || phrase.length <= 4) return false; const words = phrase.split(" "); return !words.some((w) => VI_STOPWORDS.has(w)); })
372
- .sort((a, b) => b[1] - a[1]).slice(0, 20);
373
- topicScores.sort((a, b) => b.count - a.count);
374
- const trending = [...topicScores.slice(0, 10)];
375
- const rawKeywords = [...topTrigrams, ...topBigrams].filter(([phrase, freq]) => freq >= 2).sort((a, b) => b[1] - a[1]).slice(0, 10);
376
- for (const [phrase, freq] of rawKeywords) {
377
- const slug = phrase.replace(/\s+/g, "-").replace(/[^\p{L}\p{N}-]/gu, "");
378
- if (slug && slug.length > 2 && /^[a-zà-ỹ\-]+$/.test(slug) && !trending.find((t) => t.slug === slug)) {
379
- const label = phrase.split(" ").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
380
- trending.push({ slug, label, count: freq, type: "dynamic" });
381
- }
382
- }
383
- if (trending.length < 10) {
384
- const defaults = [
385
- { slug: "ai", label: "AI", keywords: [] },
386
- { slug: "congngang", label: "Công nghệ", keywords: [] },
387
- { slug: "bongda", label: "Bóng đá", keywords: [] },
388
- { slug: "kinhte", label: "Kinh tế", keywords: [] },
389
- { slug: "thethao", label: "Thể thao", keywords: [] },
390
- { slug: "thoisu", label: "Thời sự", keywords: [] },
391
- { slug: "giao-duc", label: "Giáo dục", keywords: [] },
392
- { slug: "suc-khoe", label: "Sức khỏe", keywords: [] },
393
- { slug: "du-lich", label: "Du lịch", keywords: [] },
394
- { slug: "giai-tri", label: "Giải trí", keywords: [] },
395
- ];
396
- for (const d of defaults) {
397
- if (!trending.find((f) => f.slug === d.slug)) trending.push({ ...d, count: 1, type: "fixed" });
398
- }
399
- }
400
- const now = new Date();
401
- const start = new Date(now.getFullYear(), 0, 0);
402
- const dayOfYear = Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
403
- const seed = dayOfYear;
404
- const shuffled = [...trending];
405
- let r = seed;
406
- for (let i = shuffled.length - 1; i > 0; i--) {
407
- r = (r * 1103515245 + 12345) & 0x7fffffff;
408
- const j = r % (i + 1);
409
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
410
- }
411
- return shuffled.slice(0, 20).map(({ slug, label }) => ({ slug, label }));
412
- }
413
-
414
- async function filterNews(query, limit = 15) {
415
- const pool = await getNewsPool();
416
- const q = (query || "").trim().toLowerCase();
417
- if (!q) return pool.slice(0, limit);
418
- const SLUG_MAP = {
419
- "worldcup": ["world cup", "worldcup", "cúp thế giới", "fifa"],
420
- "aseancup": ["asean cup", "aseancup", "cúp đông nam áp", "vòng loại world cup", "vòng loại cúp"],
421
- "bongda": ["bóng đá", "bóng đá việt nam", "bóng đá ngoại hạng", "premier league", "laliga", "serie a", "bundesliga", "ligue 1", "chuyển nhượng"],
422
- "thethao": ["thể thao", "olympics", "olympic", "sea games", "asiad"],
423
- "ai": ["trí tuệ nhân tạo", "ai ", "a.i", "machine learning", "deep learning", "chatbot", "generative ai", "gen ai", "llm", "kimik3", "kimi"],
424
- "congngang": ["công nghệ", "số hóa", "digital", "tech", "startup", "fintech", "blockchain", "metaverse", "cloud"],
425
- "kinhte": ["kinh tế", "tài chính", "chứng khoán", "lạm phát", "giá vàng", "giá dầu", "giá bitcoin", "vàng", "tiền tệ", "ngân hàng"],
426
- "thoisu": ["thời sự", "chính trị", "quan hệ", "đối ngoại", "pháp luật", "luật mới", "bỏ phiếu", "bầu cử", "bộ chính phủ", "thủ tướng"],
427
- "giao-duc": ["giáo dục", "thi cử", "đại học", "trường học", "kỳ thi", "tuyển sinh", "tuyển dụng"],
428
- "suc-khoe": ["sức khỏe", "y tế", "bệnh", "bác sĩ", "bệnh viện", "tiêm chủng", "dịch bệnh", "ung thư", "tiểu đường"],
429
- "du-lich": ["du lịch", "tourism", "đi du lịch", "khách sạn", "vé máy bay", "du lịch trong nước", "du lịch quốc tế"],
430
- "oto": ["ô tô", "xe máy", "xe hơi", "toyota", "mercedes", "bmw", "audi", "honda", "hyundai", "thaco", "ô tô điện"],
431
- "thegioi": ["thế giới", "quốc tế", "mỹ", "trung quốc", "nhật bản", "hàn quốc", "châu âu", "mỹ latin"],
432
- "doisong": ["đời sống", "gia đình", "tình yêu", "kết hôn", "nuôi dạy con", "ăn uống", "ẩm thực", "làm đẹp"],
433
- "giai-tri": ["giải trí", "điện ảnh", "âm nhạc", "ca sĩ", "diễn viên", "phim", "trực tuyến", "kpop", "vpop"],
434
- "am-nhac": ["âm nhạc", "nhạc trẻ", "nhạc vào", "concert", "âm nhạc điện tử"],
435
- "phap-luat": ["pháp luật", "tội phạm", "hình sự", "hành chính", "tai nạn", "bảo vệ quyền"],
436
- };
437
- const LABEL_TO_SLUG = {};
438
- for (const [slug, terms] of Object.entries(SLUG_MAP)) {
439
- const labelKey = terms[0].replace(/\s+/g, "").toLowerCase();
440
- if (labelKey) LABEL_TO_SLUG[labelKey] = slug;
441
- }
442
- let terms = SLUG_MAP[q];
443
- if (!terms) {
444
- const qNoSpace = q.replace(/\s+/g, "").toLowerCase();
445
- const slug = LABEL_TO_SLUG[qNoSpace];
446
- if (slug) terms = SLUG_MAP[slug];
447
- }
448
- if (!terms) {
449
- if (q.includes("-")) { terms = q.split("-").filter((t) => t.length > 1); }
450
- else { terms = [q]; }
451
- }
452
- if (!terms || terms.length === 0) return [];
453
- const matched = pool.filter((it) => {
454
- const titleLower = it.title.toLowerCase();
455
- const descLower = (it.description || "").toLowerCase();
456
- return terms.some((term) => titleLower.includes(term) || descLower.includes(term));
457
- });
458
- return matched.slice(0, limit);
459
- }
460
-
461
- async function fetchArticleContent(url) {
462
- try {
463
- const resp = await fetch(url, {
464
- headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" },
465
- signal: AbortSignal.timeout(8000),
466
- });
467
- const html = await resp.text();
468
- let text = html
469
- .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
470
- .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
471
- .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
472
- .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
473
- .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
474
- .replace(/<[^>]+>/g, " ")
475
- .replace(/&[a-z]+;/g, " ")
476
- .replace(/\s+/g, " ").trim();
477
- return text.slice(0, 4000);
478
- } catch (e) {
479
- console.warn(`[Article] Failed to fetch ${url}: ${e}`);
480
- return null;
481
- }
482
- }
483
-
484
- function formatVietnameseTime(now) {
485
- let h = now.getHours();
486
- const m = now.getMinutes();
487
- let part;
488
- if (h >= 0 && h < 5) part = "đêm";
489
- else if (h < 10) part = "sáng";
490
- else if (h < 13) part = "trưa";
491
- else if (h < 18) part = "chiều";
492
- else part = "tối";
493
- const hh = h === 0 ? 12 : h;
494
- if (m === 0) return `${hh}h ${part}`;
495
- return `${hh}h${m < 10 ? "0" : ""}${m} ${part}`;
496
- }
497
-
498
- // Upload thumbnails to sidecar service (no Space rebuild)
499
- async function uploadToSidecar(path: string, buf: Uint8Array) {
500
- const sidecarUrl = Bun.env.SIDECAR_URL || "http://localhost:7861";
501
- const base64 = Buffer.from(buf).toString("base64");
502
-
503
- try {
504
- const resp = await fetch(`${sidecarUrl}/upload`, {
505
- method: "POST",
506
- headers: { "Content-Type": "application/json" },
507
- body: JSON.stringify({ path, content: base64 }),
508
- });
509
- if (!resp.ok) {
510
- const txt = await resp.text();
511
- console.warn(`[sidecar] upload failed (${resp.status}): ${txt}`);
512
- return false;
513
- }
514
- const data = await resp.json();
515
- console.log(`[sidecar] queued ${path} for dataset upload`);
516
- return true;
517
- } catch (err) {
518
- console.warn(`[sidecar] error: ${err}`);
519
- return false;
520
- }
521
- }
522
-
523
- async function uploadToHub(path: string, buf: Uint8Array) {
524
- // Use sidecar service for dataset upload (no Space rebuild)
525
- return uploadToSidecar(path, buf);
526
- }
527
-
528
- const server = Bun.serve({
529
- port: PORT,
530
- routes: {
531
- "/": index,
532
- "/api/config": { GET: () => Response.json({ lb: Boolean(UPSTREAM), allowDirect: !UPSTREAM }) },
533
- "/api/news/hot": {
534
- GET: async () => {
535
- const pool = await getNewsPool();
536
- return Response.json({ items: pool.slice(0, 15) });
537
- },
538
- },
539
- "/api/news/more": {
540
- GET: async (req) => {
541
- const url = new URL(req.url);
542
- const offset = Math.max(0, Number(url.searchParams.get("offset") ?? "0") || 0);
543
- const limit = Math.min(30, Math.max(1, Number(url.searchParams.get("limit") ?? "10") || 10));
544
- const pool = await getNewsPool();
545
- const page = pool.slice(offset, offset + limit);
546
- const nextOffset = offset + page.length;
547
- return Response.json({
548
- items: page,
549
- offset: nextOffset,
550
- hasMore: nextOffset < pool.length,
551
- total: pool.length,
552
- });
553
- },
554
- },
555
- "/api/news/filter": {
556
- GET: async (req) => {
557
- const url = new URL(req.url);
558
- const q = url.searchParams.get("q") ?? "";
559
- const limit = Math.min(30, Math.max(1, Number(url.searchParams.get("limit") ?? "15") || 15));
560
- const items = await filterNews(q, limit);
561
- return Response.json({ query: q, items });
562
- },
563
- },
564
- "/news/summary": {
565
- GET: async (req) => {
566
- const url = new URL(req.url);
567
- const articleUrl = url.searchParams.get("url");
568
- if (!articleUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
569
- const content = await fetchArticleContent(articleUrl);
570
- if (!content) return Response.json({ error: "Could not fetch article" }, { status: 502 });
571
- return Response.json({ content, url: articleUrl });
572
- },
573
- },
574
- "/api/topics/trending": {
575
- GET: async () => {
576
- const topics = await getTrendingTopics();
577
- return Response.json({ topics });
578
- },
579
- },
580
- "/api/summarize": {
581
- POST: async (req) => {
582
- try {
583
- const body = await req.json();
584
- const text = (body.text || "").trim();
585
- if (!text) return Response.json({ error: "Missing text" }, { status: 400 });
586
- const token = Bun.env.HF_TOKEN;
587
- if (!token) {
588
- const bullets = text.split(/\n\n+/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
589
- return Response.json({ title: text.slice(0, 80), body: bullets.map((b) => `• ${b}`).join("\n"), fallback: true });
590
- }
591
- const cleanText = text
592
- .split(/\n+/)
593
- .map((line) => line.replace(/^\s*(người dùng|user|avatar|bạn|trợ lí|trợ lý|assistant)\s*[:\-]\s*/i, "").trim())
594
- .filter(Boolean).join("\n");
595
- const SYSTEM_PROMPT = "Bạn là một BIÊN TẬP VIÊN báo chí tiếng Việt. VIẾT LẠI nội dung thành MỘT BÀI HOÀN CHỈNH bằng NGÔN TỪ CỦA BẠN.\n" +
596
- "QUY TẮC:\n1. KHÔNG sao chép nguyên văn. Diễn đạt lại (paraphrase), tóm gọn, tự nhiên.\n" +
597
- "2. KHÔNG giữ định dạng chat 'Người dùng:'/'Avatar:'.\n3. Dòng đầu là TIÊU ĐỀ (dưới 90 ký tự).\n" +
598
- "4. Tiếp theo: 1 mở đầu, 3-4 đoạn thân bài, 1 kết luận.\n5. Tiếng Việt chuẩn, KHÔNG liên kết, KHÔNG đánh số đầu dòng.\n6. Định dạng: TIÊU ĐỀ: <tiêu đề>\n\n<nội dung>";
599
- const USER_PROMPT = `Nội dung cần viết lại:\n${cleanText}\n\nHãy viết bài ngay:`;
600
- const SUMMARY_ENDPOINT = Bun.env.SUMMARY_ENDPOINT || "";
601
- const MODEL = Bun.env.SUMMARY_MODEL || "Qwen/Qwen2.5-7B-Instruct";
602
- let out = "";
603
- if (SUMMARY_ENDPOINT) {
604
- try {
605
- const sresp = await fetch(SUMMARY_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(90000) });
606
- if (sresp.ok) { const sdata = await sresp.json(); if (sdata && sdata.body) out = (sdata.body || "").trim(); }
607
- } catch (e) { console.warn(`[summarize] endpoint failed: ${e}`); }
608
- }
609
- if (!out) {
610
- try {
611
- const rresp = await fetch("https://router.huggingface.co/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: MODEL, messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: USER_PROMPT }], max_tokens: 800, temperature: 0.8, top_p: 0.9 }), signal: AbortSignal.timeout(45000) });
612
- if (rresp.ok) { const rdata = await rresp.json(); out = (rdata?.choices?.[0]?.message?.content || "").trim(); }
613
- } catch (e) { console.warn(`[summarize] router failed: ${e}`); }
614
- }
615
- if (!out && Bun.env.SUMMARY_MODEL_LEGACY) {
616
- try {
617
- const lresp = await fetch(`https://api-inference.huggingface.co/models/${Bun.env.SUMMARY_MODEL_LEGACY}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ inputs: `${SYSTEM_PROMPT}\n\n${USER_PROMPT}`, parameters: { max_new_tokens: 700, return_full_text: false, temperature: 0.8, do_sample: true } }), signal: AbortSignal.timeout(45000) });
618
- if (lresp.ok) { const ldata = await lresp.json(); out = (Array.isArray(ldata) ? (ldata[0]?.generated_text || "") : (ldata.generated_text || "")).trim(); }
619
- } catch (e) { console.warn(`[summarize] legacy failed: ${e}`); }
620
- }
621
- if (out) {
622
- let title = "", bodyText = out;
623
- const mm = out.match(/^TIÊU ĐỀ:\s*([^\n]+)/i);
624
- if (mm) { title = mm[1].trim(); bodyText = out.slice(mm.index + mm[0].length).trim(); }
625
- if (!title) title = bodyText.slice(0, 80);
626
- return Response.json({ title, body: bodyText || out });
627
- }
628
- const lines = cleanText.split(/\n+/).map((s) => s.trim()).filter(Boolean);
629
- const titleFb = lines[0] ? (lines[0].length > 80 ? lines[0].slice(0, 77) + "..." : lines[0]) : "Tóm tắt cuộc trò chuyện";
630
- const bodyFb = lines.map((l) => l.charAt(0).toUpperCase() + l.slice(1)).join(" ").replace(/\s+/g, " ").trim();
631
- return Response.json({ title: titleFb, body: bodyFb || cleanText, fallback: true });
632
- } catch (e) {
633
- console.warn(`[summarize] ${e}`);
634
- return Response.json({ error: "summarize failed" }, { status: 502 });
635
- }
636
- },
637
- },
638
- "/api/debug/avatars": {
639
- GET: async () => {
640
- const files = await listAllAvatarFiles();
641
- const avatarsDir = join(process.cwd(), "public/avatars");
642
- const avatars = await listAvatars();
643
- const thumbStatus = {};
644
- for (const name of avatars) { thumbStatus[name] = await avatarHasThumbnail(name); }
645
- return Response.json({ cwd: process.cwd(), avatarsDir, files, count: files.length, avatars, thumbStatus });
646
- },
647
- },
648
- "/api/debug/thumbnail/:name": {
649
- GET: async (req) => {
650
- const name = req.params.name;
651
- const thumbName = `${name}.thumb.png`;
652
- const thumbPath = join(process.cwd(), "public/avatars", thumbName);
653
- const file = Bun.file(thumbPath);
654
- const exists = await file.exists();
655
- const size = exists ? file.size : 0;
656
- return Response.json({ name, thumbName, thumbPath, exists, size, url: `/avatars/${thumbName}` });
657
- },
658
- },
659
- "/api/avatars": {
660
- GET: async () => {
661
- const names = await listAvatars();
662
- const avatars = await Promise.all(
663
- names.map(async (name) => ({
664
- name,
665
- thumbnail: await avatarHasThumbnail(name) ? `/avatars/${name}.thumb.png` : null,
666
- }))
667
- );
668
- return Response.json({ avatars });
669
- },
670
- },
671
- "/api/avatars/debug": {
672
- GET: async () => {
673
- const allFiles = await listAllAvatarFiles();
674
- const glbFiles = allFiles.filter((f) => f.endsWith(".glb"));
675
- const thumbFiles = allFiles.filter((f) => f.endsWith(".thumb.png"));
676
- const otherFiles = allFiles.filter((f) => !f.endsWith(".glb") && !f.endsWith(".thumb.png"));
677
- return Response.json({ allFiles, glbFiles, thumbFiles, otherFiles, avatarsDir: join(process.cwd(), "public/avatars") });
678
- },
679
- },
680
- "/api/wiki/search": {
681
- GET: async (req) => {
682
- const url = new URL(req.url);
683
- const q = url.searchParams.get("q");
684
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
685
- try {
686
- const resp = await fetch(`https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5&origin=*`);
687
- const data = await resp.json();
688
- const results = (data.query?.search ?? []).map((r) => ({ title: r.title, snippet: r.snippet.replace(/<[^>]+>/g, "") }));
689
- return Response.json({ results });
690
- } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
691
- },
692
- },
693
- "/api/wiki/summary": {
694
- GET: async (req) => {
695
- const url = new URL(req.url);
696
- const title = url.searchParams.get("title");
697
- if (!title) return Response.json({ error: "Missing ?title=" }, { status: 400 });
698
- try {
699
- const resp = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?origin=*`);
700
- const data = await resp.json();
701
- return Response.json({ title: data.title, extract: data.extract, url: data.content_urls?.desktop?.page });
702
- } catch { return Response.json({ error: "Wikipedia unreachable." }, { status: 502 }); }
703
- },
704
- },
705
- "/api/web/search": {
706
- GET: async (req) => {
707
- const url = new URL(req.url);
708
- const q = url.searchParams.get("q");
709
- if (!q) return Response.json({ error: "Missing ?q=" }, { status: 400 });
710
- try {
711
- const resp = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(q)}`, { headers: { "User-Agent": "Mozilla/5.0" } });
712
- const html = await resp.text();
713
- const results = [];
714
- const linkRegex = /<a[^>]+class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
715
- const snippetRegex = /<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
716
- const linkMatches = [...html.matchAll(linkRegex)];
717
- const snippetMatches = [...html.matchAll(snippetRegex)];
718
- for (let i = 0; i < Math.min(linkMatches.length, 5); i++) {
719
- let href = linkMatches[i][1];
720
- const ruMatch = href.match(/uddg=(https?%3[^&]+)/i);
721
- if (ruMatch) href = decodeURIComponent(ruMatch[1]);
722
- let title = linkMatches[i][2].replace(/<[^>]+>/g, "").trim();
723
- let snippet = snippetMatches[i] ? snippetMatches[i][1].replace(/<[^>]+>/g, "").trim() : "";
724
- if (title) results.push({ title, snippet, url: href });
725
- }
726
- return Response.json({ results });
727
- } catch { return Response.json({ error: "Web search unreachable." }, { status: 502 }); }
728
- },
729
- },
730
- "/api/web/content": {
731
- GET: async (req) => {
732
- const url = new URL(req.url);
733
- const targetUrl = url.searchParams.get("url");
734
- if (!targetUrl) return Response.json({ error: "Missing ?url=" }, { status: 400 });
735
- try {
736
- const resp = await fetch(targetUrl, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AvatarBot/1.0)" }, signal: AbortSignal.timeout(8000) });
737
- const html = await resp.text();
738
- let text = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "").replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "").replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "").replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "").replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "").replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/g, " ").replace(/\s+/g, " ").trim();
739
- const content = text.slice(0, 3000);
740
- return Response.json({ content, url: targetUrl });
741
- } catch { return Response.json({ error: "Could not fetch page." }, { status: 502 }); }
742
- },
743
- },
744
- "/api/session": {
745
- POST: async (req) => {
746
- if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
747
- try { return await proxy("/session", req, { method: "POST", body: "{}" }); }
748
- catch (err) { return Response.json({ error: "Speech service unreachable." }, { status: 502 }); }
749
- },
750
- },
751
- "/api/queue/:id": {
752
- GET: async (req) => {
753
- if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
754
- try { return await proxy(`/queue/${encodeURIComponent(req.params.id)}`, req); }
755
- catch { return Response.json({ error: "Speech service unreachable." }, { status: 502 }); }
756
- },
757
- DELETE: async (req) => {
758
- if (!UPSTREAM) return Response.json({ error: "Not configured." }, { status: 404 });
759
- try { return await proxy(`/queue/${encodeURIComponent(req.params.id)}`, req, { method: "DELETE" }); }
760
- catch { return Response.json({ error: "Speech service unreachable." }, { status: 502 }); }
761
- },
762
- },
763
- "/worklets/:name": (req) => staticFile("worklets", req.params.name),
764
- "/vendor/:name": (req) => staticFile("vendor", req.params.name),
765
- "/src/:name": async (req) => {
766
- const filePath = join(process.cwd(), "src", req.params.name);
767
- const file = Bun.file(filePath);
768
- const exists = await file.exists();
769
- if (!exists) return new Response("Not Found", { status: 404 });
770
- return new Response(file);
771
- },
772
- "/api/avatar-thumbnail": {
773
- POST: async (req) => {
774
- try {
775
- const body = await req.json();
776
- const avatar = (body.avatar || "").toString();
777
- const dataUrl = (body.dataUrl || "").toString();
778
- if (!/^[A-Za-z0-9_.\-]+\.glb$/i.test(avatar)) {
779
- return Response.json({ ok: false, error: "Invalid avatar name" }, { status: 400 });
780
- }
781
- const m = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/);
782
- if (!m) return Response.json({ ok: false, error: "Expected PNG data URL" }, { status: 400 });
783
- let buf;
784
- try { buf = Buffer.from(m[2], "base64"); } catch { return Response.json({ ok: false, error: "Bad base64" }, { status: 400 }); }
785
- if (!buf || !buf.length) return Response.json({ ok: false, error: "Empty buffer" }, { status: 400 });
786
- if (buf.length > 5 * 1024 * 1024) return Response.json({ ok: false, error: "Image too large" }, { status: 413 });
787
- // Accept PNG (89 50 4E 47) or JPEG (FF D8 FF)
788
- const isPng = buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
789
- const isJpeg = buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
790
- if (!isPng && !isJpeg) {
791
- return Response.json({ ok: false, error: "Not a PNG or JPEG" }, { status: 400 });
792
- }
793
- const fileName = `${avatar}.thumb.png`;
794
- const avatarsDir = join(process.cwd(), "public/avatars");
795
- const localPath = join(avatarsDir, fileName);
796
- const tmpPath = `/tmp/${fileName}`; // Use /tmp which is always writable
797
- const uint8Buf = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
798
-
799
- // ── 1. Write to /tmp (always writable) for immediate serving ──
800
- try {
801
- await writeFile(tmpPath, uint8Buf);
802
- console.log(`[thumbnail] saved to tmp: ${tmpPath} (${buf.length} bytes)`);
803
- } catch (writeErr) {
804
- console.warn(`[thumbnail] tmp write failed: ${writeErr}`);
805
- }
806
-
807
- // ── 2. Upload to Hub for persistence ──
808
- uploadToHub(`public/avatars/${fileName}`, buf).catch((err) => {
809
- console.warn(`[thumbnail] Hub upload failed: ${err}. Thumbnail in /tmp only.`);
810
- });
811
-
812
- // ── 3. Serve from /tmp via fallback handler ──
813
- const thumbUrl = `/avatars/${fileName}`;
814
-
815
- return Response.json({ ok: true, url: thumbUrl, size: buf.length });
816
- } catch (e) {
817
- console.error(`[thumbnail] error: ${e}`);
818
- return Response.json({ ok: false, error: "Internal server error" }, { status: 500 });
819
- }
820
- },
821
- },
822
- },
823
- // fallback: serve static files from public/ and /tmp/avatars
824
- async fetch(req) {
825
- const url = new URL(req.url);
826
- // Serve avatar thumbnails from /tmp first (where they are written)
827
- if (url.pathname.startsWith("/avatars/")) {
828
- const tmpPath = join("/tmp", url.pathname.split("/").pop() || "");
829
- const tmpFile = Bun.file(tmpPath);
830
- if (await tmpFile.exists()) return new Response(tmpFile);
831
- }
832
- const filePath = join(process.cwd(), "public", url.pathname);
833
- const file = Bun.file(filePath);
834
- const exists = await file.exists();
835
- if (exists) return new Response(file);
836
- return new Response("Not Found", { status: 404 });
837
- },
838
- });
839
-
840
- console.log(`server running on port ${PORT}`);// force rebuild Sat Jul 25 04:14:36 UTC 2026
841
- // flush 60s