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,744 @@
1
+ const express = require("express");
2
+ const axios = require("axios");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { callLLM, callImageGen, callVision } = require("../utils/aiProxy");
6
+ const { callAgent } = require("../utils/agent");
7
+ const { createConnectionRoutes } = require("../gateway");
8
+
9
+ const router = express.Router();
10
+ createConnectionRoutes(router);
11
+
12
+ // 自动保存前端传来的 API 配置供 CLI 使用
13
+ function saveConfigToFile(config) {
14
+ // 统一写入 memi-config/config.json
15
+ const dest = path.join(__dirname, "..", "..", "memi-config", "config.json");
16
+ try { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, JSON.stringify(config, null, 2)); } catch {}
17
+ }
18
+
19
+ const ASPECT_RATIO_SIZE = {
20
+ "1:1": { width: 512, height: 512 },
21
+ "16:9": { width: 512, height: 288 },
22
+ "9:16": { width: 288, height: 512 },
23
+ "4:3": { width: 512, height: 384 },
24
+ };
25
+ const IMAGE_PIPELINE_TIMEOUT = 300000;
26
+ const IMAGE_PROXY_HEADERS = {
27
+ "User-Agent":
28
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
29
+ Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
30
+ Referer: "https://pollinations.ai/",
31
+ };
32
+
33
+ function resolveProxyHeaders(imageUrl) {
34
+ // 只有 enterprise 端点才发 token;image.pollinations.ai 发 token 会被识别为认证请求而拒之
35
+ if (imageUrl && String(imageUrl).includes("enter.pollinations.ai")) {
36
+ return { ...IMAGE_PROXY_HEADERS, "x-enter-token": "1" };
37
+ }
38
+ return IMAGE_PROXY_HEADERS;
39
+ }
40
+ const STYLE_PROMPTS = {
41
+ Realistic: "Use a realistic photography style.",
42
+ Anime: "Use an anime illustration style.",
43
+ "3D": "Use a polished 3D render style.",
44
+ Cyberpunk: "Use a cyberpunk visual style.",
45
+ "Oil Painting": "Use an oil painting style with painterly brushwork.",
46
+ Watercolor: "Use a soft watercolor painting style.",
47
+ "Pixel Art": "Use a crisp pixel art style.",
48
+ Cinematic: "Use a cinematic film still style with dramatic lighting.",
49
+ Sketch: "Use a pencil sketch style.",
50
+ };
51
+
52
+ const DEFECT_KEYWORDS = [
53
+ "issue", "problem", "flaw", "missing", "wrong", "blurry", "artifact",
54
+ "not match", "doesn't match", "not align", "poor", "low quality",
55
+ "inaccurate", "inconsistent", "瑕疵", "问题", "缺陷", "不匹配",
56
+ "模糊", "不对", "错误", "缺少", "缺失", "不一致",
57
+ ];
58
+
59
+ function hasDefectMentions(content) {
60
+ const lower = (content || "").toLowerCase();
61
+ return DEFECT_KEYWORDS.some((w) => lower.includes(w));
62
+ }
63
+
64
+ function parseReviewResult(content) {
65
+ if (!content || typeof content !== "string") {
66
+ return { passed: false, score: 50, reason: "审核模型未返回内容,自动重试", issues: ["empty_review"] };
67
+ }
68
+
69
+ let score = 50;
70
+
71
+ // Try JSON first
72
+ try {
73
+ const jsonText = content
74
+ .trim()
75
+ .replace(/^```json\s*/i, "")
76
+ .replace(/^```\s*/i, "")
77
+ .replace(/```$/i, "")
78
+ .trim();
79
+ const review = JSON.parse(jsonText);
80
+ if (review && typeof review === "object") {
81
+ const s = Number(review.score);
82
+ if (!isNaN(s) && s >= 0 && s <= 100) score = s;
83
+ }
84
+ } catch {}
85
+
86
+ // If JSON didn't work, extract number from text
87
+ if (score === 50) {
88
+ const numbers = content.match(/\b(\d{1,3})\b/g);
89
+ if (numbers) {
90
+ const validScores = numbers.map(Number).filter((n) => n >= 0 && n <= 100);
91
+ if (validScores.length > 0) score = validScores[0];
92
+ }
93
+ }
94
+
95
+ // If the response mentions defects, cap the score so retry fires
96
+ if (hasDefectMentions(content) && score >= 60) {
97
+ score = Math.min(score, 50);
98
+ }
99
+
100
+ return {
101
+ passed: score >= 60,
102
+ score,
103
+ reason: "审核完成",
104
+ issues: hasDefectMentions(content) ? ["detected_defects"] : [],
105
+ };
106
+ }
107
+
108
+ async function polishPrompt(api1, prompt, style, temperature) {
109
+ const stylePrompt = STYLE_PROMPTS[style] || `Style: ${style}`;
110
+ const userContent =
111
+ style && style !== "None" ? `${prompt}\n${stylePrompt}` : prompt;
112
+
113
+ return callLLM(api1, [
114
+ {
115
+ role: "system",
116
+ content:
117
+ "You are a prompt translator for AI image generation. Translate the user's description into English literally. Keep the original meaning exactly. Do not add scenes, objects, or styles that the user did not mention. Output ONLY the English translation, no explanations.",
118
+ },
119
+ {
120
+ role: "user",
121
+ content: userContent,
122
+ },
123
+ ], { temperature });
124
+ }
125
+
126
+ async function rewritePrompt(api1, previousPrompt, review, temperature) {
127
+ return callLLM(api1, [
128
+ {
129
+ role: "system",
130
+ content: `The previous image failed review. Issues: ${review.issues.join(
131
+ ", "
132
+ )}. Rewrite the prompt to fix these issues while keeping the user's intent.`,
133
+ },
134
+ {
135
+ role: "user",
136
+ content: JSON.stringify({
137
+ previousPrompt,
138
+ reason: review.reason,
139
+ issues: review.issues,
140
+ score: review.score,
141
+ }),
142
+ },
143
+ ], { temperature });
144
+ }
145
+
146
+ function ensurePipelineTime(startTime) {
147
+ if (Date.now() - startTime >= IMAGE_PIPELINE_TIMEOUT) {
148
+ throw new Error("图片流水线超时(300秒),请减少重试次数或稍后再试");
149
+ }
150
+ }
151
+
152
+ function createHistoryItem(round, prompt, imageUrl, review) {
153
+ return {
154
+ round,
155
+ prompt,
156
+ imageUrl,
157
+ score: review.score,
158
+ reason: review.reason,
159
+ issues: review.issues,
160
+ };
161
+ }
162
+
163
+ function getReviewErrorResult(error) {
164
+ const isTimeout = error.message.includes("超时") || error.message.includes("timeout");
165
+ const isImageDownloadError = error.message.includes("图片下载失败");
166
+
167
+ if (isImageDownloadError) {
168
+ return {
169
+ passed: false,
170
+ score: 50,
171
+ reason: `审核图片下载失败,自动重试: ${error.message}`,
172
+ issues: ["review_image_download_error"],
173
+ };
174
+ }
175
+
176
+ return {
177
+ passed: false,
178
+ score: 0,
179
+ reason: isTimeout ? "审核超时" : error.message,
180
+ issues: [isTimeout ? "timeout" : "review_error"],
181
+ };
182
+ }
183
+
184
+ function sleep(ms) {
185
+ return new Promise((resolve) => {
186
+ setTimeout(resolve, ms);
187
+ });
188
+ }
189
+
190
+ async function downloadProxyImage(url) {
191
+ const response = await axios.get(url, {
192
+ responseType: "arraybuffer",
193
+ timeout: 30000,
194
+ headers: resolveProxyHeaders(url),
195
+ maxRedirects: 5,
196
+ });
197
+ const buffer = Buffer.from(response.data);
198
+ return {
199
+ buffer,
200
+ contentType: response.headers["content-type"] || "image/jpeg",
201
+ };
202
+ }
203
+
204
+ async function downloadProxyImageWithRetry(url) {
205
+ let lastError = null;
206
+
207
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
208
+ try {
209
+ return await downloadProxyImage(url);
210
+ } catch (error) {
211
+ lastError = error;
212
+
213
+ if (!error.message.includes("Status Code 5") || attempt === 3) {
214
+ throw error;
215
+ }
216
+
217
+ await sleep(800 * attempt);
218
+ }
219
+ }
220
+
221
+ throw lastError;
222
+ }
223
+
224
+ function replacePollinationsHost(imageUrl, newHost) {
225
+ try {
226
+ const parsed = new URL(imageUrl);
227
+ if (parsed.hostname.includes("pollinations")) {
228
+ parsed.hostname = new URL(newHost).hostname;
229
+ parsed.protocol = new URL(newHost).protocol;
230
+ parsed.port = new URL(newHost).port;
231
+ return parsed.toString();
232
+ }
233
+ } catch {}
234
+ return null;
235
+ }
236
+
237
+ router.get("/proxy-image", async (req, res) => {
238
+ const { url } = req.query;
239
+
240
+ if (!url || typeof url !== "string") {
241
+ return res.status(400).json({ success: false, error: "缺少图片 URL" });
242
+ }
243
+
244
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
245
+ return res.status(400).json({ success: false, error: "图片 URL 不合法" });
246
+ }
247
+
248
+ // Express 自动解码 query 参数,可能还原了 %20 → 空格等不可用于 HTTP 请求的字符,
249
+ // 使用 encodeURI 重新编码,保留 ? & # 等 URL 结构字符不变
250
+ const encodedUrl = encodeURI(url);
251
+ const tryUrls = [encodedUrl];
252
+
253
+ // 如果原始 URL 是 pollinations 企业端点,备选公共端点兜底
254
+ const publicFallback = replacePollinationsHost(encodedUrl, "https://image.pollinations.ai");
255
+ if (publicFallback && publicFallback !== encodedUrl) {
256
+ tryUrls.push(publicFallback);
257
+ }
258
+
259
+ let lastError = "";
260
+ for (const targetUrl of tryUrls) {
261
+ try {
262
+ const image = await downloadProxyImageWithRetry(targetUrl);
263
+ const ct = image.contentType || "";
264
+
265
+ // Pollinations 返回 JSON/HTML 而非图片 → 旧版端点已废弃
266
+ if (!ct.startsWith("image/")) {
267
+ const text = image.buffer.toString("utf8").slice(0, 200);
268
+ if (text.includes("legacy endpoint")) {
269
+ lastError = "Pollinations 旧版端点已废弃,图片无法通过代理加载";
270
+ } else {
271
+ lastError = `非图片响应: ${text}`;
272
+ }
273
+ continue;
274
+ }
275
+
276
+ res.setHeader("Content-Type", ct);
277
+ res.setHeader("Cache-Control", "public, max-age=3600");
278
+ res.setHeader("X-Content-Type-Options", "nosniff");
279
+ return res.send(image.buffer);
280
+ } catch (err) {
281
+ lastError = err.message || "未知错误";
282
+ }
283
+ }
284
+
285
+ return res.status(500).json({
286
+ success: false,
287
+ error: `图片代理下载失败: ${lastError}`,
288
+ });
289
+ });
290
+
291
+ router.post("/pipeline/image", async (req, res) => {
292
+ req.setTimeout(IMAGE_PIPELINE_TIMEOUT);
293
+ res.setTimeout(IMAGE_PIPELINE_TIMEOUT);
294
+
295
+ const startTime = Date.now();
296
+ const history = [];
297
+ const { api1, api2, api3, prompt, aspectRatio, style, maxRetry } = req.body;
298
+ saveConfigToFile({ api1, api2, api3 });
299
+ const retryLimit = Number.isInteger(maxRetry) ? maxRetry : Number(maxRetry) || 0;
300
+ const size = ASPECT_RATIO_SIZE[aspectRatio] || ASPECT_RATIO_SIZE["1:1"];
301
+
302
+ let polishedPrompt = "";
303
+ let imageData = "";
304
+ let latestReview = {
305
+ passed: false,
306
+ score: 0,
307
+ reason: "",
308
+ issues: [],
309
+ };
310
+ let retryCount = 0;
311
+ let exhausted = false;
312
+ const shouldSkipReview = !api3?.baseUrl;
313
+
314
+ try {
315
+ ensurePipelineTime(startTime);
316
+ polishedPrompt = await polishPrompt(api1, prompt, style);
317
+ } catch (error) {
318
+ history.push(
319
+ createHistoryItem(1, prompt || "", "", {
320
+ passed: false,
321
+ score: 0,
322
+ reason: `润色: ${error.message}`,
323
+ issues: ["polish_error"],
324
+ })
325
+ );
326
+
327
+ return res.json({
328
+ success: false,
329
+ error: `润色: ${error.message}`,
330
+ history,
331
+ });
332
+ }
333
+
334
+ while (true) {
335
+ try {
336
+ ensurePipelineTime(startTime);
337
+ imageData = await callImageGen(api2, polishedPrompt, size.width, size.height);
338
+ } catch (error) {
339
+ history.push(
340
+ createHistoryItem(history.length + 1, polishedPrompt, "", {
341
+ passed: false,
342
+ score: 0,
343
+ reason: `生图: ${error.message}`,
344
+ issues: ["image_error"],
345
+ })
346
+ );
347
+
348
+ return res.json({
349
+ success: false,
350
+ error: `生图: ${error.message}`,
351
+ history,
352
+ });
353
+ }
354
+
355
+ if (shouldSkipReview) {
356
+ latestReview = {
357
+ passed: true,
358
+ score: 100,
359
+ reason: "已跳过审核",
360
+ issues: [],
361
+ };
362
+ } else {
363
+ try {
364
+ ensurePipelineTime(startTime);
365
+ const reviewSystem =
366
+ 'Compare the generated image against the user\'s prompt. First, list any mismatches, missing elements, wrong details, blurry areas, artifacts, or quality issues. Be specific. Then assign a score 0-100 where 100 = perfect match and 0 = completely wrong. Score >= 60 passes. Return the score as a number only.';
367
+ const reviewContent = await callVision(
368
+ api3,
369
+ imageData,
370
+ prompt,
371
+ reviewSystem
372
+ );
373
+ latestReview = parseReviewResult(reviewContent);
374
+ } catch (error) {
375
+ latestReview = getReviewErrorResult(error);
376
+ }
377
+ }
378
+
379
+ history.push(
380
+ createHistoryItem(history.length + 1, polishedPrompt, imageData, latestReview)
381
+ );
382
+
383
+ if (latestReview.score >= 60) break;
384
+ if (retryCount >= retryLimit) {
385
+ exhausted = true;
386
+ break;
387
+ }
388
+
389
+ try {
390
+ ensurePipelineTime(startTime);
391
+ polishedPrompt = await rewritePrompt(api1, polishedPrompt, latestReview);
392
+ retryCount += 1;
393
+ } catch (error) {
394
+ return res.json({
395
+ success: false,
396
+ error: `重写提示词: ${error.message}`,
397
+ history,
398
+ });
399
+ }
400
+ }
401
+
402
+ res.json({
403
+ success: true,
404
+ type: "image",
405
+ imageUrl: imageData,
406
+ finalPrompt: polishedPrompt,
407
+ score: latestReview.score,
408
+ exhausted,
409
+ history,
410
+ totalTime: Date.now() - startTime,
411
+ });
412
+ });
413
+
414
+ // 融合对话接口:先判断意图,纯聊天走 api1,生图走完整流水线
415
+ router.post("/pipeline/chat-complete", async (req, res) => {
416
+ const startTime = Date.now();
417
+ const { api1, api2, api3, messages, aspectRatio, style, maxRetry, maxTokens, polishOnly, overridePrompt, temperature, skipReview } = req.body;
418
+ saveConfigToFile({ api1, api2, api3 });
419
+ const lastUserMsg = [...(messages || [])].reverse().find((m) => m.role === "user");
420
+
421
+ if (!lastUserMsg) {
422
+ return res.json({ success: false, error: "No user message", type: "error" });
423
+ }
424
+
425
+ // 本地关键词判断意图:避免 callLLM 发送复杂消息格式
426
+ const IMAGE_KEYWORDS = [
427
+ "画", "生成", "创建", "制作", "设计", "绘", "render",
428
+ "draw", "generate", "create", "make", "design", "paint",
429
+ "image", "picture", "photo", "illustration", "art",
430
+ "图", "图片", "照片", "插图",
431
+ "变成", "改成", "换", "修改", "改变", "调整", "改",
432
+ "替换", "换个", "换成", "加", "加上", "去掉", "删除",
433
+ "增加", "添加", "change", "modify", "replace", "edit",
434
+ "transform", "convert", "update", "alter", "remake",
435
+ ];
436
+ const content = typeof lastUserMsg.content === "string" ? lastUserMsg.content : String(lastUserMsg.content || "");
437
+ const text = content.toLowerCase();
438
+ let isImageRequest = IMAGE_KEYWORDS.some((kw) => text.includes(kw));
439
+
440
+ // 上下文推断:如果上一条助手消息是图片结果,则用户后续消息很大概率在要求改图
441
+ if (!isImageRequest) {
442
+ const lastAssistant = [...messages].reverse().find(
443
+ (m) => m.role === "assistant" && m.type === "image"
444
+ );
445
+ if (lastAssistant) {
446
+ isImageRequest = true;
447
+ }
448
+ }
449
+
450
+ try {
451
+ if (isImageRequest || overridePrompt || polishOnly === true) {
452
+ const prompt = overridePrompt || lastUserMsg.content;
453
+ const retryLimit = Number.isInteger(maxRetry) ? maxRetry : Number(maxRetry) || 0;
454
+ const size = ASPECT_RATIO_SIZE[aspectRatio] || ASPECT_RATIO_SIZE["1:1"];
455
+ const history = [];
456
+ let polishedPrompt = "";
457
+ let imageData = "";
458
+ let latestReview = { passed: false, score: 0, reason: "", issues: [] };
459
+ let retryCount = 0;
460
+ let exhausted = false;
461
+ const shouldSkipReview = !api3?.baseUrl || skipReview === true;
462
+
463
+ if (overridePrompt) {
464
+ polishedPrompt = overridePrompt;
465
+ } else {
466
+ try {
467
+ polishedPrompt = await polishPrompt(api1, prompt, style, temperature);
468
+ } catch (error) {
469
+ return res.json({ success: false, error: `润色: ${error.message}`, type: "error" });
470
+ }
471
+ }
472
+
473
+ if (polishOnly === true) {
474
+ return res.json({ success: true, type: "polish", polishedPrompt });
475
+ }
476
+
477
+ while (true) {
478
+ try {
479
+ imageData = await callImageGen(api2, polishedPrompt, size.width, size.height);
480
+ } catch (error) {
481
+ return res.json({ success: false, error: `生图: ${error.message}`, type: "error", history });
482
+ }
483
+
484
+ if (shouldSkipReview) {
485
+ latestReview = { passed: true, score: 100, reason: "已跳过审核", issues: [] };
486
+ } else {
487
+ try {
488
+ const reviewSystem = 'Compare the generated image against the user\'s prompt. First, list any mismatches, missing elements, wrong details, blurry areas, artifacts, or quality issues. Be specific. Then assign a score 0-100 where 100 = perfect match and 0 = completely wrong. Score >= 60 passes. Return the score as a number only.';
489
+ const reviewContent = await callVision(api3, imageData, prompt, reviewSystem);
490
+ latestReview = parseReviewResult(reviewContent);
491
+ } catch (error) {
492
+ latestReview = getReviewErrorResult(error);
493
+ }
494
+ }
495
+
496
+ history.push({ round: history.length + 1, prompt: polishedPrompt, imageUrl: imageData, score: latestReview.score, reason: latestReview.reason, issues: latestReview.issues });
497
+
498
+ if (latestReview.score >= 60) break;
499
+ if (retryCount >= retryLimit) { exhausted = true; break; }
500
+
501
+ ensurePipelineTime(startTime);
502
+ polishedPrompt = await rewritePrompt(api1, polishedPrompt, latestReview, temperature);
503
+ retryCount += 1;
504
+ }
505
+
506
+ let description = "";
507
+ try {
508
+ description = await callLLM(api1, [
509
+ { role: "system", content: "You are a helpful assistant. The user just generated an image. Provide a brief friendly response (1-2 sentences) describing what was generated. Use the prompt and score." },
510
+ { role: "user", content: JSON.stringify({ prompt: polishedPrompt, score: latestReview.score }) },
511
+ ], { temperature });
512
+ } catch {
513
+ description = `✨ Generated image${latestReview.score ? ` (Score: ${latestReview.score})` : ""}`;
514
+ }
515
+
516
+ return res.json({
517
+ success: true,
518
+ type: "image",
519
+ imageUrl: imageData,
520
+ finalPrompt: polishedPrompt,
521
+ score: latestReview.score,
522
+ exhausted,
523
+ history,
524
+ totalTime: Date.now() - startTime,
525
+ response: description,
526
+ });
527
+ }
528
+
529
+ // 纯聊天:使用与 polishPrompt 完全相同的 [{system}, {user}] 格式
530
+ const chatPrompt = messages
531
+ .map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
532
+ .join("\n");
533
+ const response = await callLLM(api1, [
534
+ { role: "system", content: "You are a helpful assistant created by Memi. Continue the conversation naturally." },
535
+ { role: "user", content: chatPrompt },
536
+ ], { maxTokens });
537
+ return res.json({ success: true, type: "chat", response });
538
+ } catch (error) {
539
+ return res.json({ success: false, error: error.message, type: "error" });
540
+ }
541
+ });
542
+
543
+ // 对话接口:纯 LLM 聊天,仅使用 api1
544
+ router.post("/pipeline/chat", async (req, res) => {
545
+ const { api1, messages, maxTokens } = req.body;
546
+
547
+ try {
548
+ const response = await callLLM(api1, messages, { maxTokens });
549
+ res.json({ success: true, response });
550
+ } catch (error) {
551
+ res.json({ success: false, error: error.message });
552
+ }
553
+ });
554
+
555
+ // 视频流水线占位接口
556
+ router.post("/pipeline/video", (req, res) => {
557
+ res.json({ status: "video pipeline placeholder" });
558
+ });
559
+
560
+ // AI 连通性测试接口
561
+ router.post("/test-ai", async (req, res) => {
562
+ const { provider, message } = req.body;
563
+
564
+ try {
565
+ const response = await callLLM(provider, [
566
+ {
567
+ role: "user",
568
+ content: message,
569
+ },
570
+ ]);
571
+
572
+ res.json({ success: true, response });
573
+ } catch (error) {
574
+ res.json({ success: false, error: error.message });
575
+ }
576
+ });
577
+
578
+ // Agent 对话接口:支持工具调用的智能代理
579
+ router.post("/agent/chat", async (req, res) => {
580
+ const { api1, api2, messages, maxTokens, skills } = req.body;
581
+ saveConfigToFile({ api1, api2 });
582
+
583
+ if (!api1?.baseUrl) {
584
+ return res.json({ success: false, error: "请先配置 API1(LLM)", type: "error" });
585
+ }
586
+
587
+ // 运行时注入工具 handler
588
+ const runtimeTools = {};
589
+ const newSkills = [];
590
+
591
+ // create_skill handler
592
+ runtimeTools.create_skill = async (args) => {
593
+ const skill = {
594
+ name: args.name || "",
595
+ nameEn: args.nameEn || args.name || "",
596
+ type: args.type || "llm",
597
+ description: args.description || "",
598
+ descriptionEn: args.descriptionEn || args.description || "",
599
+ promptTemplate: args.promptTemplate || "",
600
+ builtin: false,
601
+ enabled: true,
602
+ };
603
+ newSkills.push(skill);
604
+ return `技能 "${skill.name}" 已成功创建。类型:${skill.type === "image" ? "图片生成" : "文本处理"}`;
605
+ };
606
+
607
+ // run_skill handler
608
+ if (skills && Array.isArray(skills)) {
609
+ runtimeTools.run_skill = async (args) => {
610
+ const allSkills = [...skills, ...newSkills];
611
+ const skill = allSkills.find(
612
+ (s) => (s.name === args.skillName) || (s.nameEn === args.skillName)
613
+ );
614
+ if (!skill) return `技能 "${args.skillName}" 未找到`;
615
+ const filled = (skill.promptTemplate || "").replace(/{input}/g, args.input || "");
616
+ const result = await callLLM(api1, [
617
+ { role: "user", content: filled },
618
+ ], { maxTokens });
619
+ return result;
620
+ };
621
+ }
622
+
623
+ // generate_image handler
624
+ if (api2?.baseUrl) {
625
+ runtimeTools.generate_image = async (args) => {
626
+ const width = args.aspectRatio === "16:9" ? 512 : args.aspectRatio === "9:16" ? 288 : 512;
627
+ const height = args.aspectRatio === "9:16" ? 512 : args.aspectRatio === "4:3" ? 384 : 512;
628
+ const styleMsg = args.style && args.style !== "None" ? `, ${args.style} style` : "";
629
+ const prompt = (args.prompt || "") + styleMsg;
630
+ return await callImageGen(api2, prompt, width, height);
631
+ };
632
+ }
633
+
634
+ try {
635
+ const result = await callAgent(api1, messages, runtimeTools);
636
+ res.json({
637
+ success: true,
638
+ type: "agent",
639
+ response: result.answer,
640
+ toolCalls: result.toolCalls,
641
+ iterations: result.iterations,
642
+ newSkills: newSkills.length > 0 ? newSkills : undefined,
643
+ });
644
+ } catch (error) {
645
+ res.json({ success: false, error: error.message, type: "error" });
646
+ }
647
+ });
648
+
649
+ // 从 URL 导入技能(GitHub / raw / gist)
650
+ router.post("/skills/import", async (req, res) => {
651
+ const { url } = req.body || {};
652
+ if (!url) return res.status(400).json({ success: false, error: "请提供 URL" });
653
+
654
+ try {
655
+ const { importSkillFromUrl } = require("../utils/importSkill");
656
+ const skill = await importSkillFromUrl(url);
657
+ res.json({ success: true, skill });
658
+ } catch (e) {
659
+ res.status(400).json({ success: false, error: e.message });
660
+ }
661
+ });
662
+
663
+ // OpenAI 兼容端点 — 供 OpenClaw / 外部 Agent 框架调用
664
+ // 无:只保留 POST,GET 无意义
665
+
666
+ router.post("/v1/chat/completions", async (req, res) => {
667
+ const { model, messages, max_tokens, temperature, stream, thinking, systemPrompt } = req.body || {};
668
+
669
+ // 从请求体或 config 文件读取 API 配置
670
+ let config = { api1: {} };
671
+ if (req.body.api1) config = { api1: req.body.api1 };
672
+ else {
673
+ // 优先读 memi-config/config.json,回退到 memi-server/config.json
674
+ const homeCfg = path.join(__dirname, "..", "..", "memi-config", "config.json");
675
+ const serverCfg = path.join(__dirname, "..", "config.json");
676
+ const cfgPath = fs.existsSync(homeCfg) ? homeCfg : serverCfg;
677
+ try { if (fs.existsSync(cfgPath)) config = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
678
+ }
679
+
680
+ if (!config.api1?.baseUrl) {
681
+ return res.status(500).json({ error: { message: "请先在 Memi 设置中配置 API1" } });
682
+ }
683
+
684
+ try {
685
+ const agentMessages = (messages || []).map((m) => ({
686
+ role: m.role,
687
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
688
+ }));
689
+
690
+ // 检查是否需要工具:包含工具关键词才走 Agent 循环
691
+ // 始终走 Agent 循环,由 LLM 自行判断是否需要工具
692
+ const thinkLevel = thinking || "high";
693
+ const result = await callAgent(config.api1, agentMessages, {}, thinkLevel, systemPrompt || "");
694
+ console.log("[memi-server][chat] callAgent 完成, answer长度:", (result.answer||"").length, "tools:", result.toolCalls?.length);
695
+ let answer = result.answer || "";
696
+ let toolCalls = result.toolCalls || [];
697
+ let reasoning = result.reasoning || "";
698
+ if (!answer) answer = await callLLM(config.api1, agentMessages, { maxTokens: max_tokens || 2048 });
699
+ const id = "chatcmpl-" + Date.now().toString(36);
700
+
701
+ if (stream) {
702
+ // SSE 流式输出
703
+ res.setHeader("Content-Type", "text/event-stream");
704
+ res.setHeader("Cache-Control", "no-cache");
705
+ res.setHeader("Connection", "keep-alive");
706
+
707
+ // 先发送工具调用事件
708
+ if (toolCalls && toolCalls.length > 0) {
709
+ for (const tc of toolCalls) {
710
+ res.write(`data: ${JSON.stringify({ type: "tool_call", tool: tc.tool, args: tc.args, result: tc.result?.slice(0, 800) })}\n\n`);
711
+ await new Promise((r) => setTimeout(r, 80));
712
+ }
713
+ }
714
+
715
+ // 再流式输出答案
716
+ let i = 0;
717
+ const chunkSize = 2;
718
+ async function flush() {
719
+ while (i < answer.length) {
720
+ const token = answer.slice(i, i + chunkSize);
721
+ i += chunkSize;
722
+ res.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: model || "memi-agent", choices: [{ index: 0, delta: { content: token } }] })}\n\n`);
723
+ await new Promise((r) => setTimeout(r, 20));
724
+ }
725
+ res.write("data: [DONE]\n\n");
726
+ res.end();
727
+ }
728
+ flush();
729
+ } else {
730
+ res.json({
731
+ id, object: "chat.completion", created: Math.floor(Date.now() / 1000),
732
+ model: model || "memi-agent",
733
+ choices: [{ index: 0, message: { role: "assistant", content: answer, reasoning_content: reasoning }, finish_reason: "stop" }],
734
+ toolCalls: toolCalls,
735
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
736
+ });
737
+ }
738
+ } catch (error) {
739
+ console.error("[memi-server][chat] 处理失败:", error.message, error.response?.status, error.response?.data);
740
+ res.status(500).json({ error: { message: error.message } });
741
+ }
742
+ });
743
+
744
+ module.exports = router;