koishi-plugin-image-prompt 2.0.1 → 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();
@@ -1771,7 +1783,10 @@ Negative prompt: ${String(args.negative_prompt).trim()}` : prompt;
1771
1783
  ok: res.ok,
1772
1784
  references: capped,
1773
1785
  ...unknown.length ? { unknown } : {},
1774
- ...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
+ }
1775
1790
  });
1776
1791
  }
1777
1792
  }
@@ -1895,17 +1910,24 @@ ${echo}` : echo);
1895
1910
  done: session.text("image-prompt.messages.bgdone"),
1896
1911
  failed: session.text("image-prompt.messages.failed"),
1897
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`;
1898
1920
  }
1899
1921
  };
1900
1922
  const queued = enqueueDrawJob(snapshot, files);
1901
1923
  await sendNotice(session, `${session.text("image-prompt.messages.bgstart", [files.length])}${queued > 0 ? session.text("image-prompt.messages.bgqueue", [queued]) : ""}`);
1902
- return { ok: true };
1924
+ return { ok: true, queued: true };
1903
1925
  }
1904
- const result = await generateFigureImage(files, text);
1905
- if (!result) return { ok: false, error: "failed" };
1906
- await saveToGallery(result, buildGalleryDescription(text, userInputText), commandName);
1907
- await sendResultImage(session, result, commandName);
1908
- 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 };
1909
1931
  }
1910
1932
  async function sendNotice(session, text) {
1911
1933
  const value = String(text ?? "").trim();
@@ -1966,7 +1988,7 @@ ${echo}` : echo);
1966
1988
  if (images.length === 0) return session.text("image-prompt.messages.needimages");
1967
1989
  const res = await drawWithFiles(session, cmdConfig, promptText, images, userInputText, { optimizeFailed });
1968
1990
  if (!res.ok) {
1969
- 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");
1970
1992
  }
1971
1993
  }
1972
1994
  let runningJobs = 0;
@@ -1994,14 +2016,14 @@ ${echo}` : echo);
1994
2016
  const send = (parts) => sendBotMessage(snapshot.bot, snapshot.channelId, parts, snapshot.guildId, snapshot.messageId);
1995
2017
  try {
1996
2018
  if (!isActive || !ctx.scope.isActive) return;
1997
- const result = await generateFigureImage(files, snapshot.promptText);
1998
- if (!result) {
1999
- 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]);
2000
2022
  return;
2001
2023
  }
2002
- await saveToGallery(result, buildGalleryDescription(snapshot.promptText, ""), snapshot.commandName);
2024
+ await saveToGallery(gen.url, buildGalleryDescription(snapshot.promptText, ""), snapshot.commandName);
2003
2025
  await send([`${snapshot.texts.done}
2004
- `, await buildResultImagePart(result)]);
2026
+ `, await buildResultImagePart(gen.url)]);
2005
2027
  } catch (error) {
2006
2028
  ctx.logger.error(`[${snapshot.commandName}] \u540E\u53F0\u7ED8\u56FE\u5931\u8D25:`, error);
2007
2029
  try {
@@ -2556,18 +2578,20 @@ ${lines.join("\n")}`]);
2556
2578
  return await sendChatRequest(requestBody);
2557
2579
  } catch (error) {
2558
2580
  ctx.logger.error(`\u751F\u6210\u56FE\u7247\u65F6\u53D1\u751F\u9519\u8BEF: ${error}`);
2559
- return null;
2581
+ return { url: null, reason: String(error?.message || error) };
2560
2582
  }
2561
2583
  }
2562
2584
  async function sendChatRequest(requestBody) {
2563
2585
  let retryCount = 0;
2564
- while (retryCount <= config.maxRetries) {
2586
+ const maxAttempts = (config.maxRetries ?? 0) + 1;
2587
+ let lastReason = "";
2588
+ while (retryCount < maxAttempts) {
2565
2589
  if (!isActive || !ctx.scope.isActive) {
2566
2590
  ctx.logger.info("\u63D2\u4EF6\u5DF2\u5378\u8F7D\uFF0C\u505C\u6B62\u91CD\u8BD5");
2567
- return null;
2591
+ return { url: null, reason: lastReason };
2568
2592
  }
2569
2593
  try {
2570
- 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`);
2571
2595
  const headers = {
2572
2596
  "Content-Type": "application/json"
2573
2597
  };
@@ -2583,35 +2607,35 @@ ${lines.join("\n")}`]);
2583
2607
  if (markdownMatch && markdownMatch[1]) {
2584
2608
  const imageUrl = markdownMatch[1];
2585
2609
  logInfo2(`\u6210\u529F\u83B7\u53D6\u56FE\u7247URL: ${imageUrl}`);
2586
- return imageUrl;
2610
+ return { url: imageUrl };
2587
2611
  }
2588
2612
  }
2589
2613
  }
2590
- const errorMsg = "\u54CD\u5E94\u4E2D\u672A\u627E\u5230\u56FE\u7247URL";
2591
- throw new Error(errorMsg);
2614
+ throw new Error("\u54CD\u5E94\u4E2D\u672A\u627E\u5230\u56FE\u7247URL");
2592
2615
  } catch (error) {
2593
2616
  retryCount++;
2594
- const errorMessage = error.message || error.toString();
2595
- const statusCode = error.response?.status || 0;
2596
- logInfo2(`\u8BF7\u6C42\u5931\u8D25 (${retryCount}/${config.maxRetries}): ${errorMessage}`);
2597
- 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) {
2598
2622
  ctx.logger.error("API \u914D\u989D\u4E0D\u8DB3\uFF0C\u505C\u6B62\u91CD\u8BD5");
2599
- return null;
2623
+ return { url: null, reason };
2600
2624
  }
2601
- if (retryCount <= config.maxRetries) {
2602
- logInfo2(`\u7B49\u5F85 ${config.retryInterval}ms \u540E\u91CD\u8BD5`);
2603
- 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);
2604
2629
  if (!isActive || !ctx.scope.isActive) {
2605
2630
  ctx.logger.info("\u63D2\u4EF6\u5DF2\u5378\u8F7D\uFF0C\u505C\u6B62\u91CD\u8BD5");
2606
- return null;
2631
+ return { url: null, reason };
2607
2632
  }
2608
2633
  } else {
2609
- ctx.logger.error(`\u8FBE\u5230\u6700\u5927\u91CD\u8BD5\u6B21\u6570 (${config.maxRetries})\uFF0C\u6700\u540E\u9519\u8BEF: ${errorMessage}`);
2610
- return null;
2634
+ ctx.logger.error(`\u5DF2\u91CD\u8BD5 ${maxAttempts} \u6B21\u4ECD\u5931\u8D25\uFF0C\u6700\u540E\u9519\u8BEF\uFF1A${reason}`);
2611
2635
  }
2612
2636
  }
2613
2637
  }
2614
- return null;
2638
+ return { url: null, reason: lastReason };
2615
2639
  }
2616
2640
  function logInfo2(...args) {
2617
2641
  if (config.loggerinfo) {
@@ -2639,6 +2663,7 @@ ${lines.join("\n")}`]);
2639
2663
  extractServerMessage,
2640
2664
  extractTextToRender,
2641
2665
  fitImageSize,
2666
+ httpErrorText,
2642
2667
  inject,
2643
2668
  isPassiveReplyError,
2644
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.1",
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
@@ -2303,8 +2324,16 @@ export function apply(ctx: Context, config: CommandConfig) {
2303
2324
  references: capped,
2304
2325
  ...(unknown.length ? { unknown } : {}),
2305
2326
  ...(res.ok
2306
- ? { note: '图已经发到群里了,回一句简短说明即可,不要再问「还要我做什么」' }
2307
- : { 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
+ }),
2308
2337
  })
2309
2338
  }
2310
2339
  }
@@ -2428,7 +2457,7 @@ export function apply(ctx: Context, config: CommandConfig) {
2428
2457
  imageUrls: string[],
2429
2458
  userInputText: string,
2430
2459
  options: { optimizeFailed?: boolean, requireInput?: boolean } = {}
2431
- ): Promise<{ ok: boolean, error?: string, url?: string }> {
2460
+ ): Promise<{ ok: boolean, error?: string, url?: string, reason?: string, queued?: boolean }> {
2432
2461
  const commandName = cmdConfig.name
2433
2462
  const requireInput = options.requireInput !== false
2434
2463
  const files = await downloadFiles(imageUrls)
@@ -2474,18 +2503,26 @@ export function apply(ctx: Context, config: CommandConfig) {
2474
2503
  failed: session.text('image-prompt.messages.failed'),
2475
2504
  error: session.text('image-prompt.messages.error'),
2476
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
+ },
2477
2513
  }
2478
2514
  const queued = enqueueDrawJob(snapshot, files)
2479
2515
  await sendNotice(session, `${session.text('image-prompt.messages.bgstart', [files.length])}${queued > 0 ? session.text('image-prompt.messages.bgqueue', [queued]) : ''}`)
2480
- return { ok: true }
2516
+ // 注意:这里是「排上队了」,不是「画好了」。别让上层当成已完成通知用户
2517
+ return { ok: true, queued: true }
2481
2518
  }
2482
2519
 
2483
- const result = await generateFigureImage(files, text)
2484
- 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 }
2485
2522
 
2486
- await saveToGallery(result, buildGalleryDescription(text, userInputText), commandName)
2487
- await sendResultImage(session, result, commandName)
2488
- 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 }
2489
2526
  }
2490
2527
 
2491
2528
  /** 发一条状态说明(支持的平台用 markdown,省得长文被拆) */
@@ -2565,7 +2602,9 @@ export function apply(ctx: Context, config: CommandConfig) {
2565
2602
  if (!res.ok) {
2566
2603
  return res.error === 'invalidimage'
2567
2604
  ? session.text('image-prompt.messages.invalidimage')
2568
- : 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')
2569
2608
  }
2570
2609
  }
2571
2610
  // ============ 后台绘图 ============
@@ -2598,13 +2637,13 @@ export function apply(ctx: Context, config: CommandConfig) {
2598
2637
 
2599
2638
  try {
2600
2639
  if (!isActive || !ctx.scope.isActive) return
2601
- const result = await generateFigureImage(files, snapshot.promptText)
2602
- if (!result) {
2603
- 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])
2604
2643
  return
2605
2644
  }
2606
- await saveToGallery(result, buildGalleryDescription(snapshot.promptText, ''), snapshot.commandName)
2607
- 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)])
2608
2647
  } catch (error) {
2609
2648
  ctx.logger.error(`[${snapshot.commandName}] 后台绘图失败:`, error)
2610
2649
  try {
@@ -3273,7 +3312,8 @@ export function apply(ctx: Context, config: CommandConfig) {
3273
3312
  return images
3274
3313
  }
3275
3314
 
3276
- 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 }> {
3277
3317
  try {
3278
3318
  const dataUrls: string[] = []
3279
3319
 
@@ -3352,22 +3392,24 @@ export function apply(ctx: Context, config: CommandConfig) {
3352
3392
  return await sendChatRequest(requestBody)
3353
3393
  } catch (error) {
3354
3394
  ctx.logger.error(`生成图片时发生错误: ${error}`)
3355
- return null
3395
+ return { url: null, reason: String(error?.message || error) }
3356
3396
  }
3357
3397
  }
3358
3398
 
3359
- async function sendChatRequest(requestBody: any): Promise<string | null> {
3399
+ async function sendChatRequest(requestBody: any): Promise<{ url: string | null, reason?: string }> {
3360
3400
  let retryCount = 0
3401
+ const maxAttempts = (config.maxRetries ?? 0) + 1
3402
+ let lastReason = ''
3361
3403
 
3362
- while (retryCount <= config.maxRetries) {
3404
+ while (retryCount < maxAttempts) {
3363
3405
  // 在每次重试前检查上下文状态
3364
3406
  if (!isActive || !ctx.scope.isActive) {
3365
3407
  ctx.logger.info('插件已卸载,停止重试')
3366
- return null
3408
+ return { url: null, reason: lastReason }
3367
3409
  }
3368
3410
 
3369
3411
  try {
3370
- logInfo(`发送请求到 ${config.baseUrl},第 ${retryCount + 1} 次尝试`)
3412
+ logInfo(`发送请求到 ${config.baseUrl},第 ${retryCount + 1}/${maxAttempts} 次尝试`)
3371
3413
 
3372
3414
  const headers: Record<string, string> = {
3373
3415
  'Content-Type': 'application/json'
@@ -3389,40 +3431,41 @@ export function apply(ctx: Context, config: CommandConfig) {
3389
3431
  if (markdownMatch && markdownMatch[1]) {
3390
3432
  const imageUrl = markdownMatch[1]
3391
3433
  logInfo(`成功获取图片URL: ${imageUrl}`)
3392
- return imageUrl
3434
+ return { url: imageUrl }
3393
3435
  }
3394
3436
  }
3395
3437
  }
3396
3438
 
3397
- const errorMsg = '响应中未找到图片URL'
3398
- throw new Error(errorMsg)
3439
+ throw new Error('响应中未找到图片URL')
3399
3440
  } catch (error) {
3400
3441
  retryCount++
3401
- const errorMessage = error.message || error.toString()
3402
- 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
3403
3445
 
3404
- logInfo(`请求失败 (${retryCount}/${config.maxRetries}): ${errorMessage}`)
3446
+ logInfo(`请求失败(第 ${retryCount}/${maxAttempts} 次):${reason}`)
3405
3447
 
3406
3448
  // 检查是否为配额不足错误
3407
- if (errorMessage.includes('insufficient_quota') || statusCode === 429) {
3449
+ if (reason.includes('insufficient_quota') || statusCode === 429) {
3408
3450
  ctx.logger.error('API 配额不足,停止重试')
3409
- return null
3451
+ return { url: null, reason }
3410
3452
  }
3411
3453
 
3412
- if (retryCount <= config.maxRetries) {
3413
- logInfo(`等待 ${config.retryInterval}ms 后重试`)
3414
- 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)
3415
3459
  if (!isActive || !ctx.scope.isActive) {
3416
3460
  ctx.logger.info('插件已卸载,停止重试')
3417
- return null
3461
+ return { url: null, reason }
3418
3462
  }
3419
3463
  } else {
3420
- ctx.logger.error(`达到最大重试次数 (${config.maxRetries}),最后错误: ${errorMessage}`)
3421
- return null
3464
+ ctx.logger.error(`已重试 ${maxAttempts} 次仍失败,最后错误:${reason}`)
3422
3465
  }
3423
3466
  }
3424
3467
  }
3425
- return null
3468
+ return { url: null, reason: lastReason }
3426
3469
  }
3427
3470
 
3428
3471
  function logInfo(...args: any[]) {