@dsh-plus/image-studio 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/lib/index.js ADDED
@@ -0,0 +1,1304 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
+ import { request } from "node:http";
5
+ import { request as request$1 } from "node:https";
6
+ import { HttpsProxyAgent } from "https-proxy-agent";
7
+ import { randomUUID } from "node:crypto";
8
+ import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { pluginDataPath } from "@dsh-plus/shared";
11
+ //#endregion
12
+ //#region src/config.ts
13
+ /**
14
+ * 配置单一事实源:cordis 行级 Config(组合默认)与 settings namespace
15
+ * (用户层,$DSH_HOME/settings.yaml 热生效)共用同一 schemastery schema。
16
+ * 三组纯用户配置:提示词预设 / 参数预设 / 提供商预设(API key 不在此,
17
+ * 走 credentials,引用名由 preset id 派生)。
18
+ * @module image-studio/config
19
+ */
20
+ /** settings 命名空间(字面量即合法命名空间,编译期校验)。 */
21
+ const SETTINGS_NS = "dsh-plus-image-studio";
22
+ const PromptPreset = z.object({
23
+ id: z.string().required().description("预设 id(稳定标识,删除级联引用)"),
24
+ name: z.string().required().description("预设名称"),
25
+ text: z.string().required().description("提示词模板")
26
+ });
27
+ const ParamPreset = z.object({
28
+ id: z.string().required().description("预设 id"),
29
+ name: z.string().required().description("预设名称"),
30
+ endpoint: z.string().required().description("适用端点:generation | edit"),
31
+ /** 参数表结构由 params/spec 校验,settings 层存原始 JSON。 */
32
+ paramSpecs: z.any().required().description("参数开关表(ParamSpecMap JSON)")
33
+ });
34
+ const ProviderPreset = z.object({
35
+ id: z.string().required().description("预设 id"),
36
+ name: z.string().required().description("预设名称"),
37
+ protocol: z.string().required().description("协议 id(如 openai-images)"),
38
+ baseUrl: z.string().required().description("API 基础地址(含 /v1)"),
39
+ model: z.string().required().description("模型 id"),
40
+ credentialRef: z.string().required().description("凭据引用名(IMAGE_STUDIO_PRESET_*)"),
41
+ extraHeaders: z.dict(z.string()).description("附加请求头(部分中转需要)").default({})
42
+ });
43
+ const Config = z.object({
44
+ promptPresets: z.array(PromptPreset).description("提示词预设").default([]),
45
+ paramPresets: z.array(ParamPreset).description("参数预设").default([]),
46
+ providerPresets: z.array(ProviderPreset).description("提供商预设").default([]),
47
+ maxConcurrent: z.number().min(0).max(16).description("生图任务并发上限(0 = 不限)").default(3),
48
+ requestTimeoutMs: z.number().min(1e4).max(6e5).description("单请求超时(毫秒;生图长耗时,默认 5 分钟)").default(3e5),
49
+ proxy: z.string().description("上游请求代理(空 = 直连)").default(""),
50
+ galleryMaxItems: z.number().min(0).max(1e4).description("画廊保留上限(条,0 = 不清理;超限按最旧删除)").default(500)
51
+ });
52
+ //#endregion
53
+ //#region src/errors.ts
54
+ var ImageStudioError = class extends Error {
55
+ code;
56
+ constructor(code, message) {
57
+ super(message ?? code);
58
+ this.name = "ImageStudioError";
59
+ this.code = code;
60
+ }
61
+ };
62
+ /** 按错误码映射 HTTP 状态(端点边界统一使用)。 */
63
+ function statusOfCode(code) {
64
+ switch (code) {
65
+ case "unknown-protocol":
66
+ case "unknown-endpoint":
67
+ case "invalid-request":
68
+ case "unknown-source":
69
+ case "unknown-task":
70
+ case "task-not-runnable": return 400;
71
+ case "credential-unavailable":
72
+ case "settings-unavailable": return 409;
73
+ default: return 500;
74
+ }
75
+ }
76
+ /** 只读目录(冻结防运行期篡改)。 */
77
+ const PARAM_CATALOG = Object.freeze([
78
+ {
79
+ key: "model",
80
+ kind: "string",
81
+ applies: ["generation", "edit"],
82
+ generation: "all",
83
+ advanced: false,
84
+ description: "图像模型 id;请求基础来自提供商预设,此处仅作展示与 inline 覆盖"
85
+ },
86
+ {
87
+ key: "n",
88
+ kind: "integer",
89
+ min: 1,
90
+ max: 10,
91
+ applies: ["generation", "edit"],
92
+ generation: "current",
93
+ advanced: false,
94
+ description: "生成张数(1-10;部分中转仅支持 1)"
95
+ },
96
+ {
97
+ key: "size",
98
+ kind: "string",
99
+ applies: ["generation", "edit"],
100
+ generation: "current",
101
+ advanced: false,
102
+ description: "尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto;宽高需为 16 的倍数、最大边 ≤3840、比例 ≤3:1(依模型限制)"
103
+ },
104
+ {
105
+ key: "quality",
106
+ kind: "enum",
107
+ values: [
108
+ "auto",
109
+ "low",
110
+ "medium",
111
+ "high"
112
+ ],
113
+ applies: ["generation", "edit"],
114
+ generation: "current",
115
+ advanced: false,
116
+ description: "质量档位"
117
+ },
118
+ {
119
+ key: "background",
120
+ kind: "enum",
121
+ values: [
122
+ "auto",
123
+ "transparent",
124
+ "opaque"
125
+ ],
126
+ applies: ["generation", "edit"],
127
+ generation: "current",
128
+ advanced: false,
129
+ description: "输出背景透明行为(对提示词里的场景背景无影响)"
130
+ },
131
+ {
132
+ key: "output_format",
133
+ kind: "enum",
134
+ values: [
135
+ "png",
136
+ "jpeg",
137
+ "webp"
138
+ ],
139
+ applies: ["generation", "edit"],
140
+ generation: "current",
141
+ advanced: false,
142
+ description: "输出格式(部分中转不支持 webp)"
143
+ },
144
+ {
145
+ key: "output_compression",
146
+ kind: "integer",
147
+ min: 0,
148
+ max: 100,
149
+ applies: ["generation", "edit"],
150
+ generation: "current",
151
+ advanced: true,
152
+ description: "压缩率 0-100,仅 jpeg/webp 有效"
153
+ },
154
+ {
155
+ key: "moderation",
156
+ kind: "enum",
157
+ values: ["auto", "low"],
158
+ applies: ["generation", "edit"],
159
+ generation: "current",
160
+ advanced: true,
161
+ description: "内容审核严格度"
162
+ },
163
+ {
164
+ key: "response_format",
165
+ kind: "enum",
166
+ values: ["url", "b64_json"],
167
+ applies: ["generation", "edit"],
168
+ generation: "all",
169
+ advanced: false,
170
+ description: "返回形式;插件两种都会落盘本地化,画廊不受外链过期影响"
171
+ },
172
+ {
173
+ key: "stream",
174
+ kind: "boolean",
175
+ applies: ["generation", "edit"],
176
+ generation: "current",
177
+ advanced: true,
178
+ description: "流式返回(多数中转不支持,默认关闭)"
179
+ },
180
+ {
181
+ key: "partial_images",
182
+ kind: "integer",
183
+ min: 0,
184
+ max: 3,
185
+ applies: ["generation", "edit"],
186
+ generation: "current",
187
+ advanced: true,
188
+ description: "流式中间图数量(依赖 stream,默认关闭)"
189
+ },
190
+ {
191
+ key: "user",
192
+ kind: "string",
193
+ applies: ["generation", "edit"],
194
+ generation: "all",
195
+ advanced: true,
196
+ description: "终端用户标识(业务归因用)"
197
+ },
198
+ {
199
+ key: "input_fidelity",
200
+ kind: "enum",
201
+ values: ["low", "high"],
202
+ applies: ["edit"],
203
+ generation: "current",
204
+ advanced: false,
205
+ description: "图生图输入保真度(high 更保留原图细节,token 消耗增大)"
206
+ },
207
+ {
208
+ key: "style",
209
+ kind: "enum",
210
+ values: ["vivid", "natural"],
211
+ applies: ["generation"],
212
+ generation: "dall-e-3",
213
+ advanced: true,
214
+ description: "dall-e-3 代际风格参数(GPT Image 不需要)"
215
+ }
216
+ ].map((entry) => Object.freeze(entry)));
217
+ /** 按字段名查目录条目。 */
218
+ function paramEntryOf(key) {
219
+ return PARAM_CATALOG.find((entry) => entry.key === key) ?? null;
220
+ }
221
+ /** 某端点下可用的参数键集合(含 advanced)。 */
222
+ function paramKeysFor(endpoint) {
223
+ return PARAM_CATALOG.filter((entry) => entry.applies.includes(endpoint)).map((entry) => entry.key);
224
+ }
225
+ //#endregion
226
+ //#region src/params/spec.ts
227
+ /** 归一化失败原因(边界校验用,端点转 invalid-request)。 */
228
+ var ParamValidationError = class extends Error {
229
+ key;
230
+ constructor(key, message) {
231
+ super(`参数 ${key} ${message}`);
232
+ this.name = "ParamValidationError";
233
+ this.key = key;
234
+ }
235
+ };
236
+ /** 校验单个值符合目录条目约束;返回规范值。 */
237
+ function validateParamValue(key, value) {
238
+ const entry = paramEntryOf(key);
239
+ if (entry === null) throw new ParamValidationError(key, "不在参数目录中");
240
+ switch (entry.kind) {
241
+ case "string":
242
+ if (typeof value !== "string" || value.length === 0) throw new ParamValidationError(key, "必须是非空字符串");
243
+ return value;
244
+ case "enum":
245
+ if (typeof value !== "string" || !entry.values?.includes(value)) throw new ParamValidationError(key, `必须是 ${entry.values?.join(" | ") ?? "枚举值"} 之一`);
246
+ return value;
247
+ case "integer":
248
+ if (typeof value !== "number" || !Number.isInteger(value)) throw new ParamValidationError(key, "必须是整数");
249
+ if (entry.min !== void 0 && value < entry.min) throw new ParamValidationError(key, `不能小于 ${entry.min}`);
250
+ if (entry.max !== void 0 && value > entry.max) throw new ParamValidationError(key, `不能大于 ${entry.max}`);
251
+ return value;
252
+ case "boolean":
253
+ if (typeof value !== "boolean") throw new ParamValidationError(key, "必须是布尔值");
254
+ return value;
255
+ }
256
+ }
257
+ /**
258
+ * 校验并归一化一份参数表:仅保留启用且适用于该端点的参数;
259
+ * model 是请求基础(提供商预设承载),不计入协议参数表。
260
+ * 任何禁用/未知/越界项直接抛 ParamValidationError(边界 fail-loud)。
261
+ */
262
+ function normalizeParamSpecs(specs, endpoint) {
263
+ const out = {};
264
+ for (const [key, spec] of Object.entries(specs)) {
265
+ if (!spec?.enabled) continue;
266
+ if (key === "model") continue;
267
+ const entry = paramEntryOf(key);
268
+ if (entry === null) throw new ParamValidationError(key, "不在参数目录中");
269
+ if (!entry.applies.includes(endpoint)) throw new ParamValidationError(key, `不适用于 ${endpoint} 端点`);
270
+ out[key] = validateParamValue(key, spec.value);
271
+ }
272
+ return out;
273
+ }
274
+ /** 校验预设里的参数表(结构合法但可跨端点;应用时再按端点归一化)。 */
275
+ function validateParamSpecs(specs) {
276
+ if (specs === null || typeof specs !== "object" || Array.isArray(specs)) throw new ParamValidationError("(整体)", "必须是对象");
277
+ const out = {};
278
+ for (const [key, raw] of Object.entries(specs)) {
279
+ if (raw === null || typeof raw !== "object") throw new ParamValidationError(key, "条目必须是 { enabled, value? }");
280
+ const spec = raw;
281
+ if (typeof spec.enabled !== "boolean") throw new ParamValidationError(key, "enabled 必须是布尔值");
282
+ if (paramEntryOf(key) === null) throw new ParamValidationError(key, "不在参数目录中");
283
+ const item = { enabled: spec.enabled };
284
+ if (spec.value !== void 0) item.value = validateParamValue(key, spec.value);
285
+ out[key] = item;
286
+ }
287
+ return out;
288
+ }
289
+ //#endregion
290
+ //#region src/provider/types.ts
291
+ /** 适配器向上抛的结构化错误:HTTP 状态 + 上游响应摘要。 */
292
+ var ProviderError = class extends Error {
293
+ status;
294
+ upstreamBody;
295
+ constructor(status, upstreamBody, message) {
296
+ super(message ?? `上游返回 HTTP ${status}`);
297
+ this.name = "ProviderError";
298
+ this.status = status;
299
+ this.upstreamBody = upstreamBody;
300
+ }
301
+ };
302
+ //#endregion
303
+ //#region src/provider/openai-images.ts
304
+ /**
305
+ * OpenAI Images API 适配器(首批协议实现)。
306
+ * - 请求构造是纯函数(单测覆盖 JSON/multipart 两种形态);
307
+ * - 执行走 node:http(s),https 目标代理经 https-proxy-agent(与 llm-pi 同约定);
308
+ * - 响应统一取回字节(url → 下载 / b64_json → 解码),调用方零二次请求。
309
+ * 开发测试指向本地 mock,严禁真实调用产生费用。
310
+ * @module image-studio/provider/openai-images
311
+ */
312
+ /** 顶层响应体上限(4K PNG 约 10-20MB,放宽到 64MB 防滥用)。 */
313
+ const MAX_RESPONSE_BYTES = 67108864;
314
+ const MIME_BY_FORMAT = {
315
+ png: "image/png",
316
+ jpeg: "image/jpeg",
317
+ webp: "image/webp"
318
+ };
319
+ /** output_format 参数值 → MIME(b64 结果无 content-type 时兜底)。 */
320
+ function mimeOfFormat(format) {
321
+ return MIME_BY_FORMAT[format] ?? "image/png";
322
+ }
323
+ /** 官方端点路径。 */
324
+ function endpointPath(endpoint) {
325
+ return endpoint === "generation" ? "/images/generations" : "/images/edits";
326
+ }
327
+ /** 公共请求头(鉴权 + 用户附加头)。 */
328
+ function baseHeaders(apiKey, extra) {
329
+ return {
330
+ authorization: `Bearer ${apiKey}`,
331
+ ...extra
332
+ };
333
+ }
334
+ /** multipart 文本字段分片。 */
335
+ function formField(name, value) {
336
+ return `--BOUNDARY\r\ncontent-disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
337
+ }
338
+ /** multipart 文件分片头。 */
339
+ function formFileHead(name, index, file) {
340
+ return `--BOUNDARY\r\ncontent-disposition: form-data; name="${name}"; filename="${name}-${index}.${file.mime.split("/")[1] ?? "png"}"\r\ncontent-type: ${file.mime}\r\n\r\n`;
341
+ }
342
+ /** 构造 generations JSON 请求体(纯函数;params 已由 spec 层裁剪)。 */
343
+ function buildGenerationBody(request) {
344
+ return JSON.stringify({
345
+ prompt: request.prompt,
346
+ n: request.n,
347
+ ...request.params
348
+ });
349
+ }
350
+ /** 构造 edits multipart 请求体(纯函数;image 官方上限 ≤16 张由边界校验)。 */
351
+ function buildEditBody(request) {
352
+ const chunks = [];
353
+ const pushText = (text) => chunks.push(Buffer.from(text, "utf8"));
354
+ pushText(formField("prompt", request.prompt));
355
+ pushText(formField("n", String(request.n)));
356
+ for (const [key, value] of Object.entries(request.params)) pushText(formField(key, String(value)));
357
+ request.images.forEach((file, index) => {
358
+ pushText(formFileHead("image", index, file));
359
+ chunks.push(Buffer.from(file.data), Buffer.from("\r\n", "utf8"));
360
+ });
361
+ if (request.mask !== null) {
362
+ pushText(formFileHead("mask", 0, request.mask));
363
+ chunks.push(Buffer.from(request.mask.data), Buffer.from("\r\n", "utf8"));
364
+ }
365
+ chunks.push(Buffer.from("--BOUNDARY--\r\n", "utf8"));
366
+ return {
367
+ headers: { "content-type": "multipart/form-data; boundary=BOUNDARY" },
368
+ body: Buffer.concat(chunks)
369
+ };
370
+ }
371
+ /** 收集响应体(带字节上限;流错误结算防悬挂)。 */
372
+ function collect(req, response, done, fail) {
373
+ const chunks = [];
374
+ let size = 0;
375
+ response.on("data", (chunk) => {
376
+ size += chunk.length;
377
+ if (size <= MAX_RESPONSE_BYTES) {
378
+ chunks.push(chunk);
379
+ return;
380
+ }
381
+ response.destroy(/* @__PURE__ */ new Error("响应超过 64MB 上限"));
382
+ });
383
+ response.on("end", () => done({
384
+ status: response.statusCode ?? 0,
385
+ contentType: response.headers["content-type"]?.split(";")[0] ?? "",
386
+ body: Buffer.concat(chunks)
387
+ }));
388
+ response.on("error", fail);
389
+ req.on("error", fail);
390
+ }
391
+ /** HTTP(S) POST/GET 执行:代理 + 超时 + AbortSignal 贯穿。 */
392
+ function execRequest(url, options) {
393
+ return new Promise((resolve, reject) => {
394
+ const target = new URL(url);
395
+ const doRequest = target.protocol === "https:" ? request$1 : request;
396
+ const agent = target.protocol === "https:" && options.proxy.length > 0 ? new HttpsProxyAgent(options.proxy) : void 0;
397
+ let settled = false;
398
+ const fail = (error) => {
399
+ if (settled) return;
400
+ settled = true;
401
+ reject(error);
402
+ };
403
+ const done = (reply) => {
404
+ if (settled) return;
405
+ settled = true;
406
+ resolve(reply);
407
+ };
408
+ const req = doRequest(url, {
409
+ method: options.method,
410
+ headers: options.headers,
411
+ timeout: options.timeoutMs,
412
+ agent,
413
+ signal: options.signal
414
+ }, (response) => collect(req, response, done, fail));
415
+ req.on("timeout", () => req.destroy(/* @__PURE__ */ new Error(`请求超时(${options.timeoutMs}ms)`)));
416
+ req.on("error", fail);
417
+ req.end(options.body ?? Buffer.alloc(0));
418
+ });
419
+ }
420
+ /** url 形式结果图下载(同代理/超时策略;MIME 取响应头)。 */
421
+ async function fetchImageBytes(url, proxy, timeoutMs, signal) {
422
+ const reply = await execRequest(url, {
423
+ method: "GET",
424
+ headers: {},
425
+ proxy,
426
+ timeoutMs,
427
+ signal
428
+ });
429
+ if (reply.status !== 200) throw new ProviderError(reply.status, "", `结果图下载失败(HTTP ${reply.status})`);
430
+ return {
431
+ data: new Uint8Array(reply.body),
432
+ mime: reply.contentType || "image/png"
433
+ };
434
+ }
435
+ /** 解析响应体为结果图列表(url 下载 / b64 解码统一为字节)。 */
436
+ async function parseImagesReply(bodyText, fallbackMime, proxy, timeoutMs, signal) {
437
+ let doc;
438
+ try {
439
+ doc = JSON.parse(bodyText);
440
+ } catch {
441
+ throw new ProviderError(502, bodyText.slice(0, 500), "上游响应不是合法 JSON");
442
+ }
443
+ const images = [];
444
+ for (const item of doc.data ?? []) {
445
+ if (typeof item.b64_json === "string" && item.b64_json.length > 0) {
446
+ images.push({
447
+ data: new Uint8Array(Buffer.from(item.b64_json, "base64")),
448
+ mime: fallbackMime,
449
+ revisedPrompt: item.revised_prompt ?? null
450
+ });
451
+ continue;
452
+ }
453
+ if (typeof item.url === "string" && item.url.length > 0) {
454
+ const fetched = await fetchImageBytes(item.url, proxy, timeoutMs, signal);
455
+ images.push({
456
+ ...fetched,
457
+ revisedPrompt: item.revised_prompt ?? null
458
+ });
459
+ }
460
+ }
461
+ if (images.length === 0) throw new ProviderError(502, bodyText.slice(0, 500), "上游未返回任何图片");
462
+ return {
463
+ images,
464
+ createdAt: typeof doc.created === "number" ? doc.created : null
465
+ };
466
+ }
467
+ /** 适配器主入口:构造请求 → 执行 → 解析。 */
468
+ async function generateViaOpenAIImages(request, signal) {
469
+ const { target } = request;
470
+ const url = `${target.baseUrl.replace(/\/+$/, "")}${endpointPath(request.endpoint)}`;
471
+ const fallbackMime = mimeOfFormat(request.params.output_format);
472
+ const reply = request.endpoint === "generation" ? await execRequest(url, {
473
+ method: "POST",
474
+ headers: {
475
+ ...baseHeaders(target.apiKey, target.extraHeaders),
476
+ "content-type": "application/json"
477
+ },
478
+ body: Buffer.from(buildGenerationBody(request), "utf8"),
479
+ proxy: target.proxy,
480
+ timeoutMs: target.timeoutMs,
481
+ signal
482
+ }) : await execRequest(url, {
483
+ method: "POST",
484
+ headers: baseHeaders(target.apiKey, {
485
+ ...target.extraHeaders,
486
+ ...buildEditBody(request).headers
487
+ }),
488
+ body: buildEditBody(request).body,
489
+ proxy: target.proxy,
490
+ timeoutMs: target.timeoutMs,
491
+ signal
492
+ });
493
+ if (reply.status !== 200) throw new ProviderError(reply.status, reply.body.toString("utf8").slice(0, 2e3));
494
+ return parseImagesReply(reply.body.toString("utf8"), fallbackMime, target.proxy, target.timeoutMs, signal);
495
+ }
496
+ //#endregion
497
+ //#region src/provider/registry.ts
498
+ /**
499
+ * 协议注册表:协议 id → 适配器构造。封闭集合 + 统一注册入口,
500
+ * 新增协议(如 gemini、stable-diffusion 等)只增注册不改核心。
501
+ * @module image-studio/provider/registry
502
+ */
503
+ const PROTOCOLS = Object.freeze([{
504
+ id: "openai-images",
505
+ label: "OpenAI Images API",
506
+ endpoints: ["generation", "edit"],
507
+ generate: generateViaOpenAIImages
508
+ }]);
509
+ /** 全部已注册协议(前端下拉依据)。 */
510
+ function listProtocols() {
511
+ return PROTOCOLS.map(({ id, label, endpoints }) => ({
512
+ id,
513
+ label,
514
+ endpoints
515
+ }));
516
+ }
517
+ /** 按协议 id 查条目;未知协议抛结构化错误。 */
518
+ function protocolOf(id) {
519
+ const found = PROTOCOLS.find((entry) => entry.id === id);
520
+ if (found === void 0) throw new ImageStudioError("unknown-protocol", `未知协议:${id}`);
521
+ return found;
522
+ }
523
+ /** 构造适配器(当前实现无状态,直接包一层满足 ImageProvider 契约)。 */
524
+ function createProvider(id) {
525
+ const entry = protocolOf(id);
526
+ return {
527
+ id: entry.id,
528
+ endpoints: entry.endpoints,
529
+ generate: (request, signal) => entry.generate(request, signal)
530
+ };
531
+ }
532
+ //#endregion
533
+ //#region src/api.ts
534
+ const ROUTE_PREFIX = "/dsh-plus/image-studio";
535
+ function sendJson(res, status, body) {
536
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
537
+ res.end(JSON.stringify(body));
538
+ }
539
+ function readBody(req) {
540
+ return new Promise((resolve, reject) => {
541
+ const chunks = [];
542
+ req.on("data", (chunk) => chunks.push(chunk));
543
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
544
+ req.on("error", reject);
545
+ });
546
+ }
547
+ /** 统一错误结算:ImageStudioError/ParamValidationError 按码映射,其余 500。 */
548
+ function sendError(res, error) {
549
+ if (error instanceof ImageStudioError) {
550
+ sendJson(res, statusOfCode(error.code), {
551
+ error: error.code,
552
+ message: error.message
553
+ });
554
+ return;
555
+ }
556
+ if (error instanceof ParamValidationError) {
557
+ sendJson(res, 400, {
558
+ error: "invalid-request",
559
+ message: error.message
560
+ });
561
+ return;
562
+ }
563
+ sendJson(res, 500, {
564
+ error: "internal",
565
+ message: error instanceof Error ? error.message : String(error)
566
+ });
567
+ }
568
+ function buildHandlers(service) {
569
+ return {
570
+ async generate(_req, res, rest) {
571
+ if (rest !== "") throw new ImageStudioError("invalid-request", "未知路径");
572
+ if (method(_req) !== "POST") {
573
+ sendJson(res, 405, { error: "POST only" });
574
+ return;
575
+ }
576
+ const body = JSON.parse(await readBody(_req));
577
+ sendJson(res, 202, { taskId: await service.submitGenerate(body) });
578
+ },
579
+ async tasks(_req, res, rest) {
580
+ if (rest === "") {
581
+ sendJson(res, 200, { tasks: service.taskWires() });
582
+ return;
583
+ }
584
+ const [taskId, action] = rest.split("/");
585
+ if (taskId === void 0 || taskId.length === 0) throw new ImageStudioError("invalid-request", "缺少任务 id");
586
+ if (action === "cancel") {
587
+ if (method(_req) !== "POST") {
588
+ sendJson(res, 405, { error: "POST only" });
589
+ return;
590
+ }
591
+ const ok = service.cancelTask(taskId);
592
+ sendJson(res, ok ? 200 : 409, ok ? { ok: true } : { error: "task-not-runnable" });
593
+ return;
594
+ }
595
+ if (action !== void 0) throw new ImageStudioError("invalid-request", `未知操作:${action}`);
596
+ const wire = service.taskWire(taskId);
597
+ if (wire === null) throw new ImageStudioError("unknown-task", `任务不存在:${taskId}`);
598
+ sendJson(res, 200, wire);
599
+ },
600
+ async gallery(_req, res, rest) {
601
+ const [itemId] = rest.split("/");
602
+ if (itemId === void 0 || itemId === "") {
603
+ if (method(_req) !== "GET") {
604
+ sendJson(res, 405, { error: "GET only" });
605
+ return;
606
+ }
607
+ const items = await service.gallery();
608
+ sendJson(res, 200, {
609
+ items,
610
+ total: items.length
611
+ });
612
+ return;
613
+ }
614
+ if (itemId === "credentials" || itemId === "providers") return;
615
+ if (method(_req) === "DELETE") {
616
+ const removed = await service.deleteGalleryItem(itemId);
617
+ sendJson(res, removed ? 200 : 404, removed ? { ok: true } : { error: "not-found" });
618
+ return;
619
+ }
620
+ if (method(_req) !== "GET") {
621
+ sendJson(res, 405, { error: "GET or DELETE only" });
622
+ return;
623
+ }
624
+ const item = (await service.gallery()).find((entry) => entry.id === itemId);
625
+ if (item === void 0) {
626
+ sendJson(res, 404, { error: "not-found" });
627
+ return;
628
+ }
629
+ sendJson(res, 200, item);
630
+ },
631
+ async images(_req, res, rest) {
632
+ const imageId = rest.replace(/\/$/, "");
633
+ if (imageId.length === 0 || imageId.includes("/")) {
634
+ sendJson(res, 404, { error: "not-found" });
635
+ return;
636
+ }
637
+ const bytes = await service.imageBytes(imageId);
638
+ if (bytes === null) {
639
+ sendJson(res, 404, { error: "not-found" });
640
+ return;
641
+ }
642
+ const mime = bytes.ext === "jpg" ? "image/jpeg" : `image/${bytes.ext}`;
643
+ res.writeHead(200, {
644
+ "content-type": mime,
645
+ "cache-control": "private, max-age=3600"
646
+ });
647
+ res.end(Buffer.from(bytes.data));
648
+ },
649
+ async credentials(_req, res, rest) {
650
+ const refName = rest.replace(/\/$/, "");
651
+ if (refName.length === 0) {
652
+ sendJson(res, 400, {
653
+ error: "invalid-request",
654
+ message: "缺少凭据引用名"
655
+ });
656
+ return;
657
+ }
658
+ if (method(_req) === "GET") {
659
+ sendJson(res, 200, await service.credentialStatus(refName));
660
+ return;
661
+ }
662
+ if (method(_req) === "POST") {
663
+ const body = JSON.parse(await readBody(_req));
664
+ if (typeof body.value !== "string" || body.value.length === 0) {
665
+ sendJson(res, 400, {
666
+ error: "invalid-request",
667
+ message: "value 必须是非空字符串"
668
+ });
669
+ return;
670
+ }
671
+ await service.setCredential(refName, body.value);
672
+ sendJson(res, 200, { ok: true });
673
+ return;
674
+ }
675
+ if (method(_req) === "DELETE") {
676
+ await service.unsetCredential(refName);
677
+ sendJson(res, 200, { ok: true });
678
+ return;
679
+ }
680
+ sendJson(res, 405, { error: "GET/POST/DELETE only" });
681
+ },
682
+ async providers(_req, res, rest) {
683
+ if (rest !== "" || method(_req) !== "GET") {
684
+ sendJson(res, rest !== "" ? 404 : 405, { error: "GET /providers only" });
685
+ return;
686
+ }
687
+ sendJson(res, 200, {
688
+ protocols: listProtocols(),
689
+ params: PARAM_CATALOG
690
+ });
691
+ },
692
+ async presets(_req, res, rest) {
693
+ if (rest !== "" || method(_req) !== "GET") {
694
+ sendJson(res, 405, { error: "GET /presets only" });
695
+ return;
696
+ }
697
+ sendJson(res, 200, service.presets());
698
+ }
699
+ };
700
+ }
701
+ /** 请求方法小写化。 */
702
+ function method(req) {
703
+ return (req.method ?? "GET").toUpperCase();
704
+ }
705
+ /** 路由分发(prefix 单注册 + 内部分段)。 */
706
+ function registerImageStudioApi(ctx, service) {
707
+ const handlers = buildHandlers(service);
708
+ ctx.webServer.register({
709
+ kind: "prefix",
710
+ path: ROUTE_PREFIX,
711
+ handler: (req, res) => {
712
+ const [head, ...tail] = new URL(req.url ?? "/", "http://localhost").pathname.slice(22).replace(/^\//, "").split("/");
713
+ const rest = tail.join("/");
714
+ (async () => {
715
+ if (head === void 0 || head === "") {
716
+ sendJson(res, 404, { error: "unknown endpoint" });
717
+ return;
718
+ }
719
+ const handler = handlers[head];
720
+ if (handler === void 0) {
721
+ sendJson(res, 404, { error: `unknown endpoint: ${head}` });
722
+ return;
723
+ }
724
+ await handler(req, res, rest);
725
+ })().catch((error) => sendError(res, error));
726
+ }
727
+ });
728
+ }
729
+ //#endregion
730
+ //#region src/credentials.ts
731
+ /**
732
+ * 凭据引用名派生与校验:提供商预设 id → IMAGE_STUDIO_PRESET_<ID>。
733
+ * 规则对齐《插件存储规范》:大写下划线、全大写、前缀属主。
734
+ * 纯函数,零依赖。
735
+ * @module image-studio/credentials
736
+ */
737
+ /** 引用名前缀(属主段)。 */
738
+ const REF_PREFIX = "IMAGE_STUDIO_PRESET_";
739
+ /** 预设 id 是否合法(kebab-case,用于引用名派生)。 */
740
+ function isValidPresetId(id) {
741
+ return /^[a-z][a-z0-9-]*$/.test(id) && id.length <= 64;
742
+ }
743
+ /** 预设 id → 凭据引用名(kebab → SNAKE 大写)。 */
744
+ function credentialRefNameOf(presetId) {
745
+ if (!isValidPresetId(presetId)) throw new Error(`非法提供商预设 id:${JSON.stringify(presetId)}`);
746
+ return `${REF_PREFIX}${presetId.replaceAll("-", "_").toUpperCase()}`;
747
+ }
748
+ //#endregion
749
+ //#region src/gallery/store.ts
750
+ /**
751
+ * 画廊存储:元数据 JSONL(追加)+ 图片落盘(原子写)。
752
+ * 元数据记录完整请求参数与提示词(二次编辑回放依据);
753
+ * sourceIds 记录图生图衍生链。落点遵循《插件存储规范》:
754
+ * $DSH_HOME/dsh-plus/image-studio/gallery/{index.jsonl, images/}
755
+ * @module image-studio/gallery/store
756
+ */
757
+ /**
758
+ * 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试/dev 环境设置)。
759
+ */
760
+ function galleryRoot() {
761
+ return pluginDataPath("image-studio", "gallery");
762
+ }
763
+ function indexFile() {
764
+ return join(galleryRoot(), "index.jsonl");
765
+ }
766
+ function imagesDir() {
767
+ return join(galleryRoot(), "images");
768
+ }
769
+ /** 图片扩展名由 MIME 推导(仅白名单格式)。 */
770
+ function extOfMime(mime) {
771
+ switch (mime) {
772
+ case "image/jpeg": return "jpg";
773
+ case "image/webp": return "webp";
774
+ default: return "png";
775
+ }
776
+ }
777
+ /** 确保目录存在。 */
778
+ async function ensureDirs() {
779
+ await mkdir(imagesDir(), { recursive: true });
780
+ }
781
+ /** 解析 JSONL 全量元数据(坏行跳过并告警;空文件返回空表)。 */
782
+ async function loadGallery(log) {
783
+ let text;
784
+ try {
785
+ text = await readFile(indexFile(), "utf8");
786
+ } catch {
787
+ return [];
788
+ }
789
+ const items = [];
790
+ for (const line of text.split("\n")) {
791
+ if (line.trim().length === 0) continue;
792
+ try {
793
+ items.push(JSON.parse(line));
794
+ } catch {
795
+ log(`画廊元数据坏行跳过:${line.slice(0, 80)}`);
796
+ }
797
+ }
798
+ return items;
799
+ }
800
+ /** 追加一条元数据(JSONL 原子性:单行小写入)。 */
801
+ async function appendIndex(item) {
802
+ await ensureDirs();
803
+ await appendFile(indexFile(), `${JSON.stringify(item)}\n`, "utf8");
804
+ }
805
+ /** 图片文件绝对路径。 */
806
+ function imagePathOf(imageId, ext) {
807
+ if (!/^[a-f0-9-]{36}$/.test(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) throw new Error(`非法画廊图片标识:${imageId}.${ext}`);
808
+ return join(imagesDir(), `${imageId}.${ext}`);
809
+ }
810
+ /** 读取图片字节(未知 id 抛错由调用方映射 404)。 */
811
+ async function readImageBytes(imageId) {
812
+ const hit = (await readdir(imagesDir()).catch(() => [])).find((name) => name.startsWith(`${imageId}.`));
813
+ if (hit === void 0) throw new Error(`unknown-image: ${imageId}`);
814
+ const ext = hit.slice(imageId.length + 1);
815
+ const data = await readFile(imagePathOf(imageId, ext));
816
+ return {
817
+ data: new Uint8Array(data),
818
+ ext
819
+ };
820
+ }
821
+ /** 画廊新条目:落图片字节 + 追加元数据(一个事务语义,图片先落)。 */
822
+ async function saveGalleryItem(input) {
823
+ await ensureDirs();
824
+ const imageIds = [];
825
+ for (const image of input.images) {
826
+ const imageId = randomUUID();
827
+ const target = imagePathOf(imageId, extOfMime(image.mime));
828
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
829
+ await writeFile(tmp, image.data);
830
+ await rename(tmp, target);
831
+ imageIds.push(imageId);
832
+ }
833
+ const item = {
834
+ ...input.item,
835
+ id: randomUUID(),
836
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
837
+ imageIds,
838
+ revisedPrompts: input.images.map((image) => image.revisedPrompt)
839
+ };
840
+ await appendIndex(item);
841
+ return item;
842
+ }
843
+ /** 删除单条:图片文件与 JSONL 行(重写式删除)。 */
844
+ async function deleteGalleryItem(itemId) {
845
+ const items = await loadGallery(() => {});
846
+ const hit = items.find((item) => item.id === itemId);
847
+ if (hit === void 0) return false;
848
+ for (const imageId of hit.imageIds) {
849
+ const files = await readdir(imagesDir()).catch(() => []);
850
+ for (const name of files) if (name.startsWith(`${imageId}.`)) await rm(join(imagesDir(), name), { force: true });
851
+ }
852
+ const kept = items.filter((item) => item.id !== itemId);
853
+ const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`;
854
+ await writeFile(tmp, kept.map((item) => JSON.stringify(item)).join("\n") + (kept.length > 0 ? "\n" : ""), "utf8");
855
+ await rename(tmp, indexFile());
856
+ return true;
857
+ }
858
+ //#endregion
859
+ //#region src/task/runner.ts
860
+ /**
861
+ * 并发任务执行器。
862
+ * - submit 入队即返回记录引用(状态经 record 可变字段即时可见);
863
+ * - 每次完成自动拉起下一段(pump 链式,无定时器);
864
+ * - cancel 对 queued 置 cancelled、对 running 发 abort(任务体自行响应)。
865
+ */
866
+ var TaskRunner = class {
867
+ entries = /* @__PURE__ */ new Map();
868
+ order = [];
869
+ maxConcurrent;
870
+ nextId;
871
+ /** 完结任务保留条数(防 Map 膨胀;由调用方配置)。 */
872
+ keepFinished;
873
+ constructor(options, keepFinished = 200) {
874
+ this.maxConcurrent = Math.max(0, Math.floor(options.maxConcurrent));
875
+ this.nextId = options.nextId ?? defaultIdCounter();
876
+ this.keepFinished = keepFinished;
877
+ }
878
+ /** 并发上限热更(配置热生效入口;进行中任务不受影响)。 */
879
+ reconfigure(maxConcurrent) {
880
+ this.maxConcurrent = Math.max(0, Math.floor(maxConcurrent));
881
+ }
882
+ /** 提交任务:返回记录(后续状态经同一引用轮询)。 */
883
+ submit(snapshot, run) {
884
+ const controller = new AbortController();
885
+ const record = {
886
+ id: this.nextId(),
887
+ state: "queued",
888
+ snapshot,
889
+ result: null,
890
+ error: null,
891
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
892
+ startedAt: null,
893
+ finishedAt: null
894
+ };
895
+ this.entries.set(record.id, {
896
+ record,
897
+ run,
898
+ controller
899
+ });
900
+ this.order.push(record.id);
901
+ this.pump();
902
+ return record;
903
+ }
904
+ /** 单任务记录查询。 */
905
+ get(id) {
906
+ return this.entries.get(id)?.record ?? null;
907
+ }
908
+ /** 全部记录(完结在前端按需过滤)。 */
909
+ list() {
910
+ return this.order.map((id) => this.entries.get(id)?.record).filter((record) => record !== void 0);
911
+ }
912
+ /** 取消:queued 直接置 cancelled;running 发 abort(异步生效)。 */
913
+ cancel(id) {
914
+ const entry = this.entries.get(id);
915
+ if (entry === void 0) return false;
916
+ if (entry.record.state === "queued") {
917
+ entry.record.state = "cancelled";
918
+ entry.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
919
+ this.prune();
920
+ return true;
921
+ }
922
+ if (entry.record.state === "running") {
923
+ entry.controller.abort();
924
+ return true;
925
+ }
926
+ return false;
927
+ }
928
+ /** 活跃数(queued + running)。 */
929
+ get activeCount() {
930
+ let count = 0;
931
+ for (const entry of this.entries.values()) if (entry.record.state === "queued" || entry.record.state === "running") count += 1;
932
+ return count;
933
+ }
934
+ /** 拉起可运行任务至并发上限。 */
935
+ async pump() {
936
+ for (;;) {
937
+ if ((this.maxConcurrent === 0 ? Number.POSITIVE_INFINITY : this.maxConcurrent - this.runningCount()) <= 0) return;
938
+ const next = this.order.map((id) => this.entries.get(id)).find((entry) => entry !== void 0 && entry.record.state === "queued");
939
+ if (next === void 0) return;
940
+ this.start(next);
941
+ if (this.maxConcurrent !== 0) return;
942
+ }
943
+ }
944
+ start(entry) {
945
+ entry.record.state = "running";
946
+ entry.record.startedAt = (/* @__PURE__ */ new Date()).toISOString();
947
+ const signal = entry.controller.signal;
948
+ entry.run(signal).then((result) => {
949
+ if (signal.aborted) {
950
+ entry.record.state = "failed";
951
+ entry.record.error = "任务已取消";
952
+ entry.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
953
+ return;
954
+ }
955
+ if (entry.record.state !== "running") return;
956
+ entry.record.state = "succeeded";
957
+ entry.record.result = result;
958
+ entry.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
959
+ }).catch((error) => {
960
+ if (entry.record.state === "cancelled") return;
961
+ entry.record.state = "failed";
962
+ entry.record.error = error instanceof Error ? error.message : String(error);
963
+ entry.record.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
964
+ }).finally(() => {
965
+ this.prune();
966
+ this.pump();
967
+ });
968
+ }
969
+ runningCount() {
970
+ let count = 0;
971
+ for (const entry of this.entries.values()) if (entry.record.state === "running") count += 1;
972
+ return count;
973
+ }
974
+ /** 清理最旧完结条目(保持 Map 上限)。 */
975
+ prune() {
976
+ const finished = this.order.filter((id) => {
977
+ const state = this.entries.get(id)?.record.state;
978
+ return state === "succeeded" || state === "failed" || state === "cancelled";
979
+ });
980
+ const overflow = finished.length - this.keepFinished;
981
+ if (overflow <= 0) return;
982
+ for (const id of finished.slice(0, overflow)) {
983
+ this.entries.delete(id);
984
+ const index = this.order.indexOf(id);
985
+ if (index >= 0) this.order.splice(index, 1);
986
+ }
987
+ }
988
+ };
989
+ /** 缺省 id 计数器。 */
990
+ function defaultIdCounter() {
991
+ let counter = 0;
992
+ return () => {
993
+ counter += 1;
994
+ return `task-${counter}`;
995
+ };
996
+ }
997
+ //#endregion
998
+ //#region src/service.ts
999
+ /**
1000
+ * image-studio 服务主体:
1001
+ * - settings installSection(三组预设 + 并发/超时/代理/画廊上限);
1002
+ * - TaskRunner 并发生图:提交即排队,成功自动入画廊(元数据 + 图片落盘);
1003
+ * - 提供商预设按 id 即时 resolve credentials(不缓存);
1004
+ * - 端点:generate/tasks/gallery/images/credentials/providers(api.ts 注册)。
1005
+ * 红线:开发测试仅指向本地 mock,严禁真实调用产生费用。
1006
+ * @module image-studio/service
1007
+ */
1008
+ /** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
1009
+ var ImageStudioService = class extends Service {
1010
+ current;
1011
+ runner;
1012
+ log;
1013
+ constructor(ctx, config) {
1014
+ super(ctx, "imageStudio");
1015
+ this.current = () => config;
1016
+ this.log = (message) => ctx.logger("image-studio").warn(message);
1017
+ this.runner = new TaskRunner({ maxConcurrent: config.maxConcurrent });
1018
+ ctx.inject(["settings"], (settingsCtx) => {
1019
+ this.settingsRef = settingsCtx.settings;
1020
+ settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, config, {
1021
+ setSource: (source) => {
1022
+ this.current = source;
1023
+ this.applyConfig();
1024
+ },
1025
+ onChange: () => {}
1026
+ });
1027
+ });
1028
+ ctx.inject(["webServer"], (webCtx) => {
1029
+ registerImageStudioApi(webCtx, this);
1030
+ });
1031
+ }
1032
+ /** 配置热更:并发上限即时生效(进行中任务不受影响)。 */
1033
+ applyConfig() {
1034
+ this.runner.reconfigure(this.current().maxConcurrent);
1035
+ }
1036
+ promptPresets() {
1037
+ return this.current().promptPresets;
1038
+ }
1039
+ paramPresets() {
1040
+ return this.current().paramPresets;
1041
+ }
1042
+ providerPresets() {
1043
+ return this.current().providerPresets;
1044
+ }
1045
+ /** 预设快照(端点投影)。 */
1046
+ presets() {
1047
+ return {
1048
+ promptPresets: this.promptPresets(),
1049
+ paramPresets: this.paramPresets(),
1050
+ providerPresets: this.providerPresets()
1051
+ };
1052
+ }
1053
+ /** describe 单个提供商预设凭据(不回传值)。 */
1054
+ async credentialStatus(presetId) {
1055
+ const refName = credentialRefNameOf(presetId);
1056
+ const credentials = await this.credentialsSeam();
1057
+ if (credentials === null) return {
1058
+ credentialRef: refName,
1059
+ configured: false
1060
+ };
1061
+ return {
1062
+ credentialRef: refName,
1063
+ configured: (await credentials.describe(credentialRef(refName))).configured
1064
+ };
1065
+ }
1066
+ /** set/unset 凭据(端点写通道)。 */
1067
+ async setCredential(presetId, value) {
1068
+ await (await this.requireCredentials()).set(credentialRef(credentialRefNameOf(presetId)), value);
1069
+ }
1070
+ async unsetCredential(presetId) {
1071
+ await (await this.requireCredentials()).unset(credentialRef(credentialRefNameOf(presetId)));
1072
+ }
1073
+ /** credentials seam 可选探测(缺席返回 null)。 */
1074
+ async credentialsSeam() {
1075
+ const found = this.ctx.get?.("credentials");
1076
+ return found === void 0 || found === null ? null : found;
1077
+ }
1078
+ async requireCredentials() {
1079
+ const seam = await this.credentialsSeam();
1080
+ if (seam === null) throw new ImageStudioError("credential-unavailable");
1081
+ return seam;
1082
+ }
1083
+ /** 提交生图任务:校验 → 入队 → 返回 taskId。 */
1084
+ async submitGenerate(request) {
1085
+ const snapshot = await this.prepareTask(request);
1086
+ const summary = {
1087
+ endpoint: snapshot.endpoint,
1088
+ model: snapshot.target.model,
1089
+ prompt: snapshot.prompt
1090
+ };
1091
+ return this.runner.submit(summary, (signal) => this.executeTask(snapshot, signal)).id;
1092
+ }
1093
+ /** 单任务查询(wire 投影)。 */
1094
+ taskWire(id) {
1095
+ const record = this.runner.get(id);
1096
+ return record === null ? null : this.toTaskWire(record);
1097
+ }
1098
+ /** 任务列表(最新在前)。 */
1099
+ taskWires() {
1100
+ return this.runner.list().reverse().map((record) => this.toTaskWire(record));
1101
+ }
1102
+ /** 取消任务。 */
1103
+ cancelTask(id) {
1104
+ return this.runner.cancel(id);
1105
+ }
1106
+ toTaskWire(record) {
1107
+ const outcome = record.result;
1108
+ return {
1109
+ id: record.id,
1110
+ state: record.state,
1111
+ endpoint: record.snapshot.endpoint,
1112
+ model: record.snapshot.model,
1113
+ promptPreview: record.snapshot.prompt.slice(0, 120),
1114
+ createdAt: record.createdAt,
1115
+ startedAt: record.startedAt,
1116
+ finishedAt: record.finishedAt,
1117
+ error: record.error,
1118
+ galleryItemId: record.state === "succeeded" && outcome !== null ? outcome.galleryItemId : null
1119
+ };
1120
+ }
1121
+ /**
1122
+ * 任务准备(边界校验集中地):提供商预设/inline → target;
1123
+ * 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
1124
+ */
1125
+ async prepareTask(request) {
1126
+ const target = await this.resolveTarget(request);
1127
+ const endpoint = request.endpoint;
1128
+ if (endpoint !== "generation" && endpoint !== "edit") throw new ImageStudioError("invalid-request", `未知端点:${String(endpoint)}`);
1129
+ if (typeof request.prompt !== "string" || request.prompt.trim().length === 0) throw new ImageStudioError("invalid-request", "prompt 不能为空");
1130
+ const params = await this.resolveParams(request, endpoint);
1131
+ const nValue = params.n;
1132
+ const n = typeof nValue === "number" ? nValue : 1;
1133
+ const sourceIds = request.sourceIds ?? [];
1134
+ const images = await this.loadSourceImages(sourceIds, endpoint);
1135
+ const mask = request.maskId !== void 0 ? await this.loadMask(request.maskId) : null;
1136
+ return {
1137
+ target,
1138
+ endpoint,
1139
+ prompt: request.prompt,
1140
+ n,
1141
+ images,
1142
+ mask,
1143
+ params: stripHostParams(params),
1144
+ sourceIds
1145
+ };
1146
+ }
1147
+ /** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
1148
+ async resolveTarget(request) {
1149
+ const config = this.current();
1150
+ const inline = request.inlineProvider;
1151
+ let protocol;
1152
+ let baseUrl;
1153
+ let model;
1154
+ let extraHeaders = {};
1155
+ let refName;
1156
+ if (inline !== void 0) {
1157
+ protocol = inline.protocol;
1158
+ baseUrl = inline.baseUrl;
1159
+ model = inline.model;
1160
+ if (request.providerPresetId === void 0) throw new ImageStudioError("invalid-request", "inline 提供商必须同时给出 providerPresetId 以定位凭据");
1161
+ refName = credentialRefNameOf(request.providerPresetId);
1162
+ } else if (request.providerPresetId !== void 0) {
1163
+ const preset = this.providerPresets().find((item) => item.id === request.providerPresetId);
1164
+ if (preset === void 0) throw new ImageStudioError("invalid-request", `未知提供商预设:${request.providerPresetId}`);
1165
+ protocol = preset.protocol;
1166
+ baseUrl = preset.baseUrl;
1167
+ model = preset.model;
1168
+ extraHeaders = preset.extraHeaders ?? {};
1169
+ refName = isValidPresetId(preset.id) ? credentialRefNameOf(preset.id) : credentialRefNameOf(preset.credentialRef);
1170
+ } else throw new ImageStudioError("invalid-request", "必须提供 providerPresetId 或 inlineProvider");
1171
+ const apiKey = await this.resolveApiKey(refName);
1172
+ const target = {
1173
+ protocol,
1174
+ baseUrl,
1175
+ model,
1176
+ apiKey,
1177
+ extraHeaders,
1178
+ proxy: config.proxy,
1179
+ timeoutMs: config.requestTimeoutMs
1180
+ };
1181
+ target.presetId = inline !== void 0 ? request.providerPresetId ?? null : request.providerPresetId ?? null;
1182
+ return target;
1183
+ }
1184
+ /** 凭据解析(每次操作即时 resolve,不缓存)。 */
1185
+ async resolveApiKey(refName) {
1186
+ const resolved = await (await this.requireCredentials()).resolve(credentialRef(refName));
1187
+ if (resolved === void 0) throw new ImageStudioError("credential-unavailable", `凭据未配置:${refName}`);
1188
+ return resolved.value;
1189
+ }
1190
+ /** 参数预设 + 覆盖合并 → 端点归一化(model 由 target 承载)。 */
1191
+ async resolveParams(request, endpoint) {
1192
+ let specs = {};
1193
+ if (request.paramPresetId !== void 0) {
1194
+ const preset = this.paramPresets().find((item) => item.id === request.paramPresetId);
1195
+ if (preset === void 0) throw new ImageStudioError("invalid-request", `未知参数预设:${request.paramPresetId}`);
1196
+ specs = validateParamSpecs(preset.paramSpecs);
1197
+ }
1198
+ if (request.paramSpecs !== void 0) specs = {
1199
+ ...specs,
1200
+ ...validateParamSpecs(request.paramSpecs)
1201
+ };
1202
+ const normalized = normalizeParamSpecs(specs, endpoint);
1203
+ return {
1204
+ model: this.modelOf(request),
1205
+ ...normalized
1206
+ };
1207
+ }
1208
+ /** 模型优先级:inline > 提供商预设(参数表中的 model 仅为展示,不参与)。 */
1209
+ modelOf(request) {
1210
+ return request.inlineProvider?.model ?? this.providerModelOf(request.providerPresetId);
1211
+ }
1212
+ providerModelOf(presetId) {
1213
+ if (presetId === void 0) return "";
1214
+ return this.providerPresets().find((item) => item.id === presetId)?.model ?? "";
1215
+ }
1216
+ /** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
1217
+ async loadSourceImages(sourceIds, endpoint) {
1218
+ if (endpoint === "generation") return [];
1219
+ if (sourceIds.length === 0) throw new ImageStudioError("invalid-request", "edit 端点必须提供至少一张源图(sourceIds)");
1220
+ if (sourceIds.length > 16) throw new ImageStudioError("invalid-request", "源图最多 16 张(官方 GPT Image 限制)");
1221
+ const images = [];
1222
+ for (const imageId of sourceIds) images.push(await this.loadOneImage(imageId));
1223
+ return images;
1224
+ }
1225
+ async loadMask(maskId) {
1226
+ return this.loadOneImage(maskId);
1227
+ }
1228
+ async loadOneImage(imageId) {
1229
+ try {
1230
+ const { data, ext } = await readImageBytes(imageId);
1231
+ return {
1232
+ data,
1233
+ mime: ext === "jpg" ? "image/jpeg" : `image/${ext}`
1234
+ };
1235
+ } catch {
1236
+ throw new ImageStudioError("unknown-source", `源图不存在:${imageId}`);
1237
+ }
1238
+ }
1239
+ /** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
1240
+ async executeTask(snapshot, signal) {
1241
+ const provider = createProvider(snapshot.target.protocol);
1242
+ if (!provider.endpoints.includes(snapshot.endpoint)) throw new ImageStudioError("unknown-endpoint", `协议 ${provider.id} 不支持端点 ${snapshot.endpoint}`);
1243
+ const result = await provider.generate({
1244
+ target: snapshot.target,
1245
+ endpoint: snapshot.endpoint,
1246
+ prompt: snapshot.prompt,
1247
+ n: snapshot.n,
1248
+ images: snapshot.images,
1249
+ mask: snapshot.mask,
1250
+ params: snapshot.params
1251
+ }, signal);
1252
+ return { galleryItemId: (await saveGalleryItem({
1253
+ item: {
1254
+ providerPresetId: this.presetIdOf(snapshot),
1255
+ protocol: snapshot.target.protocol,
1256
+ endpoint: snapshot.endpoint,
1257
+ model: snapshot.target.model,
1258
+ prompt: snapshot.prompt,
1259
+ params: snapshot.params,
1260
+ sourceIds: snapshot.sourceIds
1261
+ },
1262
+ images: result.images.map((image) => ({
1263
+ data: image.data,
1264
+ mime: image.mime,
1265
+ revisedPrompt: image.revisedPrompt
1266
+ }))
1267
+ })).id };
1268
+ }
1269
+ /** 提交时上下文里的预设 id(executeTask 无原请求,经 target 旁路携带)。 */
1270
+ presetIdOf(snapshot) {
1271
+ return snapshot.target.presetId ?? null;
1272
+ }
1273
+ /** 画廊列表(全量;分页/过滤由端点层做)。 */
1274
+ async gallery() {
1275
+ return loadGallery(this.log);
1276
+ }
1277
+ /** 删除画廊条目(图片 + 元数据)。 */
1278
+ async deleteGalleryItem(itemId) {
1279
+ return deleteGalleryItem(itemId);
1280
+ }
1281
+ /** 读取图片字节(端点直投流)。 */
1282
+ async imageBytes(imageId) {
1283
+ try {
1284
+ return await readImageBytes(imageId);
1285
+ } catch {
1286
+ return null;
1287
+ }
1288
+ }
1289
+ dispose() {}
1290
+ };
1291
+ /** 剔除 host 承载参数(model 不进协议参数表——openai-images 请求体单独拼)。 */
1292
+ function stripHostParams(params) {
1293
+ const { model: _model, ...rest } = params;
1294
+ return rest;
1295
+ }
1296
+ //#endregion
1297
+ //#region src/index.ts
1298
+ const name = "dsh-plus-image-studio";
1299
+ const inject = ["credentials"];
1300
+ function apply(ctx, config) {
1301
+ ctx.plugin(ImageStudioService, config);
1302
+ }
1303
+ //#endregion
1304
+ export { Config, ImageStudioError, ImageStudioService, PARAM_CATALOG, ParamValidationError, apply, credentialRefNameOf, inject, isValidPresetId, name, normalizeParamSpecs, paramEntryOf, paramKeysFor, validateParamSpecs };