dsh-agnes-create 0.7.0 → 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,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); }
@@ -243,6 +255,8 @@ window.__ModuleLoader__.load({
243
255
  color: var(--dsw-alias-state-business-primary, #4b7bec); }
244
256
  .agn-act-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
245
257
  color: var(--dsw-alias-state-business-primary, #4b7bec); }
258
+ /* 「测试连接」的结果行:和按钮之间留一点距离 */
259
+ .agn-check { margin-top: 6px; }
246
260
  .agn-notice { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2);
247
261
  line-height: 1.6; }
248
262
  `;
@@ -261,6 +275,25 @@ window.__ModuleLoader__.load({
261
275
  return "/agnes/media?name=" + encodeURIComponent(name);
262
276
  }
263
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
+
264
297
  /* ==========================================================================
265
298
  参考图预处理(图生图)
266
299
  --------------------------------------------------------------------------
@@ -362,6 +395,7 @@ window.__ModuleLoader__.load({
362
395
  vProgress: 0,
363
396
  vStatus: "",
364
397
  videoId: null,
398
+ vTaskId: null,
365
399
  health: null,
366
400
  healthRequested: false,
367
401
 
@@ -369,17 +403,25 @@ window.__ModuleLoader__.load({
369
403
  cfg: null, // 生效配置 { keyFile, outDir }
370
404
  cfgDefaults: null,
371
405
  cfgStored: null,
372
- cfgDraft: { keyFile: "", outDir: "" },
406
+ cfgDraft: { keyFile: "", outDir: "", imageModel: "", videoModel: "" },
373
407
  cfgOpen: false,
374
408
  cfgSaving: false,
375
409
  cfgError: "",
376
410
  cfgNotice: "",
411
+ checking: false, // 正在测连通性
412
+ checkResult: null, // { ok, text, detail }
413
+ upModels: null, // 连接测试拿到的上游真实模型清单 { image: [], video: [] }
377
414
 
378
415
  /* ---- 图生图参考图 ---- */
379
416
  refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
380
417
  refsBusy: false,
381
418
  refsError: "",
382
419
  refPickOpen: false,
420
+ /* ---- 视频参考图(reference 模式,最多 5 张) ---- */
421
+ vRefs: [],
422
+ vRefsBusy: false,
423
+ vRefsError: "",
424
+ vRefPickOpen: false,
383
425
 
384
426
  /* ---- 视频关键帧(可选):本地图直接当首帧 / 尾帧 ---- */
385
427
  vFirst: null, // { src(dataURI), name, bytes }
@@ -437,6 +479,8 @@ window.__ModuleLoader__.load({
437
479
  set({ result: entry, history: [entry, ...state.history].slice(0, state.historyMax) });
438
480
  // 顺手落盘:/agnes/history 是唯一真相,写失败也不影响本次展示
439
481
  void appendHistory(entry);
482
+ // 新文件已经写进输出目录了,目录清单开着的话跟着变(没加载过就不去打扰)
483
+ void refreshLibrary();
440
484
  };
441
485
 
442
486
  /* ---------- 历史(宿主持久化) ---------- */
@@ -512,7 +556,12 @@ window.__ModuleLoader__.load({
512
556
  cfg: data.config,
513
557
  cfgDefaults: data.defaults,
514
558
  cfgStored: data.stored,
515
- 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
+ },
516
565
  cfgError: "",
517
566
  });
518
567
  } catch (e) {
@@ -530,6 +579,9 @@ window.__ModuleLoader__.load({
530
579
  body: JSON.stringify({
531
580
  keyFile: state.cfgDraft.keyFile,
532
581
  outDir: state.cfgDraft.outDir,
582
+ // 空串 = 回退到内置默认模型
583
+ imageModel: state.cfgDraft.imageModel,
584
+ videoModel: state.cfgDraft.videoModel,
533
585
  }),
534
586
  });
535
587
  if (res.status === 404) throw new Error("配置接口尚未就绪:需要完全重启应用(只重载窗口不够)");
@@ -539,7 +591,12 @@ window.__ModuleLoader__.load({
539
591
  cfg: data.config,
540
592
  cfgDefaults: data.defaults,
541
593
  cfgStored: data.stored,
542
- 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
+ },
543
600
  cfgNotice: "已保存",
544
601
  });
545
602
  // 路径变了,key 是否存在/目录是否可写都要重新算
@@ -553,6 +610,43 @@ window.__ModuleLoader__.load({
553
610
  }
554
611
  }
555
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
+
556
650
  /* ---------- 图片:一次请求就出结果 ---------- */
557
651
  async function runImage(token, prompt) {
558
652
  const refs = state.refs;
@@ -690,6 +784,101 @@ window.__ModuleLoader__.load({
690
784
  set({ refPickOpen: !state.refPickOpen, refsError: "" });
691
785
  }
692
786
 
787
+ /* ---------- 视频参考图(reference 模式,≤5 张) ---------- */
788
+ const vRefsFull = () => state.vRefs.length >= MAX_VREFS;
789
+
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
+ }
832
+ });
833
+ }
834
+
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
+ }
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
+
693
882
  /* ---------- 视频关键帧(首帧 / 尾帧,本地图直接当帧) ---------- */
694
883
  const frameField = (which) => (which === "last" ? "vLast" : "vFirst");
695
884
 
@@ -797,14 +986,17 @@ window.__ModuleLoader__.load({
797
986
  set({ notice: "已开始下载" });
798
987
  }
799
988
 
800
- /** 停掉等待:让在途的轮询/请求结果失效(服务端任务本身无法撤回) */
989
+ /** 停掉等待:让在途的轮询/请求结果失效(宿主的任务照常跑,文件会正常落盘) */
801
990
  function cancel() {
802
991
  if (!state.busy) return;
992
+ const hadTask = state.vTaskId !== null;
803
993
  runToken += 1;
804
994
  stopTimer();
805
995
  set({
806
996
  busy: false, vStatus: "", vProgress: 0,
807
- notice: "已停止等待;服务端任务可能仍在跑",
997
+ notice: hadTask
998
+ ? "已停止跟踪;宿主会继续推进,刷新面板还能接回进度,文件也会照常落盘"
999
+ : "已停止等待;服务端任务可能仍在跑",
808
1000
  });
809
1001
  }
810
1002
 
@@ -850,6 +1042,23 @@ window.__ModuleLoader__.load({
850
1042
  }
851
1043
  }
852
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
+
853
1062
  function toggleLibrary() {
854
1063
  if (state.libraryOpen) {
855
1064
  set({ libraryOpen: false });
@@ -882,59 +1091,32 @@ window.__ModuleLoader__.load({
882
1091
  });
883
1092
  }
884
1093
 
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: "排队中…" });
1094
+ /* ---------- 视频:交给宿主任务队列,面板只负责看进度 ---------- */
1095
+ // 提交后 video_id 由宿主保管:关面板、刷新页面、重启应用都不影响任务继续推进。
1096
+ async function watchTask(token, taskId, fallback) {
914
1097
  const deadline = Date.now() + VIDEO_DEADLINE_MS;
915
-
916
1098
  let transientFails = 0;
1099
+
917
1100
  for (;;) {
918
1101
  await sleep(POLL_MS);
919
1102
  if (token !== runToken) return;
920
1103
 
921
1104
  let st;
922
1105
  try {
923
- const r = await fetch("/agnes/video?id=" + encodeURIComponent(sub.videoId));
924
- 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 };
925
1108
  } catch (e) {
926
1109
  st = { ok: false, error: "网络错误:" + (e && e.message ? e.message : String(e)) };
927
1110
  }
928
1111
 
929
1112
  if (!st.ok) {
930
1113
  const msg = String(st.error || "查询任务失败");
931
- // 429/503 只是限流,任务还在服务端跑:退避后继续,别把整个任务判死。
932
- // 实测提交后立刻查询就会 429,这条路径是必需的。
1114
+ // 宿主自己会重试上游;这里只在本地接口抖动时容忍几次
933
1115
  if (/\b429\b|\b503\b|频繁|队列已满|网络错误|timeout/i.test(msg)) {
934
1116
  transientFails += 1;
935
1117
  if (transientFails > 8) throw new Error(msg);
936
- set({ vStatus: "限流重试中…" });
937
- await sleep(Math.min(30000, 6000 * transientFails));
1118
+ set({ vStatus: "重试中…" });
1119
+ await sleep(Math.min(30000, 3000 * transientFails));
938
1120
  continue;
939
1121
  }
940
1122
  throw new Error(msg);
@@ -943,33 +1125,126 @@ window.__ModuleLoader__.load({
943
1125
  transientFails = 0;
944
1126
  if (token !== runToken) return;
945
1127
 
1128
+ const task = st.task || {};
946
1129
  set({
947
- vStatus: st.status,
948
- 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,
949
1133
  });
950
1134
 
951
- if (st.status === "completed") {
1135
+ if (task.status === "completed") {
952
1136
  pushResult({
953
1137
  kind: "video",
954
- filename: st.filename,
955
- path: st.path,
956
- bytes: st.bytes,
1138
+ filename: task.filename,
1139
+ path: task.path,
1140
+ bytes: task.bytes,
957
1141
  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,
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,
964
1148
  wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
965
1149
  });
966
1150
  return;
967
1151
  }
968
- if (st.status === "failed") {
969
- const err = st.error;
970
- 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 分钟);宿主仍在推进,稍后刷新还能看到结果");
971
1156
  }
972
- if (Date.now() > deadline) throw new Error("等待超时(15 分钟)");
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
+ }
1245
+ }
1246
+ } catch {
1247
+ // 旧宿主没有 /agnes/tasks:静默跳过,不打扰用户
973
1248
  }
974
1249
  }
975
1250
 
@@ -1014,7 +1289,19 @@ window.__ModuleLoader__.load({
1014
1289
  setCfgDraft(field, value) {
1015
1290
  set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
1016
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
+ },
1017
1303
  toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
1304
+ checkKey,
1018
1305
  run,
1019
1306
  /* ---- 参考图动作 ---- */
1020
1307
  addRefsFromFiles,
@@ -1022,6 +1309,12 @@ window.__ModuleLoader__.load({
1022
1309
  removeRef,
1023
1310
  clearRefs,
1024
1311
  toggleRefPick,
1312
+ /* ---- 视频参考图(reference 模式,≤5 张) ---- */
1313
+ addVRefsFromFiles,
1314
+ addVRefFromResult,
1315
+ removeVRef,
1316
+ clearVRefs,
1317
+ toggleVRefPick,
1025
1318
  /* ---- 视频关键帧 ---- */
1026
1319
  pickFrame,
1027
1320
  setFrameFromFile,
@@ -1029,6 +1322,8 @@ window.__ModuleLoader__.load({
1029
1322
  toggleFramePick,
1030
1323
  setFrameFromResult,
1031
1324
  useResultAsFrame,
1325
+ /* ---- 视频任务(宿主侧推进,刷新可接回) ---- */
1326
+ resumeTasks,
1032
1327
  /* ---- 结果收尾 ---- */
1033
1328
  copyPath,
1034
1329
  reveal,
@@ -1041,6 +1336,7 @@ window.__ModuleLoader__.load({
1041
1336
  setLibraryTab,
1042
1337
  showLibraryFile,
1043
1338
  maxRefs: MAX_REFS,
1339
+ maxVRefs: MAX_VREFS,
1044
1340
  /** 切换主预览到历史里的某一张 */
1045
1341
  show(entry) { set({ result: entry }); },
1046
1342
  /**
@@ -1172,7 +1468,13 @@ window.__ModuleLoader__.load({
1172
1468
  return () => { off(); };
1173
1469
  }, []);
1174
1470
 
1175
- React.useEffect(() => { store.loadHealth(); store.loadConfig(); store.loadHistory(); }, []);
1471
+ React.useEffect(() => {
1472
+ store.loadHealth();
1473
+ store.loadConfig();
1474
+ store.loadHistory();
1475
+ // 上次没跑完的视频任务:宿主还在推进,接回进度
1476
+ store.resumeTasks();
1477
+ }, []);
1176
1478
 
1177
1479
  const busy = s.busy;
1178
1480
  const isVideo = s.mode === "video";
@@ -1181,13 +1483,16 @@ window.__ModuleLoader__.load({
1181
1483
  const shown = s.result;
1182
1484
  const hasRefs = s.refs.length > 0;
1183
1485
  const refsFull = s.refs.length >= MAX_REFS;
1486
+ const vRefsFull = s.vRefs.length >= MAX_VREFS;
1184
1487
  const imageHistory = s.history.filter((h) => h.kind === "image");
1488
+ const pickableImages = pickableImagesOf(s);
1185
1489
  const libraryAll = s.library || [];
1186
1490
  const libraryImages = libraryAll.filter((f) => f.kind !== "video");
1187
1491
  const libraryVideos = libraryAll.filter((f) => f.kind === "video");
1188
1492
  const libraryShown = s.libraryTab === "video" ? libraryVideos : libraryImages;
1189
1493
  const fileInput = React.useRef(null);
1190
1494
  const frameInput = React.useRef(null);
1495
+ const vRefInput = React.useRef(null);
1191
1496
 
1192
1497
  const onKeyDown = (e) => {
1193
1498
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
@@ -1279,15 +1584,15 @@ window.__ModuleLoader__.load({
1279
1584
  }, "+ 本地"),
1280
1585
  React.createElement("button", {
1281
1586
  type: "button", className: "agn-frame-from",
1282
- disabled: busy || s.frameBusy || imageHistory.length === 0,
1283
- title: imageHistory.length === 0
1587
+ disabled: busy || s.frameBusy || pickableImages.length === 0,
1588
+ title: pickableImages.length === 0
1284
1589
  ? "还没有生成过图片,先出一张图再来选"
1285
1590
  : "从生成结果里挑一张当" + label,
1286
1591
  onClick: () => store.toggleFramePick(which),
1287
1592
  }, s.framePickFrom === which ? "收起" : "+ 结果"),
1288
1593
  s.framePickFrom === which
1289
1594
  ? React.createElement("div", { className: "agn-ref-pick agn-frame-pick" },
1290
- imageHistory.map((h) => React.createElement("button", {
1595
+ pickableImages.map((h) => React.createElement("button", {
1291
1596
  key: h.filename, type: "button", title: h.filename,
1292
1597
  disabled: busy || s.frameBusy,
1293
1598
  onClick: () => store.setFrameFromResult(which, h),
@@ -1331,12 +1636,49 @@ window.__ModuleLoader__.load({
1331
1636
  },
1332
1637
  }),
1333
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
+
1334
1655
  React.createElement("div", { className: "agn-head" },
1335
1656
  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"))
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
+ })()
1340
1682
  ),
1341
1683
 
1342
1684
  React.createElement("div", { className: "agn-card" },
@@ -1402,8 +1744,8 @@ window.__ModuleLoader__.load({
1402
1744
  }, "+ 本地图片"),
1403
1745
  React.createElement("button", {
1404
1746
  type: "button", className: "agn-ref-add",
1405
- disabled: busy || refsFull || imageHistory.length === 0,
1406
- title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1747
+ disabled: busy || refsFull || pickableImages.length === 0,
1748
+ title: pickableImages.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
1407
1749
  onClick: () => store.toggleRefPick(),
1408
1750
  }, "+ 生成结果")
1409
1751
  ),
@@ -1411,9 +1753,9 @@ window.__ModuleLoader__.load({
1411
1753
  /* 从生成结果挑图:只列图片(视频不能当参考图) */
1412
1754
  s.refPickOpen
1413
1755
  ? React.createElement("div", { className: "agn-ref-pick" },
1414
- imageHistory.length === 0
1756
+ pickableImages.length === 0
1415
1757
  ? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
1416
- : imageHistory.map((h) => React.createElement("button", {
1758
+ : pickableImages.map((h) => React.createElement("button", {
1417
1759
  key: "pick-" + h.filename,
1418
1760
  type: "button",
1419
1761
  title: h.filename,
@@ -1433,6 +1775,76 @@ window.__ModuleLoader__.load({
1433
1775
  )
1434
1776
  : null,
1435
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
+
1436
1848
  React.createElement("div", { className: "agn-label" }, "提示词"),
1437
1849
  React.createElement("div", { className: "agn-presets" },
1438
1850
  PROMPT_PRESETS.map((preset) => React.createElement("button", {
@@ -1539,6 +1951,7 @@ window.__ModuleLoader__.load({
1539
1951
  + " · " + shown.size + "/" + shown.ratio
1540
1952
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
1541
1953
  + (shown.mode === "keyframe" ? " · 图生视频" : "")
1954
+ + (shown.mode === "reference" ? " · 参考生视频" : "")
1542
1955
  + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
1543
1956
  + (shown.seed !== undefined && shown.seed !== null ? " · seed " + shown.seed : "")
1544
1957
  + " · 用时 " + (shown.elapsedSec || shown.wallSec) + "s"),
@@ -1711,6 +2124,52 @@ window.__ModuleLoader__.load({
1711
2124
  )
1712
2125
  : null,
1713
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
+
1714
2173
  s.cfgOpen
1715
2174
  ? React.createElement("div", { className: "agn-row" },
1716
2175
  React.createElement("button", {
@@ -1773,7 +2232,7 @@ window.__ModuleLoader__.load({
1773
2232
 
1774
2233
  exports.apply = apply;
1775
2234
  exports.inject = ["slots"];
1776
- exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
2235
+ exports.__internals = { pickableImagesOf, getStore, createStore, srcOf, shrinkImage, MAX_REFS };
1777
2236
  return module.exports;
1778
2237
  },
1779
2238
  });