@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.
- package/README.md +337 -0
- package/dist/core/cache.d.ts +11 -0
- package/dist/core/cache.js +42 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/capabilities.d.ts +2 -0
- package/dist/core/capabilities.js +65 -0
- package/dist/core/capabilities.js.map +1 -0
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/global-config.d.ts +25 -0
- package/dist/core/global-config.js +172 -0
- package/dist/core/global-config.js.map +1 -0
- package/dist/core/image-service.d.ts +14 -0
- package/dist/core/image-service.js +42 -0
- package/dist/core/image-service.js.map +1 -0
- package/dist/core/model-browser.d.ts +5 -0
- package/dist/core/model-browser.js +76 -0
- package/dist/core/model-browser.js.map +1 -0
- package/dist/core/model-registry.d.ts +10 -0
- package/dist/core/model-registry.js +44 -0
- package/dist/core/model-registry.js.map +1 -0
- package/dist/core/provider.d.ts +2 -0
- package/dist/core/provider.js +8 -0
- package/dist/core/provider.js.map +1 -0
- package/dist/core/types.d.ts +122 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +490 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +16 -0
- package/dist/providers/openai-compatible.js +216 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/tools/shared.d.ts +42 -0
- package/dist/tools/shared.js +241 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/utils/config.d.ts +5 -0
- package/dist/utils/config.js +65 -0
- package/dist/utils/config.js.map +1 -0
- package/dist/utils/download.d.ts +8 -0
- package/dist/utils/download.js +139 -0
- package/dist/utils/download.js.map +1 -0
- package/dist/utils/files.d.ts +18 -0
- package/dist/utils/files.js +83 -0
- package/dist/utils/files.js.map +1 -0
- package/package.json +34 -0
- package/src/core/cache.ts +43 -0
- package/src/core/capabilities.ts +55 -0
- package/src/core/errors.ts +92 -0
- package/src/core/global-config.ts +167 -0
- package/src/core/image-service.ts +40 -0
- package/src/core/model-browser.ts +73 -0
- package/src/core/model-registry.ts +43 -0
- package/src/core/provider.ts +8 -0
- package/src/core/types.ts +135 -0
- package/src/index.ts +394 -0
- package/src/providers/openai-compatible.ts +198 -0
- package/src/tools/shared.ts +235 -0
- package/src/utils/config.ts +61 -0
- package/src/utils/download.ts +103 -0
- package/src/utils/files.ts +78 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { configErrorText, configText, createService, deleteProvider, discoverProviderCandidate, modelsText, providerStatus, providersText, resultText, saveProviderWithDiscovery, setDefaultProvider, updateProvider } from "./tools/shared.js";
|
|
4
|
+
import { GlobalConfigStore } from "./core/global-config.js";
|
|
5
|
+
import { maskApiKey } from "./core/errors.js";
|
|
6
|
+
import { ModelRegistry } from "./core/model-registry.js";
|
|
7
|
+
import type { DiscoveryResult, StoredProviderConfig } from "./core/types.js";
|
|
8
|
+
|
|
9
|
+
const OutputFormat = Type.Union([Type.Literal("png"), Type.Literal("jpeg"), Type.Literal("jpg"), Type.Literal("webp")]);
|
|
10
|
+
|
|
11
|
+
export default function imageGenExtension(pi: ExtensionAPI): void {
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
name: "image_generate",
|
|
14
|
+
label: "图片生成",
|
|
15
|
+
description: "根据文字描述生成图片。自动选择已配置的图片生成提供商和模型。",
|
|
16
|
+
promptSnippet: "根据文字描述生成图片。必要时自动使用已配置的全局提供商。",
|
|
17
|
+
promptGuidelines: ["当用户要求生成、创建或绘制图片时使用 image_generate。若尚未配置提供商,请引导用户运行 /image-config。不要请求或泄露 API Key。"],
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
prompt: Type.String({ description: "图片生成提示词" }),
|
|
20
|
+
provider: Type.Optional(Type.String({ description: "可选,Provider ID 或名称" })),
|
|
21
|
+
model: Type.Optional(Type.String({ description: "可选,精确模型 ID;留空则自动选择" })),
|
|
22
|
+
size: Type.Optional(Type.String({ description: "可选,图片尺寸,例如 1024x1024" })),
|
|
23
|
+
aspect_ratio: Type.Optional(Type.String({ description: "可选,宽高比,例如 16:9" })),
|
|
24
|
+
quality: Type.Optional(Type.String({ description: "可选,质量,例如 standard、hd、high" })),
|
|
25
|
+
output_format: Type.Optional(OutputFormat),
|
|
26
|
+
n: Type.Optional(Type.Number({ description: "图片数量", minimum: 1, maximum: 10 })),
|
|
27
|
+
}),
|
|
28
|
+
async execute(_id, params, signal) {
|
|
29
|
+
try { return { content: [{ type: "text", text: resultText("图片生成", await (await createService(params.provider)).generate(params, signal)) }], details: {} }; }
|
|
30
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.registerTool({
|
|
35
|
+
name: "image_edit",
|
|
36
|
+
label: "图片编辑",
|
|
37
|
+
description: "根据参考图片和文字要求编辑图片。",
|
|
38
|
+
promptSnippet: "根据参考图片和文字要求编辑图片。",
|
|
39
|
+
promptGuidelines: ["当用户提供参考图片并希望编辑时使用 image_edit。"],
|
|
40
|
+
parameters: Type.Object({
|
|
41
|
+
image: Type.String({ description: "Windows/Linux 本地路径、file:// URL 或 https URL" }),
|
|
42
|
+
prompt: Type.String({ description: "图片编辑要求" }),
|
|
43
|
+
provider: Type.Optional(Type.String({ description: "可选,Provider ID 或名称" })),
|
|
44
|
+
model: Type.Optional(Type.String({ description: "可选,模型 ID" })),
|
|
45
|
+
size: Type.Optional(Type.String({ description: "可选,图片尺寸" })),
|
|
46
|
+
output_format: Type.Optional(OutputFormat),
|
|
47
|
+
}),
|
|
48
|
+
async execute(_id, params, signal) {
|
|
49
|
+
try { return { content: [{ type: "text", text: resultText("图片编辑", await (await createService(params.provider)).edit(params, signal)) }], details: {} }; }
|
|
50
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
name: "image_variation",
|
|
56
|
+
label: "图片变体",
|
|
57
|
+
description: "根据参考图片生成新的图片变体。",
|
|
58
|
+
promptSnippet: "根据参考图片生成新的图片变体。",
|
|
59
|
+
parameters: Type.Object({
|
|
60
|
+
image: Type.String({ description: "Windows/Linux 本地路径、file:// URL 或 https URL" }),
|
|
61
|
+
provider: Type.Optional(Type.String({ description: "可选,Provider ID 或名称" })),
|
|
62
|
+
model: Type.Optional(Type.String({ description: "可选,模型 ID" })),
|
|
63
|
+
size: Type.Optional(Type.String({ description: "可选,图片尺寸" })),
|
|
64
|
+
n: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
|
65
|
+
}),
|
|
66
|
+
async execute(_id, params, signal) {
|
|
67
|
+
try { return { content: [{ type: "text", text: resultText("图片变体", await (await createService(params.provider)).variation(params, signal)) }], details: {} }; }
|
|
68
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
name: "image_models",
|
|
74
|
+
label: "图片模型",
|
|
75
|
+
description: "查询当前已配置提供商支持的图片模型。结果会自动限制长度,避免上下文过长。",
|
|
76
|
+
promptSnippet: "查询当前已配置提供商支持的图片模型。",
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
provider: Type.Optional(Type.String({ description: "提供商 ID 或名称" })),
|
|
79
|
+
search: Type.Optional(Type.String({ description: "搜索关键词" })),
|
|
80
|
+
page: Type.Optional(Type.Number({ minimum: 1 })),
|
|
81
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50 })),
|
|
82
|
+
refresh: Type.Optional(Type.Boolean({ description: "刷新当前选择的提供商" })),
|
|
83
|
+
refresh_all: Type.Optional(Type.Boolean({ description: "刷新所有已配置提供商" })),
|
|
84
|
+
include_text: Type.Optional(Type.Boolean({ description: "显示文本模型" })),
|
|
85
|
+
}),
|
|
86
|
+
async execute(_id, params, signal) {
|
|
87
|
+
try {
|
|
88
|
+
const opts = parseModelArgs("");
|
|
89
|
+
if (params.provider !== undefined) opts.provider = params.provider;
|
|
90
|
+
if (params.search !== undefined) opts.search = params.search;
|
|
91
|
+
if (params.page !== undefined) opts.page = params.page;
|
|
92
|
+
if (params.limit !== undefined) opts.limit = params.limit;
|
|
93
|
+
opts.refresh = Boolean(params.refresh);
|
|
94
|
+
opts.refreshAll = Boolean(params.refresh_all);
|
|
95
|
+
opts.includeText = Boolean(params.include_text);
|
|
96
|
+
return { content: [{ type: "text", text: await modelsText(opts, signal) }], details: {} };
|
|
97
|
+
}
|
|
98
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
pi.registerTool({
|
|
103
|
+
name: "image_config",
|
|
104
|
+
label: "图片生成配置",
|
|
105
|
+
description: "管理全局图片生成提供商、API 配置、模型和默认设置。",
|
|
106
|
+
promptSnippet: "管理全局图片生成提供商、API 配置、模型和默认设置。",
|
|
107
|
+
parameters: Type.Object({}),
|
|
108
|
+
async execute(_id, _params, signal) {
|
|
109
|
+
try { return { content: [{ type: "text", text: await configText(signal) }], details: {} }; }
|
|
110
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerTool({
|
|
115
|
+
name: "image_providers",
|
|
116
|
+
label: "提供商列表",
|
|
117
|
+
description: "列出已配置的图片提供商及其状态。",
|
|
118
|
+
promptSnippet: "列出已配置的图片提供商及其状态。",
|
|
119
|
+
parameters: Type.Object({}),
|
|
120
|
+
async execute(_id, _params, signal) {
|
|
121
|
+
try { return { content: [{ type: "text", text: await providersText(signal) }], details: {} }; }
|
|
122
|
+
catch (e) { throw new Error(configErrorText(e)); }
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.registerCommand("image-config", { description: "图片生成配置:添加提供商、获取模型、设置默认提供商。", handler: async (_args, ctx) => handleConfigCommand(ctx) });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseModelArgs(args: string) {
|
|
130
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
131
|
+
const result: { provider?: string; search?: string; page?: number; limit?: number; refresh?: boolean; refreshAll?: boolean; includeText?: boolean } = {};
|
|
132
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
133
|
+
const p = parts[i]!;
|
|
134
|
+
if (p === "--refresh") result.refresh = true;
|
|
135
|
+
else if (p === "--refresh-all") result.refreshAll = true;
|
|
136
|
+
else if (p === "--include-text") result.includeText = true;
|
|
137
|
+
else if (p === "--provider") {
|
|
138
|
+
const value = parts[++i];
|
|
139
|
+
if (value !== undefined) result.provider = value;
|
|
140
|
+
}
|
|
141
|
+
else if (p === "--page") result.page = Number(parts[++i]);
|
|
142
|
+
else if (p === "--limit") result.limit = Number(parts[++i]);
|
|
143
|
+
else result.search = result.search ? `${result.search} ${p}` : p;
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function handleConfigCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
149
|
+
// 与 Vision 配置面板相同:始终在本 command 内循环,取消或“返回”才关闭。
|
|
150
|
+
while (true) {
|
|
151
|
+
const global = await new GlobalConfigStore().read();
|
|
152
|
+
const current = global.defaultProviderId ? global.providers.find((provider) => provider.id === global.defaultProviderId) : undefined;
|
|
153
|
+
const options = global.providers.length === 0
|
|
154
|
+
? ["+ 添加提供商", "取消"]
|
|
155
|
+
: [
|
|
156
|
+
...global.providers.map((provider) => `${provider.id === global.defaultProviderId ? "●" : "○"} ${provider.name} — ${provider.defaultModel ?? "未设置默认模型"} [${provider.id}]`),
|
|
157
|
+
"+ 添加提供商", "查看当前配置", "取消",
|
|
158
|
+
];
|
|
159
|
+
const picked = await ctx.ui.select(`图片生成 Provider 设置\n当前:${current ? `${current.name} (${current.defaultModel ?? "未设置默认模型"})` : "未配置"}`, options);
|
|
160
|
+
if (!picked || picked === "取消") return;
|
|
161
|
+
if (picked === "+ 添加提供商") { await interactiveAddProvider(ctx); continue; }
|
|
162
|
+
if (picked === "查看当前配置") { ctx.ui.notify(await configText(ctx.signal), "info"); continue; }
|
|
163
|
+
const id = picked.match(/\[([^\]]+)\]$/)?.[1];
|
|
164
|
+
const provider = id ? global.providers.find((item) => item.id === id) : undefined;
|
|
165
|
+
if (provider) await handleProviderActions(ctx, provider.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function interactiveAddProvider(ctx: ExtensionCommandContext): Promise<void> {
|
|
170
|
+
const store = new GlobalConfigStore();
|
|
171
|
+
const before = await store.read();
|
|
172
|
+
const name = await ctx.ui.input("添加图片提供商 - 提供商名称", "例如:OpenAI");
|
|
173
|
+
if (!name) return;
|
|
174
|
+
const baseUrl = await ctx.ui.input("添加图片提供商 - API Base URL", "例如:https://api.openai.com/v1");
|
|
175
|
+
if (!baseUrl) return;
|
|
176
|
+
const apiKey = await ctx.ui.input("添加图片提供商 - API Key", "请输入 API Key");
|
|
177
|
+
if (!apiKey) return;
|
|
178
|
+
|
|
179
|
+
ctx.ui.notify("正在测试连接并获取模型,请稍候……", "info");
|
|
180
|
+
try {
|
|
181
|
+
const discovery = await discoverProviderCandidate({ name, baseUrl, apiKey }, ctx.signal);
|
|
182
|
+
ctx.ui.notify(renderDiscoveryChoiceText(discovery), "info");
|
|
183
|
+
const defaultModel = await selectDefaultImageModel(ctx, discovery);
|
|
184
|
+
if (!defaultModel) { ctx.ui.notify("已取消添加:未选择默认图片模型。", "info"); return; }
|
|
185
|
+
let setDefault = before.providers.length === 0;
|
|
186
|
+
if (!setDefault && before.defaultProviderId) setDefault = await ctx.ui.confirm("设置默认提供商", `是否将 ${name} 设置为默认图片提供商?`);
|
|
187
|
+
const responseFormat = await chooseResponseFormat(ctx, "自动兼容(推荐)");
|
|
188
|
+
if (!responseFormat) return;
|
|
189
|
+
const input: { name: string; baseUrl: string; apiKey: string; defaultModel?: string; responseFormat?: "auto" | "url" | "b64_json"; setDefault?: boolean } = { name, baseUrl, apiKey, responseFormat, setDefault };
|
|
190
|
+
if (defaultModel) input.defaultModel = defaultModel;
|
|
191
|
+
ctx.ui.notify(await saveProviderWithDiscovery(input, discovery, store), "info");
|
|
192
|
+
ctx.ui.notify(await configText(ctx.signal), "info");
|
|
193
|
+
} catch (e) {
|
|
194
|
+
ctx.ui.notify(configErrorText(e), "error");
|
|
195
|
+
const next = await ctx.ui.select("模型发现失败", ["重试", "手动指定模型", "返回"]);
|
|
196
|
+
if (next === "重试") await interactiveAddProvider(ctx);
|
|
197
|
+
else if (next === "手动指定模型") {
|
|
198
|
+
const defaultModel = await ctx.ui.input("手动指定默认图片模型", "例如:flux-pro");
|
|
199
|
+
if (!defaultModel) return;
|
|
200
|
+
const setDefault = before.providers.length === 0 || await ctx.ui.confirm("设置默认提供商", `是否将 ${name} 设置为默认图片提供商?`);
|
|
201
|
+
ctx.ui.notify(await saveProviderWithoutDiscovery({ name, baseUrl, apiKey, defaultModel, setDefault }, store), "info");
|
|
202
|
+
ctx.ui.notify(await configText(ctx.signal), "info");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function handleProvidersCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
208
|
+
// 兼容旧调用入口;实际 Provider 选择已经收进主配置面板。
|
|
209
|
+
await handleConfigCommand(ctx);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handleProviderActions(ctx: ExtensionCommandContext, id: string): Promise<void> {
|
|
213
|
+
while (true) {
|
|
214
|
+
const provider = await new GlobalConfigStore().resolveProvider(id);
|
|
215
|
+
if (!provider) { ctx.ui.notify(`提供商不存在:${id}`, "warning"); return; }
|
|
216
|
+
const action = await ctx.ui.select(`图片提供商:${provider.name}`, ["✎ 编辑提供商", "选择默认图片模型", "设为默认提供商", "✕ 删除提供商", "返回"]);
|
|
217
|
+
if (!action || action === "返回") return;
|
|
218
|
+
try {
|
|
219
|
+
if (action === "✎ 编辑提供商") await interactiveEditProvider(ctx, provider.id);
|
|
220
|
+
else if (action === "选择默认图片模型") await chooseProviderModel(ctx, provider.id);
|
|
221
|
+
else if (action === "设为默认提供商") ctx.ui.notify(await setDefaultProvider(provider.id), "info");
|
|
222
|
+
else if (action === "✕ 删除提供商" && await ctx.ui.confirm("删除图片提供商?", `确定删除 “${provider.name}” 吗?`)) { ctx.ui.notify(await deleteProvider(provider.id), "warning"); return; }
|
|
223
|
+
} catch (error) { ctx.ui.notify(configErrorText(error), "error"); }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function interactiveEditProvider(ctx: ExtensionCommandContext, id: string): Promise<void> {
|
|
228
|
+
const store = new GlobalConfigStore();
|
|
229
|
+
const provider = await store.resolveProvider(id);
|
|
230
|
+
if (!provider) { ctx.ui.notify(`提供商不存在:${id}`, "error"); return; }
|
|
231
|
+
|
|
232
|
+
// 直接打开回填后的表单;Provider ID 是唯一身份,空输入不会覆盖已保存的值。
|
|
233
|
+
const name = await prefilledInput(ctx, "编辑提供商 - 提供商名称", provider.name);
|
|
234
|
+
if (name === undefined) return;
|
|
235
|
+
const baseUrl = await prefilledInput(ctx, "编辑提供商 - API Base URL", provider.baseUrl);
|
|
236
|
+
if (baseUrl === undefined) return;
|
|
237
|
+
const apiKeyInput = await prefilledInput(ctx, "编辑提供商 - API Key", maskApiKey(provider.apiKey));
|
|
238
|
+
if (apiKeyInput === undefined) return;
|
|
239
|
+
const modelInput = provider.defaultModel ?? "";
|
|
240
|
+
const nextName = name || provider.name;
|
|
241
|
+
const nextBaseUrl = baseUrl || provider.baseUrl;
|
|
242
|
+
const nextApiKey = normalizeEditedApiKey(apiKeyInput, provider.apiKey);
|
|
243
|
+
const urlChanged = nextBaseUrl !== provider.baseUrl;
|
|
244
|
+
const keyChanged = nextApiKey !== provider.apiKey;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const discovery = urlChanged || keyChanged
|
|
248
|
+
? await discoverProviderCandidate({ name: nextName, baseUrl: nextBaseUrl, apiKey: nextApiKey }, ctx.signal)
|
|
249
|
+
: await (await createService(provider.id)).discover(false, ctx.signal);
|
|
250
|
+
ctx.ui.notify(renderDiscoveryChoiceText(discovery), "info");
|
|
251
|
+
const defaultModel = await selectDefaultImageModel(ctx, discovery, modelInput || provider.defaultModel);
|
|
252
|
+
if (!defaultModel) { ctx.ui.notify("已取消编辑:未选择默认图片模型。", "info"); return; }
|
|
253
|
+
const responseFormat = await chooseResponseFormat(ctx, responseFormatLabel(provider.responseFormat ?? "auto"));
|
|
254
|
+
if (!responseFormat) return;
|
|
255
|
+
const input: { id: string; name?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; responseFormat?: "auto" | "url" | "b64_json" } = { id: provider.id, responseFormat };
|
|
256
|
+
if (nextName !== provider.name) input.name = nextName;
|
|
257
|
+
if (nextBaseUrl !== provider.baseUrl) input.baseUrl = nextBaseUrl;
|
|
258
|
+
if (nextApiKey !== provider.apiKey) input.apiKey = nextApiKey;
|
|
259
|
+
if (defaultModel && defaultModel !== provider.defaultModel) input.defaultModel = defaultModel;
|
|
260
|
+
ctx.ui.notify("正在保存提供商配置……", "info");
|
|
261
|
+
ctx.ui.notify(await updateProvider(input, ctx.signal, store, urlChanged || keyChanged), "info");
|
|
262
|
+
if ((await store.read()).defaultProviderId !== provider.id && await ctx.ui.confirm("默认提供商", "是否设为默认图片提供商?")) ctx.ui.notify(await setDefaultProvider(provider.id), "info");
|
|
263
|
+
ctx.ui.notify(await configText(ctx.signal), "info");
|
|
264
|
+
} catch (e) {
|
|
265
|
+
ctx.ui.notify(configErrorText(e), "error");
|
|
266
|
+
const fallback = await ctx.ui.select("无法自动获取模型", ["手动输入模型 ID", "返回"]);
|
|
267
|
+
if (fallback === "手动输入模型 ID") {
|
|
268
|
+
const defaultModel = await prefilledInput(ctx, "手动输入默认图片模型 ID", provider.defaultModel ?? "例如:flux-pro");
|
|
269
|
+
if (!defaultModel) return;
|
|
270
|
+
const responseFormat = await chooseResponseFormat(ctx, responseFormatLabel(provider.responseFormat ?? "auto"));
|
|
271
|
+
if (!responseFormat) return;
|
|
272
|
+
const input: { id: string; name?: string; baseUrl?: string; apiKey?: string; defaultModel?: string; responseFormat?: "auto" | "url" | "b64_json" } = { id: provider.id, defaultModel, responseFormat };
|
|
273
|
+
if (nextName !== provider.name) input.name = nextName;
|
|
274
|
+
if (nextBaseUrl !== provider.baseUrl) input.baseUrl = nextBaseUrl;
|
|
275
|
+
if (nextApiKey !== provider.apiKey) input.apiKey = nextApiKey;
|
|
276
|
+
ctx.ui.notify(await updateProvider(input, ctx.signal, store, false), "info");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function chooseProviderModel(ctx: ExtensionCommandContext, id: string): Promise<void> {
|
|
282
|
+
const store = new GlobalConfigStore();
|
|
283
|
+
const provider = await store.resolveProvider(id);
|
|
284
|
+
if (!provider) { ctx.ui.notify(`提供商不存在:${id}`, "error"); return; }
|
|
285
|
+
try {
|
|
286
|
+
ctx.ui.notify("正在测试连接并获取模型,请稍候……", "info");
|
|
287
|
+
const discovery = await discoverProviderCandidate({ name: provider.name, baseUrl: provider.baseUrl, apiKey: provider.apiKey }, ctx.signal);
|
|
288
|
+
const model = await selectDefaultImageModel(ctx, discovery, provider.defaultModel, () => discoverProviderCandidate({ name: provider.name, baseUrl: provider.baseUrl, apiKey: provider.apiKey }, ctx.signal));
|
|
289
|
+
if (model && model !== provider.defaultModel) ctx.ui.notify(await updateProvider({ id: provider.id, defaultModel: model }, ctx.signal, store, false), "info");
|
|
290
|
+
else ctx.ui.notify("默认图片模型未改变。", "info");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
ctx.ui.notify(configErrorText(error), "error");
|
|
293
|
+
const fallback = await ctx.ui.select("无法自动获取模型", ["手动输入模型 ID", "返回"]);
|
|
294
|
+
if (fallback === "手动输入模型 ID") {
|
|
295
|
+
const model = await prefilledInput(ctx, "手动输入默认图片模型 ID", provider.defaultModel ?? "");
|
|
296
|
+
if (model) ctx.ui.notify(await updateProvider({ id: provider.id, defaultModel: model }, ctx.signal, store, false), "info");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function prefilledInput(ctx: ExtensionCommandContext, title: string, value: string): Promise<string | undefined> {
|
|
302
|
+
return ctx.ui.editor(title, value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function safeProviderStatus(id: string, signal?: AbortSignal): Promise<string> {
|
|
306
|
+
try { return await providerStatus(id, signal); }
|
|
307
|
+
catch (error) { return configErrorText(error); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const MODEL_PAGE_SIZE = 20;
|
|
311
|
+
const MODEL_LABEL_LIMIT = 72;
|
|
312
|
+
|
|
313
|
+
async function selectDefaultImageModel(ctx: ExtensionCommandContext, discovery: DiscoveryResult, current?: string, refresh?: () => Promise<DiscoveryResult>): Promise<string | undefined> {
|
|
314
|
+
let activeDiscovery = discovery;
|
|
315
|
+
if (new ModelRegistry(activeDiscovery.models).byCapability("image_generation").length === 0) {
|
|
316
|
+
ctx.ui.notify("/models 未返回可确认的图片生成模型。", "warning");
|
|
317
|
+
const fallback = await ctx.ui.select("选择默认图片模型", ["手动输入模型 ID", "取消"]);
|
|
318
|
+
if (fallback !== "手动输入模型 ID") return undefined;
|
|
319
|
+
const manual = await prefilledInput(ctx, "默认图片模型 ID", current ?? "");
|
|
320
|
+
return manual?.trim() || undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let query = "";
|
|
324
|
+
let page = 0;
|
|
325
|
+
while (true) {
|
|
326
|
+
const models = new ModelRegistry(activeDiscovery.models).byCapability("image_generation").map((model) => model.id);
|
|
327
|
+
const filtered = query ? models.filter((model) => model.toLowerCase().includes(query.toLowerCase())) : models;
|
|
328
|
+
const pageCount = Math.max(1, Math.ceil(filtered.length / MODEL_PAGE_SIZE));
|
|
329
|
+
page = Math.min(page, pageCount - 1);
|
|
330
|
+
const visible = filtered.slice(page * MODEL_PAGE_SIZE, (page + 1) * MODEL_PAGE_SIZE);
|
|
331
|
+
const options = visible.map((model, index) => `${model === current ? "●" : "○"} ${modelLabel(model)} [#${index + 1}]`);
|
|
332
|
+
if (current && !filtered.includes(current)) options.unshift(`● 当前模型:${modelLabel(current)}${models.includes(current) ? "(被筛选隐藏)" : "(API 未返回)"}`);
|
|
333
|
+
if (page > 0) options.push("‹ 上一页模型");
|
|
334
|
+
if (page + 1 < pageCount) options.push("下一页模型 ›");
|
|
335
|
+
options.push("⌕ 搜索/过滤模型", "↻ 重新获取模型", "取消");
|
|
336
|
+
const picked = await ctx.ui.select(`选择默认图片模型(${filtered.length}/${models.length},第 ${page + 1}/${pageCount} 页)`, options);
|
|
337
|
+
if (!picked || picked === "取消") return undefined;
|
|
338
|
+
if (picked === "⌕ 搜索/过滤模型") {
|
|
339
|
+
const next = await ctx.ui.input("搜索模型(留空显示全部)", query || "例如:flux 或 gpt-image");
|
|
340
|
+
if (next === undefined) return undefined;
|
|
341
|
+
query = next.trim(); page = 0; continue;
|
|
342
|
+
}
|
|
343
|
+
if (picked === "‹ 上一页模型") { page--; continue; }
|
|
344
|
+
if (picked === "下一页模型 ›") { page++; continue; }
|
|
345
|
+
if (picked === "↻ 重新获取模型") {
|
|
346
|
+
if (!refresh) { ctx.ui.notify("当前流程无法重新获取模型,请返回后再试。", "info"); continue; }
|
|
347
|
+
ctx.ui.notify("正在重新获取模型,请稍候……", "info");
|
|
348
|
+
try { activeDiscovery = await refresh(); query = ""; page = 0; }
|
|
349
|
+
catch (error) { ctx.ui.notify(configErrorText(error), "error"); }
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (picked.startsWith("● 当前模型:")) return current;
|
|
353
|
+
const match = picked.match(/\[#(\d+)\]$/);
|
|
354
|
+
const selected = match ? visible[Number(match[1]) - 1] : undefined;
|
|
355
|
+
if (selected) return selected;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function modelLabel(model: string): string {
|
|
360
|
+
return model.length > MODEL_LABEL_LIMIT ? `${model.slice(0, MODEL_LABEL_LIMIT - 3)}...` : model;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderDiscoveryChoiceText(discovery: DiscoveryResult): string {
|
|
364
|
+
const summary = new ModelRegistry(discovery.models).summary();
|
|
365
|
+
return [
|
|
366
|
+
"连接成功 ✓",
|
|
367
|
+
`发现模型:${discovery.models.length} 个`,
|
|
368
|
+
`图片生成模型:${summary.image_generation.length} 个`,
|
|
369
|
+
`图片编辑模型:${summary.image_edit.length} 个`,
|
|
370
|
+
`图片变体模型:${summary.image_variation.length} 个`,
|
|
371
|
+
`视觉理解模型:${summary.vision.length} 个`,
|
|
372
|
+
].join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function chooseResponseFormat(ctx: ExtensionCommandContext, current: string): Promise<"auto" | "url" | "b64_json" | undefined> {
|
|
376
|
+
const picked = await ctx.ui.select(`图片响应格式(当前:${current})`, ["自动兼容(推荐)", "仅 URL", "仅 Base64 JSON", "取消"]);
|
|
377
|
+
if (!picked || picked === "取消") return undefined;
|
|
378
|
+
return picked === "仅 URL" ? "url" : picked === "仅 Base64 JSON" ? "b64_json" : "auto";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function responseFormatLabel(format: "auto" | "url" | "b64_json"): string {
|
|
382
|
+
return format === "url" ? "仅 URL" : format === "b64_json" ? "仅 Base64 JSON" : "自动兼容(推荐)";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function normalizeEditedApiKey(input: string | undefined, current: string): string {
|
|
386
|
+
if (!input || input === maskApiKey(current) || input === "已配置") return current;
|
|
387
|
+
return input;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function saveProviderWithoutDiscovery(input: { name: string; baseUrl: string; apiKey: string; defaultModel: string; setDefault: boolean }, store: GlobalConfigStore): Promise<string> {
|
|
391
|
+
const provider = await store.addProvider(input);
|
|
392
|
+
if (input.setDefault) await store.setDefaultProvider(provider.id);
|
|
393
|
+
return [`提供商已保存:${provider.name} (${provider.id})`, `默认模型:${provider.defaultModel ?? "未设置"}`, "模型发现:未执行,已手动指定模型。"].join("\n");
|
|
394
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { detectCapabilities } from "../core/capabilities.js";
|
|
2
|
+
import { ImageGenError, errorFromStatus, sanitizeSecret } from "../core/errors.js";
|
|
3
|
+
import type { EditParams, GenerateParams, ImageGenConfig, ImageOperationResult, ImageProvider, ModelInfo, VariationParams } from "../core/types.js";
|
|
4
|
+
import { downloadImage, fetchWithTimeout, parseJsonLimited, readResponseTextLimited } from "../utils/download.js";
|
|
5
|
+
import { decodeBase64Image, loadLocalImage, saveImageBuffer } from "../utils/files.js";
|
|
6
|
+
|
|
7
|
+
interface OpenAIModelResponse { data?: unknown[]; object?: string }
|
|
8
|
+
interface OpenAIImageItem { url?: string; image_url?: string; image?: string; b64_json?: string; revised_prompt?: string }
|
|
9
|
+
interface OpenAIImageResponse { data?: OpenAIImageItem[]; created?: number }
|
|
10
|
+
|
|
11
|
+
export class OpenAICompatibleProvider implements ImageProvider {
|
|
12
|
+
readonly type = "openai-compatible";
|
|
13
|
+
readonly name = "OpenAI-compatible";
|
|
14
|
+
constructor(private readonly config: ImageGenConfig) {}
|
|
15
|
+
|
|
16
|
+
async discoverModels(signal?: AbortSignal): Promise<ModelInfo[]> {
|
|
17
|
+
const response = await this.request("GET", "/models", undefined, "获取模型列表", signal);
|
|
18
|
+
if (response.status === 404) {
|
|
19
|
+
if (this.config.model) return [this.modelFromId(this.config.model, { fallback: true })];
|
|
20
|
+
throw errorFromStatus(404, "获取模型列表");
|
|
21
|
+
}
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
if (this.config.model && (response.status === 404 || response.status === 405)) return [this.modelFromId(this.config.model, { fallback: true })];
|
|
24
|
+
throw errorFromStatus(response.status, "获取模型列表");
|
|
25
|
+
}
|
|
26
|
+
let json: OpenAIModelResponse;
|
|
27
|
+
try {
|
|
28
|
+
json = await parseJsonLimited(response, this.config.maxResponseBytes) as OpenAIModelResponse;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (this.config.model && error instanceof ImageGenError && error.code === "invalid_json") return [this.modelFromId(this.config.model, { fallback: true })];
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(json.data)) {
|
|
34
|
+
if (this.config.model) return [this.modelFromId(this.config.model, { fallback: true })];
|
|
35
|
+
throw new ImageGenError("invalid_json", "/models 响应不包含 data 数组。", "该服务商可能不支持 /models;请在添加 Provider 时手动指定模型 ID。 ");
|
|
36
|
+
}
|
|
37
|
+
const models = json.data.map((item) => this.modelFromRaw(item)).filter((m): m is ModelInfo => Boolean(m));
|
|
38
|
+
if (models.length === 0 && this.config.model) return [this.modelFromId(this.config.model, { fallback: true })];
|
|
39
|
+
return models;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async generate(params: GenerateParams, signal?: AbortSignal): Promise<ImageOperationResult> {
|
|
43
|
+
// auto 优先 URL(便于流式/CDN 服务),仅在服务端明确拒绝时降级为 b64_json。
|
|
44
|
+
const responseFormat = this.config.responseFormat === "b64_json" ? "b64_json" : "url";
|
|
45
|
+
const caps = params.modelCapabilities;
|
|
46
|
+
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
|
+
const json = await this.postImage("/images/generations", body, "生成图片", signal);
|
|
48
|
+
return this.saveImageResponse(json, params.model ?? "unknown");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async edit(params: EditParams, signal?: AbortSignal): Promise<ImageOperationResult> {
|
|
52
|
+
const image = await resolveImageInput(params.image, this.config, signal);
|
|
53
|
+
const form = new FormData();
|
|
54
|
+
form.set("model", params.model ?? "");
|
|
55
|
+
form.set("prompt", params.prompt);
|
|
56
|
+
if (params.size && params.modelCapabilities?.supportsSize !== false) form.set("size", params.size);
|
|
57
|
+
if (params.output_format) form.set("output_format", params.output_format);
|
|
58
|
+
form.set("image", new Blob([toArrayBuffer(image.buffer)], { type: image.mimeType }), image.name);
|
|
59
|
+
const json = await this.postImage("/images/edits", form, "编辑图片", signal);
|
|
60
|
+
return this.saveImageResponse(json, params.model ?? "unknown");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async variation(params: VariationParams, signal?: AbortSignal): Promise<ImageOperationResult> {
|
|
64
|
+
const image = await resolveImageInput(params.image, this.config, signal);
|
|
65
|
+
const form = new FormData();
|
|
66
|
+
form.set("model", params.model ?? "");
|
|
67
|
+
if (params.size && params.modelCapabilities?.supportsSize !== false) form.set("size", params.size);
|
|
68
|
+
if (params.n && params.modelCapabilities?.supportsMultipleImages !== false) form.set("n", String(params.n));
|
|
69
|
+
form.set("image", new Blob([toArrayBuffer(image.buffer)], { type: image.mimeType }), image.name);
|
|
70
|
+
const json = await this.postImage("/images/variations", form, "生成图片变体", signal);
|
|
71
|
+
return this.saveImageResponse(json, params.model ?? "unknown");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async postImage(path: string, body: BodyInit | Record<string, unknown>, action: string, signal?: AbortSignal): Promise<OpenAIImageResponse> {
|
|
75
|
+
const isForm = body instanceof FormData;
|
|
76
|
+
let response = await this.request("POST", path, isForm ? body : JSON.stringify(body), action, signal, isForm ? undefined : "application/json");
|
|
77
|
+
let detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
78
|
+
// 不同 OpenAI-compatible 服务对 response_format 的支持不一致;仅在服务端明确拒绝该字段时做一次无费用重试。
|
|
79
|
+
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 && this.config.responseFormat !== "url" && this.config.responseFormat !== "b64_json" && jsonBody?.response_format === "url" && isResponseFormatError(detail)) {
|
|
81
|
+
const retryBody = { ...jsonBody, response_format: "b64_json" };
|
|
82
|
+
response = await this.request("POST", path, JSON.stringify(retryBody), action, signal, "application/json");
|
|
83
|
+
detail = response.ok ? undefined : await responseErrorDetail(response, this.config);
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok) throw errorFromStatus(response.status, action, detail);
|
|
86
|
+
const json = await parseJsonLimited(response, this.config.maxResponseBytes) as OpenAIImageResponse;
|
|
87
|
+
if (!Array.isArray(json.data)) throw new ImageGenError("invalid_json", `${action}响应不包含 data 数组。`, "确认服务商图片接口响应是否 OpenAI-compatible。 ");
|
|
88
|
+
return json;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async request(method: string, path: string, body: BodyInit | undefined, action: string, signal?: AbortSignal, contentType?: string): Promise<Response> {
|
|
92
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${this.config.apiKey}` };
|
|
93
|
+
if (contentType) headers["content-type"] = contentType;
|
|
94
|
+
try {
|
|
95
|
+
const init: RequestInit = { method, headers };
|
|
96
|
+
if (body !== undefined) init.body = body;
|
|
97
|
+
if (signal !== undefined) init.signal = signal;
|
|
98
|
+
return await fetchWithTimeout(`${this.config.baseUrl}${path}`, init, this.config.timeoutMs);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e instanceof ImageGenError) throw new ImageGenError(e.code, sanitizeSecret(e.message, this.config.apiKey), e.suggestion, e.status);
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private modelFromRaw(item: unknown): ModelInfo | undefined {
|
|
106
|
+
if (!item || typeof item !== "object") return undefined;
|
|
107
|
+
const record = item as Record<string, unknown>;
|
|
108
|
+
const id = typeof record.id === "string" ? record.id : typeof record.name === "string" ? record.name : undefined;
|
|
109
|
+
if (!id) return undefined;
|
|
110
|
+
return this.modelFromId(id, record);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private modelFromId(id: string, metadata: Record<string, unknown>): ModelInfo {
|
|
114
|
+
// fallback 仅由用户在配置界面明确手动指定默认模型时产生,视为图片生成模型。
|
|
115
|
+
const fallback = metadata.fallback === true;
|
|
116
|
+
const caps = detectCapabilities(id, metadata, this.config.manualCapabilities[id] ?? (fallback ? ["image_generation"] : undefined));
|
|
117
|
+
return { id, provider: this.type, rawMetadata: metadata, ...caps };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async saveImageResponse(json: OpenAIImageResponse, model: string): Promise<ImageOperationResult> {
|
|
121
|
+
const images = [];
|
|
122
|
+
for (const [index, item] of json.data?.entries() ?? []) {
|
|
123
|
+
if (item.b64_json) {
|
|
124
|
+
const decoded = decodeBase64Image(item.b64_json);
|
|
125
|
+
images.push({ path: await saveImageBuffer(decoded.buffer, this.config.outputDir, decoded.ext), mimeType: decoded.mimeType, index });
|
|
126
|
+
} else {
|
|
127
|
+
const rawUrl = extractImageValue(item.url) ?? extractImageValue(item.image_url) ?? extractImageValue(item.image) ?? extractImageValue((item as Record<string, unknown>).image_base64);
|
|
128
|
+
if (!rawUrl) continue;
|
|
129
|
+
if (rawUrl.startsWith("data:")) {
|
|
130
|
+
const decoded = decodeBase64Image(rawUrl);
|
|
131
|
+
images.push({ path: await saveImageBuffer(decoded.buffer, this.config.outputDir, decoded.ext), mimeType: decoded.mimeType, index });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const imageUrl = resolveImageUrl(rawUrl, this.config.baseUrl);
|
|
135
|
+
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);
|
|
137
|
+
images.push({ path: saved.path, url: imageUrl, mimeType: saved.mimeType, index });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (images.length === 0) throw new ImageGenError("invalid_image", "API 未返回可保存的图片。", "确认响应包含 url 或 b64_json。 ");
|
|
141
|
+
return { provider: this.config.providerName, providerId: this.config.providerId, model, images, rawInfo: { created: json.created } };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveImageInput(input: string, config: ImageGenConfig, signal?: AbortSignal): Promise<{ name: string; buffer: Buffer; mimeType: string }> {
|
|
146
|
+
if (/^https?:\/\//i.test(input)) {
|
|
147
|
+
const authorization = isSameOrigin(input, config.baseUrl) ? `Bearer ${config.apiKey}` : undefined;
|
|
148
|
+
const saved = await downloadImage(input, config.outputDir, config.timeoutMs, config.maxDownloadBytes, authorization);
|
|
149
|
+
const local = await loadLocalImage(saved.path);
|
|
150
|
+
return local;
|
|
151
|
+
}
|
|
152
|
+
return loadLocalImage(input);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function responseErrorDetail(response: Response, config: ImageGenConfig): Promise<string | undefined> {
|
|
156
|
+
try {
|
|
157
|
+
const raw = await readResponseTextLimited(response, Math.min(config.maxResponseBytes, 32_768));
|
|
158
|
+
let detail = raw;
|
|
159
|
+
try {
|
|
160
|
+
const json = JSON.parse(raw) as { error?: { message?: unknown }; message?: unknown };
|
|
161
|
+
detail = typeof json.error?.message === "string" ? json.error.message : typeof json.message === "string" ? json.message : raw;
|
|
162
|
+
} catch { /* 非 JSON 错误页仅保留单行短摘要。 */ }
|
|
163
|
+
const safe = sanitizeSecret(detail, config.apiKey).replace(/\s+/g, " ").trim();
|
|
164
|
+
return safe ? safe.slice(0, 500) : undefined;
|
|
165
|
+
} catch { return undefined; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractImageValue(value: unknown): string | undefined {
|
|
169
|
+
if (typeof value === "string") return value;
|
|
170
|
+
if (value && typeof value === "object") {
|
|
171
|
+
const record = value as Record<string, unknown>;
|
|
172
|
+
if (typeof record.url === "string") return record.url;
|
|
173
|
+
if (typeof record.b64_json === "string") return `data:image/png;base64,${record.b64_json}`;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isResponseFormatError(detail?: string): boolean {
|
|
179
|
+
return Boolean(detail && /response_format|b64_json|base64.*format/i.test(detail));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isSameOrigin(imageUrl: string, baseUrl: string): boolean {
|
|
183
|
+
try { return new URL(imageUrl).origin === new URL(baseUrl).origin; }
|
|
184
|
+
catch { return false; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveImageUrl(raw: string, baseUrl: string): string {
|
|
188
|
+
try { return new URL(raw, `${baseUrl}/`).toString(); }
|
|
189
|
+
catch { throw new ImageGenError("invalid_image", "Provider 返回的图片地址无效。", "确认响应中的图片 URL 或图片字段是有效 http/https 地址。 "); }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compact(obj: Record<string, unknown>): Record<string, unknown> {
|
|
193
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== ""));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|
197
|
+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
|
198
|
+
}
|