@xinizai/pi-image-gen 0.1.0

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.
Files changed (62) hide show
  1. package/README.md +337 -0
  2. package/dist/core/cache.d.ts +11 -0
  3. package/dist/core/cache.js +42 -0
  4. package/dist/core/cache.js.map +1 -0
  5. package/dist/core/capabilities.d.ts +2 -0
  6. package/dist/core/capabilities.js +65 -0
  7. package/dist/core/capabilities.js.map +1 -0
  8. package/dist/core/errors.d.ts +11 -0
  9. package/dist/core/errors.js +71 -0
  10. package/dist/core/errors.js.map +1 -0
  11. package/dist/core/global-config.d.ts +25 -0
  12. package/dist/core/global-config.js +172 -0
  13. package/dist/core/global-config.js.map +1 -0
  14. package/dist/core/image-service.d.ts +14 -0
  15. package/dist/core/image-service.js +42 -0
  16. package/dist/core/image-service.js.map +1 -0
  17. package/dist/core/model-browser.d.ts +5 -0
  18. package/dist/core/model-browser.js +76 -0
  19. package/dist/core/model-browser.js.map +1 -0
  20. package/dist/core/model-registry.d.ts +10 -0
  21. package/dist/core/model-registry.js +44 -0
  22. package/dist/core/model-registry.js.map +1 -0
  23. package/dist/core/provider.d.ts +2 -0
  24. package/dist/core/provider.js +8 -0
  25. package/dist/core/provider.js.map +1 -0
  26. package/dist/core/types.d.ts +122 -0
  27. package/dist/core/types.js +2 -0
  28. package/dist/core/types.js.map +1 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +490 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/providers/openai-compatible.d.ts +16 -0
  33. package/dist/providers/openai-compatible.js +216 -0
  34. package/dist/providers/openai-compatible.js.map +1 -0
  35. package/dist/tools/shared.d.ts +42 -0
  36. package/dist/tools/shared.js +241 -0
  37. package/dist/tools/shared.js.map +1 -0
  38. package/dist/utils/config.d.ts +5 -0
  39. package/dist/utils/config.js +65 -0
  40. package/dist/utils/config.js.map +1 -0
  41. package/dist/utils/download.d.ts +8 -0
  42. package/dist/utils/download.js +139 -0
  43. package/dist/utils/download.js.map +1 -0
  44. package/dist/utils/files.d.ts +18 -0
  45. package/dist/utils/files.js +83 -0
  46. package/dist/utils/files.js.map +1 -0
  47. package/package.json +34 -0
  48. package/src/core/cache.ts +43 -0
  49. package/src/core/capabilities.ts +55 -0
  50. package/src/core/errors.ts +92 -0
  51. package/src/core/global-config.ts +167 -0
  52. package/src/core/image-service.ts +40 -0
  53. package/src/core/model-browser.ts +73 -0
  54. package/src/core/model-registry.ts +43 -0
  55. package/src/core/provider.ts +8 -0
  56. package/src/core/types.ts +135 -0
  57. package/src/index.ts +394 -0
  58. package/src/providers/openai-compatible.ts +198 -0
  59. package/src/tools/shared.ts +235 -0
  60. package/src/utils/config.ts +61 -0
  61. package/src/utils/download.ts +103 -0
  62. package/src/utils/files.ts +78 -0
@@ -0,0 +1,235 @@
1
+ import { ImageService } from "../core/image-service.js";
2
+ import { ModelDiscoveryCache } from "../core/cache.js";
3
+ import { resolveProvider } from "../core/provider.js";
4
+ import { ModelRegistry } from "../core/model-registry.js";
5
+ import { normalizeBaseUrl } from "../utils/config.js";
6
+ import { GlobalConfigStore, PROVIDERS_FILE, resolveRuntimeConfig } from "../core/global-config.js";
7
+ import { ImageGenError, maskApiKey } from "../core/errors.js";
8
+ import { DEFAULT_MODEL_LIMIT, renderModelBrowser, renderRefreshSummary } from "../core/model-browser.js";
9
+ import type { DiscoveryResult, ImageOperationResult, ModelBrowserOptions, ModelCapability, StoredProviderConfig } from "../core/types.js";
10
+
11
+ export async function createService(providerId?: string, env = process.env): Promise<ImageService> {
12
+ const config = await resolveRuntimeConfig(providerId, env);
13
+ return new ImageService(config, resolveProvider(config));
14
+ }
15
+
16
+ export function resultText(title: string, result: ImageOperationResult): string {
17
+ const lines = [`${title}完成。`, `提供商:${result.provider}(${result.providerId})`, `模型:${result.model}`, "图片:"];
18
+ for (const image of result.images) lines.push(`- ${image.path}${image.url ? `(源地址:${image.url})` : ""}`);
19
+ return lines.join("\n");
20
+ }
21
+
22
+ export function configErrorText(error: unknown): string {
23
+ if (error instanceof ImageGenError) return error.toUserMessage();
24
+ if (error instanceof Error) return error.message;
25
+ return String(error);
26
+ }
27
+
28
+ export async function modelsText(options: Partial<ModelBrowserOptions>, signal?: AbortSignal): Promise<string> {
29
+ const parsed: ModelBrowserOptions = { page: options.page ?? 1, limit: options.limit ?? DEFAULT_MODEL_LIMIT, refresh: Boolean(options.refresh), refreshAll: Boolean(options.refreshAll), includeText: Boolean(options.includeText) };
30
+ if (options.provider !== undefined) parsed.provider = options.provider;
31
+ if (options.search !== undefined) parsed.search = options.search;
32
+ const store = new GlobalConfigStore();
33
+ if (parsed.refreshAll) {
34
+ const config = await store.read();
35
+ if (config.providers.length === 0) return notConfiguredText();
36
+ const summaries: string[] = [];
37
+ for (const provider of config.providers) {
38
+ summaries.push(renderRefreshSummary(await refreshProvider(provider.id, store, signal)));
39
+ }
40
+ return summaries.join("\n\n");
41
+ }
42
+ const service = await createService(parsed.provider);
43
+ const result = await service.discover(parsed.refresh, signal);
44
+ return parsed.refresh ? renderRefreshSummary(result) : renderModelBrowser(result, parsed);
45
+ }
46
+
47
+ export async function configText(signal?: AbortSignal, store = new GlobalConfigStore()): Promise<string> {
48
+ const global = await store.read();
49
+ const lines: string[] = ["图片生成配置", `全局配置:${PROVIDERS_FILE}`, ""];
50
+ if (global.providers.length === 0) {
51
+ lines.push("还没有配置提供商。", "", "可用操作:", "→ 添加提供商");
52
+ return lines.join("\n");
53
+ }
54
+
55
+ const defaultProvider = global.defaultProviderId ? global.providers.find((provider) => provider.id === global.defaultProviderId) : undefined;
56
+ lines.push(`默认提供商:${defaultProvider ? `${defaultProvider.name} (${defaultProvider.id})` : "未设置"}`, "", "已配置提供商:");
57
+ let totalModels = 0;
58
+ let index = 1;
59
+ for (const provider of global.providers) {
60
+ const snapshot = await inspectProvider(provider.id, store, signal, false);
61
+ totalModels += snapshot.totalModels;
62
+ lines.push(`${index}. ${provider.name} (${provider.id})${global.defaultProviderId === provider.id ? " [默认]" : ""}`);
63
+ lines.push(` 状态:${snapshot.status}${snapshot.reason ? `,原因:${snapshot.reason}` : ""}`);
64
+ lines.push(` 模型来源:${provider.defaultModel && snapshot.totalModels === 1 && snapshot.summary.image_generation[0] === provider.defaultModel ? "手动指定或缓存 fallback" : "API 发现/缓存"}`);
65
+ lines.push(` 模型总数:${snapshot.totalModels}`);
66
+ lines.push(` 生图模型:${snapshot.summary.image_generation.length}`);
67
+ lines.push(` 图片编辑:${snapshot.summary.image_edit.length}`);
68
+ lines.push(` 图片变体:${snapshot.summary.image_variation.length}`);
69
+ lines.push(` 视觉理解:${snapshot.summary.vision.length}`);
70
+ lines.push("");
71
+ index += 1;
72
+ }
73
+ lines.push(`模型总数:${totalModels}`, "", "可用操作:", "→ 添加提供商", "→ 管理提供商", "→ 查看当前配置");
74
+ return lines.join("\n");
75
+ }
76
+
77
+ export async function providersText(signal?: AbortSignal): Promise<string> {
78
+ return configText(signal);
79
+ }
80
+
81
+ export async function discoverProviderCandidate(input: { name: string; baseUrl: string; apiKey: string; defaultModel?: string }, signal?: AbortSignal): Promise<DiscoveryResult> {
82
+ const now = new Date().toISOString();
83
+ const baseUrl = normalizeBaseUrl(input.baseUrl);
84
+ const config = await new GlobalConfigStore().toRuntimeConfig({
85
+ id: "pending-provider",
86
+ name: input.name,
87
+ providerType: "openai-compatible",
88
+ baseUrl,
89
+ apiKey: input.apiKey,
90
+ defaultModel: input.defaultModel,
91
+ defaultEditModel: undefined,
92
+ defaultVariationModel: undefined,
93
+ responseFormat: "auto",
94
+ manualCapabilities: {},
95
+ createdAt: now,
96
+ updatedAt: now,
97
+ apiKeyUpdatedAt: now,
98
+ });
99
+ const provider = resolveProvider(config);
100
+ const cache = new ModelDiscoveryCache();
101
+ return { providerId: config.providerId, providerName: config.providerName, providerType: provider.type, baseUrl: config.baseUrl, models: await provider.discoverModels(signal), discoveredAt: new Date().toISOString(), apiKeyFingerprint: cache.apiKeyFingerprint(config) };
102
+ }
103
+
104
+ export async function saveProviderWithDiscovery(input: { name: string; baseUrl: string; apiKey: string; defaultModel?: string; responseFormat?: "auto" | "url" | "b64_json"; setDefault?: boolean }, discovery: DiscoveryResult, store = new GlobalConfigStore()): Promise<string> {
105
+ const existing = await store.read();
106
+ const provider = await store.addProvider(input);
107
+ const shouldSetDefault = existing.providers.length === 0 || Boolean(input.setDefault);
108
+ if (shouldSetDefault) await store.setDefaultProvider(provider.id);
109
+ const runtime = await store.toRuntimeConfig(provider);
110
+ await new ModelDiscoveryCache().set(runtime, { ...discovery, providerId: provider.id, providerName: provider.name, baseUrl: provider.baseUrl });
111
+ const summary = new ModelRegistry(discovery.models).summary();
112
+ return [
113
+ `提供商已保存:${provider.name} (${provider.id})`,
114
+ `默认提供商:${shouldSetDefault ? `${provider.name} (${provider.id})` : "未变更"}`,
115
+ "连接:已连接",
116
+ "模型发现:成功",
117
+ `模型总数:${discovery.models.length}`,
118
+ `生图模型:${summary.image_generation.length}`,
119
+ `图片编辑:${summary.image_edit.length}`,
120
+ `图片变体:${summary.image_variation.length}`,
121
+ `视觉理解:${summary.vision.length}`,
122
+ ].join("\n");
123
+ }
124
+
125
+ export async function addProvider(input: { name: string; baseUrl: string; apiKey: string; defaultModel?: string; setDefault?: boolean }, signal?: AbortSignal, store = new GlobalConfigStore()): Promise<string> {
126
+ const existing = await store.read();
127
+ const provider = await store.addProvider(input);
128
+ const shouldSetDefault = existing.providers.length === 0 || Boolean(input.setDefault);
129
+ if (shouldSetDefault) await store.setDefaultProvider(provider.id);
130
+ const snapshot = await inspectProvider(provider.id, store, signal, true).catch((error) => ({ status: statusFromError(error), reason: reasonFromError(error), totalModels: 0, summary: emptySummary() }));
131
+ const lines = [
132
+ `提供商已保存:${provider.name} (${provider.id})`,
133
+ `默认提供商:${shouldSetDefault ? `${provider.name} (${provider.id})` : "未变更"}`,
134
+ `连接:${snapshot.status}${snapshot.reason ? `,原因:${snapshot.reason}` : ""}`,
135
+ ];
136
+ if (snapshot.status === "已连接") {
137
+ lines.push(`模型发现:成功`, `模型总数:${snapshot.totalModels}`, `生图模型:${snapshot.summary.image_generation.length}`, `图片编辑:${snapshot.summary.image_edit.length}`, `图片变体:${snapshot.summary.image_variation.length}`, `视觉理解:${snapshot.summary.vision.length}`);
138
+ } else {
139
+ lines.push(`模型发现:失败`, `原因:${snapshot.reason ?? "未知错误"}`);
140
+ }
141
+ return lines.join("\n");
142
+ }
143
+
144
+ export async function updateProvider(input: { id: string; name?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; responseFormat?: "auto" | "url" | "b64_json" }, signal?: AbortSignal, store = new GlobalConfigStore(), refresh = true): Promise<string> {
145
+ const patch: Partial<StoredProviderConfig> = {};
146
+ if (input.name) patch.name = input.name;
147
+ if (input.baseUrl) patch.baseUrl = input.baseUrl;
148
+ if (input.apiKey) patch.apiKey = input.apiKey;
149
+ if (input.defaultModel) patch.defaultModel = input.defaultModel;
150
+ if (input.responseFormat) patch.responseFormat = input.responseFormat;
151
+ const provider = await store.updateProvider(input.id, patch);
152
+ const snapshot = await inspectProvider(provider.id, store, signal, refresh).catch((error) => ({ status: statusFromError(error), reason: reasonFromError(error), totalModels: 0, summary: emptySummary() }));
153
+ return [
154
+ `提供商已更新:${provider.name} (${provider.id})`,
155
+ `连接:${snapshot.status}${snapshot.reason ? `,原因:${snapshot.reason}` : ""}`,
156
+ `模型总数:${snapshot.totalModels}`,
157
+ `生图模型:${snapshot.summary.image_generation.length}`,
158
+ `图片编辑:${snapshot.summary.image_edit.length}`,
159
+ ].join("\n");
160
+ }
161
+
162
+ export async function deleteProvider(id: string, store = new GlobalConfigStore()): Promise<string> {
163
+ await store.deleteProvider(id);
164
+ return `提供商已删除:${id}`;
165
+ }
166
+
167
+ export async function setDefaultProvider(id: string, store = new GlobalConfigStore()): Promise<string> {
168
+ await store.setDefaultProvider(id);
169
+ return `默认提供商已设置:${id}`;
170
+ }
171
+
172
+ export async function providerStatus(id: string, signal?: AbortSignal, store = new GlobalConfigStore()): Promise<string> {
173
+ const snapshot = await inspectProvider(id, store, signal, true);
174
+ return [
175
+ `提供商:${snapshot.provider.name} (${snapshot.provider.id})`,
176
+ `状态:${snapshot.status}${snapshot.reason ? `,原因:${snapshot.reason}` : ""}`,
177
+ `模型总数:${snapshot.totalModels}`,
178
+ `生图模型:${snapshot.summary.image_generation.length}`,
179
+ `图片编辑:${snapshot.summary.image_edit.length}`,
180
+ `图片变体:${snapshot.summary.image_variation.length}`,
181
+ `视觉理解:${snapshot.summary.vision.length}`,
182
+ ].join("\n");
183
+ }
184
+
185
+ export function notConfiguredText(): string {
186
+ return `还没有配置图片提供商。\n请运行 /image-config 添加全局图片提供商。\n全局配置位置:${PROVIDERS_FILE}`;
187
+ }
188
+
189
+ async function refreshProvider(id: string, store: GlobalConfigStore, signal?: AbortSignal): Promise<DiscoveryResult> {
190
+ const provider = await store.resolveProvider(id);
191
+ if (!provider) throw new ImageGenError("missing_config", `提供商 ${id} 不存在。`, "请在 /image-config 中重新添加或选择其他提供商。");
192
+ const runtime = await store.toRuntimeConfig(provider);
193
+ const service = new ImageService(runtime, resolveProvider(runtime));
194
+ return service.discover(true, signal);
195
+ }
196
+
197
+ async function inspectProvider(id: string, store: GlobalConfigStore, signal?: AbortSignal, refresh = false): Promise<{ provider: StoredProviderConfig; status: string; reason?: string; totalModels: number; summary: Record<ModelCapability, string[]> }> {
198
+ const provider = await store.resolveProvider(id);
199
+ if (!provider) throw new ImageGenError("missing_config", `提供商 ${id} 不存在。`, "请在 /image-config 中重新添加或选择其他提供商。");
200
+ const runtime = await store.toRuntimeConfig(provider);
201
+ const service = new ImageService(runtime, resolveProvider(runtime));
202
+ try {
203
+ const registry = await service.registry(refresh, signal);
204
+ return { provider, status: "已连接", totalModels: registry.all().length, summary: registry.summary() };
205
+ } catch (error) {
206
+ return { provider, status: statusFromError(error), reason: reasonFromError(error), totalModels: 0, summary: emptySummary() };
207
+ }
208
+ }
209
+
210
+ function statusFromError(error: unknown): string {
211
+ if (error instanceof ImageGenError) {
212
+ if (error.code === "missing_config") return "未配置";
213
+ if (error.code === "timeout") return "网络超时";
214
+ if (error.code === "invalid_json") return "模型接口不兼容";
215
+ if (error.code === "unsupported_format") return "格式不支持";
216
+ return "连接失败";
217
+ }
218
+ return "连接失败";
219
+ }
220
+
221
+ function reasonFromError(error: unknown): string {
222
+ if (error instanceof ImageGenError) {
223
+ if (error.code === "auth_failed" || error.code === "forbidden") return "API Key 无效或无权限";
224
+ if (error.code === "not_found") return "API 地址可能不正确,无法找到模型接口";
225
+ if (error.code === "timeout") return "无法连接到 API,请检查 Base URL 和网络";
226
+ if (error.code === "unsupported_format") return error.message;
227
+ return error.toUserMessage();
228
+ }
229
+ if (error instanceof Error) return error.message;
230
+ return String(error);
231
+ }
232
+
233
+ function emptySummary(): Record<ModelCapability, string[]> {
234
+ return { text: [], vision: [], image_generation: [], image_edit: [], image_variation: [], unknown: [] };
235
+ }
@@ -0,0 +1,61 @@
1
+ import { homedir } from "node:os";
2
+ import { resolve } from "node:path";
3
+ import { ImageGenError } from "../core/errors.js";
4
+ import type { ImageGenConfig, ModelCapability } from "../core/types.js";
5
+
6
+ export function normalizeBaseUrl(raw: string): string {
7
+ const trimmed = raw.trim().replace(/\/+$/, "");
8
+ let url: URL;
9
+ try { url = new URL(trimmed); } catch {
10
+ throw new ImageGenError("invalid_base_url", "IMAGE_BASE_URL 无效。", "使用完整 http/https URL,例如 https://example.com/v1。");
11
+ }
12
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
13
+ throw new ImageGenError("invalid_base_url", "IMAGE_BASE_URL 协议不受支持。", "只允许 http 或 https。生产环境建议 https。");
14
+ }
15
+ if (/\/images\/(generations|edits|variations)$/i.test(url.pathname.replace(/\/+$/, ""))) {
16
+ throw new ImageGenError("invalid_base_url", "API Base URL 不应包含图片接口路径。", "请填写到 /v1,例如 https://xxx/v1;不要填写 https://xxx/v1/images/generations。");
17
+ }
18
+ return url.toString().replace(/\/+$/, "");
19
+ }
20
+
21
+ export function parseManualCapabilities(raw?: string): Record<string, ModelCapability[]> {
22
+ if (!raw?.trim()) return {};
23
+ const result: Record<string, ModelCapability[]> = {};
24
+ for (const entry of raw.split(/[;\n]/)) {
25
+ const [model, caps] = entry.split(":");
26
+ if (!model?.trim() || !caps?.trim()) continue;
27
+ const parsed = caps.split(",").map((c) => c.trim()).filter(isCapability);
28
+ if (parsed.length > 0) result[model.trim()] = parsed;
29
+ }
30
+ return result;
31
+ }
32
+
33
+ export function isCapability(value: string): value is ModelCapability {
34
+ return ["text", "vision", "image_generation", "image_edit", "image_variation", "unknown"].includes(value);
35
+ }
36
+
37
+ export function readConfig(env: NodeJS.ProcessEnv = process.env): ImageGenConfig {
38
+ const baseUrlRaw = env.IMAGE_BASE_URL;
39
+ const apiKey = env.IMAGE_API_KEY;
40
+ if (!baseUrlRaw) throw new ImageGenError("missing_config", "缺少 IMAGE_BASE_URL。", "设置 IMAGE_BASE_URL=https://example.com/v1。 ");
41
+ if (!apiKey) throw new ImageGenError("missing_config", "缺少 IMAGE_API_KEY。", "设置 IMAGE_API_KEY=sk-xxxx。 ");
42
+ const outputDir = env.IMAGE_OUTPUT_DIR ? resolve(env.IMAGE_OUTPUT_DIR) : resolve(homedir(), ".pi", "agent", "generated");
43
+ return {
44
+ providerId: "env",
45
+ providerName: "Environment",
46
+ baseUrl: normalizeBaseUrl(baseUrlRaw),
47
+ apiKey,
48
+ model: env.IMAGE_MODEL || undefined,
49
+ editModel: env.IMAGE_EDIT_MODEL || undefined,
50
+ variationModel: env.IMAGE_VARIATION_MODEL || undefined,
51
+ responseFormat: "auto",
52
+ providerType: "openai-compatible",
53
+ timeoutMs: Number(env.IMAGE_TIMEOUT_SECONDS ?? 60) * 1000,
54
+ cacheTtlMs: Number(env.IMAGE_CACHE_TTL_SECONDS ?? 600) * 1000,
55
+ outputDir,
56
+ maxResponseBytes: Number(env.IMAGE_MAX_RESPONSE_BYTES ?? 8_000_000),
57
+ maxDownloadBytes: Number(env.IMAGE_MAX_DOWNLOAD_BYTES ?? 25_000_000),
58
+ manualCapabilities: parseManualCapabilities(env.IMAGE_MODEL_CAPABILITIES),
59
+ apiKeyUpdatedAt: "env",
60
+ };
61
+ }
@@ -0,0 +1,103 @@
1
+ import { ImageGenError, errorFromStatus } from "../core/errors.js";
2
+ import { isIP } from "node:net";
3
+ import { lookup } from "node:dns/promises";
4
+ import { detectImageFormat, extensionForMime, saveImageBuffer } from "./files.js";
5
+
6
+ export function assertSafeHttpUrl(raw: string): URL {
7
+ let url: URL;
8
+ try { url = new URL(raw); } catch { throw new ImageGenError("invalid_image", "图片 URL 无效。", "使用有效 https/http URL。 "); }
9
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new ImageGenError("invalid_image", "图片 URL 协议不受支持。", "只允许 http/https。 ");
10
+ if (!url.hostname || isBlockedHost(url.hostname)) throw new ImageGenError("invalid_image", "图片 URL 目标地址不安全。", "不能访问 localhost、内网地址或云元数据地址。 ");
11
+ return url;
12
+ }
13
+
14
+ export async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
17
+ try {
18
+ const signal = init.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal;
19
+ return await fetch(url, { ...init, signal });
20
+ } catch (e) {
21
+ if (controller.signal.aborted) throw new ImageGenError("timeout", "请求超时。", "稍后重试或增大 IMAGE_TIMEOUT_SECONDS。 ");
22
+ if (e instanceof Error && e.name === "AbortError") throw new ImageGenError("network_error", "请求已取消。", "重新发起操作即可。 ");
23
+ throw new ImageGenError("network_error", "网络请求失败。", e instanceof Error ? e.message : "检查网络连接。 ");
24
+ } finally {
25
+ clearTimeout(timer);
26
+ }
27
+ }
28
+
29
+ export async function readResponseTextLimited(response: Response, maxBytes: number): Promise<string> {
30
+ const len = Number(response.headers.get("content-length") ?? 0);
31
+ if (len > maxBytes) throw new ImageGenError("response_too_large", "API 响应超过安全大小限制。", "降低 n 或联系服务商。 ");
32
+ const text = await response.text();
33
+ if (Buffer.byteLength(text) > maxBytes) throw new ImageGenError("response_too_large", "API 响应超过安全大小限制。", "降低 n 或联系服务商。 ");
34
+ return text;
35
+ }
36
+
37
+ export async function parseJsonLimited(response: Response, maxBytes: number): Promise<unknown> {
38
+ const text = await readResponseTextLimited(response, maxBytes);
39
+ try { return JSON.parse(text) as unknown; } catch { throw new ImageGenError("invalid_json", "API 返回了无效 JSON。", "确认服务商是否兼容 OpenAI 风格 API。 "); }
40
+ }
41
+
42
+ function isBlockedHost(hostname: string): boolean {
43
+ const host = hostname.toLowerCase().replace(/[.]$/, "");
44
+ if (host === "localhost" || host.endsWith(".localhost") || host === "metadata.google.internal") return true;
45
+ const ip = isIP(host);
46
+ if (ip === 4) { const p = host.split(".").map(Number); return p.length === 4 && p[0] !== undefined && p[1] !== undefined && (p[0] === 10 || p[0] === 127 || (p[0] === 169 && p[1] === 254) || (p[0] === 192 && p[1] === 168) || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)); }
47
+ if (ip === 6) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:");
48
+ return false;
49
+ }
50
+
51
+ async function resolvesToPrivateHost(hostname: string): Promise<boolean> {
52
+ if (isBlockedHost(hostname)) return true;
53
+ try { const records = await lookup(hostname, { all: true }); return records.some((record) => isBlockedHost(record.address)); }
54
+ catch { return false; }
55
+ }
56
+
57
+ function rejectHtml(buffer: Buffer): void {
58
+ const prefix = buffer.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
59
+ if (prefix.startsWith("<!doctype html") || prefix.startsWith("<html") || prefix.startsWith("<head") || prefix.startsWith("<body")) throw new ImageGenError("invalid_image", "下载内容不是图片,可能是鉴权页或错误页面。", "检查 Provider 图片 URL 和权限。 ");
60
+ }
61
+
62
+ export async function downloadImage(url: string, outputDir: string, timeoutMs: number, maxBytes: number, authorization?: string): Promise<{ path: string; mimeType: string }> {
63
+ let target = assertSafeHttpUrl(url);
64
+ if (await resolvesToPrivateHost(target.hostname)) throw new ImageGenError("invalid_image", "图片 URL 解析到不安全的内网地址。", "不能访问 localhost、内网地址或云元数据地址。 ");
65
+ const origin = target.origin;
66
+ let response: Response | undefined;
67
+ for (let redirects = 0; redirects <= 3; redirects += 1) {
68
+ const init: RequestInit = { method: "GET", redirect: "manual" };
69
+ if (authorization && target.origin === origin) init.headers = { Authorization: authorization };
70
+ response = await fetchWithTimeout(target.toString(), init, timeoutMs);
71
+ if (![301, 302, 303, 307, 308].includes(response.status)) break;
72
+ const location = response.headers.get("location");
73
+ if (!location) throw new ImageGenError("invalid_image", "图片下载重定向缺少目标地址。", "检查 Provider 返回的图片 URL。 ");
74
+ target = assertSafeHttpUrl(new URL(location, target).toString());
75
+ if (await resolvesToPrivateHost(target.hostname)) throw new ImageGenError("invalid_image", "图片重定向目标地址不安全。", "不能重定向到内网地址。 ");
76
+ if (redirects === 3) throw new ImageGenError("network_error", "图片下载重定向次数过多。", "使用稳定的图片 URL。 ");
77
+ }
78
+ if (!response) throw new ImageGenError("network_error", "图片下载失败。", "检查图片 URL。 ");
79
+ if (!response.ok) throw errorFromStatus(response.status, "下载图片");
80
+ const len = Number(response.headers.get("content-length") ?? 0);
81
+ if (len > maxBytes) throw new ImageGenError("download_too_large", "下载图片超过大小限制。", "设置更小输出或增大 IMAGE_MAX_DOWNLOAD_BYTES。 ");
82
+ const chunks: Uint8Array[] = [];
83
+ let total = 0;
84
+ if (!response.body) throw new ImageGenError("invalid_image", "下载响应没有内容。", "检查图片 URL。 ");
85
+ const reader = response.body.getReader();
86
+ while (true) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ if (value) {
90
+ total += value.byteLength;
91
+ if (total > maxBytes) throw new ImageGenError("download_too_large", "下载图片超过大小限制。", "设置更小输出或增大 IMAGE_MAX_DOWNLOAD_BYTES。 ");
92
+ chunks.push(value);
93
+ }
94
+ }
95
+ const content = Buffer.concat(chunks);
96
+ rejectHtml(content);
97
+ const declaredMime = response.headers.get("content-type")?.split(";")[0];
98
+ const detected = detectImageFormat(content);
99
+ if (!detected) throw new ImageGenError("invalid_image", "下载内容不是有效图片。", "检查 Provider 图片 URL 和响应内容。 ");
100
+ const mimeType = declaredMime?.startsWith("image/") && extensionForMime(declaredMime) === detected.ext ? declaredMime : detected.mimeType;
101
+ const path = await saveImageBuffer(content, outputDir, detected.ext);
102
+ return { path, mimeType };
103
+ }
@@ -0,0 +1,78 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, extname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ImageGenError } from "../core/errors.js";
6
+
7
+ const allowed = new Set([".png", ".jpg", ".jpeg", ".webp"]);
8
+
9
+ export function detectImageFormat(buffer: Buffer): { mimeType: string; ext: string } | undefined {
10
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return { mimeType: "image/png", ext: "png" };
11
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return { mimeType: "image/jpeg", ext: "jpg" };
12
+ if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return { mimeType: "image/webp", ext: "webp" };
13
+ return undefined;
14
+ }
15
+
16
+ export function extensionForMime(mime: string, fallback = "png"): string {
17
+ if (mime.includes("jpeg") || mime.includes("jpg")) return "jpg";
18
+ if (mime.includes("webp")) return "webp";
19
+ if (mime.includes("png")) return "png";
20
+ return fallback.replace(/^\./, "");
21
+ }
22
+
23
+ export async function saveImageBuffer(buffer: Buffer, outputDir: string, ext: string): Promise<string> {
24
+ if (buffer.length === 0) throw new ImageGenError("invalid_image", "图片内容为空。", "检查 API 返回或输入文件。 ");
25
+ validateImageSignature(buffer, ext);
26
+ const cleanExt = ext.toLowerCase().replace(/^\./, "");
27
+ if (!["png", "jpg", "jpeg", "webp"].includes(cleanExt)) throw new ImageGenError("unsupported_format", `不支持的图片格式:${ext}`, "使用 png、jpg、jpeg 或 webp。 ");
28
+ await mkdir(outputDir, { recursive: true });
29
+ const date = new Date().toISOString().slice(0, 10);
30
+ const path = resolve(outputDir, `${date}-${randomUUID().slice(0, 8)}.${cleanExt === "jpeg" ? "jpg" : cleanExt}`);
31
+ await writeFile(path, buffer, { flag: "wx" });
32
+ return path;
33
+ }
34
+
35
+ export async function loadLocalImage(input: string): Promise<{ name: string; buffer: Buffer; mimeType: string }> {
36
+ const path = normalizeImagePath(input);
37
+ const ext = extname(path).toLowerCase();
38
+ if (!allowed.has(ext)) throw new ImageGenError("unsupported_format", `不支持的图片格式:${ext || "未知"}`, "仅支持 .png、.jpg、.jpeg、.webp。 ");
39
+ const buffer = await readFile(path).catch((cause: unknown) => {
40
+ throw new ImageGenError("invalid_image", `无法读取图片:${path}`, cause instanceof Error ? cause.message : "确认路径存在且可读。 ");
41
+ });
42
+ validateImageSignature(buffer, ext);
43
+ return { name: basename(path), buffer, mimeType: mimeFromExt(ext) };
44
+ }
45
+
46
+ export function normalizeImagePath(input: string): string {
47
+ const clean = input.startsWith("@") ? input.slice(1) : input;
48
+ if (clean.startsWith("file://")) return fileURLToPath(clean);
49
+ if (/^https?:\/\//i.test(clean)) throw new ImageGenError("unsupported_format", "此处需要本地图片路径。", "远程图片会由下载模块处理。 ");
50
+ return resolve(clean);
51
+ }
52
+
53
+ export function mimeFromExt(ext: string): string {
54
+ switch (ext.toLowerCase()) {
55
+ case ".jpg":
56
+ case ".jpeg": return "image/jpeg";
57
+ case ".webp": return "image/webp";
58
+ case ".png": return "image/png";
59
+ default: throw new ImageGenError("unsupported_format", `不支持的图片格式:${ext}`, "仅支持 png、jpg、jpeg、webp。 ");
60
+ }
61
+ }
62
+
63
+ function validateImageSignature(buffer: Buffer, ext: string): void {
64
+ const clean = ext.toLowerCase().replace(/^\./, "");
65
+ const png = buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
66
+ const jpg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
67
+ const webp = buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP";
68
+ if ((clean === "png" && !png) || ((clean === "jpg" || clean === "jpeg") && !jpg) || (clean === "webp" && !webp)) throw new ImageGenError("invalid_image", "内容不是有效的图片文件。", "Provider 可能返回了鉴权页或错误页面。 ");
69
+ }
70
+
71
+ export function decodeBase64Image(data: string): { buffer: Buffer; mimeType: string; ext: string } {
72
+ const match = data.match(/^data:(image\/(png|jpeg|jpg|webp));base64,(.+)$/i);
73
+ const mimeType = match?.[1] ?? "image/png";
74
+ const payload = match?.[3] ?? data;
75
+ const buffer = Buffer.from(payload, "base64");
76
+ if (buffer.length === 0) throw new ImageGenError("invalid_image", "API 返回了空 base64 图片。", "检查服务商响应。 ");
77
+ return { buffer, mimeType, ext: extensionForMime(mimeType) };
78
+ }