image-vision-mcp 1.0.0 → 1.0.2

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.
@@ -1,17 +1,29 @@
1
1
  import http from "http";
2
2
  import https from "https";
3
- import { URL } from "url";
3
+ import { URL, fileURLToPath } from "url";
4
4
  import zlib from "zlib";
5
5
  import fs from "fs";
6
6
  import path from "path";
7
7
  import os from "os";
8
- import { execSync } from "child_process";
8
+ import { spawn } from "child_process";
9
+ import crypto from "crypto";
9
10
  import { callMoonshot } from "../services/moonshot.js";
10
11
  // ────────────────────── 日志 ──────────────────────
11
- // 日志文件路径:优先用环境变量,否则放系统临时目录(跨平台、无需权限处理)
12
+ // 日志文件路径:优先用环境变量,否则放项目内 logs/ 目录。
13
+ // 用 import.meta.url 定位 dist/proxy/ → 反推项目根:独立代理进程是 detached
14
+ // spawn 的,cwd 不可靠(可能继承自任意 Claude Code 会话),只能用脚本自身位置定位。
15
+ const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
12
16
  const LOG_FILE = process.env.VISION_MCP_LOG_FILE ||
13
- path.join(os.tmpdir(), "vision-mcp-proxy.log");
17
+ path.join(PROJECT_ROOT, "logs", "vision-mcp-proxy.log");
14
18
  const LOG_MAX_SIZE = 10 * 1024 * 1024; // 10MB,超过后清空旧日志
19
+ // 确保日志目录存在(环境变量覆盖到自定义路径时同样需要)。同步创建一次即可,
20
+ // 失败也不影响代理功能——后续 appendFile 会静默失败,请求转发不受影响。
21
+ try {
22
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
23
+ }
24
+ catch {
25
+ // 目录已存在或无权限,忽略
26
+ }
15
27
  function log(msg) {
16
28
  const line = `[${new Date().toISOString()}] ${msg}\n`;
17
29
  console.error(line.trim());
@@ -50,133 +62,320 @@ const UPSTREAM_AUTH_TOKEN = process.env.UPSTREAM_AUTH_TOKEN ||
50
62
  "";
51
63
  const IMAGE_DESC_PROMPT = process.env.IMAGE_DESC_PROMPT ||
52
64
  "请详细描述这张图片的内容。如果是图表/截图,请提取关键信息;如果包含文字,请提取文字内容;如果是 UI 界面,请描述界面元素和布局。描述要简洁准确。";
65
+ // 复用到上游模型的连接,减少慢流式响应场景下频繁建连的额外开销。
66
+ const upstreamHttpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
67
+ const upstreamHttpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
53
68
  // ────────────────────── 图片识别 ──────────────────────
54
- async function describeImage(base64Data, mimeType) {
69
+ // ────────────────────── 图片识别缓存 ──────────────────────
70
+ // Claude Code 每次请求都带完整对话历史,历史里的图片会被反复识别(每张 30-60s)。
71
+ // 用 base64 的 sha256 做 key 缓存识别结果,同一张图只识别一次。
72
+ // LRU 淘汰:Map 按插入顺序,命中时删除重插移到末尾,超限时删最早的。
73
+ const IMAGE_CACHE_MAX = 100;
74
+ const imageCache = new Map();
75
+ // 同一张图片可能同时出现在多个并发请求或历史消息里。复用进行中的识别,
76
+ // 只有所有等待者都取消时才中止底层 Moonshot 请求,避免误伤其他请求。
77
+ const pendingImageDescriptions = new Map();
78
+ function hashImage(base64Data) {
79
+ return crypto.createHash("sha256").update(base64Data).digest("hex").slice(0, 32);
80
+ }
81
+ function getCachedDescription(base64Data) {
82
+ const key = hashImage(base64Data);
83
+ const desc = imageCache.get(key);
84
+ if (desc) {
85
+ imageCache.delete(key);
86
+ imageCache.set(key, desc); // 移到末尾,标记为最近使用
87
+ return desc;
88
+ }
89
+ return null;
90
+ }
91
+ function setCachedDescription(base64Data, description) {
92
+ const key = hashImage(base64Data);
93
+ imageCache.set(key, description);
94
+ if (imageCache.size > IMAGE_CACHE_MAX) {
95
+ const oldestKey = imageCache.keys().next().value;
96
+ if (oldestKey)
97
+ imageCache.delete(oldestKey);
98
+ }
99
+ }
100
+ // 失败短期缓存:识别失败(超时/空响应)的图 5 分钟内不重试,
101
+ // 避免对话历史里某张图每次请求都卡 60s 超时。过期后允许重试(Moonshot 恢复后能成功)。
102
+ const FAILURE_CACHE_TTL_MS = 5 * 60 * 1000;
103
+ const failureCache = new Map(); // hash -> 失败时间戳
104
+ function getFailureTimestamp(base64Data) {
105
+ const key = hashImage(base64Data);
106
+ const ts = failureCache.get(key);
107
+ if (ts === undefined)
108
+ return null;
109
+ failureCache.delete(key); // LRU 移到末尾
110
+ if (Date.now() - ts > FAILURE_CACHE_TTL_MS) {
111
+ return null; // 过期,不再缓存
112
+ }
113
+ failureCache.set(key, ts);
114
+ return ts;
115
+ }
116
+ function setFailureTimestamp(base64Data) {
117
+ const key = hashImage(base64Data);
118
+ failureCache.set(key, Date.now());
119
+ if (failureCache.size > IMAGE_CACHE_MAX) {
120
+ const oldestKey = failureCache.keys().next().value;
121
+ if (oldestKey)
122
+ failureCache.delete(oldestKey);
123
+ }
124
+ }
125
+ function isCanceledError(err) {
126
+ if (!(err instanceof Error))
127
+ return false;
128
+ return (err.name === "CanceledError" ||
129
+ err.message === "canceled" ||
130
+ err.message.includes("请求已取消"));
131
+ }
132
+ async function waitForPendingImage(task, signal) {
133
+ task.waiters++;
134
+ let removeAbortListener = () => { };
135
+ try {
136
+ if (!signal) {
137
+ return await task.promise;
138
+ }
139
+ if (signal.aborted) {
140
+ throw new Error("请求已取消");
141
+ }
142
+ return await new Promise((resolve, reject) => {
143
+ const onAbort = () => reject(new Error("请求已取消"));
144
+ removeAbortListener = () => signal.removeEventListener("abort", onAbort);
145
+ signal.addEventListener("abort", onAbort, { once: true });
146
+ task.promise.then(resolve, reject);
147
+ });
148
+ }
149
+ finally {
150
+ removeAbortListener();
151
+ task.waiters--;
152
+ if (task.waiters === 0 && !task.settled) {
153
+ task.controller.abort();
154
+ }
155
+ }
156
+ }
157
+ async function describeImage(base64Data, mimeType, signal) {
158
+ if (signal?.aborted) {
159
+ throw new Error("请求已取消");
160
+ }
161
+ // 成功缓存命中:历史消息里反复出现的同一张图不重复调 Moonshot
162
+ const cached = getCachedDescription(base64Data);
163
+ if (cached) {
164
+ log(`[image-proxy] 图片命中缓存,跳过识别`);
165
+ return cached;
166
+ }
167
+ // 失败缓存命中:近期识别失败过的图短期不重试,避免每次请求都卡超时
168
+ if (getFailureTimestamp(base64Data) !== null) {
169
+ log(`[image-proxy] 图片命中失败缓存,跳过重试`);
170
+ throw new Error("图片近期识别失败,跳过重试");
171
+ }
55
172
  const dataUrl = `data:${mimeType};base64,${base64Data}`;
56
173
  const apiKey = process.env.MOONSHOT_API_KEY || "";
57
174
  if (!apiKey) {
58
175
  throw new Error("MOONSHOT_API_KEY 未设置");
59
176
  }
60
- const response = await callMoonshot({
61
- apiKey,
62
- messages: [
63
- {
64
- role: "user",
65
- content: [
66
- { type: "image_url", image_url: { url: dataUrl } },
67
- { type: "text", text: IMAGE_DESC_PROMPT },
177
+ const key = hashImage(base64Data);
178
+ const pending = pendingImageDescriptions.get(key);
179
+ if (pending) {
180
+ log(`[image-proxy] 图片识别进行中,复用同一个请求`);
181
+ return waitForPendingImage(pending, signal);
182
+ }
183
+ const task = {
184
+ controller: new AbortController(),
185
+ waiters: 0,
186
+ settled: false,
187
+ };
188
+ task.promise = (async () => {
189
+ try {
190
+ const response = await callMoonshot({
191
+ apiKey,
192
+ messages: [
193
+ {
194
+ role: "user",
195
+ content: [
196
+ { type: "image_url", image_url: { url: dataUrl } },
197
+ { type: "text", text: IMAGE_DESC_PROMPT },
198
+ ],
199
+ },
68
200
  ],
69
- },
70
- ],
71
- maxTokens: 2048,
72
- });
73
- const description = response.choices?.[0]?.message?.content || "";
74
- if (!description) {
75
- throw new Error("Moonshot API 返回空内容");
201
+ maxTokens: 2048,
202
+ signal: task.controller.signal,
203
+ });
204
+ const description = response.choices?.[0]?.message?.content || "";
205
+ if (!description) {
206
+ throw new Error("Moonshot API 返回空内容");
207
+ }
208
+ setCachedDescription(base64Data, description);
209
+ return description;
210
+ }
211
+ catch (err) {
212
+ // 失败:记入失败缓存,短期不再重试(让对话继续,不阻塞 60s)
213
+ if (!isCanceledError(err)) {
214
+ setFailureTimestamp(base64Data);
215
+ }
216
+ throw err;
217
+ }
218
+ finally {
219
+ task.settled = true;
220
+ pendingImageDescriptions.delete(key);
221
+ }
222
+ })();
223
+ pendingImageDescriptions.set(key, task);
224
+ return waitForPendingImage(task, signal);
225
+ }
226
+ const IMAGE_MARKER_RE = /"type"\s*:\s*"(image|image_url|tool_result)"|"source"\s*:\s*\{\s*"type"\s*:\s*"base64"|data:image\/[a-z+.-]+;base64,/i;
227
+ function mayContainImagePayload(rawBody) {
228
+ // 大多数消息无图,先用轻量文本探测跳过 JSON.parse 和深度扫描。
229
+ return IMAGE_MARKER_RE.test(rawBody);
230
+ }
231
+ function contentHasImage(content) {
232
+ if (!Array.isArray(content))
233
+ return false;
234
+ for (const block of content) {
235
+ if (block?.type === "image" || block?.type === "image_url") {
236
+ return true;
237
+ }
238
+ if (block?.type === "tool_result" && contentHasImage(block.content)) {
239
+ return true;
240
+ }
76
241
  }
77
- return description;
242
+ return false;
78
243
  }
79
- async function processContent(content) {
80
- if (typeof content === "string")
81
- return content;
244
+ function collectForwardedImageTexts(content, result) {
82
245
  if (!Array.isArray(content))
83
- return content;
84
- // 日志:记录所有 block 类型,方便排查
85
- const blockTypes = content.map((b) => b?.type || "unknown");
86
- log(`[image-proxy] content blocks: ${JSON.stringify(blockTypes)}`);
87
- const newContent = [];
246
+ return;
88
247
  for (const block of content) {
89
- // 格式1: Anthropic 标准 {"type":"image","source":{"type":"base64",...}}
90
- if (block?.type === "image" && block.source?.type === "base64") {
91
- const { media_type, data } = block.source;
248
+ if (block?.type === "text" &&
249
+ typeof block.text === "string" &&
250
+ (block.text.includes("[以下是用户粘贴的图片内容描述]") ||
251
+ block.text.includes("[以下是截图内容描述]") ||
252
+ block.text.includes("[图片识别失败:"))) {
253
+ result.push(block.text);
254
+ }
255
+ if (block?.type === "tool_result") {
256
+ collectForwardedImageTexts(block.content, result);
257
+ }
258
+ }
259
+ }
260
+ function logForwardedImageTexts(messages) {
261
+ if (!Array.isArray(messages))
262
+ return;
263
+ const forwardedTexts = [];
264
+ for (const message of messages) {
265
+ if (message && typeof message === "object" && "content" in message) {
266
+ collectForwardedImageTexts(message.content, forwardedTexts);
267
+ }
268
+ }
269
+ forwardedTexts.forEach((text, index) => {
270
+ log(`[image-proxy] 转发到上游 /v1/messages 的图片内容 #${index + 1} (${text.length} chars):\n${text}`);
271
+ });
272
+ }
273
+ // 处理单个 content block:图片类型识别后替换为文字,其余原样返回。
274
+ // 同层数组的多个图片块由调用方 Promise.all 并行触发,这里只负责单块转换。
275
+ async function describeBlock(block, signal) {
276
+ // 格式1: Anthropic 标准 {"type":"image","source":{"type":"base64",...}}
277
+ if (block?.type === "image" && block.source?.type === "base64") {
278
+ const { media_type, data } = block.source;
279
+ try {
280
+ const description = await describeImage(data, media_type, signal);
281
+ log(`[image-proxy] 图片已识别 (${media_type}, ${Math.round((data.length * 0.75) / 1024)} KB)`);
282
+ return {
283
+ type: "text",
284
+ text: `[以下是用户粘贴的图片内容描述]\n${description}\n[图片描述结束]`,
285
+ };
286
+ }
287
+ catch (err) {
288
+ log(`[image-proxy] 图片识别失败: ${err.message}`);
289
+ return {
290
+ type: "text",
291
+ text: `[图片识别失败: ${err.message}]`,
292
+ };
293
+ }
294
+ }
295
+ // 格式2: OpenAI 风格 {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
296
+ if (block?.type === "image_url" &&
297
+ block.image_url?.url?.startsWith("data:image")) {
298
+ const url = block.image_url.url;
299
+ const match = url.match(/^data:(image\/[a-z+]+);base64,(.+)$/i);
300
+ if (match) {
301
+ const [, media_type, data] = match;
92
302
  try {
93
- const description = await describeImage(data, media_type);
94
- newContent.push({
303
+ const description = await describeImage(data, media_type, signal);
304
+ log(`[image-proxy] image_url 图片已识别 (${media_type})`);
305
+ return {
95
306
  type: "text",
96
- text: `[以下是用户粘贴的图片内容描述]\n${description}\n[图片描述结束]`,
97
- });
98
- log(`[image-proxy] 图片已识别 (${media_type}, ${Math.round((data.length * 0.75) / 1024)} KB)`);
307
+ text: `[以下是截图内容描述]\n${description}\n[图片描述结束]`,
308
+ };
99
309
  }
100
310
  catch (err) {
101
- log(`[image-proxy] 图片识别失败: ${err.message}`);
102
- newContent.push({
311
+ log(`[image-proxy] image_url 图片识别失败: ${err.message}`);
312
+ return {
103
313
  type: "text",
104
314
  text: `[图片识别失败: ${err.message}]`,
105
- });
315
+ };
106
316
  }
107
317
  }
108
- // 格式2: OpenAI 风格 {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
109
- else if (block?.type === "image_url" &&
110
- block.image_url?.url?.startsWith("data:image")) {
111
- const url = block.image_url.url;
112
- const match = url.match(/^data:(image\/[a-z+]+);base64,(.+)$/i);
113
- if (match) {
114
- const [, media_type, data] = match;
115
- try {
116
- const description = await describeImage(data, media_type);
117
- newContent.push({
118
- type: "text",
119
- text: `[以下是截图内容描述]\n${description}\n[图片描述结束]`,
120
- });
121
- log(`[image-proxy] image_url 图片已识别 (${media_type})`);
122
- }
123
- catch (err) {
124
- log(`[image-proxy] image_url 图片识别失败: ${err.message}`);
125
- newContent.push({
126
- type: "text",
127
- text: `[图片识别失败: ${err.message}]`,
128
- });
129
- }
130
- }
131
- else {
132
- newContent.push(block);
133
- }
134
- }
135
- // 格式3: tool_result 里嵌套图片(MCP 工具返回的截图)
136
- else if (block?.type === "tool_result" && Array.isArray(block.content)) {
137
- const toolContent = block.content;
138
- const processed = await processContent(toolContent);
139
- newContent.push({ ...block, content: processed });
140
- }
141
- else if (block?.type === "image" && block.source && !block.source.type?.startsWith("base64")) {
142
- log(`[image-proxy] 未识别的 image source type: ${block.source.type}`);
143
- newContent.push(block);
144
- }
145
- else {
146
- newContent.push(block);
147
- }
318
+ return block;
319
+ }
320
+ // 格式3: tool_result 里嵌套图片(MCP 工具返回的截图)
321
+ if (block?.type === "tool_result" && Array.isArray(block.content)) {
322
+ const toolContent = block.content;
323
+ const processed = await processContent(toolContent, signal);
324
+ return { ...block, content: processed };
148
325
  }
149
- return newContent;
326
+ // 其他未识别的 image source type
327
+ if (block?.type === "image" && block.source && !block.source.type?.startsWith("base64")) {
328
+ log(`[image-proxy] 未识别的 image source type: ${block.source.type}`);
329
+ }
330
+ return block;
331
+ }
332
+ async function processContent(content, signal) {
333
+ if (typeof content === "string")
334
+ return content;
335
+ if (!Array.isArray(content))
336
+ return content;
337
+ // 日志:记录所有 block 类型,方便排查
338
+ const blockTypes = content.map((b) => b?.type || "unknown");
339
+ log(`[image-proxy] content blocks: ${JSON.stringify(blockTypes)}`);
340
+ // 并行识别同一层级的所有图片块,map 保持原顺序
341
+ return Promise.all(content.map((block) => describeBlock(block, signal)));
150
342
  }
151
- async function processMessages(messages) {
343
+ async function processMessages(messages, signal) {
152
344
  if (!Array.isArray(messages))
153
345
  return messages;
154
346
  for (const message of messages) {
347
+ if (signal?.aborted)
348
+ throw new Error("请求已取消");
155
349
  if (message && typeof message === "object" && message.content !== undefined) {
156
- message.content = await processContent(message.content);
350
+ message.content = await processContent(message.content, signal);
157
351
  }
158
352
  }
159
353
  return messages;
160
354
  }
161
- // ────────────────────── 转发请求 ──────────────────────
162
- function forwardRequest(req, res, body) {
355
+ function forwardRequest(req, res, body, meta, signal) {
163
356
  const baseUrl = new URL(UPSTREAM_BASE_URL);
164
357
  const basePath = baseUrl.pathname.replace(/\/+$/, "");
165
358
  const fullPath = basePath + (req.url || "");
166
359
  const isHttps = baseUrl.protocol === "https:";
167
360
  const lib = isHttps ? https : http;
361
+ const upstreamUrl = new URL(fullPath, baseUrl.origin).toString();
362
+ const pathOnly = (req.url || "").split("?")[0];
363
+ if (meta) {
364
+ const elapsed = Date.now() - meta.reqStart;
365
+ log(`[阶段] 转发开始 ${req.method} ${pathOnly} -> ${upstreamUrl} body=${meta.origSizeKB}KB ${meta.hasImage ? "有图" : "无图"} elapsed=${elapsed}ms`);
366
+ }
168
367
  const headers = { ...req.headers };
169
368
  delete headers["host"];
170
- delete headers["content-length"];
171
- // body 已被解压/修改,必须删除原压缩标记和逐跳头,否则上游会尝试解压明文导致失败
172
- delete headers["content-encoding"];
173
- delete headers["transfer-encoding"];
174
369
  headers["host"] = baseUrl.host;
175
370
  if (UPSTREAM_AUTH_TOKEN) {
176
371
  headers["authorization"] = `Bearer ${UPSTREAM_AUTH_TOKEN}`;
177
372
  headers["x-api-key"] = UPSTREAM_AUTH_TOKEN;
178
373
  }
179
- if (body) {
374
+ if (body !== null) {
375
+ delete headers["content-length"];
376
+ // body 已被解压/修改,必须删除原压缩标记和逐跳头,否则上游会尝试解压明文导致失败
377
+ delete headers["content-encoding"];
378
+ delete headers["transfer-encoding"];
180
379
  headers["content-length"] = String(Buffer.byteLength(body));
181
380
  }
182
381
  const options = {
@@ -185,50 +384,103 @@ function forwardRequest(req, res, body) {
185
384
  port: baseUrl.port || (isHttps ? 443 : 80),
186
385
  path: fullPath,
187
386
  headers,
387
+ agent: isHttps ? upstreamHttpsAgent : upstreamHttpAgent,
388
+ signal,
188
389
  };
189
390
  const proxyReq = lib.request(options, (proxyRes) => {
190
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
391
+ const status = proxyRes.statusCode || 502;
392
+ if (meta) {
393
+ const elapsed = Date.now() - meta.reqStart;
394
+ log(`[阶段] 上游响应 ${req.method} ${pathOnly} -> ${upstreamUrl} status=${status} body=${meta.origSizeKB}KB ${meta.hasImage ? "有图" : "无图"} 首包耗时=${elapsed}ms`);
395
+ }
396
+ if (res.destroyed) {
397
+ proxyRes.destroy();
398
+ return;
399
+ }
400
+ res.writeHead(status, proxyRes.headers);
191
401
  proxyRes.pipe(res);
192
402
  });
193
403
  proxyReq.on("error", (err) => {
194
- log(`[image-proxy] 转发失败: ${err.message}`);
404
+ if (signal?.aborted || isCanceledError(err) || err.name === "AbortError") {
405
+ return;
406
+ }
407
+ if (meta) {
408
+ const elapsed = Date.now() - meta.reqStart;
409
+ log(`[image-proxy] 转发失败 ${req.method} ${pathOnly} -> ${upstreamUrl} (耗时=${elapsed}ms): ${err.message}`);
410
+ }
411
+ else {
412
+ log(`[image-proxy] 转发失败 ${req.method} ${pathOnly} -> ${upstreamUrl}: ${err.message}`);
413
+ }
195
414
  if (!res.headersSent) {
196
415
  res.writeHead(502, { "Content-Type": "application/json" });
197
416
  res.end(JSON.stringify({ error: { type: "proxy_error", message: err.message } }));
198
417
  }
199
418
  });
200
- if (body) {
201
- proxyReq.write(body);
419
+ if (body !== null) {
420
+ proxyReq.end(body);
202
421
  }
203
422
  else {
204
423
  req.pipe(proxyReq);
424
+ req.on("aborted", () => proxyReq.destroy(new Error("客户端请求已取消")));
425
+ req.on("error", (err) => proxyReq.destroy(err));
426
+ }
427
+ }
428
+ // ────────────────────── 单例锁 ──────────────────────
429
+ // 锁文件路径:系统临时目录,跨平台无需权限处理
430
+ const LOCK_FILE = path.join(os.tmpdir(), "vision-mcp-proxy.lock");
431
+ // 检测 PID 对应的进程是否仍在运行(信号 0 不实际发信号,仅探测存活)
432
+ function isProcessAlive(pid) {
433
+ try {
434
+ process.kill(pid, 0);
435
+ return true;
436
+ }
437
+ catch {
438
+ // ESRCH: 进程不存在;EPERM: 存在但无权限。两者都视为"不可达",允许接管
439
+ return false;
205
440
  }
206
- proxyReq.end();
207
441
  }
208
- // ────────────────────── 端口清理 ──────────────────────
209
- function killProcessOnPort(port) {
442
+ // 尝试获取单例锁:保证全局只有一个 vision-mcp 进程启动图片代理,
443
+ // 避免多个 MCP 进程互相 kill 对方占用的 8787 端口(会把正在处理请求的代理杀掉)。
444
+ // 返回 true 表示拿到锁、应由本进程启动代理;false 表示已有代理在运行,跳过。
445
+ function tryAcquireLock() {
210
446
  try {
211
- if (process.platform === "win32") {
212
- execSync(`powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`, { stdio: "ignore", timeout: 5000 });
447
+ // O_EXCL (wx) 原子创建:文件已存在则抛 EEXIST
448
+ const fd = fs.openSync(LOCK_FILE, "wx");
449
+ fs.writeFileSync(fd, String(process.pid));
450
+ fs.closeSync(fd);
451
+ return true;
452
+ }
453
+ catch (err) {
454
+ if (err.code !== "EEXIST") {
455
+ // 其他 IO 错误,保守起见不获取锁
456
+ return false;
213
457
  }
214
- else if (process.platform === "darwin" || process.platform === "linux") {
215
- // macOS/Linux: lsof 找到占用端口的 PID 并 kill -9
216
- // lsof 不存在时静默失败,|| true 保证命令返回 0
217
- execSync(`lsof -ti :${port} 2>/dev/null | xargs kill -9 2>/dev/null || true`, {
218
- stdio: "ignore",
219
- timeout: 5000,
220
- });
458
+ // 锁文件已存在:检查持有者是否还活着
459
+ try {
460
+ const holderPid = parseInt(fs.readFileSync(LOCK_FILE, "utf8").trim(), 10);
461
+ if (holderPid && isProcessAlive(holderPid)) {
462
+ return false; // 持有者仍存活,复用已有代理
463
+ }
464
+ // 持有者已退出(崩溃未释放锁):清理僵尸锁后递归重试
465
+ fs.unlinkSync(LOCK_FILE);
466
+ return tryAcquireLock();
221
467
  }
222
- else {
468
+ catch {
223
469
  return false;
224
470
  }
225
- log(`[image-proxy] 已清理占用端口 ${port} 的旧进程`);
226
- return true;
471
+ }
472
+ }
473
+ // 释放锁:仅当锁文件登记的 PID 是本进程时才删除,避免误删他人的锁
474
+ function releaseLock() {
475
+ try {
476
+ const holderPid = parseInt(fs.readFileSync(LOCK_FILE, "utf8").trim(), 10);
477
+ if (holderPid === process.pid) {
478
+ fs.unlinkSync(LOCK_FILE);
479
+ }
227
480
  }
228
481
  catch {
229
- // 忽略错误
482
+ // 忽略:文件可能已被删除
230
483
  }
231
- return false;
232
484
  }
233
485
  // ────────────────────── 启动代理 ──────────────────────
234
486
  export function startImageProxy() {
@@ -238,10 +490,44 @@ export function startImageProxy() {
238
490
  resolve();
239
491
  return;
240
492
  }
493
+ // 单例锁:保证全局只有一个代理进程。拿不到锁说明已有代理在运行,
494
+ // 直接复用,避免多 MCP 进程互相 kill 8787 端口导致正在处理的请求中断。
495
+ if (!tryAcquireLock()) {
496
+ log("[image-proxy] 已有代理进程在运行,跳过启动(复用已有代理,MCP 工具仍可用)");
497
+ resolve();
498
+ return;
499
+ }
500
+ // 进程退出时释放锁,让其他 MCP 进程下次启动时能接管
501
+ const releaseOnExit = () => releaseLock();
502
+ process.on("SIGINT", releaseOnExit);
503
+ process.on("SIGTERM", releaseOnExit);
504
+ process.on("beforeExit", releaseOnExit);
505
+ process.on("exit", releaseOnExit);
241
506
  const server = http.createServer(async (req, res) => {
507
+ const reqStart = Date.now();
508
+ // Claude Code 中断/重试时立即取消本轮代理工作,避免旧请求继续占用
509
+ // Moonshot 调用或上游流式连接。
510
+ const requestController = new AbortController();
511
+ const abortRequest = () => {
512
+ if (!res.writableEnded) {
513
+ requestController.abort();
514
+ }
515
+ };
516
+ req.on("aborted", abortRequest);
517
+ res.on("close", abortRequest);
518
+ // /v1/messages 是 Anthropic Claude Messages API 端点,只有这类请求的 body
519
+ // 里才可能带图片,需要读全 body 拦截处理;其它请求(/v1/models、健康检查等)
520
+ // 直接透传,不解析 body 以省开销。用 includes 而非 === 是因为 url 可能带
521
+ // query string 或 UPSTREAM_BASE_URL 的 basePath 前缀。
242
522
  const isMessagesEndpoint = req.method === "POST" && (req.url || "").includes("/v1/messages");
523
+ const pathOnly = (req.url || "").split("?")[0];
524
+ log(`[阶段] 接收 ${req.method} ${pathOnly}`);
243
525
  if (!isMessagesEndpoint) {
244
- forwardRequest(req, res, null);
526
+ forwardRequest(req, res, null, {
527
+ reqStart,
528
+ hasImage: false,
529
+ origSizeKB: "?",
530
+ }, requestController.signal);
245
531
  return;
246
532
  }
247
533
  const chunks = [];
@@ -249,6 +535,7 @@ export function startImageProxy() {
249
535
  req.on("end", async () => {
250
536
  const buffer = Buffer.concat(chunks);
251
537
  const contentEncoding = (req.headers["content-encoding"] || "").toLowerCase();
538
+ log(`[阶段] body读取完成 ${req.method} ${pathOnly} size=${(buffer.length / 1024).toFixed(1)}KB encoding=${contentEncoding || "none"} elapsed=${Date.now() - reqStart}ms`);
252
539
  // 解压请求体(支持 gzip / deflate / br),否则 JSON.parse 会失败
253
540
  let rawBody;
254
541
  try {
@@ -264,6 +551,7 @@ export function startImageProxy() {
264
551
  else {
265
552
  rawBody = buffer.toString("utf8");
266
553
  }
554
+ log(`[阶段] body解码完成 ${req.method} ${pathOnly} rawSize=${(Buffer.byteLength(rawBody) / 1024).toFixed(1)}KB elapsed=${Date.now() - reqStart}ms`);
267
555
  }
268
556
  catch (err) {
269
557
  log(`[image-proxy] 请求体解压失败: ${err.message}`);
@@ -276,36 +564,50 @@ export function startImageProxy() {
276
564
  return;
277
565
  }
278
566
  try {
567
+ const origSizeKB = (Buffer.byteLength(rawBody) / 1024).toFixed(1);
568
+ if (!mayContainImagePayload(rawBody)) {
569
+ log(`[阶段] 无图快速转发 ${req.method} ${pathOnly} elapsed=${Date.now() - reqStart}ms`);
570
+ forwardRequest(req, res, rawBody, {
571
+ reqStart,
572
+ hasImage: false,
573
+ origSizeKB,
574
+ }, requestController.signal);
575
+ return;
576
+ }
279
577
  const requestData = JSON.parse(rawBody);
578
+ log(`[阶段] JSON解析完成 ${req.method} ${pathOnly} elapsed=${Date.now() - reqStart}ms`);
280
579
  let hasImage = false;
281
580
  if (Array.isArray(requestData.messages)) {
282
581
  for (const msg of requestData.messages) {
283
- if (Array.isArray(msg.content)) {
284
- for (const block of msg.content) {
285
- // 检测所有可能的图片格式
286
- if (block?.type === "image" ||
287
- block?.type === "image_url" ||
288
- (block?.type === "tool_result" && Array.isArray(block.content))) {
289
- hasImage = true;
290
- break;
291
- }
292
- }
293
- }
582
+ hasImage = contentHasImage(msg.content);
294
583
  if (hasImage)
295
584
  break;
296
585
  }
297
586
  }
298
587
  if (hasImage) {
299
588
  log(`[image-proxy] 检测到图片,开始识别...`);
300
- requestData.messages = await processMessages(requestData.messages);
589
+ requestData.messages = await processMessages(requestData.messages, requestController.signal);
590
+ log(`[阶段] 图片处理完成 ${req.method} ${pathOnly} elapsed=${Date.now() - reqStart}ms`);
591
+ logForwardedImageTexts(requestData.messages);
301
592
  const newBody = JSON.stringify(requestData);
302
- forwardRequest(req, res, newBody);
593
+ forwardRequest(req, res, newBody, {
594
+ reqStart,
595
+ hasImage: true,
596
+ origSizeKB,
597
+ }, requestController.signal);
303
598
  }
304
599
  else {
305
- forwardRequest(req, res, rawBody);
600
+ forwardRequest(req, res, rawBody, {
601
+ reqStart,
602
+ hasImage: false,
603
+ origSizeKB,
604
+ }, requestController.signal);
306
605
  }
307
606
  }
308
607
  catch (err) {
608
+ if (requestController.signal.aborted || isCanceledError(err)) {
609
+ return;
610
+ }
309
611
  log(`[image-proxy] 处理失败: ${err.message}`);
310
612
  if (!res.headersSent) {
311
613
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -319,28 +621,73 @@ export function startImageProxy() {
319
621
  log(`[image-proxy] 请求错误: ${err.message}`);
320
622
  });
321
623
  });
322
- let retried = false;
323
624
  server.on("error", (err) => {
324
625
  const msg = err.message;
325
- if (msg.includes("EADDRINUSE") && !retried) {
326
- retried = true;
327
- log(`[image-proxy] 端口 ${PROXY_PORT} 被占用,清理旧进程后重试...`);
328
- killProcessOnPort(PROXY_PORT);
329
- setTimeout(() => {
330
- server.listen(PROXY_PORT, "127.0.0.1");
331
- }, 1000);
626
+ if (msg.includes("EADDRINUSE")) {
627
+ // 端口已被占用。锁机制保证正常情况下只有一个本 MCP 代理在跑,
628
+ // 这里被占用说明是其他程序,或锁在极端情况下漏网。
629
+ // 不再 kill(避免杀掉正在处理请求的代理),直接放弃启动。
630
+ log(`[image-proxy] 端口 ${PROXY_PORT} 已被占用,放弃启动代理(MCP 工具仍可用)`);
332
631
  }
333
632
  else {
334
- // 其他错误或重试仍失败,不阻塞 MCP 启动
335
633
  log(`[image-proxy] 代理启动失败(MCP 工具仍可用): ${msg}`);
336
- resolve();
337
634
  }
635
+ releaseLock();
636
+ resolve();
338
637
  });
339
638
  server.listen(PROXY_PORT, "127.0.0.1", () => {
340
- log(`[image-proxy] 代理已启动: http://127.0.0.1:${PROXY_PORT}`);
639
+ log(`[image-proxy] 代理已启动: http://127.0.0.1:${PROXY_PORT} (pid ${process.pid})`);
341
640
  log(`[image-proxy] 上游 API: ${UPSTREAM_BASE_URL}`);
342
641
  resolve();
343
642
  });
344
643
  });
345
644
  }
645
+ // ────────────────────── MCP 端:按需拉起独立代理 ──────────────────────
646
+ // 独立代理入口脚本(与本文件同目录,编译后为 dist/proxy/standaloneProxy.js)
647
+ const STANDALONE_SCRIPT = fileURLToPath(new URL("standaloneProxy.js", import.meta.url));
648
+ // 读取锁文件里登记的代理 PID;不存在/无效返回 null
649
+ function readLockHolderPid() {
650
+ try {
651
+ const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf8").trim(), 10);
652
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ }
658
+ // MCP 进程调用:确保有一个独立常驻的图片代理在运行。
659
+ // - 锁持有者进程存活 → 复用已有代理
660
+ // - 否则 detached spawn 一个独立代理进程,与本 MCP 进程生命周期解耦
661
+ // 任何一个 Claude Code 会话退出都不影响代理,代理常驻至自身崩溃或被主动 kill。
662
+ export function ensureImageProxyRunning() {
663
+ return new Promise((resolve) => {
664
+ if (!UPSTREAM_BASE_URL) {
665
+ log("[image-proxy] 未设置 UPSTREAM_BASE_URL,代理功能不可用(MCP 工具仍可用)");
666
+ resolve();
667
+ return;
668
+ }
669
+ // 1. 已有独立代理在运行则直接复用
670
+ const holderPid = readLockHolderPid();
671
+ if (holderPid !== null && isProcessAlive(holderPid)) {
672
+ log(`[image-proxy] 独立代理已在运行 (pid ${holderPid}),复用`);
673
+ resolve();
674
+ return;
675
+ }
676
+ // 2. detached spawn 独立代理进程:stdio ignore + unref,独立于父进程生命周期
677
+ try {
678
+ const child = spawn(process.execPath, [STANDALONE_SCRIPT], {
679
+ detached: true,
680
+ stdio: "ignore",
681
+ env: { ...process.env },
682
+ });
683
+ child.unref();
684
+ log(`[image-proxy] 已拉起独立代理进程 (pid ${child.pid})`);
685
+ }
686
+ catch (err) {
687
+ log(`[image-proxy] 拉起独立代理进程失败: ${err.message}(MCP 工具仍可用)`);
688
+ }
689
+ // 不等待子进程 listen 完成即返回:MCP 工具不依赖代理,代理会在后台就绪
690
+ resolve();
691
+ });
692
+ }
346
693
  //# sourceMappingURL=imageProxy.js.map