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 +6 -0
- package/lib/index.js +63 -33
- package/package.json +28 -28
- package/readme.md +25 -0
- package/src/index.ts +88 -37
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
|
-
|
|
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\
|
|
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
|
|
1900
|
-
if (!
|
|
1901
|
-
await saveToGallery(
|
|
1902
|
-
await sendResultImage(session,
|
|
1903
|
-
return { ok: true, url:
|
|
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
|
|
1993
|
-
if (!
|
|
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(
|
|
2024
|
+
await saveToGallery(gen.url, buildGalleryDescription(snapshot.promptText, ""), snapshot.commandName);
|
|
1998
2025
|
await send([`${snapshot.texts.done}
|
|
1999
|
-
`, await buildResultImagePart(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
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
|
|
2597
|
-
|
|
2598
|
-
|
|
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(`\
|
|
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.
|
|
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
|
-
|
|
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
|
-
?
|
|
2305
|
-
|
|
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
|
-
|
|
2516
|
+
// 注意:这里是「排上队了」,不是「画好了」。别让上层当成已完成通知用户
|
|
2517
|
+
return { ok: true, queued: true }
|
|
2473
2518
|
}
|
|
2474
2519
|
|
|
2475
|
-
const
|
|
2476
|
-
if (!
|
|
2520
|
+
const gen = await generateFigureImage(files, text)
|
|
2521
|
+
if (!gen.url) return { ok: false, error: 'failed', reason: gen.reason }
|
|
2477
2522
|
|
|
2478
|
-
await saveToGallery(
|
|
2479
|
-
await sendResultImage(session,
|
|
2480
|
-
return { ok: true, url:
|
|
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
|
-
:
|
|
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
|
|
2594
|
-
if (!
|
|
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(
|
|
2599
|
-
await send([`${snapshot.texts.done}\n`, await buildResultImagePart(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3390
|
-
throw new Error(errorMsg)
|
|
3439
|
+
throw new Error('响应中未找到图片URL')
|
|
3391
3440
|
} catch (error) {
|
|
3392
3441
|
retryCount++
|
|
3393
|
-
const
|
|
3394
|
-
|
|
3442
|
+
const reason = httpErrorText(error)
|
|
3443
|
+
lastReason = reason
|
|
3444
|
+
const statusCode = error.response?.status || error.status || error.code || 0
|
|
3395
3445
|
|
|
3396
|
-
logInfo(
|
|
3446
|
+
logInfo(`请求失败(第 ${retryCount}/${maxAttempts} 次):${reason}`)
|
|
3397
3447
|
|
|
3398
3448
|
// 检查是否为配额不足错误
|
|
3399
|
-
if (
|
|
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
|
|
3405
|
-
|
|
3406
|
-
|
|
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(
|
|
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[]) {
|