@xinizai/pi-image-gen 0.1.0 → 0.1.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/CHANGELOG.md +31 -0
- package/README.md +19 -1
- package/dist/core/global-config.js +2 -0
- package/dist/index.js +17 -4
- package/dist/providers/openai-compatible.js +35 -11
- package/dist/utils/config.js +5 -0
- package/dist/utils/download.js +58 -9
- package/package.json +2 -1
- package/src/core/global-config.ts +2 -0
- package/src/core/types.ts +4 -0
- package/src/index.ts +15 -4
- package/src/providers/openai-compatible.ts +31 -11
- package/src/utils/config.ts +2 -0
- package/src/utils/download.ts +58 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2 - 2026-08-21
|
|
4
|
+
|
|
5
|
+
### 修复(Fixed)
|
|
6
|
+
- README:修正「每几十秒只吐 1 字节」处被误写为 ASCII 残留「`/sql1`」的乱码。
|
|
7
|
+
- README:移除指向 unpkg 直链的 CHANGELOG 链接(unpkg 对 `.md` 直发 `text/markdown` 无 charset,中文 Windows 下浏览器按 GBK 解码会乱码),改为提示在 npm 包页 Code/Files 选项卡查看。
|
|
8
|
+
|
|
9
|
+
## 0.1.1 - 2026-08-21
|
|
10
|
+
|
|
11
|
+
### 固定(Fixed)
|
|
12
|
+
- 修复「中转站出图慢但已成功仍被判超时」的问题:`fetchWithTimeout` 由「固定 60 秒死闸」改为「stall 续命」——只要持续有字节到达就自动重新计 timing,不再因总时长把慢请求误杀(这是本次主要修复,对应报告中途已生成图片却被报「请求超时」的现象)。
|
|
13
|
+
- 总时长兜底:新增可选的「总闸」`IMAGE_TOTAL_TIMEOUT_SECONDS`(默认 600 秒 / 10 分钟),防止被「每几十秒只吐 1 字节」的续活死链无限挂起;之前版本默认「0 = 不限」存在被吊死风险。
|
|
14
|
+
- 修复与其相关的次生错误「Stream ended without finish_reason」:`image_generate` / `image_edit` / `image_variation` 在失败时不再向 pi 主程序 `throw`,改为返回结构化文本,避免主程序因异常触发流式重试对话导致的断流报错。
|
|
15
|
+
- 修复 `responseFormat=auto` 时硬塞 `response_format: "url"` 导致某些中转站绕一圈下载、变慢且易撞超时的问题;新版本在 `auto` 模式下不再发送该字段,让服务端用它最快/最顺手的方式回。
|
|
16
|
+
|
|
17
|
+
### 变更(Changed)
|
|
18
|
+
- `responseFormat` 兼容性重试方向与策略调整:当服务端明确拒绝 `response_format` 字段时,再带 800 毫秒退避做一次无费用重试(auto 模式补 `url`,显式 `url`/`b64_json` 模式改为不发字段),避免对可能已计费的出图请求做无意义重发。
|
|
19
|
+
- 图片下载与生成请求超时解耦:新增 `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`(默认 60 秒)独立计时,避免下载阶段与生成阶段计时叠加误杀。
|
|
20
|
+
- 超时/网络错误文案改为温和提示「中转站可能其实已经生成,请稍后重试或检查输出目录」,不再误报「未生成成功」。
|
|
21
|
+
|
|
22
|
+
### 新增(Added)
|
|
23
|
+
- 新增环境变量 `IMAGE_TOTAL_TIMEOUT_SECONDS`(总时长上限,默认 600 秒)。
|
|
24
|
+
- 新增环境变量 `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`(图片下载独立超时,默认 60 秒)。
|
|
25
|
+
- 新增 CHANGELOG 文件。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 0.1.0 - 初次发布
|
|
30
|
+
|
|
31
|
+
- 通用 OpenAI-compatible 图片生成扩展,自动模型发现、能力识别、`/image-config` 面板管理。
|
package/README.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
> 第一版重点支持 OpenAI-compatible API:`GET /models`、`POST /images/generations`、`POST /images/edits`、`POST /images/variations`。其它 Provider 通过 Adapter 架构预留。
|
|
6
6
|
|
|
7
|
+
## 最新更新(0.1.2)
|
|
8
|
+
|
|
9
|
+
- **文档修复**:上版 README 里「每几十秒只吐 1 字节」被误写为 ASCII 残留「`/sql1`」,已修正为正常中文。
|
|
10
|
+
- **文档修复**:去掉 README 中指向 unpkg 直链的 CHANGELOG 链接——unpkg 对 `.md` 直发 `text/markdown` 不含 charset 在中文 Windows 下会乱码;改提示「在 npm 包页 Code/Files 选项卡查看 CHANGELOG」。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 历史更新(0.1.1)
|
|
15
|
+
|
|
16
|
+
- **修复**:中转站出图慢但已成功返回仍被判“请求超时”的问题 —— 超时改为“stall 续命”(只要持续有字节到达就自动续期),不再用固定 60 秒死闸误杀慢请求。
|
|
17
|
+
- **修复**:连带报错 `Stream ended without finish_reason` —— 失败时不再向 pi 主程序抛异常,避免主程序触发流式重试导致的断流。
|
|
18
|
+
- **修复**:`responseFormat=auto` 不再硬塞 `response_format: "url"`,让中转站用它最快的方式回,出图更稳更快。
|
|
19
|
+
- **新增**:总时长兜底环境变量 `IMAGE_TOTAL_TIMEOUT_SECONDS`(默认 600 秒)防被「每几十秒只吐 1 字节」的续活死链无限挂起;图片下载独立超时 `IMAGE_DOWNLOAD_TIMEOUT_SECONDS`(默认 60 秒)。
|
|
20
|
+
|
|
21
|
+
完整的更新记录见包内 `CHANGELOG.md`(在 npm 包页「Code」/ Files 选项卡查看,避免 unpkg 直链因无 charset 显示乱码)。
|
|
22
|
+
|
|
7
23
|
## 功能
|
|
8
24
|
|
|
9
25
|
- 通过唯一用户入口 `/image-config` 管理多个跨项目共享的 Provider(环境变量仍可作为兼容 fallback)
|
|
@@ -74,7 +90,9 @@ $env:IMAGE_API_KEY="sk-xxxx"
|
|
|
74
90
|
export IMAGE_MODEL=gpt-image-1 # fallback 或显式默认文生图模型
|
|
75
91
|
export IMAGE_EDIT_MODEL=gpt-image-1 # 默认编辑模型
|
|
76
92
|
export IMAGE_VARIATION_MODEL=dall-e-2 # 默认变体模型
|
|
77
|
-
export IMAGE_TIMEOUT_SECONDS=60
|
|
93
|
+
export IMAGE_TIMEOUT_SECONDS=60 # stall 超时:只要持续有字节到达就自动续期
|
|
94
|
+
export IMAGE_TOTAL_TIMEOUT_SECONDS=600 # 总时长上限(默认 10 分钟),防被续活死链无限挂起
|
|
95
|
+
export IMAGE_DOWNLOAD_TIMEOUT_SECONDS=60 # 图片下载独立超时(与生成请求解耦)
|
|
78
96
|
export IMAGE_CACHE_TTL_SECONDS=600
|
|
79
97
|
export IMAGE_OUTPUT_DIR=/path/to/generated
|
|
80
98
|
export IMAGE_MAX_RESPONSE_BYTES=8000000
|
|
@@ -113,6 +113,8 @@ export class GlobalConfigStore {
|
|
|
113
113
|
responseFormat: provider.responseFormat ?? "auto",
|
|
114
114
|
providerType: provider.providerType,
|
|
115
115
|
timeoutMs: Number(env.IMAGE_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
116
|
+
totalTimeoutMs: Number(env.IMAGE_TOTAL_TIMEOUT_SECONDS ?? 600) * 1000,
|
|
117
|
+
downloadTimeoutMs: Number(env.IMAGE_DOWNLOAD_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
116
118
|
cacheTtlMs: Number(env.IMAGE_CACHE_TTL_SECONDS ?? 600) * 1000,
|
|
117
119
|
outputDir: env.IMAGE_OUTPUT_DIR ? resolve(env.IMAGE_OUTPUT_DIR) : resolve(homedir(), ".pi", "agent", "generated"),
|
|
118
120
|
maxResponseBytes: Number(env.IMAGE_MAX_RESPONSE_BYTES ?? 8_000_000),
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { configErrorText, configText, createService, deleteProvider, discoverProviderCandidate, modelsText, providerStatus, providersText, resultText, saveProviderWithDiscovery, setDefaultProvider, updateProvider } from "./tools/shared.js";
|
|
3
3
|
import { GlobalConfigStore } from "./core/global-config.js";
|
|
4
|
-
import { maskApiKey } from "./core/errors.js";
|
|
4
|
+
import { maskApiKey, ImageGenError } from "./core/errors.js";
|
|
5
5
|
import { ModelRegistry } from "./core/model-registry.js";
|
|
6
6
|
const OutputFormat = Type.Union([Type.Literal("png"), Type.Literal("jpeg"), Type.Literal("jpg"), Type.Literal("webp")]);
|
|
7
7
|
export default function imageGenExtension(pi) {
|
|
@@ -26,7 +26,9 @@ export default function imageGenExtension(pi) {
|
|
|
26
26
|
return { content: [{ type: "text", text: resultText("图片生成", await (await createService(params.provider)).generate(params, signal)) }], details: {} };
|
|
27
27
|
}
|
|
28
28
|
catch (e) {
|
|
29
|
-
throw
|
|
29
|
+
// 不 throw:避免 pi 主程序因异常触发流式重试对话出现 "Stream ended without finish_reason"。
|
|
30
|
+
// timeout/network_error 时图片可能其实已出,提示用户检查输出目录。
|
|
31
|
+
return { content: [{ type: "text", text: softFailureText(e) }], details: {} };
|
|
30
32
|
}
|
|
31
33
|
},
|
|
32
34
|
});
|
|
@@ -49,7 +51,7 @@ export default function imageGenExtension(pi) {
|
|
|
49
51
|
return { content: [{ type: "text", text: resultText("图片编辑", await (await createService(params.provider)).edit(params, signal)) }], details: {} };
|
|
50
52
|
}
|
|
51
53
|
catch (e) {
|
|
52
|
-
|
|
54
|
+
return { content: [{ type: "text", text: softFailureText(e) }], details: {} };
|
|
53
55
|
}
|
|
54
56
|
},
|
|
55
57
|
});
|
|
@@ -70,7 +72,7 @@ export default function imageGenExtension(pi) {
|
|
|
70
72
|
return { content: [{ type: "text", text: resultText("图片变体", await (await createService(params.provider)).variation(params, signal)) }], details: {} };
|
|
71
73
|
}
|
|
72
74
|
catch (e) {
|
|
73
|
-
|
|
75
|
+
return { content: [{ type: "text", text: softFailureText(e) }], details: {} };
|
|
74
76
|
}
|
|
75
77
|
},
|
|
76
78
|
});
|
|
@@ -487,4 +489,15 @@ async function saveProviderWithoutDiscovery(input, store) {
|
|
|
487
489
|
await store.setDefaultProvider(provider.id);
|
|
488
490
|
return [`提供商已保存:${provider.name} (${provider.id})`, `默认模型:${provider.defaultModel ?? "未设置"}`, "模型发现:未执行,已手动指定模型。"].join("\n");
|
|
489
491
|
}
|
|
492
|
+
function softFailureText(e) {
|
|
493
|
+
const isSoft = e instanceof ImageGenError && (e.code === "timeout" || e.code === "network_error");
|
|
494
|
+
const base = configErrorText(e);
|
|
495
|
+
if (!isSoft)
|
|
496
|
+
return `图片生成请求未成功完成。\n原因:${base}`;
|
|
497
|
+
return [
|
|
498
|
+
"图片生成请求被中断(超时/网络),但中转站可能其实已经生成,请稍后重试或检查输出目录。",
|
|
499
|
+
`原因:${base}`,
|
|
500
|
+
"提示:生成慢时可调大 IMAGE_TIMEOUT_SECONDS,或设置 IMAGE_TOTAL_TIMEOUT_SECONDS 给一个更宽松的总时长上限。",
|
|
501
|
+
].join("\n");
|
|
502
|
+
}
|
|
490
503
|
//# sourceMappingURL=index.js.map
|
|
@@ -41,8 +41,9 @@ export class OpenAICompatibleProvider {
|
|
|
41
41
|
return models;
|
|
42
42
|
}
|
|
43
43
|
async generate(params, signal) {
|
|
44
|
-
// auto
|
|
45
|
-
|
|
44
|
+
// auto:不发送 response_format 字段,让服务商用它最快/最顺手的方式返回;
|
|
45
|
+
// 仅当用户显式选择 url 或 b64_json 时才发送字段。
|
|
46
|
+
const responseFormat = this.config.responseFormat === "b64_json" || this.config.responseFormat === "url" ? this.config.responseFormat : undefined;
|
|
46
47
|
const caps = params.modelCapabilities;
|
|
47
48
|
const body = compact({ model: params.model, prompt: params.prompt, size: caps?.supportsSize === false ? undefined : params.size, quality: caps?.supportsQuality === false ? undefined : params.quality, n: caps?.supportsMultipleImages === false ? 1 : params.n ?? 1, response_format: responseFormat, output_format: params.output_format, aspect_ratio: caps?.supportsAspectRatio === false ? undefined : params.aspect_ratio });
|
|
48
49
|
const json = await this.postImage("/images/generations", body, "生成图片", signal);
|
|
@@ -77,12 +78,24 @@ export class OpenAICompatibleProvider {
|
|
|
77
78
|
const isForm = body instanceof FormData;
|
|
78
79
|
let response = await this.request("POST", path, isForm ? body : JSON.stringify(body), action, signal, isForm ? undefined : "application/json");
|
|
79
80
|
let detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
80
|
-
// 不同 OpenAI-compatible 服务对 response_format
|
|
81
|
+
// 不同 OpenAI-compatible 服务对 response_format 的支持不一致;
|
|
82
|
+
// auto 模式默认不发字段,若服务端反而要求字段,尝试补上 url;
|
|
83
|
+
// 若用户明确 url/b64_json 而服务端拒绝该值,则降级为不发字段或交换为另一值。
|
|
81
84
|
const jsonBody = !isForm && typeof body === "object" && body !== null && !ArrayBuffer.isView(body) && !(body instanceof ArrayBuffer) && !(body instanceof Blob) && !(body instanceof URLSearchParams) ? body : undefined;
|
|
82
|
-
if (!response.ok &&
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
if (!response.ok && jsonBody && isResponseFormatError(detail)) {
|
|
86
|
+
if (jsonBody.response_format === undefined) {
|
|
87
|
+
const retryBody = { ...jsonBody, response_format: "url" };
|
|
88
|
+
await sleep(800, signal);
|
|
89
|
+
response = await this.request("POST", path, JSON.stringify(retryBody), action, signal, "application/json");
|
|
90
|
+
detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const retryBody = { ...jsonBody };
|
|
94
|
+
delete retryBody.response_format;
|
|
95
|
+
await sleep(800, signal);
|
|
96
|
+
response = await this.request("POST", path, JSON.stringify(retryBody), action, signal, "application/json");
|
|
97
|
+
detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
98
|
+
}
|
|
86
99
|
}
|
|
87
100
|
if (!response.ok)
|
|
88
101
|
throw errorFromStatus(response.status, action, detail);
|
|
@@ -101,7 +114,8 @@ export class OpenAICompatibleProvider {
|
|
|
101
114
|
init.body = body;
|
|
102
115
|
if (signal !== undefined)
|
|
103
116
|
init.signal = signal;
|
|
104
|
-
|
|
117
|
+
// stall 超时 + 可选总超时:慢中转站只要持续吐字节就不被换杀。
|
|
118
|
+
return await fetchWithTimeout(`${this.config.baseUrl}${path}`, init, this.config.timeoutMs, { stallMs: this.config.timeoutMs, totalMs: this.config.totalTimeoutMs ?? 0 });
|
|
105
119
|
}
|
|
106
120
|
catch (e) {
|
|
107
121
|
if (e instanceof ImageGenError)
|
|
@@ -142,7 +156,7 @@ export class OpenAICompatibleProvider {
|
|
|
142
156
|
}
|
|
143
157
|
const imageUrl = resolveImageUrl(rawUrl, this.config.baseUrl);
|
|
144
158
|
const authorization = isSameOrigin(imageUrl, this.config.baseUrl) ? `Bearer ${this.config.apiKey}` : undefined;
|
|
145
|
-
const saved = await downloadImage(imageUrl, this.config.outputDir, this.config.timeoutMs, this.config.maxDownloadBytes, authorization);
|
|
159
|
+
const saved = await downloadImage(imageUrl, this.config.outputDir, this.config.downloadTimeoutMs ?? this.config.timeoutMs, this.config.maxDownloadBytes, authorization, { totalMs: this.config.totalTimeoutMs ?? 0 });
|
|
146
160
|
images.push({ path: saved.path, url: imageUrl, mimeType: saved.mimeType, index });
|
|
147
161
|
}
|
|
148
162
|
}
|
|
@@ -154,7 +168,7 @@ export class OpenAICompatibleProvider {
|
|
|
154
168
|
async function resolveImageInput(input, config, signal) {
|
|
155
169
|
if (/^https?:\/\//i.test(input)) {
|
|
156
170
|
const authorization = isSameOrigin(input, config.baseUrl) ? `Bearer ${config.apiKey}` : undefined;
|
|
157
|
-
const saved = await downloadImage(input, config.outputDir, config.timeoutMs, config.maxDownloadBytes, authorization);
|
|
171
|
+
const saved = await downloadImage(input, config.outputDir, config.downloadTimeoutMs ?? config.timeoutMs, config.maxDownloadBytes, authorization, { totalMs: config.totalTimeoutMs ?? 0 });
|
|
158
172
|
const local = await loadLocalImage(saved.path);
|
|
159
173
|
return local;
|
|
160
174
|
}
|
|
@@ -189,7 +203,17 @@ function extractImageValue(value) {
|
|
|
189
203
|
return undefined;
|
|
190
204
|
}
|
|
191
205
|
function isResponseFormatError(detail) {
|
|
192
|
-
return Boolean(detail && /response_format|b64_json|base64.*format/i.test(detail));
|
|
206
|
+
return Boolean(detail && /response_format|b64_json|base64.*format|unsupported.*param|unrecognized.*param/i.test(detail));
|
|
207
|
+
}
|
|
208
|
+
function sleep(ms, signal) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
reject(new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 "));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const t = setTimeout(resolve, ms);
|
|
215
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ")); }, { once: true });
|
|
216
|
+
});
|
|
193
217
|
}
|
|
194
218
|
function isSameOrigin(imageUrl, baseUrl) {
|
|
195
219
|
try {
|
package/dist/utils/config.js
CHANGED
|
@@ -54,6 +54,11 @@ export function readConfig(env = process.env) {
|
|
|
54
54
|
responseFormat: "auto",
|
|
55
55
|
providerType: "openai-compatible",
|
|
56
56
|
timeoutMs: Number(env.IMAGE_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
57
|
+
// stall 超时之上再叠加一个“无脑继续等”的兜底:默认 0 = 不限总时长。
|
|
58
|
+
// 出图慢的中转站只要持续吐字节就不会被掐断。
|
|
59
|
+
totalTimeoutMs: Number(env.IMAGE_TOTAL_TIMEOUT_SECONDS ?? 600) * 1000,
|
|
60
|
+
// 图片下载与生成请求解耦,独立计时;默认与生成一致以免误杀。
|
|
61
|
+
downloadTimeoutMs: Number(env.IMAGE_DOWNLOAD_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
57
62
|
cacheTtlMs: Number(env.IMAGE_CACHE_TTL_SECONDS ?? 600) * 1000,
|
|
58
63
|
outputDir,
|
|
59
64
|
maxResponseBytes: Number(env.IMAGE_MAX_RESPONSE_BYTES ?? 8_000_000),
|
package/dist/utils/download.js
CHANGED
|
@@ -16,22 +16,70 @@ export function assertSafeHttpUrl(raw) {
|
|
|
16
16
|
throw new ImageGenError("invalid_image", "图片 URL 目标地址不安全。", "不能访问 localhost、内网地址或云元数据地址。 ");
|
|
17
17
|
return url;
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* stall 超时:只要持续有字节到达就自动续期,只有“无任何字节”超过 stallMs 才超时。
|
|
21
|
+
* totalMs(>0 时为绝对上限,0 表示不限总时长):真死链兜底。
|
|
22
|
+
* 实现:拿到响应后由本函数亲自驱动 body 读取,边读边续 stall;不重新包装 ReadableStream,
|
|
23
|
+
* 避免 node 18 下 “包装流 + AbortController” 在 Windows 上的 libuv 崩溃。
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchWithTimeout(url, init, timeoutMs, opts) {
|
|
26
|
+
const stallMs = opts?.stallMs ?? timeoutMs;
|
|
27
|
+
const totalMs = opts?.totalMs ?? 0;
|
|
28
|
+
const onProgress = opts?.onProgress;
|
|
20
29
|
const controller = new AbortController();
|
|
21
|
-
|
|
30
|
+
let stallTimer;
|
|
31
|
+
let totalTimer;
|
|
32
|
+
const armStall = () => {
|
|
33
|
+
clearTimeout(stallTimer);
|
|
34
|
+
stallTimer = setTimeout(() => controller.abort(), stallMs);
|
|
35
|
+
};
|
|
36
|
+
armStall();
|
|
37
|
+
if (totalMs > 0)
|
|
38
|
+
totalTimer = setTimeout(() => controller.abort(), totalMs);
|
|
22
39
|
try {
|
|
23
40
|
const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
|
24
|
-
|
|
41
|
+
const response = await fetch(url, { ...init, signal });
|
|
42
|
+
// 首字节已到,续命一次
|
|
43
|
+
armStall();
|
|
44
|
+
const body = response.body;
|
|
45
|
+
if (!body) {
|
|
46
|
+
if (onProgress)
|
|
47
|
+
onProgress(0);
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
// 亲自驱动读取:边读边续 stall,避免慢流被整体总时长误杀;读完拼回完整 body。
|
|
51
|
+
const reader = body.getReader();
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done)
|
|
57
|
+
break;
|
|
58
|
+
if (value) {
|
|
59
|
+
armStall();
|
|
60
|
+
total += value.byteLength;
|
|
61
|
+
if (onProgress)
|
|
62
|
+
onProgress(value.byteLength);
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
const buffer = Buffer.concat(chunks.length ? chunks : []);
|
|
68
|
+
return new Response(buffer, { headers: response.headers, status: response.status, statusText: response.statusText });
|
|
25
69
|
}
|
|
26
70
|
catch (e) {
|
|
27
|
-
if (controller.signal.aborted)
|
|
28
|
-
|
|
71
|
+
if (controller.signal.aborted) {
|
|
72
|
+
const totalHit = totalMs > 0;
|
|
73
|
+
throw new ImageGenError("timeout", totalHit ? "请求超时(已达总时长上限)。" : "请求超时。", "稍后重试,或增大 IMAGE_TIMEOUT_SECONDS / IMAGE_TOTAL_TIMEOUT_SECONDS。 ");
|
|
74
|
+
}
|
|
29
75
|
if (e instanceof Error && e.name === "AbortError")
|
|
30
76
|
throw new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ");
|
|
31
77
|
throw new ImageGenError("network_error", "网络请求失败。", e instanceof Error ? e.message : "检查网络连接。 ");
|
|
32
78
|
}
|
|
33
79
|
finally {
|
|
34
|
-
clearTimeout(
|
|
80
|
+
clearTimeout(stallTimer);
|
|
81
|
+
if (totalTimer)
|
|
82
|
+
clearTimeout(totalTimer);
|
|
35
83
|
}
|
|
36
84
|
}
|
|
37
85
|
export async function readResponseTextLimited(response, maxBytes) {
|
|
@@ -81,7 +129,8 @@ function rejectHtml(buffer) {
|
|
|
81
129
|
if (prefix.startsWith("<!doctype html") || prefix.startsWith("<html") || prefix.startsWith("<head") || prefix.startsWith("<body"))
|
|
82
130
|
throw new ImageGenError("invalid_image", "下载内容不是图片,可能是鉴权页或错误页面。", "检查 Provider 图片 URL 和权限。 ");
|
|
83
131
|
}
|
|
84
|
-
export async function downloadImage(url, outputDir,
|
|
132
|
+
export async function downloadImage(url, outputDir, stallMs, maxBytes, authorization, opts) {
|
|
133
|
+
const totalMs = opts?.totalMs ?? 0;
|
|
85
134
|
let target = assertSafeHttpUrl(url);
|
|
86
135
|
if (await resolvesToPrivateHost(target.hostname))
|
|
87
136
|
throw new ImageGenError("invalid_image", "图片 URL 解析到不安全的内网地址。", "不能访问 localhost、内网地址或云元数据地址。 ");
|
|
@@ -91,7 +140,7 @@ export async function downloadImage(url, outputDir, timeoutMs, maxBytes, authori
|
|
|
91
140
|
const init = { method: "GET", redirect: "manual" };
|
|
92
141
|
if (authorization && target.origin === origin)
|
|
93
142
|
init.headers = { Authorization: authorization };
|
|
94
|
-
response = await fetchWithTimeout(target.toString(), init,
|
|
143
|
+
response = await fetchWithTimeout(target.toString(), init, stallMs, { stallMs, totalMs });
|
|
95
144
|
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
96
145
|
break;
|
|
97
146
|
const location = response.headers.get("location");
|
|
@@ -136,4 +185,4 @@ export async function downloadImage(url, outputDir, timeoutMs, maxBytes, authori
|
|
|
136
185
|
const path = await saveImageBuffer(content, outputDir, detected.ext);
|
|
137
186
|
return { path, mimeType };
|
|
138
187
|
}
|
|
139
|
-
//# sourceMappingURL=download.js.map
|
|
188
|
+
//# sourceMappingURL=download.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xinizai/pi-image-gen",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Universal Pi Agent image generation extension with OpenAI-compatible provider discovery.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"dist",
|
|
10
10
|
"src",
|
|
11
11
|
"README.md",
|
|
12
|
+
"CHANGELOG.md",
|
|
12
13
|
"package.json"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
@@ -111,6 +111,8 @@ export class GlobalConfigStore {
|
|
|
111
111
|
responseFormat: provider.responseFormat ?? "auto",
|
|
112
112
|
providerType: provider.providerType,
|
|
113
113
|
timeoutMs: Number(env.IMAGE_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
114
|
+
totalTimeoutMs: Number(env.IMAGE_TOTAL_TIMEOUT_SECONDS ?? 600) * 1000,
|
|
115
|
+
downloadTimeoutMs: Number(env.IMAGE_DOWNLOAD_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
114
116
|
cacheTtlMs: Number(env.IMAGE_CACHE_TTL_SECONDS ?? 600) * 1000,
|
|
115
117
|
outputDir: env.IMAGE_OUTPUT_DIR ? resolve(env.IMAGE_OUTPUT_DIR) : resolve(homedir(), ".pi", "agent", "generated"),
|
|
116
118
|
maxResponseBytes: Number(env.IMAGE_MAX_RESPONSE_BYTES ?? 8_000_000),
|
package/src/core/types.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface ImageGenConfig {
|
|
|
51
51
|
responseFormat: ImageResponseFormat;
|
|
52
52
|
providerType: "openai-compatible";
|
|
53
53
|
timeoutMs: number;
|
|
54
|
+
/** stall 超时之上再叠加一个“无脑继续等”的兜底;0 = 不限总时长。 */
|
|
55
|
+
totalTimeoutMs: number;
|
|
56
|
+
/** 图片下载独立超时,避免与生成请求计时叠加误杀。 */
|
|
57
|
+
downloadTimeoutMs: number;
|
|
54
58
|
cacheTtlMs: number;
|
|
55
59
|
outputDir: string;
|
|
56
60
|
maxResponseBytes: number;
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Type } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { configErrorText, configText, createService, deleteProvider, discoverProviderCandidate, modelsText, providerStatus, providersText, resultText, saveProviderWithDiscovery, setDefaultProvider, updateProvider } from "./tools/shared.js";
|
|
4
4
|
import { GlobalConfigStore } from "./core/global-config.js";
|
|
5
|
-
import { maskApiKey } from "./core/errors.js";
|
|
5
|
+
import { maskApiKey, ImageGenError } from "./core/errors.js";
|
|
6
6
|
import { ModelRegistry } from "./core/model-registry.js";
|
|
7
7
|
import type { DiscoveryResult, StoredProviderConfig } from "./core/types.js";
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
27
27
|
}),
|
|
28
28
|
async execute(_id, params, signal) {
|
|
29
29
|
try { return { content: [{ type: "text", text: resultText("图片生成", await (await createService(params.provider)).generate(params, signal)) }], details: {} }; }
|
|
30
|
-
catch (e) {
|
|
30
|
+
catch (e) { return { content: [{ type: "text", text: softFailureText(e) }], details: {} }; }
|
|
31
31
|
},
|
|
32
32
|
});
|
|
33
33
|
|
|
@@ -47,7 +47,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
47
47
|
}),
|
|
48
48
|
async execute(_id, params, signal) {
|
|
49
49
|
try { return { content: [{ type: "text", text: resultText("图片编辑", await (await createService(params.provider)).edit(params, signal)) }], details: {} }; }
|
|
50
|
-
catch (e) {
|
|
50
|
+
catch (e) { return { content: [{ type: "text", text: softFailureText(e) }], details: {} }; }
|
|
51
51
|
},
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -65,7 +65,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
|
65
65
|
}),
|
|
66
66
|
async execute(_id, params, signal) {
|
|
67
67
|
try { return { content: [{ type: "text", text: resultText("图片变体", await (await createService(params.provider)).variation(params, signal)) }], details: {} }; }
|
|
68
|
-
catch (e) {
|
|
68
|
+
catch (e) { return { content: [{ type: "text", text: softFailureText(e) }], details: {} }; }
|
|
69
69
|
},
|
|
70
70
|
});
|
|
71
71
|
|
|
@@ -392,3 +392,14 @@ async function saveProviderWithoutDiscovery(input: { name: string; baseUrl: stri
|
|
|
392
392
|
if (input.setDefault) await store.setDefaultProvider(provider.id);
|
|
393
393
|
return [`提供商已保存:${provider.name} (${provider.id})`, `默认模型:${provider.defaultModel ?? "未设置"}`, "模型发现:未执行,已手动指定模型。"].join("\n");
|
|
394
394
|
}
|
|
395
|
+
|
|
396
|
+
function softFailureText(e: unknown): string {
|
|
397
|
+
const isSoft = e instanceof ImageGenError && (e.code === "timeout" || e.code === "network_error");
|
|
398
|
+
const base = configErrorText(e);
|
|
399
|
+
if (!isSoft) return `图片生成请求未成功完成。\n原因:${base}`;
|
|
400
|
+
return [
|
|
401
|
+
"图片生成请求被中断(超时/网络),但中转站可能其实已经生成,请稍后重试或检查输出目录。",
|
|
402
|
+
`原因:${base}`,
|
|
403
|
+
"提示:生成慢时可调大 IMAGE_TIMEOUT_SECONDS,或设置 IMAGE_TOTAL_TIMEOUT_SECONDS 给一个更宽松的总时长上限。",
|
|
404
|
+
].join("\n");
|
|
405
|
+
}
|
|
@@ -40,8 +40,9 @@ export class OpenAICompatibleProvider implements ImageProvider {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
async generate(params: GenerateParams, signal?: AbortSignal): Promise<ImageOperationResult> {
|
|
43
|
-
// auto
|
|
44
|
-
|
|
43
|
+
// auto:不发送 response_format 字段,让服务商用它最快/最顺手的方式返回;
|
|
44
|
+
// 仅当用户显式选择 url 或 b64_json 时才发送字段。
|
|
45
|
+
const responseFormat = this.config.responseFormat === "b64_json" || this.config.responseFormat === "url" ? this.config.responseFormat : undefined;
|
|
45
46
|
const caps = params.modelCapabilities;
|
|
46
47
|
const body = compact({ model: params.model, prompt: params.prompt, size: caps?.supportsSize === false ? undefined : params.size, quality: caps?.supportsQuality === false ? undefined : params.quality, n: caps?.supportsMultipleImages === false ? 1 : params.n ?? 1, response_format: responseFormat, output_format: params.output_format, aspect_ratio: caps?.supportsAspectRatio === false ? undefined : params.aspect_ratio });
|
|
47
48
|
const json = await this.postImage("/images/generations", body, "生成图片", signal);
|
|
@@ -75,12 +76,23 @@ export class OpenAICompatibleProvider implements ImageProvider {
|
|
|
75
76
|
const isForm = body instanceof FormData;
|
|
76
77
|
let response = await this.request("POST", path, isForm ? body : JSON.stringify(body), action, signal, isForm ? undefined : "application/json");
|
|
77
78
|
let detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
78
|
-
// 不同 OpenAI-compatible 服务对 response_format
|
|
79
|
+
// 不同 OpenAI-compatible 服务对 response_format 的支持不一致;
|
|
80
|
+
// auto 模式默认不发字段,若服务端反而要求字段,尝试补上 url;
|
|
81
|
+
// 若用户明确 url/b64_json 而服务端拒绝该值,则降级为不发字段。
|
|
79
82
|
const jsonBody = !isForm && typeof body === "object" && body !== null && !ArrayBuffer.isView(body) && !(body instanceof ArrayBuffer) && !(body instanceof Blob) && !(body instanceof URLSearchParams) ? body as Record<string, unknown> : undefined;
|
|
80
|
-
if (!response.ok &&
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
if (!response.ok && jsonBody && isResponseFormatError(detail)) {
|
|
84
|
+
if (jsonBody.response_format === undefined) {
|
|
85
|
+
const retryBody = { ...jsonBody, response_format: "url" };
|
|
86
|
+
await sleep(800, signal);
|
|
87
|
+
response = await this.request("POST", path, JSON.stringify(retryBody), action, signal, "application/json");
|
|
88
|
+
detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
89
|
+
} else {
|
|
90
|
+
const retryBody = { ...jsonBody };
|
|
91
|
+
delete retryBody.response_format;
|
|
92
|
+
await sleep(800, signal);
|
|
93
|
+
response = await this.request("POST", path, JSON.stringify(retryBody), action, signal, "application/json");
|
|
94
|
+
detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
if (!response.ok) throw errorFromStatus(response.status, action, detail);
|
|
86
98
|
const json = await parseJsonLimited(response, this.config.maxResponseBytes) as OpenAIImageResponse;
|
|
@@ -95,7 +107,7 @@ export class OpenAICompatibleProvider implements ImageProvider {
|
|
|
95
107
|
const init: RequestInit = { method, headers };
|
|
96
108
|
if (body !== undefined) init.body = body;
|
|
97
109
|
if (signal !== undefined) init.signal = signal;
|
|
98
|
-
return await fetchWithTimeout(`${this.config.baseUrl}${path}`, init, this.config.timeoutMs);
|
|
110
|
+
return await fetchWithTimeout(`${this.config.baseUrl}${path}`, init, this.config.timeoutMs, { stallMs: this.config.timeoutMs, totalMs: this.config.totalTimeoutMs ?? 0 });
|
|
99
111
|
} catch (e) {
|
|
100
112
|
if (e instanceof ImageGenError) throw new ImageGenError(e.code, sanitizeSecret(e.message, this.config.apiKey), e.suggestion, e.status);
|
|
101
113
|
throw e;
|
|
@@ -133,7 +145,7 @@ export class OpenAICompatibleProvider implements ImageProvider {
|
|
|
133
145
|
}
|
|
134
146
|
const imageUrl = resolveImageUrl(rawUrl, this.config.baseUrl);
|
|
135
147
|
const authorization = isSameOrigin(imageUrl, this.config.baseUrl) ? `Bearer ${this.config.apiKey}` : undefined;
|
|
136
|
-
const saved = await downloadImage(imageUrl, this.config.outputDir, this.config.timeoutMs, this.config.maxDownloadBytes, authorization);
|
|
148
|
+
const saved = await downloadImage(imageUrl, this.config.outputDir, this.config.downloadTimeoutMs ?? this.config.timeoutMs, this.config.maxDownloadBytes, authorization, { totalMs: this.config.totalTimeoutMs ?? 0 });
|
|
137
149
|
images.push({ path: saved.path, url: imageUrl, mimeType: saved.mimeType, index });
|
|
138
150
|
}
|
|
139
151
|
}
|
|
@@ -145,7 +157,7 @@ export class OpenAICompatibleProvider implements ImageProvider {
|
|
|
145
157
|
async function resolveImageInput(input: string, config: ImageGenConfig, signal?: AbortSignal): Promise<{ name: string; buffer: Buffer; mimeType: string }> {
|
|
146
158
|
if (/^https?:\/\//i.test(input)) {
|
|
147
159
|
const authorization = isSameOrigin(input, config.baseUrl) ? `Bearer ${config.apiKey}` : undefined;
|
|
148
|
-
const saved = await downloadImage(input, config.outputDir, config.timeoutMs, config.maxDownloadBytes, authorization);
|
|
160
|
+
const saved = await downloadImage(input, config.outputDir, config.downloadTimeoutMs ?? config.timeoutMs, config.maxDownloadBytes, authorization, { totalMs: config.totalTimeoutMs ?? 0 });
|
|
149
161
|
const local = await loadLocalImage(saved.path);
|
|
150
162
|
return local;
|
|
151
163
|
}
|
|
@@ -176,7 +188,15 @@ function extractImageValue(value: unknown): string | undefined {
|
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
function isResponseFormatError(detail?: string): boolean {
|
|
179
|
-
return Boolean(detail && /response_format|b64_json|base64.*format/i.test(detail));
|
|
191
|
+
return Boolean(detail && /response_format|b64_json|base64.*format|unsupported.*param|unrecognized.*param/i.test(detail));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
if (signal?.aborted) { reject(new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ")); return; }
|
|
197
|
+
const t = setTimeout(resolve, ms);
|
|
198
|
+
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ")); }, { once: true });
|
|
199
|
+
});
|
|
180
200
|
}
|
|
181
201
|
|
|
182
202
|
function isSameOrigin(imageUrl: string, baseUrl: string): boolean {
|
package/src/utils/config.ts
CHANGED
|
@@ -51,6 +51,8 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): ImageGenConfig
|
|
|
51
51
|
responseFormat: "auto",
|
|
52
52
|
providerType: "openai-compatible",
|
|
53
53
|
timeoutMs: Number(env.IMAGE_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
54
|
+
totalTimeoutMs: Number(env.IMAGE_TOTAL_TIMEOUT_SECONDS ?? 600) * 1000,
|
|
55
|
+
downloadTimeoutMs: Number(env.IMAGE_DOWNLOAD_TIMEOUT_SECONDS ?? 60) * 1000,
|
|
54
56
|
cacheTtlMs: Number(env.IMAGE_CACHE_TTL_SECONDS ?? 600) * 1000,
|
|
55
57
|
outputDir,
|
|
56
58
|
maxResponseBytes: Number(env.IMAGE_MAX_RESPONSE_BYTES ?? 8_000_000),
|
package/src/utils/download.ts
CHANGED
|
@@ -11,18 +11,64 @@ export function assertSafeHttpUrl(raw: string): URL {
|
|
|
11
11
|
return url;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export
|
|
14
|
+
export interface FetchTimeoutOptions {
|
|
15
|
+
/** stall 超时(默认沿用 timeoutMs):只要持续有字节到达就自动续期。 */
|
|
16
|
+
stallMs?: number;
|
|
17
|
+
/** 绝对总时长上限,0 表示不限。 */
|
|
18
|
+
totalMs?: number;
|
|
19
|
+
/** 每收到一段数据回调,便于上层判断“还在动”。 */
|
|
20
|
+
onProgress?: (bytes: number) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* stall 超时:只要持续有字节到达就自动续期,只有“无任何字节”超过 stallMs 才超时。
|
|
25
|
+
* totalMs(>0 时为绝对上限,0 表示不限总时长):真死链兜底。
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number, opts?: FetchTimeoutOptions): Promise<Response> {
|
|
28
|
+
const stallMs = opts?.stallMs ?? timeoutMs;
|
|
29
|
+
const totalMs = opts?.totalMs ?? 0;
|
|
30
|
+
const onProgress = opts?.onProgress;
|
|
15
31
|
const controller = new AbortController();
|
|
16
|
-
|
|
32
|
+
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
let totalTimer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
const armStall = () => {
|
|
35
|
+
clearTimeout(stallTimer);
|
|
36
|
+
stallTimer = setTimeout(() => controller.abort(), stallMs);
|
|
37
|
+
};
|
|
38
|
+
armStall();
|
|
39
|
+
if (totalMs > 0) totalTimer = setTimeout(() => controller.abort(), totalMs);
|
|
17
40
|
try {
|
|
18
41
|
const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
|
|
19
|
-
|
|
42
|
+
const response = await fetch(url, { ...init, signal });
|
|
43
|
+
// 对带 body 的响应包装“边读边续期”,避免慢速流被整体总时长误杀。
|
|
44
|
+
if (response.body && onProgress) {
|
|
45
|
+
const rawBody = response.body;
|
|
46
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
47
|
+
async start(controller2) {
|
|
48
|
+
const reader = rawBody.getReader();
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) { controller2.close(); break; }
|
|
53
|
+
if (value) { armStall(); onProgress(value.byteLength); controller2.enqueue(value); }
|
|
54
|
+
}
|
|
55
|
+
} catch (e) { controller2.error(e); }
|
|
56
|
+
finally { reader.releaseLock(); }
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
return new Response(stream, { headers: response.headers, status: response.status, statusText: response.statusText });
|
|
60
|
+
}
|
|
61
|
+
return response;
|
|
20
62
|
} catch (e) {
|
|
21
|
-
if (controller.signal.aborted)
|
|
63
|
+
if (controller.signal.aborted) {
|
|
64
|
+
const totalHit = totalMs > 0;
|
|
65
|
+
throw new ImageGenError("timeout", totalHit ? "请求超时(已达总时长上限)。" : "请求超时。", "稍后重试,或增大 IMAGE_TIMEOUT_SECONDS / IMAGE_TOTAL_TIMEOUT_SECONDS。 ");
|
|
66
|
+
}
|
|
22
67
|
if (e instanceof Error && e.name === "AbortError") throw new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ");
|
|
23
68
|
throw new ImageGenError("network_error", "网络请求失败。", e instanceof Error ? e.message : "检查网络连接。 ");
|
|
24
69
|
} finally {
|
|
25
|
-
clearTimeout(
|
|
70
|
+
clearTimeout(stallTimer);
|
|
71
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
26
72
|
}
|
|
27
73
|
}
|
|
28
74
|
|
|
@@ -59,7 +105,12 @@ function rejectHtml(buffer: Buffer): void {
|
|
|
59
105
|
if (prefix.startsWith("<!doctype html") || prefix.startsWith("<html") || prefix.startsWith("<head") || prefix.startsWith("<body")) throw new ImageGenError("invalid_image", "下载内容不是图片,可能是鉴权页或错误页面。", "检查 Provider 图片 URL 和权限。 ");
|
|
60
106
|
}
|
|
61
107
|
|
|
62
|
-
export
|
|
108
|
+
export interface DownloadImageOptions {
|
|
109
|
+
totalMs?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function downloadImage(url: string, outputDir: string, stallMs: number, maxBytes: number, authorization?: string, opts?: DownloadImageOptions): Promise<{ path: string; mimeType: string }> {
|
|
113
|
+
const totalMs = opts?.totalMs ?? 0;
|
|
63
114
|
let target = assertSafeHttpUrl(url);
|
|
64
115
|
if (await resolvesToPrivateHost(target.hostname)) throw new ImageGenError("invalid_image", "图片 URL 解析到不安全的内网地址。", "不能访问 localhost、内网地址或云元数据地址。 ");
|
|
65
116
|
const origin = target.origin;
|
|
@@ -67,7 +118,7 @@ export async function downloadImage(url: string, outputDir: string, timeoutMs: n
|
|
|
67
118
|
for (let redirects = 0; redirects <= 3; redirects += 1) {
|
|
68
119
|
const init: RequestInit = { method: "GET", redirect: "manual" };
|
|
69
120
|
if (authorization && target.origin === origin) init.headers = { Authorization: authorization };
|
|
70
|
-
response = await fetchWithTimeout(target.toString(), init,
|
|
121
|
+
response = await fetchWithTimeout(target.toString(), init, stallMs, { stallMs, totalMs });
|
|
71
122
|
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
72
123
|
const location = response.headers.get("location");
|
|
73
124
|
if (!location) throw new ImageGenError("invalid_image", "图片下载重定向缺少目标地址。", "检查 Provider 返回的图片 URL。 ");
|