memi-agent 1.0.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.
@@ -0,0 +1,532 @@
1
+ // ─── Agent 网关服务 ──────────────────────────────────
2
+ // 提供 Webhook 和 WebSocket 端点,供外部应用连接 Agent
3
+
4
+ const axios = require("axios");
5
+ const fs = require("fs");
6
+ const path = require("path");
7
+ const { callAgent } = require("./utils/agent");
8
+ // callLLM unused in gateway; removed
9
+
10
+ // ─── Webhook 端点 ────────────────────────────────────
11
+ function createWebhookHandler(getConfig) {
12
+ return async (req, res) => {
13
+ const { message, apiKey } = req.body || {};
14
+ const authHeader = req.headers["x-api-key"] || "";
15
+
16
+ if (!message) {
17
+ return res.status(400).json({ success: false, error: "缺少 message 参数" });
18
+ }
19
+
20
+ // 从配置中获取 API 连接
21
+ const config = getConfig();
22
+ const api1 = config.api1;
23
+ const api2 = config.api2;
24
+
25
+ if (!api1?.baseUrl) {
26
+ return res.status(500).json({ success: false, error: "请先配置 API1(LLM)" });
27
+ }
28
+
29
+ // API Key 鉴权
30
+ const key = apiKey || authHeader;
31
+ // 如果有配置的连接密钥则验证;否则开放(开发模式)
32
+ const connections = loadConnections();
33
+ if (connections.length > 0 && key) {
34
+ const valid = connections.some((c) => c.apiKey === key);
35
+ if (!valid) {
36
+ return res.status(401).json({ success: false, error: "无效的 API Key" });
37
+ }
38
+ }
39
+
40
+ try {
41
+ const messages = [{ role: "user", content: message }];
42
+ const runtimeTools = {};
43
+
44
+ if (api2?.baseUrl) {
45
+ runtimeTools.generate_image = async (args) => {
46
+ const { callImageGen } = require("./utils/aiProxy");
47
+ const w = args.aspectRatio === "16:9" ? 512 : args.aspectRatio === "9:16" ? 288 : 512;
48
+ const h = args.aspectRatio === "9:16" ? 512 : args.aspectRatio === "4:3" ? 384 : 512;
49
+ return await callImageGen(api2, args.prompt || "", w, h);
50
+ };
51
+ }
52
+
53
+ const result = await callAgent(api1, messages, runtimeTools);
54
+ res.json({
55
+ success: true,
56
+ response: result.answer,
57
+ toolCalls: result.toolCalls,
58
+ iterations: result.iterations,
59
+ });
60
+ } catch (e) {
61
+ res.status(500).json({ success: false, error: e.message });
62
+ }
63
+ };
64
+ }
65
+
66
+ // ─── WebSocket 处理 ──────────────────────────────────
67
+ function handleWebSocket(ws, getConfig) {
68
+ ws.on("message", async (data) => {
69
+ try {
70
+ const msg = JSON.parse(data.toString());
71
+ if (!msg.message) {
72
+ ws.send(JSON.stringify({ error: "缺少 message 字段" }));
73
+ return;
74
+ }
75
+
76
+ const config = getConfig();
77
+ if (!config.api1?.baseUrl) {
78
+ ws.send(JSON.stringify({ error: "请先配置 API1(LLM)" }));
79
+ return;
80
+ }
81
+
82
+ const messages = [{ role: "user", content: msg.message }];
83
+ const runtimeTools = {};
84
+
85
+ if (config.api2?.baseUrl) {
86
+ runtimeTools.generate_image = async (args) => {
87
+ const { callImageGen } = require("./utils/aiProxy");
88
+ const w = args.aspectRatio === "16:9" ? 512 : args.aspectRatio === "9:16" ? 288 : 512;
89
+ const h = args.aspectRatio === "9:16" ? 512 : args.aspectRatio === "4:3" ? 384 : 512;
90
+ return await callImageGen(config.api2, args.prompt || "", w, h);
91
+ };
92
+ }
93
+
94
+ const result = await callAgent(config.api1, messages, runtimeTools);
95
+ ws.send(JSON.stringify({
96
+ type: "response",
97
+ response: result.answer,
98
+ toolCalls: result.toolCalls,
99
+ iterations: result.iterations,
100
+ }));
101
+ } catch (e) {
102
+ ws.send(JSON.stringify({ error: e.message }));
103
+ }
104
+ });
105
+
106
+ ws.send(JSON.stringify({ type: "connected", message: "已连接到 Memi Agent" }));
107
+ }
108
+
109
+ // ─── 连接管理(localStorage 模拟,服务端用内存)─────
110
+ let _connections = [];
111
+
112
+ function loadConnections() {
113
+ try {
114
+ // 服务端从本地 JSON 文件加载
115
+ const fs = require("fs");
116
+ const path = require("path");
117
+ const file = path.join(__dirname, "..", "connections.json");
118
+ if (fs.existsSync(file)) {
119
+ _connections = JSON.parse(fs.readFileSync(file, "utf8"));
120
+ }
121
+ } catch {}
122
+ return _connections;
123
+ }
124
+
125
+ function saveConnections(conns) {
126
+ _connections = conns;
127
+ try {
128
+ const fs = require("fs");
129
+ const path = require("path");
130
+ const file = path.join(__dirname, "..", "connections.json");
131
+ fs.writeFileSync(file, JSON.stringify(conns, null, 2));
132
+ } catch {}
133
+ }
134
+
135
+ // ─── 连接管理 API ────────────────────────────────────
136
+ function createConnectionRoutes(router) {
137
+ // 获取连接列表
138
+ router.get("/gateway/connections", (req, res) => {
139
+ res.json({ success: true, connections: loadConnections() });
140
+ });
141
+
142
+ // 创建新连接
143
+ router.post("/gateway/connections", (req, res) => {
144
+ const { name, channel } = req.body || {};
145
+ if (!name) return res.status(400).json({ success: false, error: "请提供连接名称" });
146
+
147
+ const ch = channel || "webhook";
148
+ const id = "conn_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
149
+ const apiKey = "memi_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 14);
150
+ const basePath = ch === "feishu" ? "/api/gateway/feishu" : "/api/gateway/webhook";
151
+ const webhookUrl = `http://localhost:${process.env.PORT || 3001}${basePath}`;
152
+ const chatUrl = `http://localhost:${process.env.PORT || 3001}/api/gateway/webhook`;
153
+
154
+ const connection = {
155
+ id,
156
+ name,
157
+ type: ch,
158
+ apiKey,
159
+ webhookUrl,
160
+ chatUrl,
161
+ createdAt: new Date().toISOString(),
162
+ };
163
+
164
+ const conns = loadConnections();
165
+ conns.push(connection);
166
+ saveConnections(conns);
167
+
168
+ res.json({ success: true, connection });
169
+ });
170
+
171
+ // 飞书事件回调
172
+ router.post("/gateway/feishu", async (req, res) => {
173
+ const body = req.body || {};
174
+
175
+ // URL 验证(飞书首次配置时发送 challenge)
176
+ if (body.type === "url_verification") {
177
+ return res.json({ challenge: body.challenge });
178
+ }
179
+
180
+ // 消息事件
181
+ const eventType = body.header?.event_type;
182
+ if (eventType === "im.message.receive_v1" && body.event?.message) {
183
+ let text = "";
184
+ try {
185
+ const content = JSON.parse(body.event.message.content || "{}");
186
+ text = content.text || "";
187
+ } catch {
188
+ text = body.event.message.content || "";
189
+ }
190
+
191
+ if (!text) return res.json({ code: 0, msg: "empty" });
192
+
193
+ const config = (() => {
194
+ try {
195
+ const fs = require("fs");
196
+ const path = require("path");
197
+ const file = path.join(__dirname, "..", "memi-config", "config.json");
198
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
199
+ } catch {}
200
+ return { api1: {}, api2: {} };
201
+ })();
202
+
203
+ if (!config.api1?.baseUrl) {
204
+ return res.json({ code: -1, msg: "未配置 LLM" });
205
+ }
206
+
207
+ try {
208
+ const messages = [{ role: "user", content: text }];
209
+ const result = await callAgent(config.api1, messages, {});
210
+ const reply = result.answer || "抱歉,无法处理此请求。";
211
+ return res.json({
212
+ code: 0,
213
+ msg: "success",
214
+ data: { reply, toolCalls: result.toolCalls?.length },
215
+ });
216
+ } catch (e) {
217
+ return res.json({ code: -1, msg: e.message });
218
+ }
219
+ }
220
+
221
+ // 其他事件直接 ACK
222
+ res.json({ code: 0 });
223
+ });
224
+
225
+ // 通用 Webhook — POST 消息入口
226
+ router.post("/gateway/webhook", createWebhookHandler(() => {
227
+ try {
228
+ const fs = require("fs");
229
+ const path = require("path");
230
+ const file = path.join(__dirname, "..", "memi-config", "config.json");
231
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
232
+ } catch {}
233
+ return { api1: {}, api2: {} };
234
+ }));
235
+
236
+ // Webhook 页面 — GET 浏览器访问时返回对话页
237
+ router.get("/gateway/webhook", (req, res) => {
238
+ res.send(`<!DOCTYPE html>
239
+ <html lang="zh"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
240
+ <title>Memi Agent</title>
241
+ <style>body{font-family:system-ui,sans-serif;background:#f5f5f5;margin:0;padding:20px}
242
+ .chat{max-width:600px;margin:40px auto;background:#fff;border-radius:16px;box-shadow:0 2px 12px rgba(0,0,0,.08);overflow:hidden}
243
+ .header{background:var(--theme,#111827);color:#fff;padding:16px 20px;font-weight:600;font-size:15px}
244
+ .messages{padding:16px;min-height:200px;max-height:50vh;overflow-y:auto}
245
+ .msg{margin:8px 0;padding:10px 14px;border-radius:12px;max-width:80%;font-size:14px;line-height:1.5}
246
+ .user{background:#e8e8e8;margin-left:auto}
247
+ .agent{background:#f0f0f0}
248
+ .input-area{display:flex;border-top:1px solid #eee;padding:12px}
249
+ .input-area input{flex:1;border:1px solid #ddd;border-radius:10px;padding:10px 14px;font-size:14px;outline:none}
250
+ .input-area button{background:var(--theme,#111827);color:#fff;border:none;border-radius:10px;padding:10px 20px;margin-left:8px;cursor:pointer;font-weight:500}
251
+ .loading{text-align:center;color:#999;padding:12px;font-size:13px}
252
+ </style></head><body>
253
+ <div class="chat">
254
+ <div class="header">🤖 Memi Agent</div>
255
+ <div class="messages" id="msgs"></div>
256
+ <div id="loading" class="loading" style="display:none">思考中...</div>
257
+ <div class="input-area">
258
+ <input id="inp" placeholder="输入消息..." onkeydown="if(event.key==='Enter')send()">
259
+ <button onclick="send()">发送</button>
260
+ </div></div>
261
+ <script>
262
+ function addMsg(text,role){const d=document.createElement('div');d.className='msg '+role;d.textContent=text;document.getElementById('msgs').appendChild(d);d.scrollIntoView({behavior:'smooth'})}
263
+ async function send(){const i=document.getElementById('inp');const t=i.value.trim();if(!t)return;i.value='';addMsg(t,'user');document.getElementById('loading').style.display='block';
264
+ try{const r=await fetch('/api/gateway/webhook',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:t})});
265
+ const d=await r.json();document.getElementById('loading').style.display='none';
266
+ if(d.success)addMsg(d.response||'','agent');else addMsg('错误: '+(d.error||''),'agent')}catch(e){document.getElementById('loading').style.display='none';addMsg('连接失败: '+e.message,'agent')}}
267
+ </script></body></html>`);
268
+ });
269
+
270
+ // 飞书集成 — 长连接模式
271
+ let feishuWs = null;
272
+
273
+ router.post("/gateway/feishu/connect", async (req, res) => {
274
+ const { appId, appSecret } = req.body || {};
275
+ if (!appId || !appSecret) return res.status(400).json({ success: false, error: "请提供 App ID 和 App Secret" });
276
+
277
+ try {
278
+ // 获取 tenant_access_token
279
+ const tokenRes = await axios.post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
280
+ app_id: appId, app_secret: appSecret,
281
+ });
282
+ const token = tokenRes.data?.tenant_access_token;
283
+ if (!token) throw new Error("获取飞书 token 失败");
284
+
285
+ // 断开旧连接
286
+ if (feishuWs) feishuWs.close();
287
+
288
+ // 连接飞书长连接
289
+ const WebSocket = require("ws");
290
+ const wsUrl = `wss://open.feishu.cn/open-apis/event/v1/ws/connection?app_id=${appId}&app_secret=${appSecret}`;
291
+ // 先获取连接 URL
292
+ const connRes = await axios.post("https://open.feishu.cn/open-apis/event/v1/ws/connection",
293
+ { app_id: appId, app_secret: appSecret },
294
+ { headers: { Authorization: `Bearer ${token}` } }
295
+ );
296
+ const connData = connRes.data?.data;
297
+ if (!connData?.url) throw new Error("获取飞书长连接地址失败");
298
+
299
+ feishuWs = new WebSocket(connData.url);
300
+
301
+ feishuWs.on("open", () => {
302
+ console.log("[Feishu] 长连接已建立");
303
+ });
304
+
305
+ feishuWs.on("message", async (data) => {
306
+ try {
307
+ const event = JSON.parse(data.toString());
308
+ if (event.type === "message" && event.event?.type === "im.message.receive_v1") {
309
+ const msg = event.event;
310
+ let text = "";
311
+ try { text = JSON.parse(msg.content || "{}").text || ""; } catch {}
312
+
313
+ if (text) {
314
+ const config = (() => {
315
+ try {
316
+ const fs = require("fs"), path = require("path");
317
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
318
+ if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, "utf8"));
319
+ } catch {}
320
+ return { api1: {} };
321
+ })();
322
+
323
+ const result = await callAgent(config.api1, [{ role: "user", content: text }], {});
324
+
325
+ // 发送回复
326
+ await axios.post("https://open.feishu.cn/open-apis/im/v1/messages/" + msg.message_id + "/reply",
327
+ { content: JSON.stringify({ text: result.answer }) },
328
+ { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }
329
+ );
330
+ }
331
+ }
332
+ } catch (e) { console.error("[Feishu]", e.message); }
333
+ });
334
+
335
+ feishuWs.on("close", () => { feishuWs = null; });
336
+ feishuWs.on("error", (e) => { console.error("[Feishu]", e.message); feishuWs = null; });
337
+
338
+ res.json({ success: true, message: "飞书长连接已建立" });
339
+ } catch (e) {
340
+ res.status(500).json({ success: false, error: e.message });
341
+ }
342
+ });
343
+
344
+ // 获取飞书连接状态
345
+ router.get("/gateway/feishu/status", (req, res) => {
346
+ res.json({ connected: feishuWs !== null && feishuWs.readyState === 1 });
347
+ });
348
+
349
+ // OpenClaw 微信连接 — 全自动:写配置 → 启动进程 → 推送二维码
350
+ router.post("/gateway/openclaw/start", (req, res) => {
351
+ const { spawn } = require("child_process");
352
+ const fs = require("fs");
353
+ const path = require("path");
354
+
355
+ res.setHeader("Content-Type", "text/event-stream");
356
+ res.setHeader("Cache-Control", "no-cache");
357
+ res.setHeader("Connection", "keep-alive");
358
+
359
+ const send = (type, data) => res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
360
+
361
+ // 第 1 步:先写 OpenClaw 配置,让它一启动就用 Memi
362
+ try {
363
+ const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
364
+ const configDir = path.join(home, ".openclaw");
365
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
366
+ fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
367
+ apiBase: "http://localhost:3001/api/v1",
368
+ apiKey: "",
369
+ model: "memi-agent",
370
+ }, null, 2));
371
+ send("status", { message: "已配置 API 后端 → Memi" });
372
+ } catch (e) {
373
+ send("status", { message: "配置文件写入失败: " + e.message });
374
+ }
375
+
376
+ // 第 2 步:启动 OpenClaw 微信插件
377
+ send("status", { message: "正在启动 OpenClaw 微信插件..." });
378
+
379
+ const child = spawn("npx", ["-y", "@tencent-weixin/openclaw-weixin-cli@latest", "install"], {
380
+ shell: true,
381
+ env: { ...process.env, FORCE_COLOR: "0" },
382
+ });
383
+
384
+ let output = "";
385
+
386
+ child.stdout.on("data", (chunk) => {
387
+ const text = chunk.toString();
388
+ output += text;
389
+ send("log", { text: text.trim().slice(-300) });
390
+
391
+ // 检测二维码
392
+ if ((output.includes("█") && output.length > 500) ||
393
+ (output.includes("http") && output.includes("qr"))) {
394
+ send("qr", { text: output.slice(-1000) });
395
+ }
396
+ });
397
+
398
+ child.stderr.on("data", (chunk) => {
399
+ const text = chunk.toString();
400
+ output += text;
401
+ send("log", { text: text.trim().slice(-300) });
402
+ if (text.includes("█") || (text.includes("http") && text.includes("qr"))) {
403
+ send("qr", { text: output.slice(-1000) });
404
+ }
405
+ });
406
+
407
+ child.on("close", (code) => {
408
+ send("done", { code, message: code === 0 ? "微信连接已建立" : `进程退出 (${code}),请检查终端输出` });
409
+ res.end();
410
+ });
411
+
412
+ child.on("error", (err) => {
413
+ send("error", { message: "启动失败: " + err.message });
414
+ res.end();
415
+ });
416
+
417
+ setTimeout(() => {
418
+ if (!res.writableEnded) { send("timeout", {}); child.kill(); res.end(); }
419
+ }, 120000);
420
+ });
421
+
422
+ // 删除连接
423
+ router.delete("/gateway/connections/:id", (req, res) => {
424
+ let conns = loadConnections();
425
+ conns = conns.filter((c) => c.id !== req.params.id);
426
+ saveConnections(conns);
427
+ res.json({ success: true });
428
+ });
429
+
430
+ // Telegram Bot webhook
431
+ router.post("/gateway/telegram/:token", async (req, res) => {
432
+ try {
433
+ const { token } = req.params;
434
+ const body = req.body || {};
435
+ const msg = body.message || body.edited_message;
436
+ if (!msg || !msg.text) return res.json({ ok: true });
437
+ const chatId = msg.chat.id;
438
+ const text = msg.text;
439
+
440
+ const config = (() => {
441
+ try {
442
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
443
+ if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, "utf8"));
444
+ } catch {}
445
+ return { api1: {} };
446
+ })();
447
+
448
+ axios.post(`https://api.telegram.org/bot${token}/sendChatAction`, {
449
+ chat_id: chatId, action: "typing"
450
+ }).catch(() => {});
451
+
452
+ const messages = [{ role: "user", content: text }];
453
+ const result = await callAgent(config.api1, messages, {});
454
+ const reply = result.answer || "抱歉,无法处理此请求。";
455
+
456
+ // Telegram 消息最长 4096 字符
457
+ const chunks = (reply || "").match(/[\s\S]{1,4000}/g) || [reply];
458
+ for (const chunk of chunks) {
459
+ await axios.post(`https://api.telegram.org/bot${token}/sendMessage`, {
460
+ chat_id: chatId, text: chunk, parse_mode: "Markdown"
461
+ }).catch(async () => {
462
+ await axios.post(`https://api.telegram.org/bot${token}/sendMessage`, {
463
+ chat_id: chatId, text: chunk
464
+ }).catch(() => {});
465
+ });
466
+ }
467
+ res.json({ ok: true });
468
+ } catch(e) {
469
+ res.json({ ok: false, error: e.message });
470
+ }
471
+ });
472
+
473
+ // WeCom (企业微信) Bot webhook
474
+ router.post("/gateway/wecom/:token", async (req, res) => {
475
+ try {
476
+ const body = req.body || {};
477
+ const text = body.text?.content || body.text || body.Content || "";
478
+ if (!text) return res.json({ errcode: 0, errmsg: "ok" });
479
+ const config = (() => {
480
+ try {
481
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
482
+ if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, "utf8"));
483
+ } catch {}
484
+ return { api1: {} };
485
+ })();
486
+ const result = await callAgent(config.api1, [{ role: "user", content: text }], {});
487
+ const reply = result.answer || "抱歉,无法处理此请求。";
488
+ res.json({ errcode: 0, errmsg: "ok", text: { content: reply.slice(0, 2000) } });
489
+ } catch(e) {
490
+ res.json({ errcode: -1, errmsg: e.message });
491
+ }
492
+ });
493
+
494
+ // QQ Bot webhook (兼容 go-cqhttp / 官方 QQ Bot API)
495
+ router.post("/gateway/qq/:token", async (req, res) => {
496
+ try {
497
+ const body = req.body || {};
498
+ // go-cqhttp 格式
499
+ let text = body.message || body.raw_message || body.content || "";
500
+ // 官方 QQ Bot 格式
501
+ if (body.msgtype === "text" || body.type === "text") text = body.content || body.text || "";
502
+ if (!text) return res.json({ reply: "" });
503
+ const config = (() => {
504
+ try {
505
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
506
+ if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, "utf8"));
507
+ } catch {}
508
+ return { api1: {} };
509
+ })();
510
+ const result = await callAgent(config.api1, [{ role: "user", content: text }], {});
511
+ const reply = result.answer || "抱歉,无法处理此请求。";
512
+ res.json({ reply: reply.slice(0, 2000), auto_escape: false });
513
+ } catch(e) {
514
+ res.json({ reply: "处理失败: " + e.message });
515
+ }
516
+ });
517
+
518
+ // Telegram 设置/删除 webhook
519
+ router.post("/gateway/telegram/:token/setup", async (req, res) => {
520
+ try {
521
+ const { token } = req.params;
522
+ const baseUrl = req.body.baseUrl || `http://localhost:${process.env.PORT || 3001}`;
523
+ const webhookUrl = `${baseUrl}/api/gateway/telegram/${token}`;
524
+ const r = await axios.post(`https://api.telegram.org/bot${token}/setWebhook`, { url: webhookUrl });
525
+ res.json({ success: true, webhook: webhookUrl, result: r.data });
526
+ } catch(e) {
527
+ res.status(500).json({ success: false, error: e.message });
528
+ }
529
+ });
530
+ }
531
+
532
+ module.exports = { handleWebSocket, createConnectionRoutes };
@@ -0,0 +1,152 @@
1
+ const express = require("express");
2
+ const cors = require("cors");
3
+ const http = require("http");
4
+ const path = require("path");
5
+ const fs = require("fs");
6
+ const { WebSocketServer } = require("ws");
7
+ const apiRoutes = require("./routes/api");
8
+ const { handleWebSocket } = require("./gateway");
9
+
10
+ const app = express();
11
+ const PORT = process.env.PORT || 3001;
12
+
13
+ // 只允许本地前端开发服务访问后端接口
14
+ app.use(
15
+ cors({
16
+ origin: ["http://localhost:5173", "http://localhost:3001", "http://127.0.0.1:5173", "http://127.0.0.1:3001", "null"],
17
+ credentials: true,
18
+ })
19
+ );
20
+
21
+ // 解析 JSON 请求体(base64 图片可能较大)
22
+ app.use(express.json({ limit: "10mb" }));
23
+
24
+ // Gateway 认证(可选,未配置则跳过)
25
+ const AUTH_TOKEN = process.env.MEMI_GATEWAY_TOKEN || "";
26
+ const authMiddleware = (req, res, next) => {
27
+ if (!AUTH_TOKEN) return next();
28
+ const auth = req.headers.authorization || "";
29
+ const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
30
+ if (token !== AUTH_TOKEN) return res.status(401).json({ error: "Unauthorized. Set MEMI_GATEWAY_TOKEN env or pass Bearer token." });
31
+ next();
32
+ };
33
+ // 对 /api/gateway/* 和 /api/v1/* 启用认证(Express 不认数组,分开写)
34
+ app.use("/api/gateway", authMiddleware);
35
+ app.use("/api/v1", authMiddleware);
36
+
37
+ // 健康检查接口
38
+ app.get("/health", (req, res) => {
39
+ res.json({ status: "ok", name: "memi" });
40
+ });
41
+
42
+
43
+
44
+ // Dashboard
45
+ app.get("/api/balance", async (req, res) => {
46
+ try {
47
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
48
+ if (!fs.existsSync(f)) return res.json({ balance: "—", error: "无配置" });
49
+ const cfg = JSON.parse(fs.readFileSync(f, "utf8"));
50
+ const api = cfg.api1 || {};
51
+ if (!api.apiKey) return res.json({ balance: "—", error: "未配置API Key" });
52
+ // DeepSeek 余额
53
+ const r = await fetch("https://api.deepseek.com/user/balance", {
54
+ headers: { Authorization: `Bearer ${api.apiKey}` },
55
+ signal: AbortSignal.timeout(10000),
56
+ });
57
+ const d = await r.json();
58
+ if (d?.balance_infos?.[0]?.total_balance) {
59
+ const bal = Number(d.balance_infos[0].total_balance);
60
+ return res.json({ balance: "¥" + bal.toFixed(2) });
61
+ }
62
+ if (d?.data?.balance) return res.json({ balance: "$" + Number(d.data.balance).toFixed(2) });
63
+ res.json({ balance: "—", raw: d });
64
+ } catch(e) {
65
+ res.json({ balance: "获取失败", error: e.message });
66
+ }
67
+ });
68
+
69
+ app.get("/api/config", (req, res) => {
70
+ try {
71
+ const f = path.join(__dirname, "..", "memi-config", "config.json");
72
+ if (fs.existsSync(f)) return res.json(JSON.parse(fs.readFileSync(f, "utf8")));
73
+ } catch {}
74
+ res.json({});
75
+ });
76
+
77
+ app.get("/docs", (req, res) => {
78
+ res.redirect("/dashboard");
79
+ });
80
+
81
+ app.get("/dashboard", (req, res) => {
82
+ res.sendFile(path.join(__dirname, "..", "memi-dashboard.html"));
83
+ });
84
+
85
+
86
+
87
+ // 会话 API — Web ↔ CLI 互通
88
+ const SESS_DIR = path.join(__dirname, "..", "memi-config", "sessions");
89
+ app.get("/api/sessions", (req, res) => {
90
+ try {
91
+ if (!fs.existsSync(SESS_DIR)) return res.json({ sessions: [] });
92
+ const files = fs.readdirSync(SESS_DIR).filter(f => f.endsWith(".json"));
93
+ res.json({ sessions: files.map(f => f.replace(".json", "")) });
94
+ } catch { res.json({ sessions: [] }); }
95
+ });
96
+ app.get("/api/sessions/:name", (req, res) => {
97
+ try {
98
+ const f = path.join(SESS_DIR, req.params.name + ".json");
99
+ if (!fs.existsSync(f)) return res.json({ messages: [] });
100
+ res.json({ messages: JSON.parse(fs.readFileSync(f, "utf8")) });
101
+ } catch { res.json({ messages: [] }); }
102
+ });
103
+ app.post("/api/sessions/:name", (req, res) => {
104
+ try {
105
+ if (!fs.existsSync(SESS_DIR)) fs.mkdirSync(SESS_DIR, { recursive: true });
106
+ fs.writeFileSync(path.join(SESS_DIR, req.params.name + ".json"), JSON.stringify(req.body.messages || []));
107
+ res.json({ success: true });
108
+ } catch(e) { res.status(500).json({ error: e.message }); }
109
+ });
110
+ app.delete("/api/sessions/:name", (req, res) => {
111
+ try {
112
+ const f = path.join(SESS_DIR, req.params.name + ".json");
113
+ if (fs.existsSync(f)) fs.unlinkSync(f);
114
+ res.json({ success: true });
115
+ } catch(e) { res.status(500).json({ error: e.message }); }
116
+ });
117
+
118
+ // 挂载 API 路由
119
+ app.use("/api", apiRoutes);
120
+
121
+ // API 路由的 404 返回 JSON(而不是 HTML)
122
+ app.use("/api", (req, res) => {
123
+ res.status(404).json({ success: false, error: `接口不存在: ${req.method} ${req.path}` });
124
+ });
125
+
126
+ // 全局错误处理 — 返回 JSON
127
+ app.use((err, req, res, next) => {
128
+ console.error("[memi-server]", err.stack || err.message);
129
+ res.status(500).json({ success: false, error: "服务器内部错误", detail: err.message });
130
+ });
131
+
132
+ // 创建 HTTP Server
133
+ const server = http.createServer(app);
134
+
135
+ // WebSocket 服务
136
+ const wss = new WebSocketServer({ server, path: "/api/gateway/ws" });
137
+ wss.on("connection", (ws) => {
138
+ const config = (() => {
139
+ try {
140
+ const fs = require("fs");
141
+ const file = path.join(__dirname, "..", "memi-config", "config.json");
142
+ if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, "utf8"));
143
+ } catch {}
144
+ return { api1: {}, api2: {} };
145
+ })();
146
+ handleWebSocket(ws, () => config);
147
+ });
148
+
149
+ server.listen(PORT, () => {
150
+ console.log(`memi-server is running on port ${PORT}`);
151
+ console.log(`WebSocket: ws://localhost:${PORT}/api/gateway/ws`);
152
+ });