dsh-agnes-create 0.3.0 → 0.4.1

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 CHANGED
@@ -66,9 +66,31 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
66
66
 
67
67
  | 类型 | 尺寸 | 宽高比 | 其它 |
68
68
  | --- | --- | --- | --- |
69
- | 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` | — |
69
+ | 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` | 可选 1–8 张参考图(图生图 / 多图合成) |
70
70
  | 视频 | 固定 `720P` | `21:9` `16:9` `4:3` `1:1` `3:4` `9:16` | 时长 4–12 秒 |
71
71
 
72
+ ### 图生图 / 多图合成
73
+
74
+ 「参考图」区有两种来源,可以混着加,最多 8 张:
75
+
76
+ - **+ 本地图片** —— 从磁盘选一个或多个文件(PNG / JPEG / WebP / GIF)
77
+ - **+ 生成结果** —— 从下方「最近生成」里挑一张(视频不能当参考图)
78
+
79
+ 加好之后提示词写「想怎么改」,按钮会变成 **图生图**:
80
+
81
+ | 参考图数量 | 实际行为 |
82
+ | --- | --- |
83
+ | 0 张 | 纯文生图 |
84
+ | 1 张 | 图生图(按参考图重绘 / 改风格 / 换背景) |
85
+ | 2–8 张 | 多图合成(把多张图的元素 / 配色融合) |
86
+
87
+ 实现细节:
88
+
89
+ - 图片在**浏览器侧先下采样到长边 1280** 再编码成 data URI(PNG 优先保留透明通道,超过 800 KB 自动转 JPEG),所以一张 4K 原图发出的请求也只有几百 KB
90
+ - 参考图走接口的 `extra_body.image`;多图就是往这个数组里放多条
91
+ - 校验在前端和内核各做一次:只接受 `http(s)` 或 `data:image/…;base64`,超过 8 张、空串、非图片类型都会直接报错,不会把脏数据发给上游
92
+ - 本地文件路径**不会**被读取或上传到别处:文件内容只在内存里转成 base64 随请求发给 Agnes
93
+
72
94
  ## 关于费用
73
95
 
74
96
  插件的费用由 Agnes AI 账号结算,本插件不涉及任何计费。
@@ -82,7 +104,8 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
82
104
 
83
105
  - 视频为**异步任务**:提交后轮询,通常需要 1–4 分钟;队列繁忙时提交会返回 `503`,插件会自动重试
84
106
  - 查询接口有频率限制,触发 `429` 时面板会显示「限流重试中…」并退避,任务不会因此失败
85
- - 只支持文生图 / 文生视频,暂未接图生图、首尾帧与参考音频
107
+ - 视频只支持**文生视频**:暂无首尾帧、参考音频与视频输入(Flash 版接口本身不支持这些)
108
+ - 参考图最多 8 张;单张在浏览器侧压到长边 1280,细节极多的小字参考图重绘后可能看不清
86
109
  - 生成结果只保留最近 8 条(本次会话内),刷新页面后历史清空;文件本身在输出目录里不会丢
87
110
 
88
111
  ## 开发
package/lib/agnes.js CHANGED
@@ -40,6 +40,35 @@ export const TIMEOUT_MS = 300_000; // 文档建议 60s–360s
40
40
  export const SIZES = ['1K', '2K', '3K', '4K'];
41
41
  export const RATIOS = ['1:1', '3:4', '4:3', '16:9', '9:16', '2:3', '3:2', '21:9'];
42
42
 
43
+ /** 图生图 / 多图合成:一次最多几张参考图。文档没写上限,8 张是面板与请求体的安全值。 */
44
+ export const MAX_REF_IMAGES = 8;
45
+
46
+ /** data URI 只接受图片 MIME,其余一律拒绝(防止把任意 base64 塞进上游请求)。 */
47
+ const DATA_URI_RE = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i;
48
+
49
+ /**
50
+ * 校验并规整参考图数组。
51
+ * 文档要求 extra_body.image 里是「公共图像 URL 或 Data URI Base64」,
52
+ * 所以这里只放行 http(s) 与 data:image 两种,别的直接报错而不是透传给上游。
53
+ */
54
+ export function normalizeRefs(images) {
55
+ if (images === undefined || images === null) return [];
56
+ if (!Array.isArray(images)) throw new Error('参考图必须是数组');
57
+ if (images.length > MAX_REF_IMAGES) {
58
+ throw new Error(`参考图最多 ${MAX_REF_IMAGES} 张,收到 ${images.length} 张`);
59
+ }
60
+ return images.map((value, i) => {
61
+ if (typeof value !== 'string' || value.trim() === '') {
62
+ throw new Error(`第 ${i + 1} 张参考图不是有效字符串`);
63
+ }
64
+ const s = value.trim();
65
+ if (/^https?:\/\//i.test(s)) return s;
66
+ if (DATA_URI_RE.test(s)) return s;
67
+ const kind = s.slice(0, 30).replace(/\s+/g, ' ');
68
+ throw new Error(`第 ${i + 1} 张参考图只接受 http(s) 链接或 data:image/…;base64,收到:${kind}`);
69
+ });
70
+ }
71
+
43
72
  /** 把开头的 ~ 展开成家目录;其余原样返回。 */
44
73
  export function expandHome(p) {
45
74
  const s = String(p ?? '').trim();
@@ -210,10 +239,13 @@ export async function generate({
210
239
  if (!SIZES.includes(size)) throw new Error(`size 必须是 ${SIZES.join(' / ')}`);
211
240
  if (!RATIOS.includes(ratio)) throw new Error(`ratio 必须是 ${RATIOS.join(' / ')}`);
212
241
 
242
+ // 有参考图 → 图生图 / 多图合成;没有 → 纯文生图。校验在发请求之前做完。
243
+ const refs = normalizeRefs(images);
244
+
213
245
  const key = await readKey();
214
246
  const started = Date.now();
215
247
 
216
- let { payload, item } = await post({ baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt, images }) });
248
+ let { payload, item } = await post({ baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt, images: refs }) });
217
249
 
218
250
  const dir = outDir ?? (await resolvePaths()).outDir;
219
251
  const name = filename || `agnes-${stamp()}.png`;
@@ -266,6 +298,9 @@ export async function generate({
266
298
  size,
267
299
  ratio,
268
300
  model,
301
+ // 有参考图 = 图生图 / 多图合成;没有 = 文生图。面板据此显示模式。
302
+ mode: refs.length > 0 ? 'img2img' : 'text2img',
303
+ refCount: refs.length,
269
304
  revisedPrompt: item.revised_prompt ?? null,
270
305
  remoteUrl,
271
306
  };
package/lib/client.js CHANGED
@@ -16,6 +16,12 @@ window.__ModuleLoader__.load({
16
16
  const HISTORY_MAX = 8;
17
17
  const POLL_MS = 6000;
18
18
  const VIDEO_DEADLINE_MS = 15 * 60 * 1000;
19
+ // 图生图:一次最多 8 张参考图(与宿主 MAX_REF_IMAGES 保持一致)
20
+ const MAX_REFS = 8;
21
+ // 发给上游前先下采样:长边 1280 足够模型识别,base64 也压得住
22
+ const REF_MAX_EDGE = 1280;
23
+ const REF_QUALITY = 0.88;
24
+ const REF_PNG_KEEP_BYTES = 800 * 1024;
19
25
 
20
26
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
27
 
@@ -109,6 +115,35 @@ window.__ModuleLoader__.load({
109
115
  .agn-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.6; }
110
116
  .agn-code { font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px;
111
117
  word-break: break-all; }
118
+
119
+ /* 参考图(图生图 / 多图合成) */
120
+ .agn-refs { display: flex; flex-direction: column; gap: 8px; }
121
+ .agn-ref-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
122
+ .agn-ref-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
123
+ .agn-ref { position: relative; width: 72px; height: 72px; border-radius: 8px; overflow: hidden;
124
+ flex: none; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
125
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
126
+ .agn-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
127
+ .agn-ref-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
128
+ border: none; border-radius: 50%; cursor: pointer; padding: 0;
129
+ font-size: 11px; line-height: 18px; text-align: center;
130
+ color: #fff; background: rgba(0, 0, 0, .6); }
131
+ .agn-ref-del:hover { background: rgba(0, 0, 0, .8); }
132
+ .agn-ref-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer; flex: none;
133
+ font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
134
+ color: var(--dsw-alias-label-secondary, #666); background: transparent;
135
+ border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
136
+ .agn-ref-add:disabled { opacity: .45; cursor: not-allowed; }
137
+ .agn-ref-pick { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px; border-radius: 8px;
138
+ border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
139
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
140
+ .agn-ref-pick button { width: 56px; height: 56px; padding: 0; border-radius: 6px;
141
+ overflow: hidden; cursor: pointer; flex: none;
142
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); background: transparent; }
143
+ .agn-ref-pick img { width: 100%; height: 100%; object-fit: cover; display: block; }
144
+ .agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
145
+ background: var(--dsw-alias-bg-layer-2, #f1f1f1);
146
+ color: var(--dsw-alias-label-secondary, #555); }
112
147
  `;
113
148
 
114
149
  function ensureCss() {
@@ -125,6 +160,84 @@ window.__ModuleLoader__.load({
125
160
  return "/agnes/media?name=" + encodeURIComponent(name);
126
161
  }
127
162
 
163
+ /* ==========================================================================
164
+ 参考图预处理(图生图)
165
+ --------------------------------------------------------------------------
166
+ 两个来源最终都归一成 data URI:
167
+ · 本地文件 —— <input type="file"> 给的 File
168
+ · 生成结果 —— /agnes/media 取回 Blob
169
+ 先按长边 1280 下采样再编码,否则一张 4K PNG 的 base64 就有十几 MB,
170
+ 宿主请求体和上游都要跟着炸。
171
+ ========================================================================== */
172
+
173
+ let refSeq = 0;
174
+ const nextRefId = () => "ref-" + (refSeq += 1) + "-" + Date.now();
175
+
176
+ function blobToDataUrl(blob) {
177
+ return new Promise((resolve, reject) => {
178
+ const fr = new FileReader();
179
+ fr.onload = () => resolve(String(fr.result));
180
+ fr.onerror = () => reject(new Error("读取图片失败"));
181
+ fr.readAsDataURL(blob);
182
+ });
183
+ }
184
+
185
+ /** 编码一次 canvas;toBlob 回调可能是 null(极端尺寸/内存不足) */
186
+ function canvasToBlob(canvas, type, quality) {
187
+ return new Promise((resolve) => {
188
+ try {
189
+ canvas.toBlob((b) => resolve(b), type, quality);
190
+ } catch {
191
+ resolve(null);
192
+ }
193
+ });
194
+ }
195
+
196
+ /**
197
+ * 把任意图片 Blob 压成适合上传的 data URI。
198
+ * PNG 优先保留(多图合成常带透明通道),但超过阈值就退回 JPEG。
199
+ */
200
+ async function shrinkImage(blob) {
201
+ if (!(blob instanceof Blob) || blob.size === 0) throw new Error("图片是空的");
202
+
203
+ let bitmap;
204
+ try {
205
+ bitmap = await createImageBitmap(blob);
206
+ } catch {
207
+ // 极少数格式解码不了:小图原样上传,大图明确报错而不是偷偷传十几 MB
208
+ if (blob.size <= REF_PNG_KEEP_BYTES) {
209
+ return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
210
+ }
211
+ throw new Error("无法解码这张图片(试试 PNG/JPEG/WebP)");
212
+ }
213
+
214
+ const longest = Math.max(bitmap.width, bitmap.height) || 1;
215
+ const scale = Math.min(1, REF_MAX_EDGE / longest);
216
+ const w = Math.max(1, Math.round(bitmap.width * scale));
217
+ const h = Math.max(1, Math.round(bitmap.height * scale));
218
+
219
+ const canvas = document.createElement("canvas");
220
+ canvas.width = w;
221
+ canvas.height = h;
222
+ const ctx = canvas.getContext("2d");
223
+ if (ctx === null) {
224
+ if (typeof bitmap.close === "function") bitmap.close();
225
+ return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
226
+ }
227
+ ctx.drawImage(bitmap, 0, 0, w, h);
228
+ if (typeof bitmap.close === "function") bitmap.close();
229
+
230
+ const wantPng = blob.type === "image/png";
231
+ let out = await canvasToBlob(canvas, wantPng ? "image/png" : "image/jpeg", REF_QUALITY);
232
+ if (out === null) throw new Error("图片编码失败");
233
+ if (wantPng && out.size > REF_PNG_KEEP_BYTES) {
234
+ const jpeg = await canvasToBlob(canvas, "image/jpeg", REF_QUALITY);
235
+ if (jpeg !== null && jpeg.size < out.size) out = jpeg;
236
+ }
237
+
238
+ return { src: await blobToDataUrl(out), bytes: out.size, shrunk: scale < 1 };
239
+ }
240
+
128
241
  /* ==========================================================================
129
242
  状态仓库(放组件外面:main slot 切换会卸载组件,状态必须独立存活)
130
243
  ========================================================================== */
@@ -160,6 +273,12 @@ window.__ModuleLoader__.load({
160
273
  cfgSaving: false,
161
274
  cfgError: "",
162
275
  cfgNotice: "",
276
+
277
+ /* ---- 图生图参考图 ---- */
278
+ refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
279
+ refsBusy: false,
280
+ refsError: "",
281
+ refPickOpen: false,
163
282
  };
164
283
 
165
284
  const snapshot = () => ({ ...state, history: state.history.slice() });
@@ -172,6 +291,12 @@ window.__ModuleLoader__.load({
172
291
  Object.assign(state, patch);
173
292
  notify();
174
293
  };
294
+ /** 基于当前状态计算补丁:避免「读旧值再写回」导致的覆盖。 */
295
+ const update = (fn) => {
296
+ const patch = fn(state);
297
+ if (patch !== null && patch !== undefined) Object.assign(state, patch);
298
+ notify();
299
+ };
175
300
 
176
301
  let timer = null;
177
302
  let runToken = 0;
@@ -259,10 +384,18 @@ window.__ModuleLoader__.load({
259
384
 
260
385
  /* ---------- 图片:一次请求就出结果 ---------- */
261
386
  async function runImage(token, prompt) {
387
+ const refs = state.refs;
388
+ const payload = {
389
+ prompt,
390
+ size: state.size,
391
+ ratio: state.ratio,
392
+ // 有参考图就是图生图 / 多图合成;宿主原样透传给上游的 extra_body.image
393
+ ...(refs.length > 0 ? { images: refs.map((r) => r.src) } : {}),
394
+ };
262
395
  const res = await fetch("/agnes/generate", {
263
396
  method: "POST",
264
397
  headers: { "content-type": "application/json" },
265
- body: JSON.stringify({ prompt, size: state.size, ratio: state.ratio }),
398
+ body: JSON.stringify(payload),
266
399
  });
267
400
  const data = await res.json();
268
401
  if (!data.ok) throw new Error(data.error || "生成失败");
@@ -270,6 +403,122 @@ window.__ModuleLoader__.load({
270
403
  pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
271
404
  }
272
405
 
406
+ /* ---------- 参考图(图生图) ---------- */
407
+
408
+ const refsFull = () => state.refs.length >= MAX_REFS;
409
+
410
+ /**
411
+ * 参考图操作串行化。
412
+ *
413
+ * 之前用 `if (state.refsBusy) return` 做互斥:一旦某个操作正在跑
414
+ * (读生成结果要 fetch + 解码,可能几百毫秒),这期间用户的另一次添加
415
+ * 会被**静默丢弃**——点了没反应,看起来就像"刚加的图不见了"。
416
+ * 现在改成排队执行:不丢单,也不会出现两个操作互相覆盖 refs。
417
+ */
418
+ let refQueue = Promise.resolve();
419
+ function queueRefOp(task) {
420
+ const run = refQueue.then(task, task);
421
+ // 单个操作失败不能卡死后面的队列
422
+ refQueue = run.catch(() => {});
423
+ return run;
424
+ }
425
+
426
+ /** 来源一:本地磁盘文件(可多选)。返回 Promise,调用方可等它读完再清空 input。 */
427
+ function addRefsFromFiles(files) {
428
+ const list = Array.from(files || []).filter(
429
+ (f) => f && String(f.type || "").startsWith("image/")
430
+ );
431
+ if (list.length === 0) {
432
+ set({ refsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
433
+ return Promise.resolve();
434
+ }
435
+
436
+ return queueRefOp(async () => {
437
+ set({ refsBusy: true, refsError: "" });
438
+ const added = [];
439
+ const failed = [];
440
+ try {
441
+ for (const file of list) {
442
+ if (state.refs.length + added.length >= MAX_REFS) {
443
+ failed.push("超出 " + MAX_REFS + " 张上限");
444
+ break;
445
+ }
446
+ try {
447
+ // 先把文件读进内存再继续:即使 input.value 被清空,读也不会断
448
+ const r = await shrinkImage(file);
449
+ added.push({
450
+ id: nextRefId(),
451
+ name: file.name || "本地图片",
452
+ src: r.src,
453
+ bytes: r.bytes,
454
+ from: "local",
455
+ });
456
+ } catch (e) {
457
+ failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
458
+ }
459
+ }
460
+ if (added.length > 0) {
461
+ // 用 update 基于最新状态追加,杜绝覆盖别的来源刚加进来的图
462
+ update((prev) => ({ refs: prev.refs.concat(added) }));
463
+ }
464
+ set({
465
+ refsError: failed.length > 0 ? failed.join(";") : "",
466
+ refPickOpen: false,
467
+ });
468
+ } finally {
469
+ set({ refsBusy: false });
470
+ }
471
+ });
472
+ }
473
+
474
+ /** 来源二:生成结果里的图片(通过 /agnes/media 取回再下采样) */
475
+ function addRefFromResult(entry) {
476
+ if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
477
+ if (refsFull()) {
478
+ set({ refsError: "参考图最多 " + MAX_REFS + " 张" });
479
+ return Promise.resolve();
480
+ }
481
+
482
+ return queueRefOp(async () => {
483
+ set({ refsBusy: true, refsError: "" });
484
+ try {
485
+ const res = await fetch(srcOf(entry.filename));
486
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
487
+ const blob = await res.blob();
488
+ const r = await shrinkImage(blob);
489
+ update((prev) => ({
490
+ refs: prev.refs.concat([{
491
+ id: nextRefId(),
492
+ name: entry.filename,
493
+ src: r.src,
494
+ bytes: r.bytes,
495
+ from: "result",
496
+ }]),
497
+ refPickOpen: false,
498
+ }));
499
+ } catch (e) {
500
+ set({ refsError: e && e.message ? e.message : String(e) });
501
+ } finally {
502
+ set({ refsBusy: false });
503
+ }
504
+ });
505
+ }
506
+
507
+ function removeRef(id) {
508
+ if (state.busy) return;
509
+ update((prev) => ({ refs: prev.refs.filter((r) => r.id !== id), refsError: "" }));
510
+ }
511
+
512
+ function clearRefs() {
513
+ if (state.busy) return;
514
+ set({ refs: [], refsError: "", refPickOpen: false });
515
+ }
516
+
517
+ function toggleRefPick() {
518
+ if (state.busy) return;
519
+ set({ refPickOpen: !state.refPickOpen, refsError: "" });
520
+ }
521
+
273
522
  /* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
274
523
  async function runVideo(token, prompt) {
275
524
  set({ vStatus: "提交中…", vProgress: 0, videoId: null });
@@ -344,7 +593,7 @@ window.__ModuleLoader__.load({
344
593
  }
345
594
 
346
595
  async function run() {
347
- if (state.busy) return;
596
+ if (state.busy || state.refsBusy) return;
348
597
  const prompt = state.prompt.trim();
349
598
  if (prompt === "") return;
350
599
 
@@ -385,6 +634,13 @@ window.__ModuleLoader__.load({
385
634
  },
386
635
  toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
387
636
  run,
637
+ /* ---- 参考图动作 ---- */
638
+ addRefsFromFiles,
639
+ addRefFromResult,
640
+ removeRef,
641
+ clearRefs,
642
+ toggleRefPick,
643
+ maxRefs: MAX_REFS,
388
644
  /** 切换主预览到历史里的某一张 */
389
645
  show(entry) { set({ result: entry }); },
390
646
  /**
@@ -506,14 +762,18 @@ window.__ModuleLoader__.load({
506
762
  const key = s.health && s.health.key ? s.health.key : null;
507
763
  const keyBad = key !== null && key.present !== true;
508
764
  const shown = s.result;
765
+ const hasRefs = s.refs.length > 0;
766
+ const refsFull = s.refs.length >= MAX_REFS;
767
+ const imageHistory = s.history.filter((h) => h.kind === "image");
768
+ const fileInput = React.useRef(null);
509
769
 
510
770
  const onKeyDown = (e) => {
511
771
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
512
772
  };
513
773
 
514
774
  const buttonLabel = busy
515
- ? (isVideo ? "生成视频… " : "生成中… ") + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
516
- : (isVideo ? "生成视频" : "生成");
775
+ ? (isVideo ? "生成视频… " : (hasRefs ? "图生图中… " : "生成中… ")) + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
776
+ : (isVideo ? "生成视频" : (hasRefs ? "图生图" : "生成"));
517
777
 
518
778
  const modeBtn = (mode, text) => React.createElement("button", {
519
779
  key: mode,
@@ -556,6 +816,25 @@ window.__ModuleLoader__.load({
556
816
  : (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
557
817
 
558
818
  return React.createElement("div", { className: "agn-root" },
819
+ /* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
820
+ React.createElement("input", {
821
+ ref: fileInput,
822
+ type: "file",
823
+ accept: "image/png,image/jpeg,image/webp,image/gif",
824
+ multiple: true,
825
+ style: { display: "none" },
826
+ onChange: (e) => {
827
+ const el = e.target;
828
+ const files = el.files;
829
+ // 关键:等文件真正读完再清空 input。
830
+ // 之前是「发起读取后立刻 el.value = ''」,Chromium 下会把刚选的文件
831
+ // 的底层数据一起释放,读取中途失败 → 这一次添加被整单丢掉。
832
+ Promise.resolve(store.addRefsFromFiles(files))
833
+ .catch(() => {})
834
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
835
+ },
836
+ }),
837
+
559
838
  React.createElement("div", { className: "agn-head" },
560
839
  React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
561
840
  React.createElement("span", { className: "agn-meta" },
@@ -569,6 +848,79 @@ window.__ModuleLoader__.load({
569
848
  modeBtn("image", "图片"),
570
849
  modeBtn("video", "视频")
571
850
  ),
851
+
852
+ /* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
853
+ !isVideo
854
+ ? React.createElement("div", { className: "agn-refs" },
855
+ React.createElement("div", { className: "agn-ref-head" },
856
+ React.createElement("span", { className: "agn-label" }, "参考图"),
857
+ React.createElement("span", { className: "agn-tag" },
858
+ s.refs.length + " / " + MAX_REFS),
859
+ s.refsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
860
+ s.refs.length > 0
861
+ ? React.createElement("button", {
862
+ type: "button", className: "agn-btn-ghost", disabled: busy,
863
+ onClick: () => store.clearRefs(),
864
+ }, "清空参考图")
865
+ : null
866
+ ),
867
+ React.createElement("div", { className: "agn-ref-strip" },
868
+ s.refs.map((r) => React.createElement("div", {
869
+ key: r.id,
870
+ className: "agn-ref",
871
+ title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
872
+ (r.from === "result" ? "来自生成结果" : "来自本地"),
873
+ },
874
+ React.createElement("img", { src: r.src, alt: r.name }),
875
+ React.createElement("button", {
876
+ type: "button", className: "agn-ref-del",
877
+ title: "移除这张参考图",
878
+ disabled: busy,
879
+ onClick: () => store.removeRef(r.id),
880
+ }, "×")
881
+ )),
882
+ React.createElement("button", {
883
+ type: "button", className: "agn-ref-add",
884
+ disabled: busy || refsFull,
885
+ title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
886
+ onClick: () => {
887
+ const el = fileInput.current;
888
+ if (el !== null) el.click();
889
+ },
890
+ }, "+ 本地图片"),
891
+ React.createElement("button", {
892
+ type: "button", className: "agn-ref-add",
893
+ disabled: busy || refsFull || imageHistory.length === 0,
894
+ title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
895
+ onClick: () => store.toggleRefPick(),
896
+ }, "+ 生成结果")
897
+ ),
898
+
899
+ /* 从生成结果挑图:只列图片(视频不能当参考图) */
900
+ s.refPickOpen
901
+ ? React.createElement("div", { className: "agn-ref-pick" },
902
+ imageHistory.length === 0
903
+ ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
904
+ : imageHistory.map((h) => React.createElement("button", {
905
+ key: "pick-" + h.filename,
906
+ type: "button",
907
+ title: h.filename,
908
+ onClick: () => store.addRefFromResult(h),
909
+ }, React.createElement("img", {
910
+ src: srcOf(h.filename), alt: h.filename, loading: "lazy",
911
+ })))
912
+ )
913
+ : null,
914
+
915
+ React.createElement("div", { className: "agn-note" },
916
+ hasRefs
917
+ ? "将按参考图重绘(" + s.refs.length + " 张" +
918
+ (s.refs.length > 1 ? ",多图合成" : "") + ")——提示词写「想怎么改」"
919
+ : "不加参考图 = 纯文生图;加 1 张 = 图生图;加多张 = 多图合成"),
920
+ s.refsError !== "" ? React.createElement("div", { className: "agn-err" }, s.refsError) : null
921
+ )
922
+ : null,
923
+
572
924
  React.createElement("div", { className: "agn-label" }, "提示词"),
573
925
  React.createElement("textarea", {
574
926
  className: "agn-textarea",
@@ -584,7 +936,7 @@ window.__ModuleLoader__.load({
584
936
  params,
585
937
  React.createElement("button", {
586
938
  className: "agn-btn", type: "button",
587
- disabled: busy || s.prompt.trim() === "" || keyBad,
939
+ disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
588
940
  onClick: () => store.run(),
589
941
  }, buttonLabel)
590
942
  ),
@@ -642,6 +994,7 @@ window.__ModuleLoader__.load({
642
994
  : (shown.bytes / 1024).toFixed(0) + " KB")
643
995
  + " · " + shown.size + "/" + shown.ratio
644
996
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
997
+ + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
645
998
  + " · 用时 " + shown.elapsedSec + "s"),
646
999
  React.createElement("br"),
647
1000
  React.createElement("span", null, "已保存到 "),
@@ -777,7 +1130,7 @@ window.__ModuleLoader__.load({
777
1130
 
778
1131
  exports.apply = apply;
779
1132
  exports.inject = ["slots"];
780
- exports.__internals = { getStore, createStore, srcOf };
1133
+ exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
781
1134
  return module.exports;
782
1135
  },
783
1136
  });
package/lib/index.js CHANGED
@@ -20,13 +20,20 @@
20
20
 
21
21
  import {
22
22
  MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
23
- DEFAULT_BASE, SIZES, RATIOS,
23
+ DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES,
24
24
  generate, keyStatus, readMedia, resolvePaths, saveConfig,
25
25
  submitVideo, queryVideo, saveVideo,
26
26
  } from './agnes.js';
27
27
 
28
28
  export const name = 'dsh-agnes-create';
29
29
 
30
+ /** 普通 JSON 请求(配置、视频)1 MB 够用。 */
31
+ const BODY_LIMIT = 1_000_000;
32
+
33
+ /** 图生图的参考图走 base64 data URI,必须单独放宽到 32 MB:
34
+ * 8 张 × 前端下采样后的约 300–600 KB,base64 膨胀 4/3,再留足余量。 */
35
+ const REF_BODY_LIMIT = 32 * 1024 * 1024;
36
+
30
37
  function sendJson(res, data, status = 200) {
31
38
  res.writeHead(status, {
32
39
  'content-type': 'application/json; charset=utf-8',
@@ -35,20 +42,24 @@ function sendJson(res, data, status = 200) {
35
42
  res.end(JSON.stringify(data));
36
43
  }
37
44
 
38
- function readJson(req) {
45
+ function readJson(req, limit = BODY_LIMIT) {
39
46
  return new Promise((resolve, reject) => {
40
47
  const chunks = [];
41
48
  let size = 0;
49
+ let aborted = false;
42
50
  req.on('data', (c) => {
51
+ if (aborted) return;
43
52
  size += c.length;
44
- if (size > 1_000_000) {
45
- reject(new Error('请求体过大'));
53
+ if (size > limit) {
54
+ aborted = true;
55
+ reject(new Error(`请求体过大(上限 ${(limit / 1024 / 1024).toFixed(0)} MB)`));
46
56
  req.destroy();
47
57
  return;
48
58
  }
49
59
  chunks.push(c);
50
60
  });
51
61
  req.on('end', () => {
62
+ if (aborted) return;
52
63
  const text = Buffer.concat(chunks).toString('utf8');
53
64
  if (text.trim() === '') return resolve({});
54
65
  try {
@@ -148,6 +159,7 @@ export function apply(ctx) {
148
159
  defaults: cfg.defaults,
149
160
  sizes: SIZES,
150
161
  ratios: RATIOS,
162
+ maxRefImages: MAX_REF_IMAGES, // 图生图 / 多图合成的参考图上限
151
163
  videoSizes: [VIDEO_SIZE],
152
164
  videoSeconds: VIDEO_SECONDS,
153
165
  videoRatios: VIDEO_RATIOS,
@@ -210,7 +222,7 @@ export function apply(ctx) {
210
222
  handler: async (req, res) => {
211
223
  if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
212
224
  try {
213
- const body = await readJson(req);
225
+ const body = await readJson(req, REF_BODY_LIMIT);
214
226
  if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
215
227
  return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
216
228
  }
@@ -219,10 +231,16 @@ export function apply(ctx) {
219
231
  size: body.size,
220
232
  ratio: body.ratio,
221
233
  model: body.model,
234
+ // 图生图 / 多图合成:参考图数组(data URI 或 http(s) 链接)。
235
+ // 校验在内核 normalizeRefs 里做,这里只负责透传。
236
+ images: body.images,
222
237
  });
223
238
  sendJson(res, { ...result, kind: 'image' });
224
239
  } catch (e) {
225
- sendJson(res, { ok: false, error: errMessage(e) }, 500);
240
+ // 参考图参数不合法属于用户输入问题,用 400 区分于上游 500
241
+ const msg = errMessage(e);
242
+ const status = /参考图|images|prompt|size 必须|ratio 必须/.test(msg) ? 400 : 500;
243
+ sendJson(res, { ok: false, error: msg }, status);
226
244
  }
227
245
  },
228
246
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-agnes-create",
3
- "version": "0.3.0",
4
- "description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash 出图、agnes-video-2.5-flash 出视频。侧边栏面板 + 提示词输入框,Key 与输出目录可在界面里配置。",
3
+ "version": "0.4.1",
4
+ "description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash 出图(含图生图 / 多图合成)、agnes-video-2.5-flash 出视频。侧边栏面板 + 提示词 + 参考图,结果内联展示,Key 与输出目录可在面板配置。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -31,7 +31,10 @@
31
31
  "deepseek-harness",
32
32
  "agnes",
33
33
  "image-generation",
34
- "video-generation"
34
+ "video-generation",
35
+ "image-to-image",
36
+ "img2img",
37
+ "multi-image"
35
38
  ],
36
39
  "engines": {
37
40
  "dsh": ">=0.2.2-rc.1"