dsh-agnes-create 0.4.1 → 0.10.3

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/client.js CHANGED
@@ -13,16 +13,34 @@ window.__ModuleLoader__.load({
13
13
  // 视频:Flash 固定 720P,seconds 是字符串 "4"–"12"
14
14
  const VIDEO_SECONDS = ["4", "5", "6", "7", "8", "9", "10", "11", "12"];
15
15
  const VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
16
- const HISTORY_MAX = 8;
16
+ /* 与宿主 agnes.js 的 HISTORY_MAX 保持一致:首屏先按 24 条渲染,
17
+ loadHistory() 回来后以宿主返回的 max 为准 */
18
+ const HISTORY_MAX = 24;
17
19
  const POLL_MS = 6000;
18
20
  const VIDEO_DEADLINE_MS = 15 * 60 * 1000;
19
21
  // 图生图:一次最多 8 张参考图(与宿主 MAX_REF_IMAGES 保持一致)
20
22
  const MAX_REFS = 8;
23
+
24
+ /** 视频 reference 模式的参考图上限(上游:images 最多 5 张) */
25
+ const MAX_VREFS = 5;
21
26
  // 发给上游前先下采样:长边 1280 足够模型识别,base64 也压得住
22
27
  const REF_MAX_EDGE = 1280;
23
28
  const REF_QUALITY = 0.88;
24
29
  const REF_PNG_KEEP_BYTES = 800 * 1024;
25
30
 
31
+ /**
32
+ * 提示词预设:点一下把风格描述追加到当前提示词末尾。
33
+ * 刻意写成「可叠加的风格后缀」,而不是整段替换——用户已经写好的主体不会被冲掉。
34
+ */
35
+ const PROMPT_PRESETS = [
36
+ { label: "写实摄影", text: "写实摄影风格,自然光,浅景深,35mm 镜头,细节丰富" },
37
+ { label: "水彩", text: "水彩画风格,纸纹质感,柔和晕染,留白构图" },
38
+ { label: "3D 渲染", text: "3D 渲染风格,柔光棚拍,材质清晰,背景简洁" },
39
+ { label: "海报", text: "平面海报构图,大面积留白,主体突出,配色克制" },
40
+ { label: "夜景霓虹", text: "夜景霓虹灯光,湿润地面反光,氛围浓烈" },
41
+ { label: "极简", text: "极简构图,单一主体,纯净背景,柔和渐变光" },
42
+ ];
43
+
26
44
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
45
 
28
46
  /* ---------- 样式 ---------- */
@@ -32,6 +50,13 @@ window.__ModuleLoader__.load({
32
50
  .agn-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
33
51
  .agn-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, inherit); }
34
52
  .agn-meta { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }
53
+ /* 标题栏里的模型下拉:长得像一行说明文字,但可以点开换模型 */
54
+ .agn-model { font: inherit; font-size: 12px; line-height: 1.4; padding: 1px 4px;
55
+ max-width: 220px; cursor: pointer; border-radius: 6px;
56
+ color: var(--dsw-alias-label-secondary, #888); background: transparent;
57
+ border: 1px solid transparent; }
58
+ .agn-model:hover:not(:disabled) { border-color: var(--dsw-alias-border-l2, #ddd); }
59
+ .agn-model:disabled { opacity: .6; cursor: not-allowed; }
35
60
  .agn-card { border: 1px solid var(--dsw-alias-border-l2, #e3e3e3); border-radius: 10px;
36
61
  padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
37
62
  .agn-label { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #666); }
@@ -73,7 +98,8 @@ window.__ModuleLoader__.load({
73
98
  transition: width .3s ease; }
74
99
 
75
100
  /* 媒体展示区 */
76
- .agn-shot { border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
101
+ .agn-shot { position: relative; border-radius: 10px;
102
+ border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
77
103
  background: var(--dsw-alias-bg-layer-2, #fafafa); overflow: hidden;
78
104
  display: flex; align-items: center; justify-content: center; min-height: 120px; }
79
105
  .agn-shot img, .agn-shot video { display: block; max-width: 100%; height: auto; }
@@ -82,6 +108,39 @@ window.__ModuleLoader__.load({
82
108
  .agn-shot-msg { padding: 18px; font-size: 12px; text-align: center; line-height: 1.7;
83
109
  color: var(--dsw-alias-label-secondary, #888); }
84
110
 
111
+ /* 悬停浮现的提示词:贴在媒体底部,默认透明,鼠标进来才出现 */
112
+ .agn-prompt { position: absolute; left: 0; right: 0; bottom: 0;
113
+ display: flex; align-items: flex-end; gap: 8px; padding: 8px 10px;
114
+ background: linear-gradient(to top, rgba(0,0,0,.62), rgba(0,0,0,0));
115
+ opacity: 0; transition: opacity .16s ease; pointer-events: none; }
116
+ .agn-shot:hover .agn-prompt { opacity: 1; pointer-events: auto; }
117
+ .agn-prompt-text { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 1.5;
118
+ color: #fff; display: -webkit-box; -webkit-line-clamp: 3;
119
+ -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
120
+ .agn-prompt-copy { flex: none; font: inherit; font-size: 11px; line-height: 1.3;
121
+ padding: 2px 8px; border-radius: 5px; cursor: pointer;
122
+ color: #fff; background: rgba(255,255,255,.18);
123
+ border: 1px solid rgba(255,255,255,.45); }
124
+ .agn-prompt-copy:hover { background: rgba(255,255,255,.3); }
125
+
126
+ /* 输出目录清单 */
127
+ .agn-lib { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
128
+ .agn-lib-tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
129
+ .agn-lib-grid { display: flex; gap: 8px; flex-wrap: wrap;
130
+ max-height: 300px; overflow-y: auto; padding: 2px; }
131
+ .agn-lib-item { position: relative; width: 84px; height: 84px; border-radius: 8px;
132
+ padding: 0; overflow: hidden; cursor: pointer; flex: none;
133
+ border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
134
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
135
+ .agn-lib-item:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
136
+ .agn-lib-item img, .agn-lib-item video { width: 100%; height: 100%;
137
+ object-fit: cover; display: block; pointer-events: none; }
138
+ .agn-lib-badge { position: absolute; left: 4px; bottom: 4px; font-size: 10px;
139
+ line-height: 1.4; padding: 0 5px; border-radius: 4px;
140
+ color: #fff; background: rgba(0,0,0,.55); }
141
+ .agn-lib-empty { font-size: 12px; line-height: 1.6;
142
+ color: var(--dsw-alias-label-secondary, #888); }
143
+
85
144
  /* 历史缩略图 */
86
145
  .agn-hist { display: flex; flex-direction: column; gap: 6px; }
87
146
  .agn-hist-strip { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -90,10 +149,15 @@ window.__ModuleLoader__.load({
90
149
  border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
91
150
  background: var(--dsw-alias-bg-layer-2, #fafafa); }
92
151
  .agn-thumb-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
93
- .agn-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
152
+ /* 缩略图里放 <video> 取首帧;pointer-events: none 让点击继续落到外层按钮上
153
+ (video 属于可交互内容,套在 button 里会吃掉点击) */
154
+ .agn-thumb img, .agn-thumb video { width: 100%; height: 100%;
155
+ object-fit: cover; display: block; pointer-events: none; }
156
+ /* ▶ 只是角标,压在首帧上;别挡点击,也别把首帧盖住 */
94
157
  .agn-thumb-play { position: absolute; inset: 0; display: flex; align-items: center;
95
- justify-content: center; font-size: 22px; line-height: 1;
96
- color: #fff; background: rgba(0, 0, 0, .3); }
158
+ justify-content: center; font-size: 20px; line-height: 1;
159
+ color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .75);
160
+ pointer-events: none; background: rgba(0, 0, 0, .12); }
97
161
  .agn-thumb-cap { position: absolute; left: 0; right: 0; bottom: 0; font-size: 10px;
98
162
  text-align: center; padding: 1px 0;
99
163
  color: var(--dsw-alias-label-primary-foreground, #fff);
@@ -144,6 +208,57 @@ window.__ModuleLoader__.load({
144
208
  .agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
145
209
  background: var(--dsw-alias-bg-layer-2, #f1f1f1);
146
210
  color: var(--dsw-alias-label-secondary, #555); }
211
+
212
+ /* 提示词预设 */
213
+ .agn-presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
214
+ .agn-preset { font: inherit; font-size: 11px; line-height: 1.4; padding: 2px 9px;
215
+ border-radius: 999px; cursor: pointer;
216
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
217
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
218
+ .agn-preset:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
219
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
220
+ .agn-preset:disabled { opacity: .45; cursor: not-allowed; }
221
+
222
+ /* 视频关键帧(图生视频) */
223
+ .agn-frames { display: flex; flex-direction: column; gap: 8px; }
224
+ .agn-frames-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
225
+ .agn-frame { display: flex; flex-direction: column; gap: 4px; flex: none; }
226
+ .agn-frame-cap { font-size: 11px; color: var(--dsw-alias-label-secondary, #888); }
227
+ .agn-frame-box { position: relative; width: 72px; height: 72px; border-radius: 8px;
228
+ overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
229
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
230
+ .agn-frame-box img { width: 100%; height: 100%; object-fit: cover; display: block; }
231
+ .agn-frame-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer;
232
+ font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
233
+ color: var(--dsw-alias-label-secondary, #666); background: transparent;
234
+ border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
235
+ .agn-frame-add:disabled { opacity: .45; cursor: not-allowed; }
236
+ /* 「+ 结果」:从已生成的图片里挑一张当关键帧 */
237
+ .agn-frame-from { width: 72px; font: inherit; font-size: 11px; line-height: 1.4;
238
+ padding: 2px 0; border-radius: 6px; cursor: pointer;
239
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
240
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
241
+ .agn-frame-from:hover:not(:disabled) {
242
+ border-color: var(--dsw-alias-state-business-primary, #4b7bec);
243
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
244
+ .agn-frame-from:disabled { opacity: .45; cursor: not-allowed; }
245
+ .agn-frame .agn-ref-pick { margin-top: 2px; }
246
+ .agn-seed { width: 72px; }
247
+
248
+ /* 结果收尾动作 */
249
+ .agn-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
250
+ .agn-act { font: inherit; font-size: 11px; line-height: 1.4; padding: 3px 10px;
251
+ border-radius: 6px; cursor: pointer;
252
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
253
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
254
+ .agn-act:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
255
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
256
+ .agn-act-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
257
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
258
+ /* 「测试连接」的结果行:和按钮之间留一点距离 */
259
+ .agn-check { margin-top: 6px; }
260
+ .agn-notice { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2);
261
+ line-height: 1.6; }
147
262
  `;
148
263
 
149
264
  function ensureCss() {
@@ -160,6 +275,25 @@ window.__ModuleLoader__.load({
160
275
  return "/agnes/media?name=" + encodeURIComponent(name);
161
276
  }
162
277
 
278
+ /* 「+ 结果」的候选:最近生成的图片 + 当前结果区那张(按文件名去重)。
279
+ 从「输出目录」点出来的图只进了结果区、没进历史,只翻历史会漏掉它,
280
+ 于是刚在眼前看到的那张图反而选不进参考图 / 关键帧。 */
281
+ function pickableImagesOf(state) {
282
+ const history = (Array.isArray(state.history) ? state.history : []).filter((h) => h.kind === "image");
283
+ const cur = state.result;
284
+ if (cur === null || cur === undefined || cur.kind !== "image") return history;
285
+ if (typeof cur.filename !== "string" || cur.filename === "") return history;
286
+ if (history.some((h) => h.filename === cur.filename)) return history;
287
+ return [cur].concat(history);
288
+ }
289
+
290
+ /* 视频生成方式:宿主回来的 mode 只可能是这三种之一,其余一律当纯文本 */
291
+ function modeOf(mode) {
292
+ if (mode === "keyframe") return "keyframe";
293
+ if (mode === "reference") return "reference";
294
+ return "text";
295
+ }
296
+
163
297
  /* ==========================================================================
164
298
  参考图预处理(图生图)
165
299
  --------------------------------------------------------------------------
@@ -261,6 +395,7 @@ window.__ModuleLoader__.load({
261
395
  vProgress: 0,
262
396
  vStatus: "",
263
397
  videoId: null,
398
+ vTaskId: null,
264
399
  health: null,
265
400
  healthRequested: false,
266
401
 
@@ -268,17 +403,46 @@ window.__ModuleLoader__.load({
268
403
  cfg: null, // 生效配置 { keyFile, outDir }
269
404
  cfgDefaults: null,
270
405
  cfgStored: null,
271
- cfgDraft: { keyFile: "", outDir: "" },
406
+ cfgDraft: { keyFile: "", outDir: "", imageModel: "", videoModel: "" },
272
407
  cfgOpen: false,
273
408
  cfgSaving: false,
274
409
  cfgError: "",
275
410
  cfgNotice: "",
411
+ checking: false, // 正在测连通性
412
+ checkResult: null, // { ok, text, detail }
413
+ upModels: null, // 连接测试拿到的上游真实模型清单 { image: [], video: [] }
276
414
 
277
415
  /* ---- 图生图参考图 ---- */
278
416
  refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
279
417
  refsBusy: false,
280
418
  refsError: "",
281
419
  refPickOpen: false,
420
+ /* ---- 视频参考图(reference 模式,最多 5 张) ---- */
421
+ vRefs: [],
422
+ vRefsBusy: false,
423
+ vRefsError: "",
424
+ vRefPickOpen: false,
425
+
426
+ /* ---- 视频关键帧(可选):本地图直接当首帧 / 尾帧 ---- */
427
+ vFirst: null, // { src(dataURI), name, bytes }
428
+ vLast: null,
429
+ vSeed: "", // 空串 = 不传 seed
430
+ frameBusy: false, // 正在压缩关键帧图片
431
+ framePick: "", // '' | 'first' | 'last':本地文件选择器的目标
432
+ framePickFrom: "", // '' | 'first' | 'last':生成结果选择面板的目标
433
+
434
+ /* ---- 历史:宿主落盘,刷新/重启后还在 ---- */
435
+ historyMax: HISTORY_MAX,
436
+ historyLoaded: false,
437
+ notice: "", // 一次性操作反馈(复制路径、打开访达…)
438
+
439
+ /* ---- 输出目录清单:把磁盘上已有的图/视频捞回来看 ---- */
440
+ library: null, // null = 还没加载过;[] = 加载过但是空
441
+ libraryOpen: false,
442
+ libraryTab: "image", // 'image' | 'video'
443
+ libraryBusy: false,
444
+ libraryError: "",
445
+ copiedKey: "", // 刚刚复制了哪一条的提示词(用于显示「已复制」)
282
446
  };
283
447
 
284
448
  const snapshot = () => ({ ...state, history: state.history.slice() });
@@ -312,9 +476,60 @@ window.__ModuleLoader__.load({
312
476
  };
313
477
 
314
478
  const pushResult = (entry) => {
315
- set({ result: entry, history: [entry, ...state.history].slice(0, HISTORY_MAX) });
479
+ set({ result: entry, history: [entry, ...state.history].slice(0, state.historyMax) });
480
+ // 顺手落盘:/agnes/history 是唯一真相,写失败也不影响本次展示
481
+ void appendHistory(entry);
482
+ // 新文件已经写进输出目录了,目录清单开着的话跟着变(没加载过就不去打扰)
483
+ void refreshLibrary();
316
484
  };
317
485
 
486
+ /* ---------- 历史(宿主持久化) ---------- */
487
+ async function loadHistory() {
488
+ try {
489
+ const res = await fetch("/agnes/history");
490
+ if (!res.ok) {
491
+ // 旧版宿主没有这条路由:保持空历史即可,不要报错吓人
492
+ set({ historyLoaded: true });
493
+ return;
494
+ }
495
+ const data = await res.json();
496
+ if (!data || data.ok !== true) {
497
+ set({ historyLoaded: true });
498
+ return;
499
+ }
500
+ const entries = Array.isArray(data.entries) ? data.entries : [];
501
+ const max = typeof data.max === "number" && data.max > 0 ? data.max : HISTORY_MAX;
502
+ update((prev) => ({
503
+ history: entries,
504
+ historyMax: max,
505
+ historyLoaded: true,
506
+ // 主预览默认显示最新一条;已经有结果时不动它
507
+ result: prev.result === null ? (entries[0] || null) : prev.result,
508
+ }));
509
+ } catch {
510
+ set({ historyLoaded: true });
511
+ }
512
+ }
513
+
514
+ async function appendHistory(entry) {
515
+ try {
516
+ const res = await fetch("/agnes/history", {
517
+ method: "POST",
518
+ headers: { "content-type": "application/json" },
519
+ body: JSON.stringify(entry),
520
+ });
521
+ if (!res.ok) return;
522
+ const data = await res.json();
523
+ if (!data || data.ok !== true) return;
524
+ // 只有宿主确实回了列表才覆盖本地:宁可用旧列表,也不要把界面清空
525
+ if (!Array.isArray(data.entries)) return;
526
+ update((prev) => ({
527
+ history: data.entries,
528
+ historyMax: typeof data.max === "number" && data.max > 0 ? data.max : prev.historyMax,
529
+ }));
530
+ } catch { /* 落盘失败不影响当前结果展示 */ }
531
+ }
532
+
318
533
  async function loadHealth() {
319
534
  if (state.healthRequested) return;
320
535
  state.healthRequested = true;
@@ -341,7 +556,12 @@ window.__ModuleLoader__.load({
341
556
  cfg: data.config,
342
557
  cfgDefaults: data.defaults,
343
558
  cfgStored: data.stored,
344
- cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
559
+ cfgDraft: {
560
+ keyFile: data.config.keyFile,
561
+ outDir: data.config.outDir,
562
+ imageModel: data.config.imageModel || "",
563
+ videoModel: data.config.videoModel || "",
564
+ },
345
565
  cfgError: "",
346
566
  });
347
567
  } catch (e) {
@@ -359,6 +579,9 @@ window.__ModuleLoader__.load({
359
579
  body: JSON.stringify({
360
580
  keyFile: state.cfgDraft.keyFile,
361
581
  outDir: state.cfgDraft.outDir,
582
+ // 空串 = 回退到内置默认模型
583
+ imageModel: state.cfgDraft.imageModel,
584
+ videoModel: state.cfgDraft.videoModel,
362
585
  }),
363
586
  });
364
587
  if (res.status === 404) throw new Error("配置接口尚未就绪:需要完全重启应用(只重载窗口不够)");
@@ -368,7 +591,12 @@ window.__ModuleLoader__.load({
368
591
  cfg: data.config,
369
592
  cfgDefaults: data.defaults,
370
593
  cfgStored: data.stored,
371
- cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
594
+ cfgDraft: {
595
+ keyFile: data.config.keyFile,
596
+ outDir: data.config.outDir,
597
+ imageModel: data.config.imageModel || "",
598
+ videoModel: data.config.videoModel || "",
599
+ },
372
600
  cfgNotice: "已保存",
373
601
  });
374
602
  // 路径变了,key 是否存在/目录是否可写都要重新算
@@ -382,6 +610,43 @@ window.__ModuleLoader__.load({
382
610
  }
383
611
  }
384
612
 
613
+ /** 测一下 Key 能不能连上游:顺便把上游真实的模型名单拉回来 */
614
+ async function checkKey() {
615
+ if (state.checking) return;
616
+ set({ checking: true, checkResult: null, cfgError: "" });
617
+ try {
618
+ const res = await fetch("/agnes/models");
619
+ const data = await res.json().catch(() => ({}));
620
+ if (res.status === 404) {
621
+ throw new Error("模型探测接口尚未就绪:需要完全重启应用(只重载窗口不够)");
622
+ }
623
+ if (!res.ok || !data || data.ok !== true) {
624
+ throw new Error((data && data.error) || ("探测失败:HTTP " + res.status));
625
+ }
626
+ const up = data.upstream || {};
627
+ const imgs = Array.isArray(up.image) ? up.image.length : 0;
628
+ const vids = Array.isArray(up.video) ? up.video.length : 0;
629
+ set({
630
+ checking: false,
631
+ // 把上游真实清单留下:顶部的模型下拉优先用它,比内置清单更可信
632
+ upModels: {
633
+ image: Array.isArray(up.image) ? up.image : [],
634
+ video: Array.isArray(up.video) ? up.video : [],
635
+ },
636
+ checkResult: {
637
+ ok: true,
638
+ text: "连接正常:上游可用 " + imgs + " 个图片模型、" + vids + " 个视频模型",
639
+ detail: ""
640
+ },
641
+ });
642
+ } catch (e) {
643
+ set({
644
+ checking: false,
645
+ checkResult: { ok: false, text: e && e.message ? e.message : String(e), detail: "" },
646
+ });
647
+ }
648
+ }
649
+
385
650
  /* ---------- 图片:一次请求就出结果 ---------- */
386
651
  async function runImage(token, prompt) {
387
652
  const refs = state.refs;
@@ -400,7 +665,7 @@ window.__ModuleLoader__.load({
400
665
  const data = await res.json();
401
666
  if (!data.ok) throw new Error(data.error || "生成失败");
402
667
  if (token !== runToken) return;
403
- pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
668
+ pushResult({ ...data, kind: "image", prompt, wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
404
669
  }
405
670
 
406
671
  /* ---------- 参考图(图生图) ---------- */
@@ -519,44 +784,339 @@ window.__ModuleLoader__.load({
519
784
  set({ refPickOpen: !state.refPickOpen, refsError: "" });
520
785
  }
521
786
 
522
- /* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
523
- async function runVideo(token, prompt) {
524
- set({ vStatus: "提交中…", vProgress: 0, videoId: null });
787
+ /* ---------- 视频参考图(reference 模式,≤5 张) ---------- */
788
+ const vRefsFull = () => state.vRefs.length >= MAX_VREFS;
525
789
 
526
- const subRes = await fetch("/agnes/video", {
527
- method: "POST",
528
- headers: { "content-type": "application/json" },
529
- body: JSON.stringify({ prompt, seconds: state.vSeconds, ratio: state.vRatio }),
790
+ function addVRefsFromFiles(files) {
791
+ const list = Array.from(files || []).filter(
792
+ (f) => f && String(f.type || "").startsWith("image/")
793
+ );
794
+ if (list.length === 0) {
795
+ set({ vRefsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
796
+ return Promise.resolve();
797
+ }
798
+
799
+ return queueRefOp(async () => {
800
+ set({ vRefsBusy: true, vRefsError: "" });
801
+ const added = [];
802
+ const failed = [];
803
+ try {
804
+ for (const file of list) {
805
+ if (state.vRefs.length + added.length >= MAX_VREFS) {
806
+ failed.push("超出 " + MAX_VREFS + " 张上限");
807
+ break;
808
+ }
809
+ try {
810
+ const r = await shrinkImage(file);
811
+ added.push({
812
+ id: nextRefId(),
813
+ name: file.name || "本地图片",
814
+ src: r.src,
815
+ bytes: r.bytes,
816
+ from: "local",
817
+ });
818
+ } catch (e) {
819
+ failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
820
+ }
821
+ }
822
+ if (added.length > 0) {
823
+ update((prev) => ({ vRefs: prev.vRefs.concat(added) }));
824
+ }
825
+ set({
826
+ vRefsError: failed.length > 0 ? failed.join(";") : "",
827
+ vRefPickOpen: false,
828
+ });
829
+ } finally {
830
+ set({ vRefsBusy: false });
831
+ }
530
832
  });
531
- const sub = await subRes.json();
532
- if (!sub.ok) throw new Error(sub.error || "提交失败");
533
- if (token !== runToken) return;
833
+ }
534
834
 
535
- set({ videoId: sub.videoId, vStatus: "排队中…" });
536
- const deadline = Date.now() + VIDEO_DEADLINE_MS;
835
+ function addVRefFromResult(entry) {
836
+ if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
837
+ if (vRefsFull()) {
838
+ set({ vRefsError: "参考图最多 " + MAX_VREFS + " 张" });
839
+ return Promise.resolve();
840
+ }
537
841
 
842
+ return queueRefOp(async () => {
843
+ set({ vRefsBusy: true, vRefsError: "" });
844
+ try {
845
+ const res = await fetch(srcOf(entry.filename));
846
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
847
+ const blob = await res.blob();
848
+ const r = await shrinkImage(blob);
849
+ update((prev) => ({
850
+ vRefs: prev.vRefs.concat([{
851
+ id: nextRefId(),
852
+ name: entry.filename,
853
+ src: r.src,
854
+ bytes: r.bytes,
855
+ from: "result",
856
+ }]),
857
+ vRefPickOpen: false,
858
+ }));
859
+ } catch (e) {
860
+ set({ vRefsError: e && e.message ? e.message : String(e) });
861
+ } finally {
862
+ set({ vRefsBusy: false });
863
+ }
864
+ });
865
+ }
866
+
867
+ function removeVRef(id) {
868
+ if (state.busy) return;
869
+ update((prev) => ({ vRefs: prev.vRefs.filter((r) => r.id !== id), vRefsError: "" }));
870
+ }
871
+
872
+ function clearVRefs() {
873
+ if (state.busy) return;
874
+ set({ vRefs: [], vRefsError: "", vRefPickOpen: false });
875
+ }
876
+
877
+ function toggleVRefPick() {
878
+ if (state.busy) return;
879
+ set({ vRefPickOpen: !state.vRefPickOpen, vRefsError: "" });
880
+ }
881
+
882
+ /* ---------- 视频关键帧(首帧 / 尾帧,本地图直接当帧) ---------- */
883
+ const frameField = (which) => (which === "last" ? "vLast" : "vFirst");
884
+
885
+ function pickFrame(which) {
886
+ if (state.busy || state.frameBusy) return;
887
+ set({ framePick: which, error: "" });
888
+ }
889
+
890
+ async function setFrameFromFile(which, file) {
891
+ set({ frameBusy: true, error: "" });
892
+ try {
893
+ // 和参考图走同一套下采样:长边 1280、PNG 优先,既省上行又不会糊
894
+ const r = await shrinkImage(file);
895
+ set({
896
+ [frameField(which)]: { src: r.src, name: file.name || "关键帧", bytes: r.bytes },
897
+ framePick: "",
898
+ });
899
+ } catch (e) {
900
+ set({ framePick: "", error: e && e.message ? e.message : String(e) });
901
+ } finally {
902
+ set({ frameBusy: false });
903
+ }
904
+ }
905
+
906
+ function clearFrame(which) {
907
+ if (state.busy) return;
908
+ set({ [frameField(which)]: null, framePickFrom: "" });
909
+ }
910
+
911
+ /* 把「生成结果」里的图直接当关键帧:和本地图完全同一条链路(blob → 下采样 → data URI) */
912
+ function toggleFramePick(which) {
913
+ if (state.busy || state.frameBusy) return;
914
+ set({ framePickFrom: state.framePickFrom === which ? "" : which, error: "" });
915
+ }
916
+
917
+ async function setFrameFromResult(which, entry) {
918
+ if (!entry || entry.kind !== "image" || !entry.filename) return;
919
+ if (state.busy || state.frameBusy) return;
920
+ set({ frameBusy: true, error: "" });
921
+ try {
922
+ const res = await fetch(srcOf(entry.filename));
923
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
924
+ const blob = await res.blob();
925
+ const r = await shrinkImage(blob);
926
+ set({
927
+ [frameField(which)]: { src: r.src, name: entry.filename, bytes: r.bytes },
928
+ framePickFrom: "",
929
+ });
930
+ } catch (e) {
931
+ set({ framePickFrom: "", error: e && e.message ? e.message : String(e) });
932
+ } finally {
933
+ set({ frameBusy: false });
934
+ }
935
+ }
936
+
937
+ /* 结果区的一键设置:只在「结果本身是图片」时有意义 */
938
+ function useResultAsFrame(which) {
939
+ const cur = state.result;
940
+ if (cur === null || cur.kind !== "image") return;
941
+ return setFrameFromResult(which, cur);
942
+ }
943
+
944
+ /* ---------- 结果收尾动作:复制路径 / 访达显示 / 下载 ---------- */
945
+ async function copyPath() {
946
+ const cur = state.result;
947
+ if (cur === null) return;
948
+ const text = cur.path || "";
949
+ try {
950
+ await navigator.clipboard.writeText(text);
951
+ set({ notice: "路径已复制" });
952
+ } catch {
953
+ set({ notice: "复制失败,可手动复制:" + text });
954
+ }
955
+ }
956
+
957
+ async function reveal() {
958
+ const cur = state.result;
959
+ if (cur === null) return;
960
+ try {
961
+ const res = await fetch("/agnes/reveal", {
962
+ method: "POST",
963
+ headers: { "content-type": "application/json" },
964
+ body: JSON.stringify({ name: cur.filename }),
965
+ });
966
+ const data = await res.json().catch(() => ({}));
967
+ set({
968
+ notice: data && data.ok === true
969
+ ? "已在访达中显示"
970
+ : "打开失败:" + ((data && data.error) || "HTTP " + res.status),
971
+ });
972
+ } catch (e) {
973
+ set({ notice: "打开失败:" + (e && e.message ? e.message : String(e)) });
974
+ }
975
+ }
976
+
977
+ function downloadResult() {
978
+ const cur = state.result;
979
+ if (cur === null) return;
980
+ const a = document.createElement("a");
981
+ a.href = srcOf(cur.filename);
982
+ a.download = cur.filename;
983
+ document.body.appendChild(a);
984
+ a.click();
985
+ a.remove();
986
+ set({ notice: "已开始下载" });
987
+ }
988
+
989
+ /** 停掉等待:让在途的轮询/请求结果失效(宿主的任务照常跑,文件会正常落盘) */
990
+ function cancel() {
991
+ if (!state.busy) return;
992
+ const hadTask = state.vTaskId !== null;
993
+ runToken += 1;
994
+ stopTimer();
995
+ set({
996
+ busy: false, vStatus: "", vProgress: 0,
997
+ notice: hadTask
998
+ ? "已停止跟踪;宿主会继续推进,刷新面板还能接回进度,文件也会照常落盘"
999
+ : "已停止等待;服务端任务可能仍在跑",
1000
+ });
1001
+ }
1002
+
1003
+ /* ---------- 输出目录:把磁盘上已经有的图/视频捞回来 ---------- */
1004
+ async function copyPrompt(text) {
1005
+ const t = typeof text === "string" ? text.trim() : "";
1006
+ if (t === "") return;
1007
+ try {
1008
+ await navigator.clipboard.writeText(t);
1009
+ set({ notice: "提示词已复制", copiedKey: t });
1010
+ } catch {
1011
+ set({ notice: "复制失败,可手动选中复制" });
1012
+ }
1013
+ }
1014
+
1015
+ async function loadLibrary() {
1016
+ if (state.libraryBusy) return;
1017
+ set({ libraryBusy: true, libraryError: "" });
1018
+ try {
1019
+ const res = await fetch("/agnes/library");
1020
+ const data = await res.json().catch(() => ({}));
1021
+ if (!res.ok || !data || data.ok !== true) {
1022
+ throw new Error((data && data.error) || "HTTP " + res.status);
1023
+ }
1024
+ const files = Array.isArray(data.files) ? data.files : [];
1025
+ set({
1026
+ library: files,
1027
+ libraryOpen: true,
1028
+ libraryBusy: false,
1029
+ libraryError: "",
1030
+ });
1031
+ const images = files.filter((f) => f.kind === "image").length;
1032
+ if (images === 0) {
1033
+ const videos = files.filter((f) => f.kind === "video").length;
1034
+ if (videos > 0) set({ libraryTab: "video" });
1035
+ }
1036
+ } catch (e) {
1037
+ set({
1038
+ libraryBusy: false,
1039
+ libraryOpen: true,
1040
+ libraryError: e && e.message ? e.message : String(e),
1041
+ });
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * 静默刷新输出目录清单:生成完成后新文件已经落盘,列表要跟着更新。
1047
+ * 只在清单已经加载过时才拉(没展开过就别偷偷把面板撑开),失败保留旧清单。
1048
+ */
1049
+ async function refreshLibrary() {
1050
+ if (state.library === null || state.libraryBusy) return;
1051
+ try {
1052
+ const res = await fetch("/agnes/library");
1053
+ const data = await res.json().catch(() => ({}));
1054
+ if (!res.ok || !data || data.ok !== true) return;
1055
+ if (!Array.isArray(data.files)) return;
1056
+ update(() => ({ library: data.files, libraryError: "" }));
1057
+ } catch {
1058
+ // 刷新失败保留旧清单,不打扰用户
1059
+ }
1060
+ }
1061
+
1062
+ function toggleLibrary() {
1063
+ if (state.libraryOpen) {
1064
+ set({ libraryOpen: false });
1065
+ return;
1066
+ }
1067
+ // 没加载过就先拉一次;已经有清单则直接展开
1068
+ if (state.library === null) return loadLibrary();
1069
+ set({ libraryOpen: true });
1070
+ }
1071
+
1072
+ function setLibraryTab(tab) {
1073
+ set({ libraryTab: tab === "video" ? "video" : "image" });
1074
+ }
1075
+
1076
+ /** 点磁盘上的文件:直接放进结果区看,不经过生成流程 */
1077
+ function showLibraryFile(file) {
1078
+ if (!file || !file.name) return;
1079
+ if (state.busy) return;
1080
+ set({
1081
+ result: {
1082
+ kind: file.kind === "video" ? "video" : "image",
1083
+ filename: file.name,
1084
+ path: file.path || "",
1085
+ bytes: file.bytes || 0,
1086
+ prompt: typeof file.prompt === "string" ? file.prompt : "",
1087
+ fromDisk: true,
1088
+ },
1089
+ error: "",
1090
+ notice: "",
1091
+ });
1092
+ }
1093
+
1094
+ /* ---------- 视频:交给宿主任务队列,面板只负责看进度 ---------- */
1095
+ // 提交后 video_id 由宿主保管:关面板、刷新页面、重启应用都不影响任务继续推进。
1096
+ async function watchTask(token, taskId, fallback) {
1097
+ const deadline = Date.now() + VIDEO_DEADLINE_MS;
538
1098
  let transientFails = 0;
1099
+
539
1100
  for (;;) {
540
1101
  await sleep(POLL_MS);
541
1102
  if (token !== runToken) return;
542
1103
 
543
1104
  let st;
544
1105
  try {
545
- const r = await fetch("/agnes/video?id=" + encodeURIComponent(sub.videoId));
546
- st = await r.json();
1106
+ const r = await fetch("/agnes/task?id=" + encodeURIComponent(taskId));
1107
+ st = r.ok ? await r.json() : { ok: false, error: "查询任务失败:HTTP " + r.status };
547
1108
  } catch (e) {
548
1109
  st = { ok: false, error: "网络错误:" + (e && e.message ? e.message : String(e)) };
549
1110
  }
550
1111
 
551
1112
  if (!st.ok) {
552
1113
  const msg = String(st.error || "查询任务失败");
553
- // 429/503 只是限流,任务还在服务端跑:退避后继续,别把整个任务判死。
554
- // 实测提交后立刻查询就会 429,这条路径是必需的。
1114
+ // 宿主自己会重试上游;这里只在本地接口抖动时容忍几次
555
1115
  if (/\b429\b|\b503\b|频繁|队列已满|网络错误|timeout/i.test(msg)) {
556
1116
  transientFails += 1;
557
1117
  if (transientFails > 8) throw new Error(msg);
558
- set({ vStatus: "限流重试中…" });
559
- await sleep(Math.min(30000, 6000 * transientFails));
1118
+ set({ vStatus: "重试中…" });
1119
+ await sleep(Math.min(30000, 3000 * transientFails));
560
1120
  continue;
561
1121
  }
562
1122
  throw new Error(msg);
@@ -565,30 +1125,126 @@ window.__ModuleLoader__.load({
565
1125
  transientFails = 0;
566
1126
  if (token !== runToken) return;
567
1127
 
1128
+ const task = st.task || {};
568
1129
  set({
569
- vStatus: st.status,
570
- vProgress: typeof st.progress === "number" ? st.progress : 0,
1130
+ vStatus: task.status ? String(task.status) : "",
1131
+ vProgress: typeof task.progress === "number" ? task.progress : 0,
1132
+ videoId: task.videoId || fallback.videoId || null,
571
1133
  });
572
1134
 
573
- if (st.status === "completed") {
1135
+ if (task.status === "completed") {
574
1136
  pushResult({
575
1137
  kind: "video",
576
- filename: st.filename,
577
- path: st.path,
578
- bytes: st.bytes,
1138
+ filename: task.filename,
1139
+ path: task.path,
1140
+ bytes: task.bytes,
579
1141
  size: "720P",
580
- ratio: state.vRatio,
581
- seconds: st.seconds || state.vSeconds,
582
- transport: "异步任务",
1142
+ ratio: task.ratio || fallback.ratio,
1143
+ seconds: task.seconds || fallback.seconds,
1144
+ transport: "宿主任务",
1145
+ mode: modeOf(task.mode || fallback.mode),
1146
+ seed: task.seed === null || task.seed === undefined ? undefined : task.seed,
1147
+ prompt: task.prompt || fallback.prompt,
583
1148
  wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
584
1149
  });
585
1150
  return;
586
1151
  }
587
- if (st.status === "failed") {
588
- const err = st.error;
589
- throw new Error(typeof err === "string" ? err : (err && err.message) || "视频生成失败");
1152
+ if (task.status === "failed") throw new Error(task.error || "视频生成失败");
1153
+ if (task.status === "cancelled") throw new Error("任务已取消");
1154
+ if (Date.now() > deadline) {
1155
+ throw new Error("等待超时(15 分钟);宿主仍在推进,稍后刷新还能看到结果");
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ async function runVideo(token, prompt) {
1161
+ set({ vStatus: "提交中…", vProgress: 0, videoId: null, vTaskId: null });
1162
+
1163
+ const seedText = String(state.vSeed).trim();
1164
+ const seedValue = seedText === "" ? null : Number(seedText);
1165
+ if (seedValue !== null && !(Number.isInteger(seedValue) && seedValue >= 0)) {
1166
+ throw new Error("seed 需要非负整数(也可以留空)");
1167
+ }
1168
+
1169
+ // 关键帧直接发 data URI:上游已实测接受 base64,本地图片不用先传图床
1170
+ const subRes = await fetch("/agnes/tasks", {
1171
+ method: "POST",
1172
+ headers: { "content-type": "application/json" },
1173
+ body: JSON.stringify({
1174
+ prompt,
1175
+ seconds: state.vSeconds,
1176
+ ratio: state.vRatio,
1177
+ seed: seedValue,
1178
+ firstFrame: state.vFirst !== null ? state.vFirst.src : null,
1179
+ lastFrame: state.vLast !== null ? state.vLast.src : null,
1180
+ // 参考图(reference 模式):有首/尾帧时上游会用 keyframe,参考图自动让位
1181
+ referenceImages: state.vRefs.map((r) => r.src),
1182
+ }),
1183
+ });
1184
+ const sub = await subRes.json();
1185
+ if (!sub.ok) throw new Error(sub.error || "提交失败");
1186
+ if (token !== runToken) return;
1187
+
1188
+ const task = sub.task || {};
1189
+ if (!task.id) throw new Error("宿主没有返回任务号");
1190
+ set({ videoId: task.videoId || null, vTaskId: task.id, vStatus: "排队中…" });
1191
+
1192
+ await watchTask(token, task.id, {
1193
+ prompt,
1194
+ ratio: state.vRatio,
1195
+ seconds: state.vSeconds,
1196
+ mode: task.mode,
1197
+ videoId: task.videoId,
1198
+ });
1199
+ }
1200
+
1201
+ /* 面板重新挂载时把宿主上还在跑的任务接回来:刷新页面不会让进度消失 */
1202
+ async function resumeTasks() {
1203
+ if (state.busy) return;
1204
+ try {
1205
+ const r = await fetch("/agnes/tasks");
1206
+ if (!r.ok) return;
1207
+ const data = await r.json();
1208
+ if (!data || data.ok !== true || !Array.isArray(data.tasks)) return;
1209
+ const active = data.tasks.find((t) => t.status === "queued" || t.status === "running");
1210
+ if (!active || state.busy) return;
1211
+
1212
+ const token = ++runToken;
1213
+ const created = Date.parse(active.createdAt);
1214
+ set({
1215
+ mode: "video",
1216
+ busy: true,
1217
+ error: "",
1218
+ notice: "",
1219
+ startedAt: Number.isFinite(created) ? created : Date.now(),
1220
+ elapsed: 0,
1221
+ vFirst: null,
1222
+ vLast: null,
1223
+ videoId: active.videoId || null,
1224
+ vTaskId: active.id,
1225
+ vStatus: active.status,
1226
+ vProgress: typeof active.progress === "number" ? active.progress : 0,
1227
+ prompt: state.prompt.trim() === "" ? String(active.prompt || "") : state.prompt,
1228
+ });
1229
+ startTimer();
1230
+ try {
1231
+ await watchTask(token, active.id, {
1232
+ prompt: active.prompt,
1233
+ ratio: active.ratio || state.vRatio,
1234
+ seconds: active.seconds || state.vSeconds,
1235
+ mode: active.mode,
1236
+ videoId: active.videoId,
1237
+ });
1238
+ } catch (e) {
1239
+ if (token === runToken) set({ error: e && e.message ? e.message : String(e) });
1240
+ } finally {
1241
+ if (token === runToken) {
1242
+ stopTimer();
1243
+ set({ busy: false, elapsed: (Date.now() - state.startedAt) / 1000 });
1244
+ }
590
1245
  }
591
- if (Date.now() > deadline) throw new Error("等待超时(15 分钟)");
1246
+ } catch {
1247
+ // 旧宿主没有 /agnes/tasks:静默跳过,不打扰用户
592
1248
  }
593
1249
  }
594
1250
 
@@ -628,11 +1284,24 @@ window.__ModuleLoader__.load({
628
1284
  set,
629
1285
  loadHealth,
630
1286
  loadConfig,
1287
+ loadHistory,
631
1288
  saveConfig,
632
1289
  setCfgDraft(field, value) {
633
1290
  set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
634
1291
  },
1292
+
1293
+ /* 顶部标题栏直接换模型:先写进草稿,再立刻落盘(不然刷新就回退了) */
1294
+ async setModel(kind, value) {
1295
+ if (state.busy || state.cfgSaving) return;
1296
+ const field = kind === "video" ? "videoModel" : "imageModel";
1297
+ set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "", cfgError: "" });
1298
+ await saveConfig();
1299
+ if (state.cfgError === "") {
1300
+ set({ notice: "已切换模型:" + (value === "" ? "内置默认" : value) });
1301
+ }
1302
+ },
635
1303
  toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
1304
+ checkKey,
636
1305
  run,
637
1306
  /* ---- 参考图动作 ---- */
638
1307
  addRefsFromFiles,
@@ -640,16 +1309,45 @@ window.__ModuleLoader__.load({
640
1309
  removeRef,
641
1310
  clearRefs,
642
1311
  toggleRefPick,
1312
+ /* ---- 视频参考图(reference 模式,≤5 张) ---- */
1313
+ addVRefsFromFiles,
1314
+ addVRefFromResult,
1315
+ removeVRef,
1316
+ clearVRefs,
1317
+ toggleVRefPick,
1318
+ /* ---- 视频关键帧 ---- */
1319
+ pickFrame,
1320
+ setFrameFromFile,
1321
+ clearFrame,
1322
+ toggleFramePick,
1323
+ setFrameFromResult,
1324
+ useResultAsFrame,
1325
+ /* ---- 视频任务(宿主侧推进,刷新可接回) ---- */
1326
+ resumeTasks,
1327
+ /* ---- 结果收尾 ---- */
1328
+ copyPath,
1329
+ reveal,
1330
+ downloadResult,
1331
+ cancel,
1332
+ copyPrompt,
1333
+ /* ---- 输出目录清单 ---- */
1334
+ loadLibrary,
1335
+ toggleLibrary,
1336
+ setLibraryTab,
1337
+ showLibraryFile,
643
1338
  maxRefs: MAX_REFS,
1339
+ maxVRefs: MAX_VREFS,
644
1340
  /** 切换主预览到历史里的某一张 */
645
1341
  show(entry) { set({ result: entry }); },
646
1342
  /**
647
- * 清空界面上的生成结果。只清显示状态:不删磁盘文件、不动提示词和参数。
648
- * 进行中的请求不清(否则结果回来又会写一遍,反而更乱)。
1343
+ * 清空界面上的生成结果。删的是界面状态和宿主持久化的历史记录,
1344
+ * 不删磁盘文件、不动提示词和参数。进行中的请求不清。
649
1345
  */
650
1346
  clear() {
651
1347
  if (state.busy) return;
652
- set({ result: null, history: [], error: "" });
1348
+ set({ result: null, history: [], error: "", notice: "" });
1349
+ // 宿主是历史的真源;这里的 DELETE 失败也不该打断界面
1350
+ void fetch("/agnes/history", { method: "DELETE" }).catch(() => {});
653
1351
  },
654
1352
  dispose() {
655
1353
  stopTimer();
@@ -666,7 +1364,7 @@ window.__ModuleLoader__.load({
666
1364
  }
667
1365
 
668
1366
  /* ---------- 媒体显示:视频直接用 <video>,图片带加载兜底 ---------- */
669
- function MediaBox({ entry, big }) {
1367
+ function MediaBox({ entry, big, onCopyPrompt }) {
670
1368
  const isVideo = entry !== null && entry.kind === "video";
671
1369
  const name = entry === null ? null : entry.filename;
672
1370
 
@@ -715,6 +1413,8 @@ window.__ModuleLoader__.load({
715
1413
  React.createElement("span", { className: "agn-code" }, entry.path || name)));
716
1414
  }
717
1415
 
1416
+ const promptText = typeof entry.prompt === "string" ? entry.prompt.trim() : "";
1417
+
718
1418
  return React.createElement("div", { className: "agn-shot" + (big ? " agn-shot-big" : "") },
719
1419
  isVideo
720
1420
  ? React.createElement("video", {
@@ -724,7 +1424,20 @@ window.__ModuleLoader__.load({
724
1424
  preload: "metadata",
725
1425
  onError: () => setFailed(true),
726
1426
  })
727
- : React.createElement("img", { src, alt: "生成结果", onError })
1427
+ : React.createElement("img", { src, alt: "生成结果", onError }),
1428
+ /* 鼠标移上去才浮出来的一层:显示当初的提示词,带一键复制 */
1429
+ promptText === ""
1430
+ ? null
1431
+ : React.createElement("div", { className: "agn-prompt" },
1432
+ React.createElement("div", { className: "agn-prompt-text", title: promptText }, promptText),
1433
+ React.createElement("button", {
1434
+ type: "button", className: "agn-prompt-copy",
1435
+ title: "复制这段提示词",
1436
+ onClick: (e) => {
1437
+ e.stopPropagation();
1438
+ if (typeof onCopyPrompt === "function") onCopyPrompt(promptText);
1439
+ },
1440
+ }, "复制"))
728
1441
  );
729
1442
  }
730
1443
 
@@ -755,7 +1468,13 @@ window.__ModuleLoader__.load({
755
1468
  return () => { off(); };
756
1469
  }, []);
757
1470
 
758
- React.useEffect(() => { store.loadHealth(); store.loadConfig(); }, []);
1471
+ React.useEffect(() => {
1472
+ store.loadHealth();
1473
+ store.loadConfig();
1474
+ store.loadHistory();
1475
+ // 上次没跑完的视频任务:宿主还在推进,接回进度
1476
+ store.resumeTasks();
1477
+ }, []);
759
1478
 
760
1479
  const busy = s.busy;
761
1480
  const isVideo = s.mode === "video";
@@ -764,8 +1483,16 @@ window.__ModuleLoader__.load({
764
1483
  const shown = s.result;
765
1484
  const hasRefs = s.refs.length > 0;
766
1485
  const refsFull = s.refs.length >= MAX_REFS;
1486
+ const vRefsFull = s.vRefs.length >= MAX_VREFS;
767
1487
  const imageHistory = s.history.filter((h) => h.kind === "image");
1488
+ const pickableImages = pickableImagesOf(s);
1489
+ const libraryAll = s.library || [];
1490
+ const libraryImages = libraryAll.filter((f) => f.kind !== "video");
1491
+ const libraryVideos = libraryAll.filter((f) => f.kind === "video");
1492
+ const libraryShown = s.libraryTab === "video" ? libraryVideos : libraryImages;
768
1493
  const fileInput = React.useRef(null);
1494
+ const frameInput = React.useRef(null);
1495
+ const vRefInput = React.useRef(null);
769
1496
 
770
1497
  const onKeyDown = (e) => {
771
1498
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
@@ -797,6 +1524,13 @@ window.__ModuleLoader__.load({
797
1524
  onChange: (e) => store.set({ vRatio: e.target.value }),
798
1525
  }, VIDEO_RATIOS.map((x) => React.createElement("option", { key: x, value: x }, x))),
799
1526
  React.createElement("span", { key: "m", className: "agn-meta" }, "720P"),
1527
+ React.createElement("span", { key: "l3", className: "agn-label" }, "种子"),
1528
+ React.createElement("input", {
1529
+ key: "s3", type: "text", inputMode: "numeric", className: "agn-seed",
1530
+ value: s.vSeed, disabled: busy, placeholder: "随机",
1531
+ title: "固定 seed 可让同一提示词出片更稳定;留空由服务端随机",
1532
+ onChange: (e) => store.set({ vSeed: e.target.value }),
1533
+ }),
800
1534
  ]
801
1535
  : [
802
1536
  React.createElement("span", { key: "l1", className: "agn-label" }, "尺寸"),
@@ -815,6 +1549,57 @@ window.__ModuleLoader__.load({
815
1549
  ? "生成结果(还没有)"
816
1550
  : (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
817
1551
 
1552
+ /* 视频缩略图:放一个真的 <video>,浏览器会解码出首帧当封面。
1553
+ #t=0.1 是媒体片段定位,避免第一帧偶尔是黑场;muted + playsInline
1554
+ 保证不会被自动播放策略拦掉,也不会突然出声。 */
1555
+ const videoThumb = (name) => React.createElement("video", {
1556
+ src: srcOf(name) + "#t=0.1",
1557
+ preload: "metadata",
1558
+ muted: true,
1559
+ playsInline: true,
1560
+ tabIndex: -1,
1561
+ });
1562
+
1563
+ /* 关键帧小格:有图显示缩略图 + ×,无图显示「+ 本地」;
1564
+ 下方固定一行「+ 结果」,从已经生成过的图片里挑一张 */
1565
+ const frameSlot = (which, label, cur) => React.createElement("div", { className: "agn-frame" },
1566
+ React.createElement("div", { className: "agn-frame-cap" }, label),
1567
+ cur !== null
1568
+ ? React.createElement("div", { className: "agn-frame-box" },
1569
+ React.createElement("img", { src: cur.src, alt: label }),
1570
+ React.createElement("button", {
1571
+ type: "button", className: "agn-ref-del", title: "移除" + label,
1572
+ disabled: busy,
1573
+ onClick: () => store.clearFrame(which),
1574
+ }, "×"))
1575
+ : React.createElement("button", {
1576
+ type: "button", className: "agn-frame-add",
1577
+ disabled: busy || s.frameBusy,
1578
+ title: "从本地磁盘选择" + label + "图片(PNG / JPEG / WebP / GIF)",
1579
+ onClick: () => {
1580
+ store.pickFrame(which);
1581
+ const el = frameInput.current;
1582
+ if (el !== null) el.click();
1583
+ },
1584
+ }, "+ 本地"),
1585
+ React.createElement("button", {
1586
+ type: "button", className: "agn-frame-from",
1587
+ disabled: busy || s.frameBusy || pickableImages.length === 0,
1588
+ title: pickableImages.length === 0
1589
+ ? "还没有生成过图片,先出一张图再来选"
1590
+ : "从生成结果里挑一张当" + label,
1591
+ onClick: () => store.toggleFramePick(which),
1592
+ }, s.framePickFrom === which ? "收起" : "+ 结果"),
1593
+ s.framePickFrom === which
1594
+ ? React.createElement("div", { className: "agn-ref-pick agn-frame-pick" },
1595
+ pickableImages.map((h) => React.createElement("button", {
1596
+ key: h.filename, type: "button", title: h.filename,
1597
+ disabled: busy || s.frameBusy,
1598
+ onClick: () => store.setFrameFromResult(which, h),
1599
+ }, React.createElement("img", { src: srcOf(h.filename), alt: h.filename }))))
1600
+ : null
1601
+ );
1602
+
818
1603
  return React.createElement("div", { className: "agn-root" },
819
1604
  /* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
820
1605
  React.createElement("input", {
@@ -835,12 +1620,65 @@ window.__ModuleLoader__.load({
835
1620
  },
836
1621
  }),
837
1622
 
1623
+ /* 隐藏的关键帧选择器:首帧 / 尾帧共用,靠 store.framePick 区分目标 */
1624
+ React.createElement("input", {
1625
+ ref: frameInput,
1626
+ type: "file",
1627
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1628
+ style: { display: "none" },
1629
+ onChange: (e) => {
1630
+ const el = e.target;
1631
+ const file = el.files && el.files[0];
1632
+ const which = s.framePick === "last" ? "last" : "first";
1633
+ Promise.resolve(store.setFrameFromFile(which, file))
1634
+ .catch(() => {})
1635
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1636
+ },
1637
+ }),
1638
+
1639
+ /* 隐藏的视频参考图选择器(reference 模式,最多 5 张) */
1640
+ React.createElement("input", {
1641
+ ref: vRefInput,
1642
+ type: "file",
1643
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1644
+ multiple: true,
1645
+ style: { display: "none" },
1646
+ onChange: (e) => {
1647
+ const el = e.target;
1648
+ const files = el.files;
1649
+ Promise.resolve(store.addVRefsFromFiles(files))
1650
+ .catch(() => {})
1651
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1652
+ },
1653
+ }),
1654
+
838
1655
  React.createElement("div", { className: "agn-head" },
839
1656
  React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
840
- React.createElement("span", { className: "agn-meta" },
841
- isVideo
842
- ? (s.health && s.health.videoModel ? s.health.videoModel : "agnes-video-2.5-flash")
843
- : (s.health && s.health.model ? s.health.model : "agnes-image-2.5-flash"))
1657
+ (() => {
1658
+ const kind = isVideo ? "video" : "image";
1659
+ const cur = isVideo
1660
+ ? (s.cfgDraft.videoModel || (s.health && s.health.videoModel) || "agnes-video-2.5-flash")
1661
+ : (s.cfgDraft.imageModel || (s.health && s.health.model) || "agnes-image-2.5-flash");
1662
+ /* 选项优先用连接测试拿到的上游真实清单,没有就退回内置清单 */
1663
+ const fromUp = s.upModels && Array.isArray(s.upModels[kind]) ? s.upModels[kind] : null;
1664
+ const fromHealth = s.health && s.health.models && Array.isArray(s.health.models[kind])
1665
+ ? s.health.models[kind]
1666
+ : [];
1667
+ let list = fromUp !== null && fromUp.length > 0 ? fromUp : fromHealth;
1668
+ if (list.length < 2) {
1669
+ /* 只有一个模型时给下拉没有意义,直接显示名字 */
1670
+ return React.createElement("span", { className: "agn-meta" }, cur);
1671
+ }
1672
+ list = list.slice();
1673
+ if (list.indexOf(cur) === -1) list.unshift(cur);
1674
+ return React.createElement("select", {
1675
+ className: "agn-model",
1676
+ value: cur,
1677
+ disabled: busy || s.cfgSaving,
1678
+ title: "切换" + (isVideo ? "视频" : "图片") + "模型(立刻保存到配置)",
1679
+ onChange: (e) => store.setModel(kind, e.target.value),
1680
+ }, list.map((m) => React.createElement("option", { key: m, value: m }, m)));
1681
+ })()
844
1682
  ),
845
1683
 
846
1684
  React.createElement("div", { className: "agn-card" },
@@ -849,6 +1687,22 @@ window.__ModuleLoader__.load({
849
1687
  modeBtn("video", "视频")
850
1688
  ),
851
1689
 
1690
+ /* ---- 视频关键帧(图生视频):本地图片直接当首帧/尾帧,留空即纯文生视频 ---- */
1691
+ isVideo
1692
+ ? React.createElement("div", { className: "agn-frames" },
1693
+ React.createElement("div", { className: "agn-ref-head" },
1694
+ React.createElement("span", { className: "agn-label" }, "关键帧"),
1695
+ React.createElement("span", { className: "agn-hint" },
1696
+ s.vFirst !== null || s.vLast !== null ? "图生视频" : "留空 = 纯文生视频"),
1697
+ s.frameBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null
1698
+ ),
1699
+ React.createElement("div", { className: "agn-frames-row" },
1700
+ frameSlot("first", "首帧", s.vFirst),
1701
+ frameSlot("last", "尾帧", s.vLast)
1702
+ )
1703
+ )
1704
+ : null,
1705
+
852
1706
  /* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
853
1707
  !isVideo
854
1708
  ? React.createElement("div", { className: "agn-refs" },
@@ -890,8 +1744,8 @@ window.__ModuleLoader__.load({
890
1744
  }, "+ 本地图片"),
891
1745
  React.createElement("button", {
892
1746
  type: "button", className: "agn-ref-add",
893
- disabled: busy || refsFull || imageHistory.length === 0,
894
- title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1747
+ disabled: busy || refsFull || pickableImages.length === 0,
1748
+ title: pickableImages.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
895
1749
  onClick: () => store.toggleRefPick(),
896
1750
  }, "+ 生成结果")
897
1751
  ),
@@ -899,9 +1753,9 @@ window.__ModuleLoader__.load({
899
1753
  /* 从生成结果挑图:只列图片(视频不能当参考图) */
900
1754
  s.refPickOpen
901
1755
  ? React.createElement("div", { className: "agn-ref-pick" },
902
- imageHistory.length === 0
1756
+ pickableImages.length === 0
903
1757
  ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
904
- : imageHistory.map((h) => React.createElement("button", {
1758
+ : pickableImages.map((h) => React.createElement("button", {
905
1759
  key: "pick-" + h.filename,
906
1760
  type: "button",
907
1761
  title: h.filename,
@@ -921,7 +1775,91 @@ window.__ModuleLoader__.load({
921
1775
  )
922
1776
  : null,
923
1777
 
1778
+ /* ---- 视频参考图(reference 模式,≤5 张):给风格 / 主体参考,不是首帧 ---- */
1779
+ isVideo
1780
+ ? React.createElement("div", { className: "agn-refs" },
1781
+ React.createElement("div", { className: "agn-ref-head" },
1782
+ React.createElement("span", { className: "agn-label" }, "参考图"),
1783
+ React.createElement("span", { className: "agn-tag" },
1784
+ s.vRefs.length + " / " + MAX_VREFS),
1785
+ s.vRefsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
1786
+ s.vRefs.length > 0
1787
+ ? React.createElement("button", {
1788
+ type: "button", className: "agn-btn-ghost", disabled: busy,
1789
+ onClick: () => store.clearVRefs(),
1790
+ }, "清空参考图")
1791
+ : null
1792
+ ),
1793
+ React.createElement("div", { className: "agn-ref-strip" },
1794
+ s.vRefs.map((r) => React.createElement("div", {
1795
+ key: r.id,
1796
+ className: "agn-ref",
1797
+ title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
1798
+ (r.from === "result" ? "来自生成结果" : "来自本地"),
1799
+ },
1800
+ React.createElement("img", { src: r.src, alt: r.name }),
1801
+ React.createElement("button", {
1802
+ type: "button", className: "agn-ref-del",
1803
+ title: "移除这张参考图",
1804
+ disabled: busy,
1805
+ onClick: () => store.removeVRef(r.id),
1806
+ }, "×")
1807
+ )),
1808
+ React.createElement("button", {
1809
+ type: "button", className: "agn-ref-add",
1810
+ disabled: busy || vRefsFull,
1811
+ title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
1812
+ onClick: () => {
1813
+ const el = vRefInput.current;
1814
+ if (el !== null) el.click();
1815
+ },
1816
+ }, "+ 本地"),
1817
+ React.createElement("button", {
1818
+ type: "button", className: "agn-ref-add",
1819
+ disabled: busy || vRefsFull || pickableImages.length === 0,
1820
+ title: pickableImages.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1821
+ onClick: () => store.toggleVRefPick(),
1822
+ }, "+ 结果")
1823
+ ),
1824
+ s.vRefPickOpen
1825
+ ? React.createElement("div", { className: "agn-ref-pick" },
1826
+ pickableImages.length === 0
1827
+ ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
1828
+ : pickableImages.map((h) => React.createElement("button", {
1829
+ key: "vpick-" + h.filename,
1830
+ type: "button",
1831
+ title: h.filename,
1832
+ onClick: () => store.addVRefFromResult(h),
1833
+ }, React.createElement("img", {
1834
+ src: srcOf(h.filename), alt: h.filename, loading: "lazy",
1835
+ })))
1836
+ )
1837
+ : null,
1838
+ React.createElement("div", { className: "agn-note" },
1839
+ s.vRefs.length > 0
1840
+ ? (s.vFirst !== null || s.vLast !== null
1841
+ ? "已给关键帧,本次按首尾帧生成,参考图不生效"
1842
+ : "将按参考图生成(" + s.vRefs.length + " 张)——参考风格 / 主体 / 构图")
1843
+ : "可选:给上游 1–5 张参考图;留空 = 纯文本生视频"),
1844
+ s.vRefsError !== "" ? React.createElement("div", { className: "agn-err" }, s.vRefsError) : null
1845
+ )
1846
+ : null,
1847
+
924
1848
  React.createElement("div", { className: "agn-label" }, "提示词"),
1849
+ React.createElement("div", { className: "agn-presets" },
1850
+ PROMPT_PRESETS.map((preset) => React.createElement("button", {
1851
+ key: preset.label,
1852
+ type: "button",
1853
+ className: "agn-preset",
1854
+ disabled: busy,
1855
+ title: "追加到提示词末尾:" + preset.text,
1856
+ onClick: () => store.set({
1857
+ prompt: s.prompt.trim() === ""
1858
+ ? preset.text
1859
+ : s.prompt.replace(/[\s,,;;。.]+$/, "") + "," + preset.text,
1860
+ }),
1861
+ }, preset.label))
1862
+ ),
925
1863
  React.createElement("textarea", {
926
1864
  className: "agn-textarea",
927
1865
  value: s.prompt,
@@ -938,7 +1876,17 @@ window.__ModuleLoader__.load({
938
1876
  className: "agn-btn", type: "button",
939
1877
  disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
940
1878
  onClick: () => store.run(),
941
- }, buttonLabel)
1879
+ }, buttonLabel),
1880
+ /* 视频是异步任务,等待可能很长:给出真正的「停止等待」而不是让用户干等 */
1881
+ busy
1882
+ ? React.createElement("button", {
1883
+ className: "agn-btn-ghost", type: "button",
1884
+ title: isVideo
1885
+ ? "停止等待(服务端任务可能仍在跑,已生成的文件不会丢)"
1886
+ : "放弃这次等待(请求已发出,结果可能仍会落盘)",
1887
+ onClick: () => store.cancel(),
1888
+ }, "取消")
1889
+ : null
942
1890
  ),
943
1891
 
944
1892
  /* 视频是异步任务:给出阶段与百分比,避免只看到一个跳动的秒数 */
@@ -960,6 +1908,7 @@ window.__ModuleLoader__.load({
960
1908
  "未找到 Key:" + (key.file || "") + " —— 把 Key 写进该文件后可立即使用")
961
1909
  : null,
962
1910
  s.error !== "" ? React.createElement("div", { className: "agn-err" }, s.error) : null,
1911
+ s.notice !== "" ? React.createElement("div", { className: "agn-notice" }, s.notice) : null,
963
1912
  busy && !isVideo
964
1913
  ? React.createElement("div", { className: "agn-hint" },
965
1914
  "正在生成:可切到别的面板,回来仍会接着显示结果(请求不会中断)")
@@ -970,6 +1919,12 @@ window.__ModuleLoader__.load({
970
1919
  React.createElement("div", { className: "agn-card" },
971
1920
  React.createElement("div", { className: "agn-card-head" },
972
1921
  React.createElement("div", { className: "agn-label" }, resultLabel),
1922
+ React.createElement("button", {
1923
+ type: "button",
1924
+ className: "agn-btn-ghost",
1925
+ title: "把输出目录里已有的图片 / 视频列出来,点一下就能显示在上方",
1926
+ onClick: () => store.toggleLibrary(),
1927
+ }, s.libraryOpen ? "收起目录" : "输出目录"),
973
1928
  React.createElement("button", {
974
1929
  type: "button",
975
1930
  className: "agn-btn-ghost",
@@ -980,7 +1935,7 @@ window.__ModuleLoader__.load({
980
1935
  ),
981
1936
 
982
1937
  shown !== null
983
- ? React.createElement(MediaBox, { entry: shown, big: true })
1938
+ ? React.createElement(MediaBox, { entry: shown, big: true, onCopyPrompt: (t) => store.copyPrompt(t) })
984
1939
  : React.createElement("div", { className: "agn-shot" },
985
1940
  React.createElement("div", { className: "agn-shot-msg" },
986
1941
  busy ? (isVideo ? "正在生成视频…" : "正在生成图片…")
@@ -989,19 +1944,117 @@ window.__ModuleLoader__.load({
989
1944
  shown !== null
990
1945
  ? React.createElement("div", { className: "agn-hint" },
991
1946
  React.createElement("span", null,
992
- shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
1947
+ (shown.fromDisk ? "来自输出目录 · " : "")
1948
+ + shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
993
1949
  ? (shown.bytes / 1024 / 1024).toFixed(2) + " MB"
994
1950
  : (shown.bytes / 1024).toFixed(0) + " KB")
995
1951
  + " · " + shown.size + "/" + shown.ratio
996
1952
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
1953
+ + (shown.mode === "keyframe" ? " · 图生视频" : "")
1954
+ + (shown.mode === "reference" ? " · 参考生视频" : "")
997
1955
  + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
998
- + " · 用时 " + shown.elapsedSec + "s"),
1956
+ + (shown.seed !== undefined && shown.seed !== null ? " · seed " + shown.seed : "")
1957
+ + " · 用时 " + (shown.elapsedSec || shown.wallSec) + "s"),
999
1958
  React.createElement("br"),
1000
1959
  React.createElement("span", null, "已保存到 "),
1001
1960
  React.createElement("span", { className: "agn-code" }, shown.path || ""))
1961
+ : null,
1962
+
1963
+ shown !== null
1964
+ ? React.createElement("div", { className: "agn-acts" },
1965
+ React.createElement("button", {
1966
+ type: "button", className: "agn-act",
1967
+ title: "把文件路径复制到剪贴板",
1968
+ onClick: () => store.copyPath(),
1969
+ }, "复制路径"),
1970
+ React.createElement("button", {
1971
+ type: "button", className: "agn-act",
1972
+ title: "在访达中显示这个文件",
1973
+ onClick: () => store.reveal(),
1974
+ }, "在访达中显示"),
1975
+ React.createElement("button", {
1976
+ type: "button", className: "agn-act",
1977
+ title: "另存一份到「下载」目录",
1978
+ onClick: () => store.downloadResult(),
1979
+ }, "下载"),
1980
+ shown.kind === "image"
1981
+ ? React.createElement("button", {
1982
+ type: "button", className: "agn-act",
1983
+ disabled: busy || s.frameBusy,
1984
+ title: "把这张图设为视频首帧(切到视频模式即可看到)",
1985
+ onClick: () => store.useResultAsFrame("first"),
1986
+ }, "设为首帧")
1987
+ : null,
1988
+ shown.kind === "image"
1989
+ ? React.createElement("button", {
1990
+ type: "button", className: "agn-act",
1991
+ disabled: busy || s.frameBusy,
1992
+ title: "把这张图设为视频尾帧(切到视频模式即可看到)",
1993
+ onClick: () => store.useResultAsFrame("last"),
1994
+ }, "设为尾帧")
1995
+ : null)
1002
1996
  : null
1003
1997
  ),
1004
1998
 
1999
+ /* ---- 输出目录:把磁盘上已有的图 / 视频捞回来看 ---- */
2000
+ s.libraryOpen
2001
+ ? React.createElement("div", { className: "agn-card" },
2002
+ React.createElement("div", { className: "agn-card-head" },
2003
+ React.createElement("div", { className: "agn-label" }, "输出目录"),
2004
+ React.createElement("button", {
2005
+ type: "button", className: "agn-btn-ghost",
2006
+ disabled: s.libraryBusy,
2007
+ title: "重新读一遍输出目录(新生成的文件会自动出现在这里)",
2008
+ onClick: () => store.loadLibrary(),
2009
+ }, s.libraryBusy ? "读取中…" : "刷新")
2010
+ ),
2011
+ React.createElement("div", { className: "agn-hint" },
2012
+ "点缩略图会把它显示到上方结果区,并带上当初写进图片里的提示词"),
2013
+ React.createElement("div", { className: "agn-lib" },
2014
+ React.createElement("div", { className: "agn-lib-tabs" },
2015
+ React.createElement("button", {
2016
+ type: "button",
2017
+ className: "agn-act" + (s.libraryTab === "image" ? " agn-act-on" : ""),
2018
+ onClick: () => store.setLibraryTab("image"),
2019
+ }, "图片 " + libraryImages.length),
2020
+ React.createElement("button", {
2021
+ type: "button",
2022
+ className: "agn-act" + (s.libraryTab === "video" ? " agn-act-on" : ""),
2023
+ onClick: () => store.setLibraryTab("video"),
2024
+ }, "视频 " + libraryVideos.length)
2025
+ ),
2026
+ s.libraryError !== ""
2027
+ ? React.createElement("div", { className: "agn-err" }, s.libraryError)
2028
+ : null,
2029
+ libraryShown.length === 0
2030
+ ? React.createElement("div", { className: "agn-lib-empty" },
2031
+ s.libraryBusy
2032
+ ? "正在读取输出目录…"
2033
+ : "这个目录里还没有" + (s.libraryTab === "video" ? "视频" : "图片"))
2034
+ : React.createElement("div", { className: "agn-lib-grid" },
2035
+ libraryShown.map((f) => React.createElement("button", {
2036
+ key: f.name,
2037
+ type: "button",
2038
+ className: "agn-lib-item" + (shown && shown.filename === f.name ? " agn-thumb-on" : ""),
2039
+ title: (f.prompt ? f.prompt + "\n" : "") + f.name,
2040
+ disabled: busy,
2041
+ onClick: () => store.showLibraryFile(f),
2042
+ },
2043
+ f.kind === "video"
2044
+ ? videoThumb(f.name)
2045
+ : React.createElement("img", { src: srcOf(f.name), alt: f.name, loading: "lazy" }),
2046
+ f.kind === "video"
2047
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
2048
+ : null,
2049
+ f.kind === "video"
2050
+ ? React.createElement("span", { className: "agn-lib-badge" }, "视频")
2051
+ : null
2052
+ ))
2053
+ )
2054
+ )
2055
+ )
2056
+ : null,
2057
+
1005
2058
  /* ---- 历史 ---- */
1006
2059
  s.history.length > 1
1007
2060
  ? React.createElement("div", { className: "agn-card agn-hist" },
@@ -1015,8 +2068,11 @@ window.__ModuleLoader__.load({
1015
2068
  onClick: () => store.show(h),
1016
2069
  },
1017
2070
  h.kind === "video"
1018
- ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
2071
+ ? videoThumb(h.filename)
1019
2072
  : React.createElement("img", { src: srcOf(h.filename), alt: h.filename, loading: "lazy" }),
2073
+ h.kind === "video"
2074
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
2075
+ : null,
1020
2076
  React.createElement("span", { className: "agn-thumb-cap" },
1021
2077
  h.kind === "video"
1022
2078
  ? (h.seconds ? h.seconds + "s" : "视频")
@@ -1068,6 +2124,52 @@ window.__ModuleLoader__.load({
1068
2124
  )
1069
2125
  : null,
1070
2126
 
2127
+ s.cfgOpen
2128
+ ? React.createElement("div", { className: "agn-field" },
2129
+ React.createElement("div", { className: "agn-label" }, "图片模型"),
2130
+ React.createElement("select", {
2131
+ className: "agn-input", value: s.cfgDraft.imageModel, disabled: s.cfgSaving,
2132
+ onChange: (e) => store.setCfgDraft("imageModel", e.target.value),
2133
+ },
2134
+ React.createElement("option", { value: "" }, "默认(" + ((s.health && s.health.model) || "agnes-image-2.5-flash") + ")"),
2135
+ ((s.health && s.health.models && s.health.models.image) || []).map((m) =>
2136
+ React.createElement("option", { key: m, value: m }, m))
2137
+ ),
2138
+ React.createElement("div", { className: "agn-note" }, "只列出当前上游真实存在的图片模型")
2139
+ )
2140
+ : null,
2141
+
2142
+ s.cfgOpen
2143
+ ? React.createElement("div", { className: "agn-field" },
2144
+ React.createElement("div", { className: "agn-label" }, "视频模型"),
2145
+ React.createElement("select", {
2146
+ className: "agn-input", value: s.cfgDraft.videoModel, disabled: s.cfgSaving,
2147
+ onChange: (e) => store.setCfgDraft("videoModel", e.target.value),
2148
+ },
2149
+ React.createElement("option", { value: "" }, "默认(" + ((s.health && s.health.videoModel) || "agnes-video-2.5-flash") + ")"),
2150
+ ((s.health && s.health.models && s.health.models.video) || []).map((m) =>
2151
+ React.createElement("option", { key: m, value: m }, m))
2152
+ ),
2153
+ React.createElement("div", { className: "agn-note" }, "换模型后新提交的任务才会用新模型")
2154
+ )
2155
+ : null,
2156
+
2157
+ s.cfgOpen
2158
+ ? React.createElement("div", { className: "agn-field" },
2159
+ React.createElement("button", {
2160
+ type: "button", className: "agn-btn-ghost", disabled: s.checking,
2161
+ title: "用当前 Key 请求一次上游模型清单,确认 Key 能用、网络通",
2162
+ onClick: () => store.checkKey(),
2163
+ }, s.checking ? "连接中…" : "测试连接"),
2164
+ s.checkResult !== null
2165
+ ? React.createElement("div", {
2166
+ // 别给 style 传字符串:React 会抛错并卸载整块面板(白屏)
2167
+ className: (s.checkResult.ok ? "agn-okline" : "agn-err") + " agn-check",
2168
+ }, s.checkResult.text)
2169
+ : null
2170
+ )
2171
+ : null,
2172
+
1071
2173
  s.cfgOpen
1072
2174
  ? React.createElement("div", { className: "agn-row" },
1073
2175
  React.createElement("button", {
@@ -1091,7 +2193,7 @@ window.__ModuleLoader__.load({
1091
2193
  s.cfgOpen
1092
2194
  ? React.createElement("div", { className: "agn-note" },
1093
2195
  "保存到 ",
1094
- (s.health && s.health.configFile) || "~/.dsh/agnes-image/config.json")
2196
+ (s.health && s.health.configFile) || "~/.dsh/agnes-create/config.json")
1095
2197
  : null
1096
2198
  ),
1097
2199
 
@@ -1130,7 +2232,7 @@ window.__ModuleLoader__.load({
1130
2232
 
1131
2233
  exports.apply = apply;
1132
2234
  exports.inject = ["slots"];
1133
- exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
2235
+ exports.__internals = { pickableImagesOf, getStore, createStore, srcOf, shrinkImage, MAX_REFS };
1134
2236
  return module.exports;
1135
2237
  },
1136
2238
  });