@bao826078/chatai 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,973 @@
1
+ import express from "express";
2
+ import dotenv from "dotenv";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { GoogleGenAI } from "@google/genai";
6
+ import { dbManager } from "./server/db.ts";
7
+ dotenv.config();
8
+ await dbManager.init();
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const app = express();
12
+ function resolvePort() {
13
+ const args = process.argv;
14
+ for (let i = 0; i < args.length; i++) {
15
+ if ((args[i] === "--port" || args[i] === "-p") && args[i + 1]) {
16
+ const p = Number(args[i + 1]);
17
+ if (!isNaN(p) && p > 0) return p;
18
+ }
19
+ if (args[i].startsWith("--port=")) {
20
+ const p = Number(args[i].split("=")[1]);
21
+ if (!isNaN(p) && p > 0) return p;
22
+ }
23
+ }
24
+ if (process.env.PORT) {
25
+ const p = Number(process.env.PORT);
26
+ if (!isNaN(p) && p > 0) return p;
27
+ }
28
+ return 8088;
29
+ }
30
+ const PRIMARY_PORT = resolvePort();
31
+ app.use(express.json({ limit: "10mb" }));
32
+ app.use(express.urlencoded({ extended: true, limit: "10mb" }));
33
+ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
34
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
35
+ const DEFAULT_9ROUTER_BASE_URL = "https://api.9router.com/v1";
36
+ function getGoogleAIClient(customApiKey) {
37
+ const apiKey = customApiKey && customApiKey.trim().length > 0 ? customApiKey.trim() : process.env.GEMINI_API_KEY;
38
+ if (!apiKey || apiKey === "MY_GEMINI_API_KEY") {
39
+ const err = new Error("No API Key provided. Please enter a valid API Key to proceed.");
40
+ err.code = "MISSING_API_KEY";
41
+ throw err;
42
+ }
43
+ return new GoogleGenAI({
44
+ apiKey,
45
+ httpOptions: {
46
+ headers: {
47
+ "User-Agent": "aistudio-build"
48
+ }
49
+ }
50
+ });
51
+ }
52
+ function classifyError(error) {
53
+ const rawMsg = (error?.message || "").toLowerCase();
54
+ const status = error?.status || error?.statusCode;
55
+ if (rawMsg.includes("api_key_invalid") || rawMsg.includes("api key not valid") || rawMsg.includes("api key expired") || rawMsg.includes("unauthenticated") || rawMsg.includes("permission_denied") || rawMsg.includes("authentication_error") || rawMsg.includes("invalid_api_key") || rawMsg.includes("invalid x-api-key") || rawMsg.includes("key has expired") || rawMsg.includes("invalid api key") || rawMsg.includes("invalid_argument") || rawMsg.includes("incorrect api key") || status === 400 || status === 401 || status === 403) {
56
+ return {
57
+ isApiKeyError: true,
58
+ friendlyMessage: "Kh\xF3a API kh\xF4ng h\u1EE3p l\u1EC7 ho\u1EB7c \u0111\xE3 h\u1EBFt h\u1EA1n. Vui l\xF2ng ki\u1EC3m tra v\xE0 c\u1EADp nh\u1EADt l\u1EA1i API Key m\u1EDBi \u0111\u1EC3 ti\u1EBFp t\u1EE5c."
59
+ };
60
+ }
61
+ if (rawMsg.includes("resource_exhausted") || rawMsg.includes("quota") || rawMsg.includes("rate limit") || status === 429) {
62
+ return {
63
+ isApiKeyError: true,
64
+ friendlyMessage: "Kh\xF3a API \u0111\xE3 v\u01B0\u1EE3t qu\xE1 h\u1EA1n m\u1EE9c s\u1EED d\u1EE5ng (Quota / Rate Limit). Vui l\xF2ng th\u1EED l\u1EA1i sau ho\u1EB7c \u0111\u1ED5i kh\xF3a kh\xE1c."
65
+ };
66
+ }
67
+ if (rawMsg.includes("not_found") || rawMsg.includes("404") || rawMsg.includes("no longer available") || status === 404) {
68
+ return {
69
+ isApiKeyError: false,
70
+ friendlyMessage: "M\xF4 h\xECnh n\xE0y hi\u1EC7n kh\xF4ng kh\u1EA3 d\u1EE5ng ho\u1EB7c \u0111\xE3 ng\u1EEBng h\u1ED7 tr\u1EE3. H\u1EC7 th\u1ED1ng khuy\u1EBFn ngh\u1ECB ch\u1ECDn m\xF4 h\xECnh ho\u1EA1t \u0111\u1ED9ng nh\u01B0 gemini-3.8-flash."
71
+ };
72
+ }
73
+ return {
74
+ isApiKeyError: false,
75
+ friendlyMessage: error?.message || "\u0110\xE3 x\u1EA3y ra l\u1ED7i khi k\u1EBFt n\u1ED1i v\u1EDBi m\xE1y ch\u1EE7 AI."
76
+ };
77
+ }
78
+ app.get("/api/config", (_req, res) => {
79
+ const hasValidDefault = Boolean(
80
+ process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim().length > 0 && process.env.GEMINI_API_KEY !== "MY_GEMINI_API_KEY"
81
+ );
82
+ console.log(`[Config] Serving configuration (hasDefaultKey: ${hasValidDefault})`);
83
+ res.json({
84
+ hasDefaultApiKey: hasValidDefault,
85
+ defaultOpenAiUrl: DEFAULT_OPENAI_BASE_URL,
86
+ defaultAnthropicUrl: DEFAULT_ANTHROPIC_BASE_URL,
87
+ default9RouterUrl: DEFAULT_9ROUTER_BASE_URL,
88
+ serverTime: (/* @__PURE__ */ new Date()).toISOString()
89
+ });
90
+ });
91
+ function getTargetBaseUrl(provider, baseUrl) {
92
+ if (baseUrl && baseUrl.trim().length > 0) {
93
+ return baseUrl.trim().replace(/\/+$/, "");
94
+ }
95
+ if (provider === "openai") return DEFAULT_OPENAI_BASE_URL;
96
+ if (provider === "claude") return DEFAULT_ANTHROPIC_BASE_URL;
97
+ if (provider === "9router") return DEFAULT_9ROUTER_BASE_URL;
98
+ return "";
99
+ }
100
+ app.post("/api/models", async (req, res) => {
101
+ try {
102
+ const { apiKey, provider = "openai", baseUrl } = req.body;
103
+ const activeKey = apiKey && typeof apiKey === "string" ? apiKey.trim() : "";
104
+ if (!activeKey) {
105
+ console.log(`[Models] No API key provided for provider "${provider}". Returning empty list.`);
106
+ return res.json({
107
+ ok: true,
108
+ models: [],
109
+ count: 0,
110
+ message: "Ch\u01B0a c\xF3 API Key. Vui l\xF2ng nh\u1EADp API Key \u0111\u1EC3 nh\u1EADn danh s\xE1ch m\xF4 h\xECnh t\u1EEB nh\xE0 cung c\u1EA5p."
111
+ });
112
+ }
113
+ console.log(`[Models] Fetching live models from API for provider: "${provider}"...`);
114
+ if (provider === "google") {
115
+ const googleUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${activeKey}`;
116
+ const response2 = await fetch(googleUrl);
117
+ if (!response2.ok) {
118
+ const errText = await response2.text();
119
+ console.warn(`[Models] Failed to fetch Google models (HTTP ${response2.status}): ${errText.slice(0, 100)}`);
120
+ return res.json({
121
+ ok: false,
122
+ models: [],
123
+ error: `Kh\xF3a Google API kh\xF4ng h\u1EE3p l\u1EC7 ho\u1EB7c \u0111\xE3 h\u1EBFt h\u1EA1n (M\xE3 l\u1ED7i: ${response2.status}).`
124
+ });
125
+ }
126
+ const data2 = await response2.json();
127
+ const rawList2 = Array.isArray(data2?.models) ? data2.models : [];
128
+ const deprecatedModels = [
129
+ "gemini-2.5-flash",
130
+ "gemini-2.5-pro",
131
+ "gemini-2.0-flash",
132
+ "gemini-2.0-pro",
133
+ "gemini-1.5-flash",
134
+ "gemini-1.5-pro",
135
+ "gemini-1.0-pro"
136
+ ];
137
+ const nonTextKeywords = ["tts", "image", "embedding", "transcribe", "robotics"];
138
+ const supportedModels = rawList2.filter((m) => {
139
+ const id = (m.name || "").replace(/^models\//, "").toLowerCase();
140
+ const methods = Array.isArray(m.supportedGenerationMethods) ? m.supportedGenerationMethods : [];
141
+ if (!methods.includes("generateContent")) return false;
142
+ if (deprecatedModels.includes(id)) return false;
143
+ if (nonTextKeywords.some((kw) => id.includes(kw))) return false;
144
+ return true;
145
+ });
146
+ const priorityOrder = [
147
+ "gemini-3.8-flash",
148
+ "gemini-3.7-flash",
149
+ "gemini-3.6-flash",
150
+ "gemini-3.5-flash",
151
+ "gemini-flash-latest",
152
+ "gemini-3.1-flash-lite",
153
+ "gemini-flash-lite-latest",
154
+ "gemini-pro-latest",
155
+ "gemini-2.5-flash-lite",
156
+ "gemini-4-argon"
157
+ ];
158
+ supportedModels.sort((a, b) => {
159
+ const idA = (a.name || "").replace(/^models\//, "").toLowerCase();
160
+ const idB = (b.name || "").replace(/^models\//, "").toLowerCase();
161
+ const idxA = priorityOrder.findIndex((p) => idA.startsWith(p));
162
+ const idxB = priorityOrder.findIndex((p) => idB.startsWith(p));
163
+ if (idxA !== -1 && idxB !== -1) return idxA - idxB;
164
+ if (idxA !== -1) return -1;
165
+ if (idxB !== -1) return 1;
166
+ return idA.localeCompare(idB);
167
+ });
168
+ const mapped = supportedModels.map((m) => {
169
+ const id = (m.name || "").replace(/^models\//, "");
170
+ return {
171
+ id,
172
+ name: m.displayName || id,
173
+ badge: "Google",
174
+ description: m.description || `M\xF4 h\xECnh Google AI: ${id}`,
175
+ provider: "google"
176
+ };
177
+ });
178
+ console.log(`[Models] Successfully retrieved ${mapped.length} live models from Google API.`);
179
+ return res.json({
180
+ ok: true,
181
+ models: mapped,
182
+ count: mapped.length
183
+ });
184
+ }
185
+ if (provider === "claude") {
186
+ const cleanBase2 = getTargetBaseUrl("claude", baseUrl);
187
+ const response2 = await fetch(`${cleanBase2}/models`, {
188
+ headers: {
189
+ "x-api-key": activeKey,
190
+ "anthropic-version": "2023-06-01"
191
+ }
192
+ });
193
+ if (!response2.ok) {
194
+ const errText = await response2.text();
195
+ console.warn(`[Models] Failed to fetch Claude models (HTTP ${response2.status}): ${errText.slice(0, 100)}`);
196
+ return res.json({
197
+ ok: false,
198
+ models: [],
199
+ error: `Kh\xF3a API Claude kh\xF4ng h\u1EE3p l\u1EC7 ho\u1EB7c kh\xF4ng c\xF3 quy\u1EC1n truy c\u1EADp (M\xE3 l\u1ED7i: ${response2.status}).`
200
+ });
201
+ }
202
+ const data2 = await response2.json();
203
+ const rawList2 = Array.isArray(data2?.data) ? data2.data : [];
204
+ const mapped = rawList2.map((m) => ({
205
+ id: m.id,
206
+ name: m.display_name || m.id,
207
+ badge: "Claude",
208
+ description: `M\xF4 h\xECnh Anthropic Claude: ${m.display_name || m.id}`,
209
+ provider: "claude"
210
+ }));
211
+ console.log(`[Models] Successfully retrieved ${mapped.length} live models from Anthropic API.`);
212
+ return res.json({
213
+ ok: true,
214
+ models: mapped,
215
+ count: mapped.length
216
+ });
217
+ }
218
+ const cleanBase = getTargetBaseUrl(provider, baseUrl) || (provider === "9router" ? DEFAULT_9ROUTER_BASE_URL : DEFAULT_OPENAI_BASE_URL);
219
+ const response = await fetch(`${cleanBase}/models`, {
220
+ headers: {
221
+ Authorization: `Bearer ${activeKey}`
222
+ }
223
+ });
224
+ if (!response.ok) {
225
+ const errText = await response.text();
226
+ console.warn(`[Models] Failed to fetch models for "${provider}" (HTTP ${response.status}): ${errText.slice(0, 100)}`);
227
+ return res.json({
228
+ ok: false,
229
+ models: [],
230
+ error: `Kh\xF4ng th\u1EC3 l\u1EA5y danh s\xE1ch m\xF4 h\xECnh t\u1EEB nh\xE0 cung c\u1EA5p (M\xE3 l\u1ED7i: ${response.status}). Vui l\xF2ng ki\u1EC3m tra l\u1EA1i API Key ho\u1EB7c URL.`
231
+ });
232
+ }
233
+ const data = await response.json();
234
+ const rawList = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
235
+ let filteredList = rawList;
236
+ if (provider === "openai") {
237
+ const nonChatKeywords = ["whisper", "tts", "embedding", "moderation", "dall-e", "davinci", "babbage", "curie", "realtime"];
238
+ filteredList = rawList.filter((m) => {
239
+ const id = (typeof m === "string" ? m : m.id || "").toLowerCase();
240
+ return !nonChatKeywords.some((kw) => id.includes(kw));
241
+ });
242
+ const priorityOrder = ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini", "o1-mini", "chatgpt-4o-latest", "gpt-4.5", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo"];
243
+ filteredList.sort((a, b) => {
244
+ const idA = (typeof a === "string" ? a : a.id || "").toLowerCase();
245
+ const idB = (typeof b === "string" ? b : b.id || "").toLowerCase();
246
+ const indexA = priorityOrder.findIndex((p) => idA.startsWith(p));
247
+ const indexB = priorityOrder.findIndex((p) => idB.startsWith(p));
248
+ if (indexA !== -1 && indexB !== -1) return indexA - indexB;
249
+ if (indexA !== -1) return -1;
250
+ if (indexB !== -1) return 1;
251
+ return idA.localeCompare(idB);
252
+ });
253
+ }
254
+ const badgeLabel = provider === "openai" ? "OpenAI" : provider === "9router" ? "9Router" : "AI Cung C\u1EA5p";
255
+ const mappedModels = filteredList.map((m) => {
256
+ const id = typeof m === "string" ? m : m.id || m.name;
257
+ return {
258
+ id,
259
+ name: id,
260
+ badge: badgeLabel,
261
+ description: m.description || `M\xF4 h\xECnh AI: ${id}`,
262
+ provider
263
+ };
264
+ });
265
+ console.log(`[Models] Successfully retrieved ${mappedModels.length} live models for provider "${provider}".`);
266
+ return res.json({
267
+ ok: true,
268
+ models: mappedModels,
269
+ count: mappedModels.length
270
+ });
271
+ } catch (error) {
272
+ console.error(`[Models] Unexpected error fetching models:`, error?.message);
273
+ return res.json({
274
+ ok: false,
275
+ models: [],
276
+ error: error?.message || "L\u1ED7i khi t\u1EA3i danh s\xE1ch m\xF4 h\xECnh t\u1EEB m\xE1y ch\u1EE7."
277
+ });
278
+ }
279
+ });
280
+ app.post("/api/verify-key", async (req, res) => {
281
+ try {
282
+ const { apiKey, provider = "openai", baseUrl } = req.body;
283
+ const keyToTest = apiKey && typeof apiKey === "string" ? apiKey.trim() : "";
284
+ if (!keyToTest) {
285
+ return res.json({
286
+ ok: false,
287
+ errorType: "INVALID_OR_EXPIRED_KEY",
288
+ error: "Vui l\xF2ng nh\u1EADp m\xE3 API Key \u0111\u1EC3 ki\u1EC3m tra k\u1EBFt n\u1ED1i."
289
+ });
290
+ }
291
+ console.log(`[Verify] Verifying credentials for provider: "${provider}"...`);
292
+ if (provider === "google") {
293
+ const googleUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${keyToTest}`;
294
+ const response2 = await fetch(googleUrl);
295
+ if (response2.ok) {
296
+ const data = await response2.json();
297
+ const count = Array.isArray(data?.models) ? data.models.length : 0;
298
+ console.log(`[Verify] Google API key verification passed (found ${count} models).`);
299
+ return res.json({
300
+ ok: true,
301
+ message: `K\u1EBFt n\u1ED1i th\xE0nh c\xF4ng v\u1EDBi Google AI Studio! \u0110\xE3 x\xE1c th\u1EF1c kh\xF3a API (t\xECm th\u1EA5y ${count} m\xF4 h\xECnh kh\u1EA3 d\u1EE5ng).`
302
+ });
303
+ } else {
304
+ console.warn(`[Verify] Google API key verification failed with status: ${response2.status}`);
305
+ return res.json({
306
+ ok: false,
307
+ errorType: "INVALID_OR_EXPIRED_KEY",
308
+ error: `Kh\xF3a Google API kh\xF4ng h\u1EE3p l\u1EC7 (M\xE3 ph\u1EA3n h\u1ED3i: ${response2.status}).`
309
+ });
310
+ }
311
+ }
312
+ if (provider === "claude") {
313
+ const cleanBase2 = getTargetBaseUrl("claude", baseUrl);
314
+ const response2 = await fetch(`${cleanBase2}/models`, {
315
+ headers: {
316
+ "x-api-key": keyToTest,
317
+ "anthropic-version": "2023-06-01"
318
+ }
319
+ });
320
+ if (response2.ok) {
321
+ console.log("[Verify] Anthropic Claude API key verification passed.");
322
+ return res.json({
323
+ ok: true,
324
+ message: "K\u1EBFt n\u1ED1i th\xE0nh c\xF4ng v\u1EDBi Anthropic Claude! Kh\xF3a API c\u1EE7a b\u1EA1n ho\u1EA1t \u0111\u1ED9ng b\xECnh th\u01B0\u1EDDng."
325
+ });
326
+ } else {
327
+ const errText = await response2.text();
328
+ console.warn(`[Verify] Anthropic Claude verification failed with status: ${response2.status}`);
329
+ return res.json({
330
+ ok: false,
331
+ errorType: "INVALID_OR_EXPIRED_KEY",
332
+ error: `Kh\xF3a API Claude kh\xF4ng h\u1EE3p l\u1EC7 (M\xE3: ${response2.status}). ${errText.slice(0, 80)}`
333
+ });
334
+ }
335
+ }
336
+ const cleanBase = getTargetBaseUrl(provider, baseUrl) || (provider === "9router" ? DEFAULT_9ROUTER_BASE_URL : DEFAULT_OPENAI_BASE_URL);
337
+ const response = await fetch(`${cleanBase}/models`, {
338
+ headers: {
339
+ Authorization: `Bearer ${keyToTest}`
340
+ }
341
+ });
342
+ if (response.ok) {
343
+ const data = await response.json();
344
+ const count = Array.isArray(data?.data) ? data.data.length : 0;
345
+ const providerLabel = provider === "openai" ? "OpenAI" : provider === "9router" ? "9Router" : "m\xE1y ch\u1EE7 AI";
346
+ console.log(`[Verify] API key verification passed for "${providerLabel}" (found ${count} models).`);
347
+ return res.json({
348
+ ok: true,
349
+ message: `K\u1EBFt n\u1ED1i th\xE0nh c\xF4ng v\u1EDBi ${providerLabel}! Kh\xF3a API v\xE0 URL ho\u1EA1t \u0111\u1ED9ng b\xECnh th\u01B0\u1EDDng (T\xECm th\u1EA5y ${count} m\xF4 h\xECnh).`
350
+ });
351
+ } else {
352
+ const errText = await response.text();
353
+ console.warn(`[Verify] Verification failed for "${provider}" (HTTP ${response.status})`);
354
+ return res.json({
355
+ ok: false,
356
+ errorType: "INVALID_OR_EXPIRED_KEY",
357
+ error: `Kh\xF3a API ho\u1EB7c \u0111\u1ECBa ch\u1EC9 URL kh\xF4ng h\u1EE3p l\u1EC7 (M\xE3: ${response.status}). ${errText.slice(0, 80)}`
358
+ });
359
+ }
360
+ } catch (error) {
361
+ const { isApiKeyError, friendlyMessage } = classifyError(error);
362
+ console.error(`[Verify] Exception during key verification:`, error?.message);
363
+ return res.json({
364
+ ok: false,
365
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR",
366
+ error: friendlyMessage
367
+ });
368
+ }
369
+ });
370
+ function estimateTextTokens(text) {
371
+ if (!text) return 0;
372
+ const trimmed = text.trim();
373
+ if (trimmed.length === 0) return 0;
374
+ const hasCode = trimmed.includes("```") || trimmed.includes("{") || trimmed.includes("function") || trimmed.includes("const ");
375
+ const vietnameseRegex = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]/i;
376
+ const isVietnamese = vietnameseRegex.test(trimmed);
377
+ if (hasCode) {
378
+ return Math.max(1, Math.ceil(trimmed.length / 3.2));
379
+ } else if (isVietnamese) {
380
+ const words = trimmed.split(/\s+/).filter(Boolean);
381
+ return Math.max(1, Math.ceil(words.length * 1.35) + Math.ceil(trimmed.length / 10));
382
+ } else {
383
+ const words = trimmed.split(/\s+/).filter(Boolean);
384
+ return Math.max(1, Math.ceil(words.length * 1.3));
385
+ }
386
+ }
387
+ app.get("/api/conversations", (_req, res) => {
388
+ try {
389
+ const list = dbManager.getConversations();
390
+ res.json({ ok: true, conversations: list });
391
+ } catch (err) {
392
+ res.status(500).json({ ok: false, error: err.message });
393
+ }
394
+ });
395
+ app.post("/api/conversations", (req, res) => {
396
+ try {
397
+ const { id, title, model, provider, system_instruction, user_id } = req.body;
398
+ const convId = id || "chat-" + Date.now();
399
+ const conv = dbManager.createConversation({
400
+ id: convId,
401
+ title,
402
+ model,
403
+ provider,
404
+ system_instruction,
405
+ user_id
406
+ });
407
+ res.json({ ok: true, conversation: conv });
408
+ } catch (err) {
409
+ res.status(500).json({ ok: false, error: err.message });
410
+ }
411
+ });
412
+ app.get("/api/conversations/:id", (req, res) => {
413
+ try {
414
+ const result = dbManager.getConversation(req.params.id);
415
+ if (!result) {
416
+ return res.status(404).json({ ok: false, error: "Conversation not found" });
417
+ }
418
+ res.json({ ok: true, ...result });
419
+ } catch (err) {
420
+ res.status(500).json({ ok: false, error: err.message });
421
+ }
422
+ });
423
+ app.put("/api/conversations/:id", (req, res) => {
424
+ try {
425
+ const { title, model, provider, system_instruction } = req.body;
426
+ const updated = dbManager.updateConversation(req.params.id, {
427
+ title,
428
+ model,
429
+ provider,
430
+ system_instruction
431
+ });
432
+ if (!updated) {
433
+ return res.status(404).json({ ok: false, error: "Conversation not found" });
434
+ }
435
+ res.json({ ok: true, conversation: updated });
436
+ } catch (err) {
437
+ res.status(500).json({ ok: false, error: err.message });
438
+ }
439
+ });
440
+ app.delete("/api/conversations/:id", (req, res) => {
441
+ try {
442
+ const success = dbManager.deleteConversation(req.params.id);
443
+ res.json({ ok: success });
444
+ } catch (err) {
445
+ res.status(500).json({ ok: false, error: err.message });
446
+ }
447
+ });
448
+ app.post("/api/conversations/:id/messages", (req, res) => {
449
+ try {
450
+ const { id, role, sender, content, metadata } = req.body;
451
+ const saved = dbManager.saveMessage({
452
+ id: id || "msg-" + Date.now(),
453
+ conversation_id: req.params.id,
454
+ role: role || "user",
455
+ sender: sender || "user",
456
+ content: content || "",
457
+ metadata
458
+ });
459
+ res.json({ ok: true, message: saved });
460
+ } catch (err) {
461
+ res.status(500).json({ ok: false, error: err.message });
462
+ }
463
+ });
464
+ app.post("/api/conversations/bulk-sync", (req, res) => {
465
+ try {
466
+ const { conversations = [] } = req.body;
467
+ let syncedConvs = 0;
468
+ let syncedMsgs = 0;
469
+ for (const c of conversations) {
470
+ if (!c.id) continue;
471
+ dbManager.createConversation({
472
+ id: c.id,
473
+ title: c.title,
474
+ model: c.model,
475
+ provider: c.provider,
476
+ system_instruction: c.systemInstruction,
477
+ created_at: c.createdAt ? new Date(c.createdAt).toISOString() : void 0,
478
+ updated_at: c.updatedAt ? new Date(c.updatedAt).toISOString() : void 0
479
+ });
480
+ syncedConvs++;
481
+ if (Array.isArray(c.messages)) {
482
+ for (const m of c.messages) {
483
+ if (!m.id || !m.content) continue;
484
+ dbManager.saveMessage({
485
+ id: m.id,
486
+ conversation_id: c.id,
487
+ role: m.role || (m.sender === "ai" ? "model" : "user"),
488
+ sender: m.sender || (m.role === "model" || m.role === "assistant" ? "ai" : "user"),
489
+ content: m.content,
490
+ created_at: m.timestamp ? new Date(m.timestamp).toISOString() : void 0,
491
+ metadata: {
492
+ modelUsed: m.modelUsed,
493
+ estimatedTokens: m.estimatedTokens
494
+ }
495
+ });
496
+ syncedMsgs++;
497
+ }
498
+ }
499
+ }
500
+ dbManager.persistNow();
501
+ res.json({ ok: true, syncedConvs, syncedMsgs });
502
+ } catch (err) {
503
+ res.status(500).json({ ok: false, error: err.message });
504
+ }
505
+ });
506
+ app.get("/api/analytics/monthly", (req, res) => {
507
+ try {
508
+ const month = typeof req.query.month === "string" ? req.query.month : void 0;
509
+ const stats = dbManager.getMonthlyAnalytics(month);
510
+ res.json({ ok: true, ...stats });
511
+ } catch (err) {
512
+ res.status(500).json({ ok: false, error: err.message });
513
+ }
514
+ });
515
+ app.get("/api/analytics/summary", (req, res) => {
516
+ try {
517
+ const count = Number(req.query.count) || 6;
518
+ const summary = dbManager.getRecentMonthsSummary(count);
519
+ res.json({ ok: true, summary });
520
+ } catch (err) {
521
+ res.status(500).json({ ok: false, error: err.message });
522
+ }
523
+ });
524
+ app.post("/api/analytics/record", (req, res) => {
525
+ try {
526
+ const record = dbManager.recordTokenUsage(req.body);
527
+ res.json({ ok: true, record });
528
+ } catch (err) {
529
+ res.status(500).json({ ok: false, error: err.message });
530
+ }
531
+ });
532
+ app.post("/api/chat/stream", async (req, res) => {
533
+ const {
534
+ messages = [],
535
+ model = "",
536
+ systemInstruction,
537
+ temperature = 0.7,
538
+ apiKey,
539
+ provider = "openai",
540
+ baseUrl,
541
+ conversationId,
542
+ userMessageId,
543
+ aiMessageId,
544
+ requestId
545
+ } = req.body;
546
+ res.setHeader("Content-Type", "text/event-stream");
547
+ res.setHeader("Cache-Control", "no-cache");
548
+ res.setHeader("Connection", "keep-alive");
549
+ res.flushHeaders?.();
550
+ const activeKey = apiKey && typeof apiKey === "string" ? apiKey.trim() : "";
551
+ if (!activeKey) {
552
+ console.warn(`[Stream] Chat stream rejected: Missing API key`);
553
+ res.write(
554
+ `data: ${JSON.stringify({
555
+ error: "Ch\u01B0a c\xF3 API Key. Vui l\xF2ng c\u1EA5u h\xECnh API Key trong m\u1EE5c C\xE0i \u0111\u1EB7t tr\u01B0\u1EDBc khi tr\xF2 chuy\u1EC7n.",
556
+ errorType: "INVALID_OR_EXPIRED_KEY"
557
+ })}
558
+
559
+ `
560
+ );
561
+ res.write("data: [DONE]\n\n");
562
+ return res.end();
563
+ }
564
+ const rawModel = model || (provider === "google" ? "gemini-3.8-flash" : provider === "claude" ? "claude-3-5-sonnet-20241022" : "gpt-4o-mini");
565
+ let targetModel = String(rawModel).replace(/^models\//, "").trim();
566
+ if (provider === "google") {
567
+ const deprecated = ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-2.0-pro", "gemini-1.5-flash", "gemini-1.5-pro", "gemini-1.0-pro"];
568
+ if (deprecated.includes(targetModel.toLowerCase())) {
569
+ console.log(`[Stream] Migrating deprecated model "${targetModel}" to "gemini-3.8-flash"`);
570
+ targetModel = "gemini-3.8-flash";
571
+ }
572
+ }
573
+ console.log(`[Stream] Starting chat stream (provider: "${provider}", model: "${targetModel}", messageCount: ${messages.length})`);
574
+ const activeConvId = conversationId || "chat-" + Date.now();
575
+ const lastUserMsg = messages.filter((m) => m.role === "user" || m.sender === "user").slice(-1)[0];
576
+ const userText = lastUserMsg?.content || "";
577
+ try {
578
+ const existingConv = dbManager.getConversation(activeConvId);
579
+ if (!existingConv) {
580
+ dbManager.createConversation({
581
+ id: activeConvId,
582
+ title: userText ? userText.slice(0, 32) + (userText.length > 32 ? "..." : "") : "\u0110o\u1EA1n chat m\u1EDBi",
583
+ model: targetModel,
584
+ provider,
585
+ system_instruction: systemInstruction
586
+ });
587
+ }
588
+ if (userText && userMessageId) {
589
+ dbManager.saveMessage({
590
+ id: userMessageId,
591
+ conversation_id: activeConvId,
592
+ role: "user",
593
+ sender: "user",
594
+ content: userText
595
+ });
596
+ }
597
+ } catch (err) {
598
+ console.error("[Stream] Error initializing conversation in SQLite:", err);
599
+ }
600
+ const promptText = messages.map((m) => m.content || "").join("\n");
601
+ const fallbackInputTokens = estimateTextTokens(promptText);
602
+ let fullAiText = "";
603
+ let inputTokens = 0;
604
+ let outputTokens = 0;
605
+ let totalTokens = 0;
606
+ let cachedTokens = 0;
607
+ let reasoningTokens = 0;
608
+ let usageSource = "estimated";
609
+ const finalizeAndSave = (status = "success") => {
610
+ if (usageSource === "estimated" || totalTokens === 0) {
611
+ inputTokens = inputTokens || fallbackInputTokens;
612
+ outputTokens = outputTokens || estimateTextTokens(fullAiText);
613
+ totalTokens = inputTokens + outputTokens;
614
+ usageSource = "estimated";
615
+ }
616
+ const aiMsgId = aiMessageId || "msg-" + Date.now() + "-ai";
617
+ try {
618
+ if (fullAiText.trim().length > 0) {
619
+ dbManager.saveMessage({
620
+ id: aiMsgId,
621
+ conversation_id: activeConvId,
622
+ role: "model",
623
+ sender: "ai",
624
+ content: fullAiText,
625
+ metadata: {
626
+ modelUsed: targetModel,
627
+ provider,
628
+ usageSource,
629
+ inputTokens,
630
+ outputTokens,
631
+ totalTokens
632
+ }
633
+ });
634
+ }
635
+ dbManager.recordTokenUsage({
636
+ conversation_id: activeConvId,
637
+ message_id: aiMsgId,
638
+ provider,
639
+ model: targetModel,
640
+ input_tokens: inputTokens,
641
+ output_tokens: outputTokens,
642
+ total_tokens: totalTokens,
643
+ cached_input_tokens: cachedTokens,
644
+ reasoning_tokens: reasoningTokens,
645
+ usage_source: usageSource,
646
+ request_id: requestId || activeConvId + "-" + Date.now(),
647
+ status
648
+ });
649
+ } catch (saveErr) {
650
+ console.error("[Stream] Error saving to SQLite:", saveErr);
651
+ }
652
+ res.write(
653
+ `data: ${JSON.stringify({
654
+ usage: {
655
+ inputTokens,
656
+ outputTokens,
657
+ totalTokens,
658
+ cachedTokens,
659
+ reasoningTokens,
660
+ usageSource,
661
+ model: targetModel,
662
+ provider
663
+ }
664
+ })}
665
+
666
+ `
667
+ );
668
+ };
669
+ if (provider === "google") {
670
+ try {
671
+ const ai = getGoogleAIClient(activeKey);
672
+ const formattedContents = messages.map((msg) => {
673
+ const isModel = msg.role === "model" || msg.role === "assistant" || msg.sender === "ai";
674
+ return {
675
+ role: isModel ? "model" : "user",
676
+ parts: [{ text: msg.content || " " }]
677
+ };
678
+ });
679
+ const config = {
680
+ temperature: Number(temperature) || 0.7
681
+ };
682
+ if (systemInstruction && systemInstruction.trim().length > 0) {
683
+ config.systemInstruction = systemInstruction.trim();
684
+ }
685
+ const responseStream = await ai.models.generateContentStream({
686
+ model: targetModel,
687
+ contents: formattedContents,
688
+ config
689
+ });
690
+ for await (const chunk of responseStream) {
691
+ const chunkText = chunk.text;
692
+ if (chunkText) {
693
+ fullAiText += chunkText;
694
+ res.write(`data: ${JSON.stringify({ text: chunkText })}
695
+
696
+ `);
697
+ }
698
+ if (chunk.usageMetadata) {
699
+ inputTokens = chunk.usageMetadata.promptTokenCount || inputTokens;
700
+ outputTokens = chunk.usageMetadata.candidatesTokenCount || outputTokens;
701
+ totalTokens = chunk.usageMetadata.totalTokenCount || inputTokens + outputTokens;
702
+ cachedTokens = chunk.usageMetadata.cachedContentTokenCount || cachedTokens;
703
+ usageSource = "provider";
704
+ }
705
+ }
706
+ console.log(`[Stream] Google stream completed successfully for model: "${targetModel}"`);
707
+ finalizeAndSave("success");
708
+ res.write("data: [DONE]\n\n");
709
+ return res.end();
710
+ } catch (error) {
711
+ finalizeAndSave("error");
712
+ const { isApiKeyError, friendlyMessage } = classifyError(error);
713
+ console.error(`[Stream] Google stream error:`, error?.message);
714
+ res.write(
715
+ `data: ${JSON.stringify({
716
+ error: friendlyMessage,
717
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR"
718
+ })}
719
+
720
+ `
721
+ );
722
+ res.write("data: [DONE]\n\n");
723
+ return res.end();
724
+ }
725
+ }
726
+ if (provider === "claude") {
727
+ try {
728
+ const cleanBase = getTargetBaseUrl("claude", baseUrl);
729
+ const formattedClaudeMessages = messages.map((msg) => {
730
+ const isAI = msg.role === "model" || msg.role === "assistant" || msg.sender === "ai";
731
+ return {
732
+ role: isAI ? "assistant" : "user",
733
+ content: msg.content || " "
734
+ };
735
+ });
736
+ const response = await fetch(`${cleanBase}/messages`, {
737
+ method: "POST",
738
+ headers: {
739
+ "Content-Type": "application/json",
740
+ "x-api-key": activeKey,
741
+ "anthropic-version": "2023-06-01"
742
+ },
743
+ body: JSON.stringify({
744
+ model: targetModel,
745
+ max_tokens: 4096,
746
+ system: systemInstruction && systemInstruction.trim().length > 0 ? systemInstruction.trim() : void 0,
747
+ messages: formattedClaudeMessages,
748
+ temperature: Number(temperature) || 0.7,
749
+ stream: true
750
+ })
751
+ });
752
+ if (!response.ok) {
753
+ finalizeAndSave("error");
754
+ const errorText = await response.text();
755
+ console.warn(`[Stream] Claude stream HTTP ${response.status}: ${errorText.slice(0, 100)}`);
756
+ let parsedError = errorText;
757
+ try {
758
+ const parsed = JSON.parse(errorText);
759
+ parsedError = parsed.error?.message || parsed.message || errorText;
760
+ } catch (e) {
761
+ }
762
+ const { isApiKeyError, friendlyMessage } = classifyError({ message: parsedError, status: response.status });
763
+ res.write(
764
+ `data: ${JSON.stringify({
765
+ error: friendlyMessage || parsedError,
766
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR"
767
+ })}
768
+
769
+ `
770
+ );
771
+ res.write("data: [DONE]\n\n");
772
+ return res.end();
773
+ }
774
+ if (!response.body) {
775
+ throw new Error("Anthropic Claude server did not open response stream.");
776
+ }
777
+ const reader = response.body.getReader();
778
+ const decoder = new TextDecoder("utf-8");
779
+ let buffer = "";
780
+ while (true) {
781
+ const { value, done } = await reader.read();
782
+ if (done) break;
783
+ buffer += decoder.decode(value, { stream: true });
784
+ const lines = buffer.split("\n");
785
+ buffer = lines.pop() || "";
786
+ for (const line of lines) {
787
+ const trimmed = line.trim();
788
+ if (trimmed.startsWith("data: ")) {
789
+ const dataStr = trimmed.slice(6);
790
+ if (dataStr === "[DONE]") {
791
+ break;
792
+ }
793
+ try {
794
+ const parsed = JSON.parse(dataStr);
795
+ if (parsed.type === "message_start" && parsed.message?.usage) {
796
+ inputTokens = parsed.message.usage.input_tokens || 0;
797
+ }
798
+ if (parsed.type === "content_block_delta" && parsed.delta?.text) {
799
+ fullAiText += parsed.delta.text;
800
+ res.write(`data: ${JSON.stringify({ text: parsed.delta.text })}
801
+
802
+ `);
803
+ }
804
+ if (parsed.type === "message_delta" && parsed.usage) {
805
+ outputTokens = parsed.usage.output_tokens || 0;
806
+ totalTokens = inputTokens + outputTokens;
807
+ usageSource = "provider";
808
+ }
809
+ } catch (err) {
810
+ }
811
+ }
812
+ }
813
+ }
814
+ console.log(`[Stream] Claude stream completed successfully for model: "${targetModel}"`);
815
+ finalizeAndSave("success");
816
+ res.write("data: [DONE]\n\n");
817
+ return res.end();
818
+ } catch (error) {
819
+ finalizeAndSave("error");
820
+ const { isApiKeyError, friendlyMessage } = classifyError(error);
821
+ console.error(`[Stream] Claude stream error:`, error?.message);
822
+ res.write(
823
+ `data: ${JSON.stringify({
824
+ error: friendlyMessage,
825
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR"
826
+ })}
827
+
828
+ `
829
+ );
830
+ res.write("data: [DONE]\n\n");
831
+ return res.end();
832
+ }
833
+ }
834
+ try {
835
+ const formattedMessages = [];
836
+ if (systemInstruction && systemInstruction.trim().length > 0) {
837
+ formattedMessages.push({
838
+ role: "system",
839
+ content: systemInstruction.trim()
840
+ });
841
+ }
842
+ for (const msg of messages) {
843
+ const isAI = msg.role === "model" || msg.role === "assistant" || msg.sender === "ai";
844
+ formattedMessages.push({
845
+ role: isAI ? "assistant" : "user",
846
+ content: msg.content || " "
847
+ });
848
+ }
849
+ const cleanBase = getTargetBaseUrl(provider, baseUrl) || (provider === "9router" ? DEFAULT_9ROUTER_BASE_URL : DEFAULT_OPENAI_BASE_URL);
850
+ const response = await fetch(`${cleanBase}/chat/completions`, {
851
+ method: "POST",
852
+ headers: {
853
+ "Content-Type": "application/json",
854
+ Authorization: `Bearer ${activeKey}`
855
+ },
856
+ body: JSON.stringify({
857
+ model: targetModel,
858
+ messages: formattedMessages,
859
+ temperature: Number(temperature) || 0.7,
860
+ stream: true,
861
+ stream_options: { include_usage: true }
862
+ })
863
+ });
864
+ if (!response.ok) {
865
+ finalizeAndSave("error");
866
+ const errorText = await response.text();
867
+ console.warn(`[Stream] "${provider}" stream HTTP ${response.status}: ${errorText.slice(0, 100)}`);
868
+ let parsedError = errorText;
869
+ try {
870
+ const parsed = JSON.parse(errorText);
871
+ parsedError = parsed.error?.message || parsed.message || errorText;
872
+ } catch (e) {
873
+ }
874
+ const { isApiKeyError, friendlyMessage } = classifyError({ message: parsedError, status: response.status });
875
+ res.write(
876
+ `data: ${JSON.stringify({
877
+ error: friendlyMessage || parsedError,
878
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR"
879
+ })}
880
+
881
+ `
882
+ );
883
+ res.write("data: [DONE]\n\n");
884
+ return res.end();
885
+ }
886
+ if (!response.body) {
887
+ throw new Error("AI server did not open response stream.");
888
+ }
889
+ const reader = response.body.getReader();
890
+ const decoder = new TextDecoder("utf-8");
891
+ let buffer = "";
892
+ while (true) {
893
+ const { value, done } = await reader.read();
894
+ if (done) break;
895
+ buffer += decoder.decode(value, { stream: true });
896
+ const lines = buffer.split("\n");
897
+ buffer = lines.pop() || "";
898
+ for (const line of lines) {
899
+ const trimmed = line.trim();
900
+ if (trimmed.startsWith("data: ")) {
901
+ const dataStr = trimmed.slice(6);
902
+ if (dataStr === "[DONE]") {
903
+ break;
904
+ }
905
+ try {
906
+ const parsed = JSON.parse(dataStr);
907
+ if (parsed.usage) {
908
+ inputTokens = parsed.usage.prompt_tokens || 0;
909
+ outputTokens = parsed.usage.completion_tokens || 0;
910
+ totalTokens = parsed.usage.total_tokens || inputTokens + outputTokens;
911
+ cachedTokens = parsed.usage.prompt_tokens_details?.cached_tokens || 0;
912
+ reasoningTokens = parsed.usage.completion_tokens_details?.reasoning_tokens || 0;
913
+ usageSource = "provider";
914
+ }
915
+ const textChunk = parsed.choices?.[0]?.delta?.content || "";
916
+ if (textChunk) {
917
+ fullAiText += textChunk;
918
+ res.write(`data: ${JSON.stringify({ text: textChunk })}
919
+
920
+ `);
921
+ }
922
+ } catch (err) {
923
+ }
924
+ }
925
+ }
926
+ }
927
+ console.log(`[Stream] "${provider}" stream completed successfully for model: "${targetModel}"`);
928
+ finalizeAndSave("success");
929
+ res.write("data: [DONE]\n\n");
930
+ return res.end();
931
+ } catch (error) {
932
+ finalizeAndSave("error");
933
+ const { isApiKeyError, friendlyMessage } = classifyError(error);
934
+ console.error(`[Stream] "${provider}" stream error:`, error?.message);
935
+ res.write(
936
+ `data: ${JSON.stringify({
937
+ error: friendlyMessage,
938
+ errorType: isApiKeyError ? "INVALID_OR_EXPIRED_KEY" : "GENERAL_ERROR"
939
+ })}
940
+
941
+ `
942
+ );
943
+ res.write("data: [DONE]\n\n");
944
+ return res.end();
945
+ }
946
+ });
947
+ if (process.env.NODE_ENV === "production") {
948
+ app.use(express.static(path.resolve(__dirname, "dist")));
949
+ app.get("*", (_req, res) => {
950
+ res.sendFile(path.resolve(__dirname, "dist", "index.html"));
951
+ });
952
+ } else {
953
+ const { createServer: createViteServer } = await import("vite");
954
+ const vite = await createViteServer({
955
+ server: { middlewareMode: true },
956
+ appType: "spa"
957
+ });
958
+ app.use(vite.middlewares);
959
+ }
960
+ const primaryServer = app.listen(PRIMARY_PORT, "0.0.0.0", () => {
961
+ console.log(`[Server] ChatAI server started and listening on http://0.0.0.0:${PRIMARY_PORT}`);
962
+ });
963
+ if (PRIMARY_PORT !== 8088) {
964
+ try {
965
+ const secondaryServer = app.listen(8088, "0.0.0.0", () => {
966
+ console.log(`[Server] ChatAI npm default listener active on http://0.0.0.0:8088`);
967
+ });
968
+ secondaryServer.on("error", (err) => {
969
+ console.log(`[Server] Secondary listener on port 8088 note: ${err.message}`);
970
+ });
971
+ } catch (err) {
972
+ }
973
+ }