dsh-agnes-create 0.7.0 → 0.11.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/client.js CHANGED
@@ -13,11 +13,16 @@ 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;
@@ -45,6 +50,13 @@ window.__ModuleLoader__.load({
45
50
  .agn-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
46
51
  .agn-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, inherit); }
47
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; }
48
60
  .agn-card { border: 1px solid var(--dsw-alias-border-l2, #e3e3e3); border-radius: 10px;
49
61
  padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
50
62
  .agn-label { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #666); }
@@ -206,6 +218,27 @@ window.__ModuleLoader__.load({
206
218
  .agn-preset:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
207
219
  color: var(--dsw-alias-state-business-primary, #4b7bec); }
208
220
  .agn-preset:disabled { opacity: .45; cursor: not-allowed; }
221
+ /* 我的预设:名字 + 删除叉,包在一个 span 里 */
222
+ .agn-preset-user { display: inline-flex; align-items: stretch; }
223
+ .agn-preset-mine { border-top-right-radius: 0; border-bottom-right-radius: 0;
224
+ border-right: none; }
225
+ .agn-preset-x { font: inherit; font-size: 11px; line-height: 1.4; padding: 2px 7px;
226
+ border-radius: 0 999px 999px 0; cursor: pointer;
227
+ color: var(--dsw-alias-label-secondary, #888); background: transparent;
228
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
229
+ .agn-preset-x:hover:not(:disabled) { color: var(--dsw-alias-state-error-primary, #e5484d);
230
+ border-color: var(--dsw-alias-state-error-primary, #e5484d); }
231
+ .agn-preset-x:disabled { opacity: .45; cursor: not-allowed; }
232
+ .agn-preset-add { border-style: dashed; }
233
+ .agn-preset-expand { color: var(--dsw-alias-state-business-primary, #4b7bec);
234
+ border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
235
+ .agn-preset-form { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
236
+ .agn-input-sm { flex: 1 1 auto; min-width: 0; font: inherit; font-size: 12px;
237
+ padding: 3px 8px; border-radius: 6px;
238
+ color: var(--dsw-alias-label-primary, #222); background: transparent;
239
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
240
+ .agn-act-link { text-decoration: none; color: inherit; display: inline-flex;
241
+ align-items: center; }
209
242
 
210
243
  /* 视频关键帧(图生视频) */
211
244
  .agn-frames { display: flex; flex-direction: column; gap: 8px; }
@@ -243,6 +276,8 @@ window.__ModuleLoader__.load({
243
276
  color: var(--dsw-alias-state-business-primary, #4b7bec); }
244
277
  .agn-act-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
245
278
  color: var(--dsw-alias-state-business-primary, #4b7bec); }
279
+ /* 「测试连接」的结果行:和按钮之间留一点距离 */
280
+ .agn-check { margin-top: 6px; }
246
281
  .agn-notice { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2);
247
282
  line-height: 1.6; }
248
283
  `;
@@ -261,6 +296,25 @@ window.__ModuleLoader__.load({
261
296
  return "/agnes/media?name=" + encodeURIComponent(name);
262
297
  }
263
298
 
299
+ /* 「+ 结果」的候选:最近生成的图片 + 当前结果区那张(按文件名去重)。
300
+ 从「输出目录」点出来的图只进了结果区、没进历史,只翻历史会漏掉它,
301
+ 于是刚在眼前看到的那张图反而选不进参考图 / 关键帧。 */
302
+ function pickableImagesOf(state) {
303
+ const history = (Array.isArray(state.history) ? state.history : []).filter((h) => h.kind === "image");
304
+ const cur = state.result;
305
+ if (cur === null || cur === undefined || cur.kind !== "image") return history;
306
+ if (typeof cur.filename !== "string" || cur.filename === "") return history;
307
+ if (history.some((h) => h.filename === cur.filename)) return history;
308
+ return [cur].concat(history);
309
+ }
310
+
311
+ /* 视频生成方式:宿主回来的 mode 只可能是这三种之一,其余一律当纯文本 */
312
+ function modeOf(mode) {
313
+ if (mode === "keyframe") return "keyframe";
314
+ if (mode === "reference") return "reference";
315
+ return "text";
316
+ }
317
+
264
318
  /* ==========================================================================
265
319
  参考图预处理(图生图)
266
320
  --------------------------------------------------------------------------
@@ -362,6 +416,7 @@ window.__ModuleLoader__.load({
362
416
  vProgress: 0,
363
417
  vStatus: "",
364
418
  videoId: null,
419
+ vTaskId: null,
365
420
  health: null,
366
421
  healthRequested: false,
367
422
 
@@ -369,17 +424,25 @@ window.__ModuleLoader__.load({
369
424
  cfg: null, // 生效配置 { keyFile, outDir }
370
425
  cfgDefaults: null,
371
426
  cfgStored: null,
372
- cfgDraft: { keyFile: "", outDir: "" },
427
+ cfgDraft: { keyFile: "", outDir: "", imageModel: "", videoModel: "" },
373
428
  cfgOpen: false,
374
429
  cfgSaving: false,
375
430
  cfgError: "",
376
431
  cfgNotice: "",
432
+ checking: false, // 正在测连通性
433
+ checkResult: null, // { ok, text, detail }
434
+ upModels: null, // 连接测试拿到的上游真实模型清单 { image: [], video: [] }
377
435
 
378
436
  /* ---- 图生图参考图 ---- */
379
437
  refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
380
438
  refsBusy: false,
381
439
  refsError: "",
382
440
  refPickOpen: false,
441
+ /* ---- 视频参考图(reference 模式,最多 5 张) ---- */
442
+ vRefs: [],
443
+ vRefsBusy: false,
444
+ vRefsError: "",
445
+ vRefPickOpen: false,
383
446
 
384
447
  /* ---- 视频关键帧(可选):本地图直接当首帧 / 尾帧 ---- */
385
448
  vFirst: null, // { src(dataURI), name, bytes }
@@ -401,6 +464,14 @@ window.__ModuleLoader__.load({
401
464
  libraryBusy: false,
402
465
  libraryError: "",
403
466
  copiedKey: "", // 刚刚复制了哪一条的提示词(用于显示「已复制」)
467
+
468
+ /* ---- 自定义提示词预设(宿主落盘) + 扩写 ---- */
469
+ presets: [], // [{ label, text }]
470
+ presetsLoaded: false,
471
+ presetFormOpen: false, // 「存为预设」的小表单
472
+ presetLabel: "",
473
+ presetBusy: false,
474
+ expandBusy: false,
404
475
  };
405
476
 
406
477
  const snapshot = () => ({ ...state, history: state.history.slice() });
@@ -437,8 +508,98 @@ window.__ModuleLoader__.load({
437
508
  set({ result: entry, history: [entry, ...state.history].slice(0, state.historyMax) });
438
509
  // 顺手落盘:/agnes/history 是唯一真相,写失败也不影响本次展示
439
510
  void appendHistory(entry);
511
+ // 新文件已经写进输出目录了,目录清单开着的话跟着变(没加载过就不去打扰)
512
+ void refreshLibrary();
440
513
  };
441
514
 
515
+ /* ---------- 自定义提示词预设(宿主落盘) ---------- */
516
+ async function loadPresets() {
517
+ try {
518
+ const res = await fetch("/agnes/presets");
519
+ if (!res.ok) { set({ presetsLoaded: true }); return; }
520
+ const data = await res.json();
521
+ if (!data || data.ok !== true) { set({ presetsLoaded: true }); return; }
522
+ set({ presets: Array.isArray(data.presets) ? data.presets : [], presetsLoaded: true });
523
+ } catch {
524
+ set({ presetsLoaded: true });
525
+ }
526
+ }
527
+
528
+ const presetText = (preset) => (
529
+ state.prompt.trim() === ""
530
+ ? preset.text
531
+ : state.prompt.replace(/[\s,,;;。.]+$/, "") + "," + preset.text
532
+ );
533
+
534
+ async function saveCurrentAsPreset() {
535
+ if (state.presetBusy) return;
536
+ const text = state.prompt.trim();
537
+ if (text === "") { set({ notice: "先写提示词,再存成预设" }); return; }
538
+ const label = (state.presetLabel.trim() || text.slice(0, 10)).trim();
539
+ set({ presetBusy: true });
540
+ try {
541
+ const res = await fetch("/agnes/presets", {
542
+ method: "POST",
543
+ headers: { "content-type": "application/json" },
544
+ body: JSON.stringify({ label, text }),
545
+ });
546
+ const data = await res.json().catch(() => null);
547
+ if (!res.ok || !data || data.ok !== true) {
548
+ throw new Error((data && data.error) || ("HTTP " + res.status));
549
+ }
550
+ set({
551
+ presets: Array.isArray(data.presets) ? data.presets : state.presets,
552
+ presetBusy: false, presetFormOpen: false, presetLabel: "",
553
+ notice: "已存为预设「" + label + "」",
554
+ });
555
+ } catch (e) {
556
+ set({ presetBusy: false, notice: "存预设失败:" + (e && e.message ? e.message : String(e)) });
557
+ }
558
+ }
559
+
560
+ async function deletePreset(label) {
561
+ if (state.presetBusy) return;
562
+ set({ presetBusy: true });
563
+ try {
564
+ const res = await fetch("/agnes/presets?label=" + encodeURIComponent(label), { method: "DELETE" });
565
+ const data = await res.json().catch(() => null);
566
+ if (!res.ok || !data || data.ok !== true) {
567
+ throw new Error((data && data.error) || ("HTTP " + res.status));
568
+ }
569
+ set({
570
+ presets: Array.isArray(data.presets) ? data.presets : [],
571
+ presetBusy: false,
572
+ notice: "已删除预设「" + label + "」",
573
+ });
574
+ } catch (e) {
575
+ set({ presetBusy: false, notice: "删除预设失败:" + (e && e.message ? e.message : String(e)) });
576
+ }
577
+ }
578
+
579
+ /** 让上游文本模型(agnes-2.5-flash)把这一句扩写成一段描述。 */
580
+ async function expandPrompt() {
581
+ if (state.expandBusy) return;
582
+ const text = state.prompt.trim();
583
+ if (text === "") { set({ notice: "先写一句想画什么,再来扩写" }); return; }
584
+ set({ expandBusy: true, notice: "" });
585
+ try {
586
+ const res = await fetch("/agnes/expand", {
587
+ method: "POST",
588
+ headers: { "content-type": "application/json" },
589
+ body: JSON.stringify({ prompt: text, kind: state.mode === "video" ? "video" : "image" }),
590
+ });
591
+ const data = await res.json().catch(() => null);
592
+ if (!res.ok || !data || data.ok !== true) {
593
+ throw new Error((data && data.error) || ("HTTP " + res.status));
594
+ }
595
+ const next = typeof data.prompt === "string" ? data.prompt.trim() : "";
596
+ if (next === "") throw new Error("上游没有返回内容");
597
+ set({ prompt: next, expandBusy: false, notice: "已扩写(" + (data.model || "agnes-2.5-flash") + ")" });
598
+ } catch (e) {
599
+ set({ expandBusy: false, notice: "扩写失败:" + (e && e.message ? e.message : String(e)) });
600
+ }
601
+ }
602
+
442
603
  /* ---------- 历史(宿主持久化) ---------- */
443
604
  async function loadHistory() {
444
605
  try {
@@ -512,7 +673,12 @@ window.__ModuleLoader__.load({
512
673
  cfg: data.config,
513
674
  cfgDefaults: data.defaults,
514
675
  cfgStored: data.stored,
515
- cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
676
+ cfgDraft: {
677
+ keyFile: data.config.keyFile,
678
+ outDir: data.config.outDir,
679
+ imageModel: data.config.imageModel || "",
680
+ videoModel: data.config.videoModel || "",
681
+ },
516
682
  cfgError: "",
517
683
  });
518
684
  } catch (e) {
@@ -530,6 +696,9 @@ window.__ModuleLoader__.load({
530
696
  body: JSON.stringify({
531
697
  keyFile: state.cfgDraft.keyFile,
532
698
  outDir: state.cfgDraft.outDir,
699
+ // 空串 = 回退到内置默认模型
700
+ imageModel: state.cfgDraft.imageModel,
701
+ videoModel: state.cfgDraft.videoModel,
533
702
  }),
534
703
  });
535
704
  if (res.status === 404) throw new Error("配置接口尚未就绪:需要完全重启应用(只重载窗口不够)");
@@ -539,7 +708,12 @@ window.__ModuleLoader__.load({
539
708
  cfg: data.config,
540
709
  cfgDefaults: data.defaults,
541
710
  cfgStored: data.stored,
542
- cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
711
+ cfgDraft: {
712
+ keyFile: data.config.keyFile,
713
+ outDir: data.config.outDir,
714
+ imageModel: data.config.imageModel || "",
715
+ videoModel: data.config.videoModel || "",
716
+ },
543
717
  cfgNotice: "已保存",
544
718
  });
545
719
  // 路径变了,key 是否存在/目录是否可写都要重新算
@@ -553,6 +727,43 @@ window.__ModuleLoader__.load({
553
727
  }
554
728
  }
555
729
 
730
+ /** 测一下 Key 能不能连上游:顺便把上游真实的模型名单拉回来 */
731
+ async function checkKey() {
732
+ if (state.checking) return;
733
+ set({ checking: true, checkResult: null, cfgError: "" });
734
+ try {
735
+ const res = await fetch("/agnes/models");
736
+ const data = await res.json().catch(() => ({}));
737
+ if (res.status === 404) {
738
+ throw new Error("模型探测接口尚未就绪:需要完全重启应用(只重载窗口不够)");
739
+ }
740
+ if (!res.ok || !data || data.ok !== true) {
741
+ throw new Error((data && data.error) || ("探测失败:HTTP " + res.status));
742
+ }
743
+ const up = data.upstream || {};
744
+ const imgs = Array.isArray(up.image) ? up.image.length : 0;
745
+ const vids = Array.isArray(up.video) ? up.video.length : 0;
746
+ set({
747
+ checking: false,
748
+ // 把上游真实清单留下:顶部的模型下拉优先用它,比内置清单更可信
749
+ upModels: {
750
+ image: Array.isArray(up.image) ? up.image : [],
751
+ video: Array.isArray(up.video) ? up.video : [],
752
+ },
753
+ checkResult: {
754
+ ok: true,
755
+ text: "连接正常:上游可用 " + imgs + " 个图片模型、" + vids + " 个视频模型",
756
+ detail: ""
757
+ },
758
+ });
759
+ } catch (e) {
760
+ set({
761
+ checking: false,
762
+ checkResult: { ok: false, text: e && e.message ? e.message : String(e), detail: "" },
763
+ });
764
+ }
765
+ }
766
+
556
767
  /* ---------- 图片:一次请求就出结果 ---------- */
557
768
  async function runImage(token, prompt) {
558
769
  const refs = state.refs;
@@ -690,6 +901,101 @@ window.__ModuleLoader__.load({
690
901
  set({ refPickOpen: !state.refPickOpen, refsError: "" });
691
902
  }
692
903
 
904
+ /* ---------- 视频参考图(reference 模式,≤5 张) ---------- */
905
+ const vRefsFull = () => state.vRefs.length >= MAX_VREFS;
906
+
907
+ function addVRefsFromFiles(files) {
908
+ const list = Array.from(files || []).filter(
909
+ (f) => f && String(f.type || "").startsWith("image/")
910
+ );
911
+ if (list.length === 0) {
912
+ set({ vRefsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
913
+ return Promise.resolve();
914
+ }
915
+
916
+ return queueRefOp(async () => {
917
+ set({ vRefsBusy: true, vRefsError: "" });
918
+ const added = [];
919
+ const failed = [];
920
+ try {
921
+ for (const file of list) {
922
+ if (state.vRefs.length + added.length >= MAX_VREFS) {
923
+ failed.push("超出 " + MAX_VREFS + " 张上限");
924
+ break;
925
+ }
926
+ try {
927
+ const r = await shrinkImage(file);
928
+ added.push({
929
+ id: nextRefId(),
930
+ name: file.name || "本地图片",
931
+ src: r.src,
932
+ bytes: r.bytes,
933
+ from: "local",
934
+ });
935
+ } catch (e) {
936
+ failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
937
+ }
938
+ }
939
+ if (added.length > 0) {
940
+ update((prev) => ({ vRefs: prev.vRefs.concat(added) }));
941
+ }
942
+ set({
943
+ vRefsError: failed.length > 0 ? failed.join(";") : "",
944
+ vRefPickOpen: false,
945
+ });
946
+ } finally {
947
+ set({ vRefsBusy: false });
948
+ }
949
+ });
950
+ }
951
+
952
+ function addVRefFromResult(entry) {
953
+ if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
954
+ if (vRefsFull()) {
955
+ set({ vRefsError: "参考图最多 " + MAX_VREFS + " 张" });
956
+ return Promise.resolve();
957
+ }
958
+
959
+ return queueRefOp(async () => {
960
+ set({ vRefsBusy: true, vRefsError: "" });
961
+ try {
962
+ const res = await fetch(srcOf(entry.filename));
963
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
964
+ const blob = await res.blob();
965
+ const r = await shrinkImage(blob);
966
+ update((prev) => ({
967
+ vRefs: prev.vRefs.concat([{
968
+ id: nextRefId(),
969
+ name: entry.filename,
970
+ src: r.src,
971
+ bytes: r.bytes,
972
+ from: "result",
973
+ }]),
974
+ vRefPickOpen: false,
975
+ }));
976
+ } catch (e) {
977
+ set({ vRefsError: e && e.message ? e.message : String(e) });
978
+ } finally {
979
+ set({ vRefsBusy: false });
980
+ }
981
+ });
982
+ }
983
+
984
+ function removeVRef(id) {
985
+ if (state.busy) return;
986
+ update((prev) => ({ vRefs: prev.vRefs.filter((r) => r.id !== id), vRefsError: "" }));
987
+ }
988
+
989
+ function clearVRefs() {
990
+ if (state.busy) return;
991
+ set({ vRefs: [], vRefsError: "", vRefPickOpen: false });
992
+ }
993
+
994
+ function toggleVRefPick() {
995
+ if (state.busy) return;
996
+ set({ vRefPickOpen: !state.vRefPickOpen, vRefsError: "" });
997
+ }
998
+
693
999
  /* ---------- 视频关键帧(首帧 / 尾帧,本地图直接当帧) ---------- */
694
1000
  const frameField = (which) => (which === "last" ? "vLast" : "vFirst");
695
1001
 
@@ -797,14 +1103,17 @@ window.__ModuleLoader__.load({
797
1103
  set({ notice: "已开始下载" });
798
1104
  }
799
1105
 
800
- /** 停掉等待:让在途的轮询/请求结果失效(服务端任务本身无法撤回) */
1106
+ /** 停掉等待:让在途的轮询/请求结果失效(宿主的任务照常跑,文件会正常落盘) */
801
1107
  function cancel() {
802
1108
  if (!state.busy) return;
1109
+ const hadTask = state.vTaskId !== null;
803
1110
  runToken += 1;
804
1111
  stopTimer();
805
1112
  set({
806
1113
  busy: false, vStatus: "", vProgress: 0,
807
- notice: "已停止等待;服务端任务可能仍在跑",
1114
+ notice: hadTask
1115
+ ? "已停止跟踪;宿主会继续推进,刷新面板还能接回进度,文件也会照常落盘"
1116
+ : "已停止等待;服务端任务可能仍在跑",
808
1117
  });
809
1118
  }
810
1119
 
@@ -850,6 +1159,23 @@ window.__ModuleLoader__.load({
850
1159
  }
851
1160
  }
852
1161
 
1162
+ /**
1163
+ * 静默刷新输出目录清单:生成完成后新文件已经落盘,列表要跟着更新。
1164
+ * 只在清单已经加载过时才拉(没展开过就别偷偷把面板撑开),失败保留旧清单。
1165
+ */
1166
+ async function refreshLibrary() {
1167
+ if (state.library === null || state.libraryBusy) return;
1168
+ try {
1169
+ const res = await fetch("/agnes/library");
1170
+ const data = await res.json().catch(() => ({}));
1171
+ if (!res.ok || !data || data.ok !== true) return;
1172
+ if (!Array.isArray(data.files)) return;
1173
+ update(() => ({ library: data.files, libraryError: "" }));
1174
+ } catch {
1175
+ // 刷新失败保留旧清单,不打扰用户
1176
+ }
1177
+ }
1178
+
853
1179
  function toggleLibrary() {
854
1180
  if (state.libraryOpen) {
855
1181
  set({ libraryOpen: false });
@@ -882,59 +1208,32 @@ window.__ModuleLoader__.load({
882
1208
  });
883
1209
  }
884
1210
 
885
- /* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
886
- async function runVideo(token, prompt) {
887
- set({ vStatus: "提交中…", vProgress: 0, videoId: null });
888
-
889
- const seedText = String(state.vSeed).trim();
890
- const seedValue = seedText === "" ? null : Number(seedText);
891
- if (seedValue !== null && !(Number.isInteger(seedValue) && seedValue >= 0)) {
892
- throw new Error("seed 需要非负整数(也可以留空)");
893
- }
894
-
895
- // 关键帧直接发 data URI:上游已实测接受 base64,本地图片不用先传图床
896
- const subRes = await fetch("/agnes/video", {
897
- method: "POST",
898
- headers: { "content-type": "application/json" },
899
- body: JSON.stringify({
900
- prompt,
901
- seconds: state.vSeconds,
902
- ratio: state.vRatio,
903
- seed: seedValue,
904
- firstFrame: state.vFirst !== null ? state.vFirst.src : null,
905
- lastFrame: state.vLast !== null ? state.vLast.src : null,
906
- }),
907
- });
908
- const sub = await subRes.json();
909
- if (!sub.ok) throw new Error(sub.error || "提交失败");
910
- if (token !== runToken) return;
911
-
912
- const usedMode = sub.mode === "keyframe" ? "keyframe" : "text";
913
- set({ videoId: sub.videoId, vStatus: "排队中…" });
1211
+ /* ---------- 视频:交给宿主任务队列,面板只负责看进度 ---------- */
1212
+ // 提交后 video_id 由宿主保管:关面板、刷新页面、重启应用都不影响任务继续推进。
1213
+ async function watchTask(token, taskId, fallback) {
914
1214
  const deadline = Date.now() + VIDEO_DEADLINE_MS;
915
-
916
1215
  let transientFails = 0;
1216
+
917
1217
  for (;;) {
918
1218
  await sleep(POLL_MS);
919
1219
  if (token !== runToken) return;
920
1220
 
921
1221
  let st;
922
1222
  try {
923
- const r = await fetch("/agnes/video?id=" + encodeURIComponent(sub.videoId));
924
- st = await r.json();
1223
+ const r = await fetch("/agnes/task?id=" + encodeURIComponent(taskId));
1224
+ st = r.ok ? await r.json() : { ok: false, error: "查询任务失败:HTTP " + r.status };
925
1225
  } catch (e) {
926
1226
  st = { ok: false, error: "网络错误:" + (e && e.message ? e.message : String(e)) };
927
1227
  }
928
1228
 
929
1229
  if (!st.ok) {
930
1230
  const msg = String(st.error || "查询任务失败");
931
- // 429/503 只是限流,任务还在服务端跑:退避后继续,别把整个任务判死。
932
- // 实测提交后立刻查询就会 429,这条路径是必需的。
1231
+ // 宿主自己会重试上游;这里只在本地接口抖动时容忍几次
933
1232
  if (/\b429\b|\b503\b|频繁|队列已满|网络错误|timeout/i.test(msg)) {
934
1233
  transientFails += 1;
935
1234
  if (transientFails > 8) throw new Error(msg);
936
- set({ vStatus: "限流重试中…" });
937
- await sleep(Math.min(30000, 6000 * transientFails));
1235
+ set({ vStatus: "重试中…" });
1236
+ await sleep(Math.min(30000, 3000 * transientFails));
938
1237
  continue;
939
1238
  }
940
1239
  throw new Error(msg);
@@ -943,33 +1242,126 @@ window.__ModuleLoader__.load({
943
1242
  transientFails = 0;
944
1243
  if (token !== runToken) return;
945
1244
 
1245
+ const task = st.task || {};
946
1246
  set({
947
- vStatus: st.status,
948
- vProgress: typeof st.progress === "number" ? st.progress : 0,
1247
+ vStatus: task.status ? String(task.status) : "",
1248
+ vProgress: typeof task.progress === "number" ? task.progress : 0,
1249
+ videoId: task.videoId || fallback.videoId || null,
949
1250
  });
950
1251
 
951
- if (st.status === "completed") {
1252
+ if (task.status === "completed") {
952
1253
  pushResult({
953
1254
  kind: "video",
954
- filename: st.filename,
955
- path: st.path,
956
- bytes: st.bytes,
1255
+ filename: task.filename,
1256
+ path: task.path,
1257
+ bytes: task.bytes,
957
1258
  size: "720P",
958
- ratio: state.vRatio,
959
- seconds: st.seconds || state.vSeconds,
960
- transport: "异步任务",
961
- mode: usedMode,
962
- seed: seedValue === null ? undefined : seedValue,
963
- prompt,
1259
+ ratio: task.ratio || fallback.ratio,
1260
+ seconds: task.seconds || fallback.seconds,
1261
+ transport: "宿主任务",
1262
+ mode: modeOf(task.mode || fallback.mode),
1263
+ seed: task.seed === null || task.seed === undefined ? undefined : task.seed,
1264
+ prompt: task.prompt || fallback.prompt,
964
1265
  wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
965
1266
  });
966
1267
  return;
967
1268
  }
968
- if (st.status === "failed") {
969
- const err = st.error;
970
- throw new Error(typeof err === "string" ? err : (err && err.message) || "视频生成失败");
1269
+ if (task.status === "failed") throw new Error(task.error || "视频生成失败");
1270
+ if (task.status === "cancelled") throw new Error("任务已取消");
1271
+ if (Date.now() > deadline) {
1272
+ throw new Error("等待超时(15 分钟);宿主仍在推进,稍后刷新还能看到结果");
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ async function runVideo(token, prompt) {
1278
+ set({ vStatus: "提交中…", vProgress: 0, videoId: null, vTaskId: null });
1279
+
1280
+ const seedText = String(state.vSeed).trim();
1281
+ const seedValue = seedText === "" ? null : Number(seedText);
1282
+ if (seedValue !== null && !(Number.isInteger(seedValue) && seedValue >= 0)) {
1283
+ throw new Error("seed 需要非负整数(也可以留空)");
1284
+ }
1285
+
1286
+ // 关键帧直接发 data URI:上游已实测接受 base64,本地图片不用先传图床
1287
+ const subRes = await fetch("/agnes/tasks", {
1288
+ method: "POST",
1289
+ headers: { "content-type": "application/json" },
1290
+ body: JSON.stringify({
1291
+ prompt,
1292
+ seconds: state.vSeconds,
1293
+ ratio: state.vRatio,
1294
+ seed: seedValue,
1295
+ firstFrame: state.vFirst !== null ? state.vFirst.src : null,
1296
+ lastFrame: state.vLast !== null ? state.vLast.src : null,
1297
+ // 参考图(reference 模式):有首/尾帧时上游会用 keyframe,参考图自动让位
1298
+ referenceImages: state.vRefs.map((r) => r.src),
1299
+ }),
1300
+ });
1301
+ const sub = await subRes.json();
1302
+ if (!sub.ok) throw new Error(sub.error || "提交失败");
1303
+ if (token !== runToken) return;
1304
+
1305
+ const task = sub.task || {};
1306
+ if (!task.id) throw new Error("宿主没有返回任务号");
1307
+ set({ videoId: task.videoId || null, vTaskId: task.id, vStatus: "排队中…" });
1308
+
1309
+ await watchTask(token, task.id, {
1310
+ prompt,
1311
+ ratio: state.vRatio,
1312
+ seconds: state.vSeconds,
1313
+ mode: task.mode,
1314
+ videoId: task.videoId,
1315
+ });
1316
+ }
1317
+
1318
+ /* 面板重新挂载时把宿主上还在跑的任务接回来:刷新页面不会让进度消失 */
1319
+ async function resumeTasks() {
1320
+ if (state.busy) return;
1321
+ try {
1322
+ const r = await fetch("/agnes/tasks");
1323
+ if (!r.ok) return;
1324
+ const data = await r.json();
1325
+ if (!data || data.ok !== true || !Array.isArray(data.tasks)) return;
1326
+ const active = data.tasks.find((t) => t.status === "queued" || t.status === "running");
1327
+ if (!active || state.busy) return;
1328
+
1329
+ const token = ++runToken;
1330
+ const created = Date.parse(active.createdAt);
1331
+ set({
1332
+ mode: "video",
1333
+ busy: true,
1334
+ error: "",
1335
+ notice: "",
1336
+ startedAt: Number.isFinite(created) ? created : Date.now(),
1337
+ elapsed: 0,
1338
+ vFirst: null,
1339
+ vLast: null,
1340
+ videoId: active.videoId || null,
1341
+ vTaskId: active.id,
1342
+ vStatus: active.status,
1343
+ vProgress: typeof active.progress === "number" ? active.progress : 0,
1344
+ prompt: state.prompt.trim() === "" ? String(active.prompt || "") : state.prompt,
1345
+ });
1346
+ startTimer();
1347
+ try {
1348
+ await watchTask(token, active.id, {
1349
+ prompt: active.prompt,
1350
+ ratio: active.ratio || state.vRatio,
1351
+ seconds: active.seconds || state.vSeconds,
1352
+ mode: active.mode,
1353
+ videoId: active.videoId,
1354
+ });
1355
+ } catch (e) {
1356
+ if (token === runToken) set({ error: e && e.message ? e.message : String(e) });
1357
+ } finally {
1358
+ if (token === runToken) {
1359
+ stopTimer();
1360
+ set({ busy: false, elapsed: (Date.now() - state.startedAt) / 1000 });
1361
+ }
971
1362
  }
972
- if (Date.now() > deadline) throw new Error("等待超时(15 分钟)");
1363
+ } catch {
1364
+ // 旧宿主没有 /agnes/tasks:静默跳过,不打扰用户
973
1365
  }
974
1366
  }
975
1367
 
@@ -1014,7 +1406,19 @@ window.__ModuleLoader__.load({
1014
1406
  setCfgDraft(field, value) {
1015
1407
  set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
1016
1408
  },
1409
+
1410
+ /* 顶部标题栏直接换模型:先写进草稿,再立刻落盘(不然刷新就回退了) */
1411
+ async setModel(kind, value) {
1412
+ if (state.busy || state.cfgSaving) return;
1413
+ const field = kind === "video" ? "videoModel" : "imageModel";
1414
+ set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "", cfgError: "" });
1415
+ await saveConfig();
1416
+ if (state.cfgError === "") {
1417
+ set({ notice: "已切换模型:" + (value === "" ? "内置默认" : value) });
1418
+ }
1419
+ },
1017
1420
  toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
1421
+ checkKey,
1018
1422
  run,
1019
1423
  /* ---- 参考图动作 ---- */
1020
1424
  addRefsFromFiles,
@@ -1022,6 +1426,12 @@ window.__ModuleLoader__.load({
1022
1426
  removeRef,
1023
1427
  clearRefs,
1024
1428
  toggleRefPick,
1429
+ /* ---- 视频参考图(reference 模式,≤5 张) ---- */
1430
+ addVRefsFromFiles,
1431
+ addVRefFromResult,
1432
+ removeVRef,
1433
+ clearVRefs,
1434
+ toggleVRefPick,
1025
1435
  /* ---- 视频关键帧 ---- */
1026
1436
  pickFrame,
1027
1437
  setFrameFromFile,
@@ -1029,18 +1439,30 @@ window.__ModuleLoader__.load({
1029
1439
  toggleFramePick,
1030
1440
  setFrameFromResult,
1031
1441
  useResultAsFrame,
1442
+ /* ---- 视频任务(宿主侧推进,刷新可接回) ---- */
1443
+ resumeTasks,
1032
1444
  /* ---- 结果收尾 ---- */
1033
1445
  copyPath,
1034
1446
  reveal,
1035
1447
  downloadResult,
1036
1448
  cancel,
1037
1449
  copyPrompt,
1450
+ /* ---- 自定义预设 / 扩写 ---- */
1451
+ loadPresets,
1452
+ saveCurrentAsPreset,
1453
+ deletePreset,
1454
+ expandPrompt,
1455
+ /** 追加内置或自定义预设到提示词末尾 */
1456
+ appendPreset(preset) { set({ prompt: presetText(preset) }); },
1457
+ setPresetLabel(value) { set({ presetLabel: value }); },
1458
+ togglePresetForm() { set({ presetFormOpen: !state.presetFormOpen, presetLabel: "" }); },
1038
1459
  /* ---- 输出目录清单 ---- */
1039
1460
  loadLibrary,
1040
1461
  toggleLibrary,
1041
1462
  setLibraryTab,
1042
1463
  showLibraryFile,
1043
1464
  maxRefs: MAX_REFS,
1465
+ maxVRefs: MAX_VREFS,
1044
1466
  /** 切换主预览到历史里的某一张 */
1045
1467
  show(entry) { set({ result: entry }); },
1046
1468
  /**
@@ -1172,7 +1594,14 @@ window.__ModuleLoader__.load({
1172
1594
  return () => { off(); };
1173
1595
  }, []);
1174
1596
 
1175
- React.useEffect(() => { store.loadHealth(); store.loadConfig(); store.loadHistory(); }, []);
1597
+ React.useEffect(() => {
1598
+ store.loadHealth();
1599
+ store.loadConfig();
1600
+ store.loadHistory();
1601
+ store.loadPresets();
1602
+ // 上次没跑完的视频任务:宿主还在推进,接回进度
1603
+ store.resumeTasks();
1604
+ }, []);
1176
1605
 
1177
1606
  const busy = s.busy;
1178
1607
  const isVideo = s.mode === "video";
@@ -1181,13 +1610,16 @@ window.__ModuleLoader__.load({
1181
1610
  const shown = s.result;
1182
1611
  const hasRefs = s.refs.length > 0;
1183
1612
  const refsFull = s.refs.length >= MAX_REFS;
1613
+ const vRefsFull = s.vRefs.length >= MAX_VREFS;
1184
1614
  const imageHistory = s.history.filter((h) => h.kind === "image");
1615
+ const pickableImages = pickableImagesOf(s);
1185
1616
  const libraryAll = s.library || [];
1186
1617
  const libraryImages = libraryAll.filter((f) => f.kind !== "video");
1187
1618
  const libraryVideos = libraryAll.filter((f) => f.kind === "video");
1188
1619
  const libraryShown = s.libraryTab === "video" ? libraryVideos : libraryImages;
1189
1620
  const fileInput = React.useRef(null);
1190
1621
  const frameInput = React.useRef(null);
1622
+ const vRefInput = React.useRef(null);
1191
1623
 
1192
1624
  const onKeyDown = (e) => {
1193
1625
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
@@ -1279,15 +1711,15 @@ window.__ModuleLoader__.load({
1279
1711
  }, "+ 本地"),
1280
1712
  React.createElement("button", {
1281
1713
  type: "button", className: "agn-frame-from",
1282
- disabled: busy || s.frameBusy || imageHistory.length === 0,
1283
- title: imageHistory.length === 0
1714
+ disabled: busy || s.frameBusy || pickableImages.length === 0,
1715
+ title: pickableImages.length === 0
1284
1716
  ? "还没有生成过图片,先出一张图再来选"
1285
1717
  : "从生成结果里挑一张当" + label,
1286
1718
  onClick: () => store.toggleFramePick(which),
1287
1719
  }, s.framePickFrom === which ? "收起" : "+ 结果"),
1288
1720
  s.framePickFrom === which
1289
1721
  ? React.createElement("div", { className: "agn-ref-pick agn-frame-pick" },
1290
- imageHistory.map((h) => React.createElement("button", {
1722
+ pickableImages.map((h) => React.createElement("button", {
1291
1723
  key: h.filename, type: "button", title: h.filename,
1292
1724
  disabled: busy || s.frameBusy,
1293
1725
  onClick: () => store.setFrameFromResult(which, h),
@@ -1331,12 +1763,49 @@ window.__ModuleLoader__.load({
1331
1763
  },
1332
1764
  }),
1333
1765
 
1766
+ /* 隐藏的视频参考图选择器(reference 模式,最多 5 张) */
1767
+ React.createElement("input", {
1768
+ ref: vRefInput,
1769
+ type: "file",
1770
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1771
+ multiple: true,
1772
+ style: { display: "none" },
1773
+ onChange: (e) => {
1774
+ const el = e.target;
1775
+ const files = el.files;
1776
+ Promise.resolve(store.addVRefsFromFiles(files))
1777
+ .catch(() => {})
1778
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1779
+ },
1780
+ }),
1781
+
1334
1782
  React.createElement("div", { className: "agn-head" },
1335
1783
  React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
1336
- React.createElement("span", { className: "agn-meta" },
1337
- isVideo
1338
- ? (s.health && s.health.videoModel ? s.health.videoModel : "agnes-video-2.5-flash")
1339
- : (s.health && s.health.model ? s.health.model : "agnes-image-2.5-flash"))
1784
+ (() => {
1785
+ const kind = isVideo ? "video" : "image";
1786
+ const cur = isVideo
1787
+ ? (s.cfgDraft.videoModel || (s.health && s.health.videoModel) || "agnes-video-2.5-flash")
1788
+ : (s.cfgDraft.imageModel || (s.health && s.health.model) || "agnes-image-2.5-flash");
1789
+ /* 选项优先用连接测试拿到的上游真实清单,没有就退回内置清单 */
1790
+ const fromUp = s.upModels && Array.isArray(s.upModels[kind]) ? s.upModels[kind] : null;
1791
+ const fromHealth = s.health && s.health.models && Array.isArray(s.health.models[kind])
1792
+ ? s.health.models[kind]
1793
+ : [];
1794
+ let list = fromUp !== null && fromUp.length > 0 ? fromUp : fromHealth;
1795
+ if (list.length < 2) {
1796
+ /* 只有一个模型时给下拉没有意义,直接显示名字 */
1797
+ return React.createElement("span", { className: "agn-meta" }, cur);
1798
+ }
1799
+ list = list.slice();
1800
+ if (list.indexOf(cur) === -1) list.unshift(cur);
1801
+ return React.createElement("select", {
1802
+ className: "agn-model",
1803
+ value: cur,
1804
+ disabled: busy || s.cfgSaving,
1805
+ title: "切换" + (isVideo ? "视频" : "图片") + "模型(立刻保存到配置)",
1806
+ onChange: (e) => store.setModel(kind, e.target.value),
1807
+ }, list.map((m) => React.createElement("option", { key: m, value: m }, m)));
1808
+ })()
1340
1809
  ),
1341
1810
 
1342
1811
  React.createElement("div", { className: "agn-card" },
@@ -1402,8 +1871,8 @@ window.__ModuleLoader__.load({
1402
1871
  }, "+ 本地图片"),
1403
1872
  React.createElement("button", {
1404
1873
  type: "button", className: "agn-ref-add",
1405
- disabled: busy || refsFull || imageHistory.length === 0,
1406
- title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1874
+ disabled: busy || refsFull || pickableImages.length === 0,
1875
+ title: pickableImages.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1407
1876
  onClick: () => store.toggleRefPick(),
1408
1877
  }, "+ 生成结果")
1409
1878
  ),
@@ -1411,9 +1880,9 @@ window.__ModuleLoader__.load({
1411
1880
  /* 从生成结果挑图:只列图片(视频不能当参考图) */
1412
1881
  s.refPickOpen
1413
1882
  ? React.createElement("div", { className: "agn-ref-pick" },
1414
- imageHistory.length === 0
1883
+ pickableImages.length === 0
1415
1884
  ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
1416
- : imageHistory.map((h) => React.createElement("button", {
1885
+ : pickableImages.map((h) => React.createElement("button", {
1417
1886
  key: "pick-" + h.filename,
1418
1887
  type: "button",
1419
1888
  title: h.filename,
@@ -1433,21 +1902,140 @@ window.__ModuleLoader__.load({
1433
1902
  )
1434
1903
  : null,
1435
1904
 
1905
+ /* ---- 视频参考图(reference 模式,≤5 张):给风格 / 主体参考,不是首帧 ---- */
1906
+ isVideo
1907
+ ? React.createElement("div", { className: "agn-refs" },
1908
+ React.createElement("div", { className: "agn-ref-head" },
1909
+ React.createElement("span", { className: "agn-label" }, "参考图"),
1910
+ React.createElement("span", { className: "agn-tag" },
1911
+ s.vRefs.length + " / " + MAX_VREFS),
1912
+ s.vRefsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
1913
+ s.vRefs.length > 0
1914
+ ? React.createElement("button", {
1915
+ type: "button", className: "agn-btn-ghost", disabled: busy,
1916
+ onClick: () => store.clearVRefs(),
1917
+ }, "清空参考图")
1918
+ : null
1919
+ ),
1920
+ React.createElement("div", { className: "agn-ref-strip" },
1921
+ s.vRefs.map((r) => React.createElement("div", {
1922
+ key: r.id,
1923
+ className: "agn-ref",
1924
+ title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
1925
+ (r.from === "result" ? "来自生成结果" : "来自本地"),
1926
+ },
1927
+ React.createElement("img", { src: r.src, alt: r.name }),
1928
+ React.createElement("button", {
1929
+ type: "button", className: "agn-ref-del",
1930
+ title: "移除这张参考图",
1931
+ disabled: busy,
1932
+ onClick: () => store.removeVRef(r.id),
1933
+ }, "×")
1934
+ )),
1935
+ React.createElement("button", {
1936
+ type: "button", className: "agn-ref-add",
1937
+ disabled: busy || vRefsFull,
1938
+ title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
1939
+ onClick: () => {
1940
+ const el = vRefInput.current;
1941
+ if (el !== null) el.click();
1942
+ },
1943
+ }, "+ 本地"),
1944
+ React.createElement("button", {
1945
+ type: "button", className: "agn-ref-add",
1946
+ disabled: busy || vRefsFull || pickableImages.length === 0,
1947
+ title: pickableImages.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1948
+ onClick: () => store.toggleVRefPick(),
1949
+ }, "+ 结果")
1950
+ ),
1951
+ s.vRefPickOpen
1952
+ ? React.createElement("div", { className: "agn-ref-pick" },
1953
+ pickableImages.length === 0
1954
+ ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
1955
+ : pickableImages.map((h) => React.createElement("button", {
1956
+ key: "vpick-" + h.filename,
1957
+ type: "button",
1958
+ title: h.filename,
1959
+ onClick: () => store.addVRefFromResult(h),
1960
+ }, React.createElement("img", {
1961
+ src: srcOf(h.filename), alt: h.filename, loading: "lazy",
1962
+ })))
1963
+ )
1964
+ : null,
1965
+ React.createElement("div", { className: "agn-note" },
1966
+ s.vRefs.length > 0
1967
+ ? (s.vFirst !== null || s.vLast !== null
1968
+ ? "已给关键帧,本次按首尾帧生成,参考图不生效"
1969
+ : "将按参考图生成(" + s.vRefs.length + " 张)——参考风格 / 主体 / 构图")
1970
+ : "可选:给上游 1–5 张参考图;留空 = 纯文本生视频"),
1971
+ s.vRefsError !== "" ? React.createElement("div", { className: "agn-err" }, s.vRefsError) : null
1972
+ )
1973
+ : null,
1974
+
1436
1975
  React.createElement("div", { className: "agn-label" }, "提示词"),
1437
1976
  React.createElement("div", { className: "agn-presets" },
1438
1977
  PROMPT_PRESETS.map((preset) => React.createElement("button", {
1439
- key: preset.label,
1978
+ key: "builtin-" + preset.label,
1440
1979
  type: "button",
1441
1980
  className: "agn-preset",
1442
1981
  disabled: busy,
1443
1982
  title: "追加到提示词末尾:" + preset.text,
1444
- onClick: () => store.set({
1445
- prompt: s.prompt.trim() === ""
1446
- ? preset.text
1447
- : s.prompt.replace(/[\s,,;;。.]+$/, "") + "," + preset.text,
1448
- }),
1449
- }, preset.label))
1983
+ onClick: () => store.appendPreset(preset),
1984
+ }, preset.label)),
1985
+ s.presets.map((preset) => React.createElement("span", {
1986
+ key: "mine-" + preset.label,
1987
+ className: "agn-preset-user",
1988
+ },
1989
+ React.createElement("button", {
1990
+ type: "button",
1991
+ className: "agn-preset agn-preset-mine",
1992
+ disabled: busy,
1993
+ title: "我的预设,点一下追加:" + preset.text,
1994
+ onClick: () => store.appendPreset(preset),
1995
+ }, preset.label),
1996
+ React.createElement("button", {
1997
+ type: "button",
1998
+ className: "agn-preset-x",
1999
+ disabled: busy || s.presetBusy,
2000
+ title: "删除这个预设",
2001
+ onClick: () => store.deletePreset(preset.label),
2002
+ }, "×")
2003
+ )),
2004
+ React.createElement("button", {
2005
+ type: "button",
2006
+ className: "agn-preset agn-preset-add",
2007
+ disabled: busy || s.prompt.trim() === "" || s.presetBusy,
2008
+ title: "把当前提示词存成可复用的预设",
2009
+ onClick: () => store.togglePresetForm(),
2010
+ }, s.presetFormOpen ? "取消保存" : "+ 存为预设"),
2011
+ React.createElement("button", {
2012
+ type: "button",
2013
+ className: "agn-preset agn-preset-expand",
2014
+ disabled: busy || s.expandBusy || s.prompt.trim() === "",
2015
+ title: "用 agnes-2.5-flash 把这一句扩写成一段具体描述",
2016
+ onClick: () => store.expandPrompt(),
2017
+ }, s.expandBusy ? "扩写中…" : "扩写")
1450
2018
  ),
2019
+ s.presetFormOpen
2020
+ ? React.createElement("div", { className: "agn-preset-form" },
2021
+ React.createElement("input", {
2022
+ className: "agn-input agn-input-sm",
2023
+ value: s.presetLabel,
2024
+ placeholder: "给这个预设起个名字(留空则取提示词前 10 个字)",
2025
+ maxLength: 20,
2026
+ disabled: s.presetBusy,
2027
+ onChange: (e) => store.setPresetLabel(e.target.value),
2028
+ onKeyDown: (e) => {
2029
+ if (e.key === "Enter") { e.preventDefault(); store.saveCurrentAsPreset(); }
2030
+ },
2031
+ }),
2032
+ React.createElement("button", {
2033
+ type: "button", className: "agn-btn",
2034
+ disabled: s.presetBusy,
2035
+ onClick: () => store.saveCurrentAsPreset(),
2036
+ }, s.presetBusy ? "保存中…" : "保存")
2037
+ )
2038
+ : null,
1451
2039
  React.createElement("textarea", {
1452
2040
  className: "agn-textarea",
1453
2041
  value: s.prompt,
@@ -1539,6 +2127,7 @@ window.__ModuleLoader__.load({
1539
2127
  + " · " + shown.size + "/" + shown.ratio
1540
2128
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
1541
2129
  + (shown.mode === "keyframe" ? " · 图生视频" : "")
2130
+ + (shown.mode === "reference" ? " · 参考生视频" : "")
1542
2131
  + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
1543
2132
  + (shown.seed !== undefined && shown.seed !== null ? " · seed " + shown.seed : "")
1544
2133
  + " · 用时 " + (shown.elapsedSec || shown.wallSec) + "s"),
@@ -1564,6 +2153,13 @@ window.__ModuleLoader__.load({
1564
2153
  title: "另存一份到「下载」目录",
1565
2154
  onClick: () => store.downloadResult(),
1566
2155
  }, "下载"),
2156
+ React.createElement("a", {
2157
+ className: "agn-act agn-act-link",
2158
+ href: srcOf(shown.filename),
2159
+ target: "_blank",
2160
+ rel: "noopener noreferrer",
2161
+ title: "用浏览器新标签页打开原文件",
2162
+ }, "新窗口打开"),
1567
2163
  shown.kind === "image"
1568
2164
  ? React.createElement("button", {
1569
2165
  type: "button", className: "agn-act",
@@ -1711,6 +2307,52 @@ window.__ModuleLoader__.load({
1711
2307
  )
1712
2308
  : null,
1713
2309
 
2310
+ s.cfgOpen
2311
+ ? React.createElement("div", { className: "agn-field" },
2312
+ React.createElement("div", { className: "agn-label" }, "图片模型"),
2313
+ React.createElement("select", {
2314
+ className: "agn-input", value: s.cfgDraft.imageModel, disabled: s.cfgSaving,
2315
+ onChange: (e) => store.setCfgDraft("imageModel", e.target.value),
2316
+ },
2317
+ React.createElement("option", { value: "" }, "默认(" + ((s.health && s.health.model) || "agnes-image-2.5-flash") + ")"),
2318
+ ((s.health && s.health.models && s.health.models.image) || []).map((m) =>
2319
+ React.createElement("option", { key: m, value: m }, m))
2320
+ ),
2321
+ React.createElement("div", { className: "agn-note" }, "只列出当前上游真实存在的图片模型")
2322
+ )
2323
+ : null,
2324
+
2325
+ s.cfgOpen
2326
+ ? React.createElement("div", { className: "agn-field" },
2327
+ React.createElement("div", { className: "agn-label" }, "视频模型"),
2328
+ React.createElement("select", {
2329
+ className: "agn-input", value: s.cfgDraft.videoModel, disabled: s.cfgSaving,
2330
+ onChange: (e) => store.setCfgDraft("videoModel", e.target.value),
2331
+ },
2332
+ React.createElement("option", { value: "" }, "默认(" + ((s.health && s.health.videoModel) || "agnes-video-2.5-flash") + ")"),
2333
+ ((s.health && s.health.models && s.health.models.video) || []).map((m) =>
2334
+ React.createElement("option", { key: m, value: m }, m))
2335
+ ),
2336
+ React.createElement("div", { className: "agn-note" }, "换模型后新提交的任务才会用新模型")
2337
+ )
2338
+ : null,
2339
+
2340
+ s.cfgOpen
2341
+ ? React.createElement("div", { className: "agn-field" },
2342
+ React.createElement("button", {
2343
+ type: "button", className: "agn-btn-ghost", disabled: s.checking,
2344
+ title: "用当前 Key 请求一次上游模型清单,确认 Key 能用、网络通",
2345
+ onClick: () => store.checkKey(),
2346
+ }, s.checking ? "连接中…" : "测试连接"),
2347
+ s.checkResult !== null
2348
+ ? React.createElement("div", {
2349
+ // 别给 style 传字符串:React 会抛错并卸载整块面板(白屏)
2350
+ className: (s.checkResult.ok ? "agn-okline" : "agn-err") + " agn-check",
2351
+ }, s.checkResult.text)
2352
+ : null
2353
+ )
2354
+ : null,
2355
+
1714
2356
  s.cfgOpen
1715
2357
  ? React.createElement("div", { className: "agn-row" },
1716
2358
  React.createElement("button", {
@@ -1773,7 +2415,7 @@ window.__ModuleLoader__.load({
1773
2415
 
1774
2416
  exports.apply = apply;
1775
2417
  exports.inject = ["slots"];
1776
- exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
2418
+ exports.__internals = { pickableImagesOf, getStore, createStore, srcOf, shrinkImage, MAX_REFS };
1777
2419
  return module.exports;
1778
2420
  },
1779
2421
  });