Spaces:
Paused
Paused
Add backend AI topic keywords and direct AI filter support
#9
by bep40 - opened
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 |
-
"Ô": "Ô", "ò": "ò", "Ò": "Ò", "ô": "ô",
|
| 211 |
-
"à": "à", "À": "À", "é": "é", "É": "É",
|
| 212 |
-
"ù": "ù", "Ù": "Ù", "â": "â", "Â": "Â",
|
| 213 |
-
"ê": "ê", "Ê": "Ê", "î": "î", "Î": "Î",
|
| 214 |
-
"ô": "ô", "Ô": "Ô", "û": "û", "Û": "Û",
|
| 215 |
-
"ñ": "ñ", "Ñ": "Ñ", "&": "&", "<": "<", ">": ">",
|
| 216 |
-
""": '"', "'": "'", "'": "'", " ": " ",
|
| 217 |
-
"ý": "ý", "Ý": "Ý", "á": "á", "Á": "Á",
|
| 218 |
-
"é": "é", "É": "É", "í": "í", "Í": "Í",
|
| 219 |
-
"ó": "ó", "Ó": "Ó", "ú": "ú", "Ú": "Ú",
|
| 220 |
-
"ü": "ü", "Ü": "Ü", "ö": "ö", "Ö": "Ö",
|
| 221 |
-
"ä": "ä", "Ä": "Ä", "ñ": "ñ", "Ñ": "Ñ",
|
| 222 |
-
"ç": "ç", "Ç": "Ç", "ñ": "ñ", "Ñ": "Ñ",
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|