koishi-plugin-image-prompt 2.0.0 → 2.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.
package/lib/index.d.ts CHANGED
@@ -18,6 +18,12 @@ export declare const usage = "\n---\n\n\u6B64\u63D2\u4EF6\u76F4\u63A5\u8C03\u752
18
18
  * @param attempt 第几次重试(从 0 开始)
19
19
  */
20
20
  export declare function computeRetryDelay(status: any, retryAfter: number | undefined, baseInterval: number, attempt: number): number;
21
+ /**
22
+ * 从接口抛出的错误里挖出「到底为什么失败」。
23
+ * koishi 的 http 错误对 5xx 往往只留一句状态文本("Internal Server Error"),
24
+ * 真正的病根(比如上游 ECONNREFUSED、模型负载过高)藏在响应体里,得自己捞出来。
25
+ */
26
+ export declare function httpErrorText(error: any): string;
21
27
  /** 从任意文本里挖出第一个 JSON 对象(容忍代码块围栏和前后废话) */
22
28
  export declare function extractJsonObject(raw: string | null | undefined): any | null;
23
29
  /**
package/lib/index.js CHANGED
@@ -46,6 +46,7 @@ __export(src_exports, {
46
46
  extractServerMessage: () => extractServerMessage,
47
47
  extractTextToRender: () => extractTextToRender,
48
48
  fitImageSize: () => fitImageSize,
49
+ httpErrorText: () => httpErrorText,
49
50
  inject: () => inject,
50
51
  isPassiveReplyError: () => isPassiveReplyError,
51
52
  isRetryableStatus: () => isRetryableStatus,
@@ -136,12 +137,23 @@ Agent \u6307\u4EE4\u6A21\u677F\u53EF\u7528\u5360\u4F4D\u7B26\uFF1A{command} \u63
136
137
  var logger = new import_koishi.Logger(name);
137
138
  function computeRetryDelay(status, retryAfter, baseInterval, attempt) {
138
139
  const base = baseInterval > 0 ? baseInterval : 1e3;
139
- if (status === 429 || status === 503) {
140
+ const worthBackoff = status === 429 || status === 503 || typeof status === "number" && status >= 500 || status === 0;
141
+ if (worthBackoff) {
140
142
  if (retryAfter && retryAfter > 0) return retryAfter;
141
143
  return Math.min(base * Math.pow(2, attempt), 3e4);
142
144
  }
143
145
  return base;
144
146
  }
147
+ function httpErrorText(error) {
148
+ if (!error) return "\u672A\u77E5\u539F\u56E0";
149
+ const data = error?.response?.data ?? error?.data ?? error?.response;
150
+ const upstream = typeof data === "string" ? data : data?.error?.message ?? data?.message ?? data?.msg;
151
+ let text = String(error?.message || error?.toString?.() || "\u672A\u77E5\u539F\u56E0").trim();
152
+ if (typeof upstream === "string" && upstream.trim() && !text.includes(upstream.trim())) {
153
+ text = `${text}\uFF5C\u63A5\u53E3\u8FD4\u56DE\uFF1A${upstream.trim().slice(0, 200)}`;
154
+ }
155
+ return text;
156
+ }
145
157
  function extractJsonObject(raw) {
146
158
  if (!raw) return null;
147
159
  let text = String(raw).trim();
@@ -1519,6 +1531,7 @@ function apply(ctx, config) {
1519
1531
  await bot.sendMessage(channelId, body, guildId);
1520
1532
  }
1521
1533
  const agentHistories = /* @__PURE__ */ new Map();
1534
+ let agentUrlWarned = false;
1522
1535
  function agentHistoryKey(session) {
1523
1536
  return `${session.platform}:${session.channelId || session.userId || ""}`;
1524
1537
  }
@@ -1770,7 +1783,10 @@ Negative prompt: ${String(args.negative_prompt).trim()}` : prompt;
1770
1783
  ok: res.ok,
1771
1784
  references: capped,
1772
1785
  ...unknown.length ? { unknown } : {},
1773
- ...res.ok ? { note: "\u56FE\u5DF2\u7ECF\u53D1\u5230\u7FA4\u91CC\u4E86\uFF0C\u56DE\u4E00\u53E5\u7B80\u77ED\u8BF4\u660E\u5373\u53EF\uFF0C\u4E0D\u8981\u518D\u95EE\u300C\u8FD8\u8981\u6211\u505A\u4EC0\u4E48\u300D" } : { error: res.error === "invalidimage" ? "\u8FD9\u4E9B\u53C2\u8003\u56FE\u4E00\u5F20\u90FD\u4E0B\u8F7D\u4E0D\u4E0B\u6765\uFF0C\u68C0\u67E5\u94FE\u63A5\u8FD8\u80FD\u4E0D\u80FD\u8BBF\u95EE" : "\u7ED8\u56FE\u63A5\u53E3\u6CA1\u6709\u8FD4\u56DE\u56FE\u7247" }
1786
+ ...res.ok ? res.queued ? { queued: true, note: "\u7ED8\u56FE\u4EFB\u52A1\u5DF2\u6392\u8FDB\u540E\u53F0\u961F\u5217\uFF0C\u8FD8\u6CA1\u6709\u753B\u5B8C\u3002\u8BF7\u544A\u8BC9\u7528\u6237\u300C\u5DF2\u7ECF\u6392\u4E0A\u961F\uFF0C\u753B\u597D\u540E\u4F1A\u5728\u8FD9\u91CC\u901A\u77E5\u300D\uFF0C\u4E0D\u8981\u8BF4\u5DF2\u7ECF\u753B\u597D\u6216\u5DF2\u7ECF\u53D1\u51FA\u56FE\u7247" } : { sent: true, note: "\u56FE\u5DF2\u7ECF\u53D1\u5230\u7FA4\u91CC\u4E86\uFF0C\u56DE\u4E00\u53E5\u7B80\u77ED\u8BF4\u660E\u5373\u53EF\uFF0C\u4E0D\u8981\u518D\u95EE\u300C\u8FD8\u8981\u6211\u505A\u4EC0\u4E48\u300D" } : {
1787
+ error: res.error === "invalidimage" ? "\u8FD9\u4E9B\u53C2\u8003\u56FE\u4E00\u5F20\u90FD\u4E0B\u8F7D\u4E0D\u4E0B\u6765\uFF0C\u68C0\u67E5\u94FE\u63A5\u8FD8\u80FD\u4E0D\u80FD\u8BBF\u95EE" : `\u7ED8\u56FE\u63A5\u53E3\u8FD9\u6B21\u6CA1\u51FA\u56FE\uFF1A${(res.reason || "\u672A\u77E5\u539F\u56E0").slice(0, 160)}`,
1788
+ note: "\u5982\u5B9E\u544A\u8BC9\u7528\u6237\u8FD9\u6B21\u6CA1\u753B\u51FA\u6765\u3001\u53EF\u80FD\u662F\u4EC0\u4E48\u539F\u56E0\uFF0C\u5E76\u5EFA\u8BAE\u7A0D\u540E\u518D\u8BD5\u6216\u6362\u4E2A\u6A21\u578B\uFF0C\u4E0D\u8981\u8BF4\u5DF2\u7ECF\u753B\u597D\u4E86"
1789
+ }
1774
1790
  });
1775
1791
  }
1776
1792
  }
@@ -1802,6 +1818,10 @@ Negative prompt: ${String(args.negative_prompt).trim()}` : prompt;
1802
1818
  const model = agentCfg.model || selector.model || "Qwen/Qwen2.5-7B-Instruct";
1803
1819
  const url = agentCfg.baseUrl || selector.baseUrl || config.baseUrl;
1804
1820
  const apiKey = agentCfg.apiKey || selector.apiKey || config.apiKey;
1821
+ if (!agentCfg.baseUrl && !selector.baseUrl && !agentUrlWarned) {
1822
+ agentUrlWarned = true;
1823
+ ctx.logger.warn("Agent \u7528\u7684\u662F\u7ED8\u56FE\u63A5\u53E3\u5730\u5740\uFF08\u300CAI \u6A21\u578B\u63A5\u53E3 \u2192 \u5BF9\u8BDD\u63A5\u53E3\u5730\u5740\u300D\u7559\u7A7A\u4E86\uFF09\u3002\u7ED8\u56FE\u6A21\u578B\u4E0D\u4F1A\u804A\u5929\u4E5F\u4E0D\u4F1A\u8C03\u5DE5\u5177\uFF0C\u8BF7\u5728\u300CAI \u6A21\u578B\u63A5\u53E3\u300D\u6216\u300CAgent \u914D\u7F6E\u300D\u91CC\u5355\u72EC\u586B\u4E00\u4E2A\u652F\u6301\u5DE5\u5177\u8C03\u7528\u7684\u5BF9\u8BDD\u6A21\u578B\u5730\u5740");
1824
+ }
1805
1825
  const timeout = (agentCfg.timeout || selector.timeout || 60) * 1e3;
1806
1826
  const temperature = typeof agentCfg.temperature === "number" ? agentCfg.temperature : typeof selector.temperature === "number" ? selector.temperature : 0.3;
1807
1827
  const maxTokens = typeof agentCfg.maxTokens === "number" ? agentCfg.maxTokens : 8e3;
@@ -1890,17 +1910,24 @@ ${echo}` : echo);
1890
1910
  done: session.text("image-prompt.messages.bgdone"),
1891
1911
  failed: session.text("image-prompt.messages.failed"),
1892
1912
  error: session.text("image-prompt.messages.error")
1913
+ },
1914
+ /** 带上接口给的真实原因,用户一看就知道是模型那边的问题还是自己配置的问题 */
1915
+ formatFailed: (reason) => {
1916
+ const head = String(session.text("image-prompt.messages.failed") || "").replace(/[,,。]\s*$/, "");
1917
+ if (!reason) return `${head}\u3002`;
1918
+ const clean = String(reason).replace(/\s+/g, " ").trim();
1919
+ return `${head}\uFF08${clean.slice(0, 120)}\uFF09`;
1893
1920
  }
1894
1921
  };
1895
1922
  const queued = enqueueDrawJob(snapshot, files);
1896
1923
  await sendNotice(session, `${session.text("image-prompt.messages.bgstart", [files.length])}${queued > 0 ? session.text("image-prompt.messages.bgqueue", [queued]) : ""}`);
1897
- return { ok: true };
1924
+ return { ok: true, queued: true };
1898
1925
  }
1899
- const result = await generateFigureImage(files, text);
1900
- if (!result) return { ok: false, error: "failed" };
1901
- await saveToGallery(result, buildGalleryDescription(text, userInputText), commandName);
1902
- await sendResultImage(session, result, commandName);
1903
- return { ok: true, url: result };
1926
+ const gen = await generateFigureImage(files, text);
1927
+ if (!gen.url) return { ok: false, error: "failed", reason: gen.reason };
1928
+ await saveToGallery(gen.url, buildGalleryDescription(text, userInputText), commandName);
1929
+ await sendResultImage(session, gen.url, commandName);
1930
+ return { ok: true, url: gen.url };
1904
1931
  }
1905
1932
  async function sendNotice(session, text) {
1906
1933
  const value = String(text ?? "").trim();
@@ -1961,7 +1988,7 @@ ${echo}` : echo);
1961
1988
  if (images.length === 0) return session.text("image-prompt.messages.needimages");
1962
1989
  const res = await drawWithFiles(session, cmdConfig, promptText, images, userInputText, { optimizeFailed });
1963
1990
  if (!res.ok) {
1964
- return res.error === "invalidimage" ? session.text("image-prompt.messages.invalidimage") : session.text("image-prompt.messages.failed");
1991
+ return res.error === "invalidimage" ? session.text("image-prompt.messages.invalidimage") : res.reason ? `${String(session.text("image-prompt.messages.failed") || "").replace(/[,,。]\s*$/, "")}\uFF08${res.reason.replace(/\s+/g, " ").trim().slice(0, 120)}\uFF09` : session.text("image-prompt.messages.failed");
1965
1992
  }
1966
1993
  }
1967
1994
  let runningJobs = 0;
@@ -1989,14 +2016,14 @@ ${echo}` : echo);
1989
2016
  const send = (parts) => sendBotMessage(snapshot.bot, snapshot.channelId, parts, snapshot.guildId, snapshot.messageId);
1990
2017
  try {
1991
2018
  if (!isActive || !ctx.scope.isActive) return;
1992
- const result = await generateFigureImage(files, snapshot.promptText);
1993
- if (!result) {
1994
- await send([snapshot.texts.failed]);
2019
+ const gen = await generateFigureImage(files, snapshot.promptText);
2020
+ if (!gen.url) {
2021
+ await send([snapshot.formatFailed ? snapshot.formatFailed(gen.reason) : snapshot.texts.failed]);
1995
2022
  return;
1996
2023
  }
1997
- await saveToGallery(result, buildGalleryDescription(snapshot.promptText, ""), snapshot.commandName);
2024
+ await saveToGallery(gen.url, buildGalleryDescription(snapshot.promptText, ""), snapshot.commandName);
1998
2025
  await send([`${snapshot.texts.done}
1999
- `, await buildResultImagePart(result)]);
2026
+ `, await buildResultImagePart(gen.url)]);
2000
2027
  } catch (error) {
2001
2028
  ctx.logger.error(`[${snapshot.commandName}] \u540E\u53F0\u7ED8\u56FE\u5931\u8D25:`, error);
2002
2029
  try {
@@ -2551,18 +2578,20 @@ ${lines.join("\n")}`]);
2551
2578
  return await sendChatRequest(requestBody);
2552
2579
  } catch (error) {
2553
2580
  ctx.logger.error(`\u751F\u6210\u56FE\u7247\u65F6\u53D1\u751F\u9519\u8BEF: ${error}`);
2554
- return null;
2581
+ return { url: null, reason: String(error?.message || error) };
2555
2582
  }
2556
2583
  }
2557
2584
  async function sendChatRequest(requestBody) {
2558
2585
  let retryCount = 0;
2559
- while (retryCount <= config.maxRetries) {
2586
+ const maxAttempts = (config.maxRetries ?? 0) + 1;
2587
+ let lastReason = "";
2588
+ while (retryCount < maxAttempts) {
2560
2589
  if (!isActive || !ctx.scope.isActive) {
2561
2590
  ctx.logger.info("\u63D2\u4EF6\u5DF2\u5378\u8F7D\uFF0C\u505C\u6B62\u91CD\u8BD5");
2562
- return null;
2591
+ return { url: null, reason: lastReason };
2563
2592
  }
2564
2593
  try {
2565
- logInfo2(`\u53D1\u9001\u8BF7\u6C42\u5230 ${config.baseUrl}\uFF0C\u7B2C ${retryCount + 1} \u6B21\u5C1D\u8BD5`);
2594
+ logInfo2(`\u53D1\u9001\u8BF7\u6C42\u5230 ${config.baseUrl}\uFF0C\u7B2C ${retryCount + 1}/${maxAttempts} \u6B21\u5C1D\u8BD5`);
2566
2595
  const headers = {
2567
2596
  "Content-Type": "application/json"
2568
2597
  };
@@ -2578,35 +2607,35 @@ ${lines.join("\n")}`]);
2578
2607
  if (markdownMatch && markdownMatch[1]) {
2579
2608
  const imageUrl = markdownMatch[1];
2580
2609
  logInfo2(`\u6210\u529F\u83B7\u53D6\u56FE\u7247URL: ${imageUrl}`);
2581
- return imageUrl;
2610
+ return { url: imageUrl };
2582
2611
  }
2583
2612
  }
2584
2613
  }
2585
- const errorMsg = "\u54CD\u5E94\u4E2D\u672A\u627E\u5230\u56FE\u7247URL";
2586
- throw new Error(errorMsg);
2614
+ throw new Error("\u54CD\u5E94\u4E2D\u672A\u627E\u5230\u56FE\u7247URL");
2587
2615
  } catch (error) {
2588
2616
  retryCount++;
2589
- const errorMessage = error.message || error.toString();
2590
- const statusCode = error.response?.status || 0;
2591
- logInfo2(`\u8BF7\u6C42\u5931\u8D25 (${retryCount}/${config.maxRetries}): ${errorMessage}`);
2592
- if (errorMessage.includes("insufficient_quota") || statusCode === 429) {
2617
+ const reason = httpErrorText(error);
2618
+ lastReason = reason;
2619
+ const statusCode = error.response?.status || error.status || error.code || 0;
2620
+ logInfo2(`\u8BF7\u6C42\u5931\u8D25\uFF08\u7B2C ${retryCount}/${maxAttempts} \u6B21\uFF09\uFF1A${reason}`);
2621
+ if (reason.includes("insufficient_quota") || statusCode === 429) {
2593
2622
  ctx.logger.error("API \u914D\u989D\u4E0D\u8DB3\uFF0C\u505C\u6B62\u91CD\u8BD5");
2594
- return null;
2623
+ return { url: null, reason };
2595
2624
  }
2596
- if (retryCount <= config.maxRetries) {
2597
- logInfo2(`\u7B49\u5F85 ${config.retryInterval}ms \u540E\u91CD\u8BD5`);
2598
- await (0, import_koishi.sleep)(config.retryInterval);
2625
+ if (retryCount < maxAttempts) {
2626
+ const delay = computeRetryDelay(statusCode, void 0, config.retryInterval, retryCount - 1);
2627
+ logInfo2(`${delay}ms \u540E\u91CD\u8BD5`);
2628
+ await (0, import_koishi.sleep)(delay);
2599
2629
  if (!isActive || !ctx.scope.isActive) {
2600
2630
  ctx.logger.info("\u63D2\u4EF6\u5DF2\u5378\u8F7D\uFF0C\u505C\u6B62\u91CD\u8BD5");
2601
- return null;
2631
+ return { url: null, reason };
2602
2632
  }
2603
2633
  } else {
2604
- ctx.logger.error(`\u8FBE\u5230\u6700\u5927\u91CD\u8BD5\u6B21\u6570 (${config.maxRetries})\uFF0C\u6700\u540E\u9519\u8BEF: ${errorMessage}`);
2605
- return null;
2634
+ ctx.logger.error(`\u5DF2\u91CD\u8BD5 ${maxAttempts} \u6B21\u4ECD\u5931\u8D25\uFF0C\u6700\u540E\u9519\u8BEF\uFF1A${reason}`);
2606
2635
  }
2607
2636
  }
2608
2637
  }
2609
- return null;
2638
+ return { url: null, reason: lastReason };
2610
2639
  }
2611
2640
  function logInfo2(...args) {
2612
2641
  if (config.loggerinfo) {
@@ -2634,6 +2663,7 @@ ${lines.join("\n")}`]);
2634
2663
  extractServerMessage,
2635
2664
  extractTextToRender,
2636
2665
  fitImageSize,
2666
+ httpErrorText,
2637
2667
  inject,
2638
2668
  isPassiveReplyError,
2639
2669
  isRetryableStatus,
package/package.json CHANGED
@@ -1,28 +1,28 @@
1
- {
2
- "name": "koishi-plugin-image-prompt",
3
- "description": "多功能 AI 图像处理插件:Agent 模式把「查不查图库 / 要不要追问 / 什么时候开画」全交给模型自己决定,支持手办化、Coser 化、Minecraft 化等 47 种风格与自定义提示词",
4
- "version": "2.0.0",
5
- "main": "lib/index.js",
6
- "typings": "lib/index.d.ts",
7
- "files": [
8
- "lib",
9
- "src"
10
- ],
11
- "license": "GPL-3.0",
12
- "keywords": [
13
- "chatbot",
14
- "koishi",
15
- "plugin",
16
- "手办化"
17
- ],
18
- "contributors": [
19
- "maimai993 <3544815113@qq.com>"
20
- ],
21
- "homepage": "https://github.com/maimai993/koishi-plugin-image-prompt",
22
- "bugs": {
23
- "url": "https://github.com/maimai993/koishi-plugin-image-prompt/issues"
24
- },
25
- "peerDependencies": {
26
- "koishi": "^4.18.7"
27
- }
28
- }
1
+ {
2
+ "name": "koishi-plugin-image-prompt",
3
+ "description": "多功能 AI 图像处理插件:Agent 模式把「查不查图库 / 要不要追问 / 什么时候开画」全交给模型自己决定,支持手办化、Coser 化、Minecraft 化等 47 种风格与自定义提示词",
4
+ "version": "2.0.2",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "src"
10
+ ],
11
+ "license": "GPL-3.0",
12
+ "keywords": [
13
+ "chatbot",
14
+ "koishi",
15
+ "plugin",
16
+ "手办化"
17
+ ],
18
+ "contributors": [
19
+ "maimai993 <3544815113@qq.com>"
20
+ ],
21
+ "homepage": "https://github.com/maimai993/koishi-plugin-image-prompt",
22
+ "bugs": {
23
+ "url": "https://github.com/maimai993/koishi-plugin-image-prompt/issues"
24
+ },
25
+ "peerDependencies": {
26
+ "koishi": "^4.18.7"
27
+ }
28
+ }
package/readme.md CHANGED
@@ -460,6 +460,31 @@ max_tokens=8000 → finish_reason: stop, content: "{...}", reasoning_tokens:
460
460
  每一轮的模型输出与工具调用都会写进日志,一眼就能看出是模型没调工具、
461
461
  还是调了但参数不对。
462
462
 
463
+ ### 🩺 排查「画不出来 / 接口报错」
464
+
465
+ 失败时插件会把**服务端返回的真实原因**原样带出来,先看这一句再决定怎么办:
466
+
467
+ ```
468
+ [I] 请求失败(第 3/3 次):Internal Server Error|接口返回:connect ECONNREFUSED 192.168.0.85:20000
469
+ ```
470
+
471
+ 群里收到的失败消息里同样会带上这句。**这串东西不是乱码**,它通常直接说明问题在哪:
472
+
473
+ | 看到的说法 | 含义 | 怎么办 |
474
+ | --- | --- | --- |
475
+ | `connect ECONNREFUSED <ip>:<端口>` | 中转站的后端节点没起来 / 挂了 | **跟接口提供方无关也不在你这边**,换模型或等它恢复 |
476
+ | `当前模型负载较高` | 该模型现在挤,不是配置问题 | 换个负载均衡点的模型,或稍后再试 |
477
+ | `Model Not Exist` | 模型名写错了 | 核对模型名(大小写也算) |
478
+ | `Invalid API key` | 密钥不对或已过期 | 换密钥 |
479
+ | `insufficient_quota` | 额度没了 | 充值后会立刻恢复,插件不会傻重试 |
480
+
481
+ > 同一个中转站上**所有**绘图模型都报同一个 `ECONNREFUSED` 时,基本可以确定是对方整条链路挂了,
482
+ > 换个模型名也没用,只能等它恢复。判断是否全挂:换 2~3 个不同的绘图模型各试一次,
483
+ > 如果都指向同一个 IP 端口,就是服务端的问题。
484
+
485
+ 另外,`429 限流` 和 `5xx 服务端错误` 现在是**指数退避**重试(1s → 2s → 4s…),
486
+ 不再用固定间隔连着猛打 —— 对端真的在重启的时候,等久一点反而更容易成功。
487
+
463
488
  ### ❌ 不会重试的错误
464
489
 
465
490
  `400 / 401 / 403 / 404 / 422` 这类属于永久性错误(参数错、密钥错、地址错、模型不存在),
package/src/index.ts CHANGED
@@ -81,13 +81,34 @@ const logger = new Logger(name)
81
81
  */
82
82
  export function computeRetryDelay(status: any, retryAfter: number | undefined, baseInterval: number, attempt: number): number {
83
83
  const base = baseInterval > 0 ? baseInterval : 1000
84
- if (status === 429 || status === 503) {
84
+ // 429 限流、5xx 服务端错误、status=0(网络层就断了)都值得退避:
85
+ // 上游节点挂了的时候,固定间隔连着猛打四次只是徒增负担
86
+ const worthBackoff = status === 429 || status === 503 || (typeof status === 'number' && status >= 500) || status === 0
87
+ if (worthBackoff) {
85
88
  if (retryAfter && retryAfter > 0) return retryAfter
86
89
  return Math.min(base * Math.pow(2, attempt), 30000)
87
90
  }
88
91
  return base
89
92
  }
90
93
 
94
+ /**
95
+ * 从接口抛出的错误里挖出「到底为什么失败」。
96
+ * koishi 的 http 错误对 5xx 往往只留一句状态文本("Internal Server Error"),
97
+ * 真正的病根(比如上游 ECONNREFUSED、模型负载过高)藏在响应体里,得自己捞出来。
98
+ */
99
+ export function httpErrorText(error: any): string {
100
+ if (!error) return '未知原因'
101
+ const data = error?.response?.data ?? error?.data ?? error?.response
102
+ const upstream = typeof data === 'string'
103
+ ? data
104
+ : data?.error?.message ?? data?.message ?? data?.msg
105
+ let text = String(error?.message || error?.toString?.() || '未知原因').trim()
106
+ if (typeof upstream === 'string' && upstream.trim() && !text.includes(upstream.trim())) {
107
+ text = `${text}|接口返回:${upstream.trim().slice(0, 200)}`
108
+ }
109
+ return text
110
+ }
111
+
91
112
  /** 从任意文本里挖出第一个 JSON 对象(容忍代码块围栏和前后废话) */
92
113
  export function extractJsonObject(raw: string | null | undefined): any | null {
93
114
  if (!raw) return null
@@ -1977,6 +1998,8 @@ export function apply(ctx: Context, config: CommandConfig) {
1977
1998
 
1978
1999
  // 每个频道最近几轮的对话(agent 记忆,只存纯文本,重启即失效)
1979
2000
  const agentHistories = new Map<string, any[]>()
2001
+ /** 「agent 接口地址没单独配」这条警告只打一次,别刷屏 */
2002
+ let agentUrlWarned = false
1980
2003
 
1981
2004
  function agentHistoryKey(session: Session): string {
1982
2005
  return `${session.platform}:${session.channelId || session.userId || ''}`
@@ -2301,8 +2324,16 @@ export function apply(ctx: Context, config: CommandConfig) {
2301
2324
  references: capped,
2302
2325
  ...(unknown.length ? { unknown } : {}),
2303
2326
  ...(res.ok
2304
- ? { note: '图已经发到群里了,回一句简短说明即可,不要再问「还要我做什么」' }
2305
- : { error: res.error === 'invalidimage' ? '这些参考图一张都下载不下来,检查链接还能不能访问' : '绘图接口没有返回图片' }),
2327
+ ? (res.queued
2328
+ // 后台绘图:这里只是排上队了,图还没开始画,绝不能让模型告诉用户「画好了」
2329
+ ? { queued: true, note: '绘图任务已排进后台队列,还没有画完。请告诉用户「已经排上队,画好后会在这里通知」,不要说已经画好或已经发出图片' }
2330
+ : { sent: true, note: '图已经发到群里了,回一句简短说明即可,不要再问「还要我做什么」' })
2331
+ : {
2332
+ error: res.error === 'invalidimage'
2333
+ ? '这些参考图一张都下载不下来,检查链接还能不能访问'
2334
+ : `绘图接口这次没出图:${(res.reason || '未知原因').slice(0, 160)}`,
2335
+ note: '如实告诉用户这次没画出来、可能是什么原因,并建议稍后再试或换个模型,不要说已经画好了',
2336
+ }),
2306
2337
  })
2307
2338
  }
2308
2339
  }
@@ -2348,6 +2379,12 @@ export function apply(ctx: Context, config: CommandConfig) {
2348
2379
  const model = agentCfg.model || selector.model || 'Qwen/Qwen2.5-7B-Instruct'
2349
2380
  const url = agentCfg.baseUrl || selector.baseUrl || config.baseUrl
2350
2381
  const apiKey = agentCfg.apiKey || selector.apiKey || config.apiKey
2382
+ // agent 要用的是**对话**模型。接口地址一个都没填时会落到绘图接口上,
2383
+ // 而绘图模型(gpt-image 之类)根本不会聊天/调工具,这里提前说清楚。
2384
+ if (!agentCfg.baseUrl && !selector.baseUrl && !agentUrlWarned) {
2385
+ agentUrlWarned = true
2386
+ ctx.logger.warn('Agent 用的是绘图接口地址(「AI 模型接口 → 对话接口地址」留空了)。绘图模型不会聊天也不会调工具,请在「AI 模型接口」或「Agent 配置」里单独填一个支持工具调用的对话模型地址')
2387
+ }
2351
2388
  const timeout = (agentCfg.timeout || selector.timeout || 60) * 1000
2352
2389
  const temperature = typeof agentCfg.temperature === 'number'
2353
2390
  ? agentCfg.temperature
@@ -2420,7 +2457,7 @@ export function apply(ctx: Context, config: CommandConfig) {
2420
2457
  imageUrls: string[],
2421
2458
  userInputText: string,
2422
2459
  options: { optimizeFailed?: boolean, requireInput?: boolean } = {}
2423
- ): Promise<{ ok: boolean, error?: string, url?: string }> {
2460
+ ): Promise<{ ok: boolean, error?: string, url?: string, reason?: string, queued?: boolean }> {
2424
2461
  const commandName = cmdConfig.name
2425
2462
  const requireInput = options.requireInput !== false
2426
2463
  const files = await downloadFiles(imageUrls)
@@ -2466,18 +2503,26 @@ export function apply(ctx: Context, config: CommandConfig) {
2466
2503
  failed: session.text('image-prompt.messages.failed'),
2467
2504
  error: session.text('image-prompt.messages.error'),
2468
2505
  },
2506
+ /** 带上接口给的真实原因,用户一看就知道是模型那边的问题还是自己配置的问题 */
2507
+ formatFailed: (reason?: string) => {
2508
+ const head = String(session.text('image-prompt.messages.failed') || '').replace(/[,,。]\s*$/, '')
2509
+ if (!reason) return `${head}。`
2510
+ const clean = String(reason).replace(/\s+/g, ' ').trim()
2511
+ return `${head}(${clean.slice(0, 120)})`
2512
+ },
2469
2513
  }
2470
2514
  const queued = enqueueDrawJob(snapshot, files)
2471
2515
  await sendNotice(session, `${session.text('image-prompt.messages.bgstart', [files.length])}${queued > 0 ? session.text('image-prompt.messages.bgqueue', [queued]) : ''}`)
2472
- return { ok: true }
2516
+ // 注意:这里是「排上队了」,不是「画好了」。别让上层当成已完成通知用户
2517
+ return { ok: true, queued: true }
2473
2518
  }
2474
2519
 
2475
- const result = await generateFigureImage(files, text)
2476
- if (!result) return { ok: false, error: 'failed' }
2520
+ const gen = await generateFigureImage(files, text)
2521
+ if (!gen.url) return { ok: false, error: 'failed', reason: gen.reason }
2477
2522
 
2478
- await saveToGallery(result, buildGalleryDescription(text, userInputText), commandName)
2479
- await sendResultImage(session, result, commandName)
2480
- return { ok: true, url: result }
2523
+ await saveToGallery(gen.url, buildGalleryDescription(text, userInputText), commandName)
2524
+ await sendResultImage(session, gen.url, commandName)
2525
+ return { ok: true, url: gen.url }
2481
2526
  }
2482
2527
 
2483
2528
  /** 发一条状态说明(支持的平台用 markdown,省得长文被拆) */
@@ -2557,7 +2602,9 @@ export function apply(ctx: Context, config: CommandConfig) {
2557
2602
  if (!res.ok) {
2558
2603
  return res.error === 'invalidimage'
2559
2604
  ? session.text('image-prompt.messages.invalidimage')
2560
- : session.text('image-prompt.messages.failed')
2605
+ : res.reason
2606
+ ? `${String(session.text('image-prompt.messages.failed') || '').replace(/[,,。]\s*$/, '')}(${res.reason.replace(/\s+/g, ' ').trim().slice(0, 120)})`
2607
+ : session.text('image-prompt.messages.failed')
2561
2608
  }
2562
2609
  }
2563
2610
  // ============ 后台绘图 ============
@@ -2590,13 +2637,13 @@ export function apply(ctx: Context, config: CommandConfig) {
2590
2637
 
2591
2638
  try {
2592
2639
  if (!isActive || !ctx.scope.isActive) return
2593
- const result = await generateFigureImage(files, snapshot.promptText)
2594
- if (!result) {
2595
- await send([snapshot.texts.failed])
2640
+ const gen = await generateFigureImage(files, snapshot.promptText)
2641
+ if (!gen.url) {
2642
+ await send([snapshot.formatFailed ? snapshot.formatFailed(gen.reason) : snapshot.texts.failed])
2596
2643
  return
2597
2644
  }
2598
- await saveToGallery(result, buildGalleryDescription(snapshot.promptText, ''), snapshot.commandName)
2599
- await send([`${snapshot.texts.done}\n`, await buildResultImagePart(result)])
2645
+ await saveToGallery(gen.url, buildGalleryDescription(snapshot.promptText, ''), snapshot.commandName)
2646
+ await send([`${snapshot.texts.done}\n`, await buildResultImagePart(gen.url)])
2600
2647
  } catch (error) {
2601
2648
  ctx.logger.error(`[${snapshot.commandName}] 后台绘图失败:`, error)
2602
2649
  try {
@@ -3265,7 +3312,8 @@ export function apply(ctx: Context, config: CommandConfig) {
3265
3312
  return images
3266
3313
  }
3267
3314
 
3268
- async function generateFigureImage(files: any[], prompt: string): Promise<string | null> {
3315
+ /** @returns 图片地址;失败时带回接口给的真实原因,方便直接告诉用户「是模型那边的毛病」 */
3316
+ async function generateFigureImage(files: any[], prompt: string): Promise<{ url: string | null, reason?: string }> {
3269
3317
  try {
3270
3318
  const dataUrls: string[] = []
3271
3319
 
@@ -3344,22 +3392,24 @@ export function apply(ctx: Context, config: CommandConfig) {
3344
3392
  return await sendChatRequest(requestBody)
3345
3393
  } catch (error) {
3346
3394
  ctx.logger.error(`生成图片时发生错误: ${error}`)
3347
- return null
3395
+ return { url: null, reason: String(error?.message || error) }
3348
3396
  }
3349
3397
  }
3350
3398
 
3351
- async function sendChatRequest(requestBody: any): Promise<string | null> {
3399
+ async function sendChatRequest(requestBody: any): Promise<{ url: string | null, reason?: string }> {
3352
3400
  let retryCount = 0
3401
+ const maxAttempts = (config.maxRetries ?? 0) + 1
3402
+ let lastReason = ''
3353
3403
 
3354
- while (retryCount <= config.maxRetries) {
3404
+ while (retryCount < maxAttempts) {
3355
3405
  // 在每次重试前检查上下文状态
3356
3406
  if (!isActive || !ctx.scope.isActive) {
3357
3407
  ctx.logger.info('插件已卸载,停止重试')
3358
- return null
3408
+ return { url: null, reason: lastReason }
3359
3409
  }
3360
3410
 
3361
3411
  try {
3362
- logInfo(`发送请求到 ${config.baseUrl},第 ${retryCount + 1} 次尝试`)
3412
+ logInfo(`发送请求到 ${config.baseUrl},第 ${retryCount + 1}/${maxAttempts} 次尝试`)
3363
3413
 
3364
3414
  const headers: Record<string, string> = {
3365
3415
  'Content-Type': 'application/json'
@@ -3381,40 +3431,41 @@ export function apply(ctx: Context, config: CommandConfig) {
3381
3431
  if (markdownMatch && markdownMatch[1]) {
3382
3432
  const imageUrl = markdownMatch[1]
3383
3433
  logInfo(`成功获取图片URL: ${imageUrl}`)
3384
- return imageUrl
3434
+ return { url: imageUrl }
3385
3435
  }
3386
3436
  }
3387
3437
  }
3388
3438
 
3389
- const errorMsg = '响应中未找到图片URL'
3390
- throw new Error(errorMsg)
3439
+ throw new Error('响应中未找到图片URL')
3391
3440
  } catch (error) {
3392
3441
  retryCount++
3393
- const errorMessage = error.message || error.toString()
3394
- const statusCode = error.response?.status || 0
3442
+ const reason = httpErrorText(error)
3443
+ lastReason = reason
3444
+ const statusCode = error.response?.status || error.status || error.code || 0
3395
3445
 
3396
- logInfo(`请求失败 (${retryCount}/${config.maxRetries}): ${errorMessage}`)
3446
+ logInfo(`请求失败(第 ${retryCount}/${maxAttempts} 次):${reason}`)
3397
3447
 
3398
3448
  // 检查是否为配额不足错误
3399
- if (errorMessage.includes('insufficient_quota') || statusCode === 429) {
3449
+ if (reason.includes('insufficient_quota') || statusCode === 429) {
3400
3450
  ctx.logger.error('API 配额不足,停止重试')
3401
- return null
3451
+ return { url: null, reason }
3402
3452
  }
3403
3453
 
3404
- if (retryCount <= config.maxRetries) {
3405
- logInfo(`等待 ${config.retryInterval}ms 后重试`)
3406
- await sleep(config.retryInterval)
3454
+ if (retryCount < maxAttempts) {
3455
+ // 服务端错误/网络错误走指数退避,别用固定间隔猛打
3456
+ const delay = computeRetryDelay(statusCode, undefined, config.retryInterval, retryCount - 1)
3457
+ logInfo(`${delay}ms 后重试`)
3458
+ await sleep(delay)
3407
3459
  if (!isActive || !ctx.scope.isActive) {
3408
3460
  ctx.logger.info('插件已卸载,停止重试')
3409
- return null
3461
+ return { url: null, reason }
3410
3462
  }
3411
3463
  } else {
3412
- ctx.logger.error(`达到最大重试次数 (${config.maxRetries}),最后错误: ${errorMessage}`)
3413
- return null
3464
+ ctx.logger.error(`已重试 ${maxAttempts} 次仍失败,最后错误:${reason}`)
3414
3465
  }
3415
3466
  }
3416
3467
  }
3417
- return null
3468
+ return { url: null, reason: lastReason }
3418
3469
  }
3419
3470
 
3420
3471
  function logInfo(...args: any[]) {