dsh-agnes-create 0.4.1 → 0.7.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
@@ -23,6 +23,19 @@ window.__ModuleLoader__.load({
23
23
  const REF_QUALITY = 0.88;
24
24
  const REF_PNG_KEEP_BYTES = 800 * 1024;
25
25
 
26
+ /**
27
+ * 提示词预设:点一下把风格描述追加到当前提示词末尾。
28
+ * 刻意写成「可叠加的风格后缀」,而不是整段替换——用户已经写好的主体不会被冲掉。
29
+ */
30
+ const PROMPT_PRESETS = [
31
+ { label: "写实摄影", text: "写实摄影风格,自然光,浅景深,35mm 镜头,细节丰富" },
32
+ { label: "水彩", text: "水彩画风格,纸纹质感,柔和晕染,留白构图" },
33
+ { label: "3D 渲染", text: "3D 渲染风格,柔光棚拍,材质清晰,背景简洁" },
34
+ { label: "海报", text: "平面海报构图,大面积留白,主体突出,配色克制" },
35
+ { label: "夜景霓虹", text: "夜景霓虹灯光,湿润地面反光,氛围浓烈" },
36
+ { label: "极简", text: "极简构图,单一主体,纯净背景,柔和渐变光" },
37
+ ];
38
+
26
39
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
40
 
28
41
  /* ---------- 样式 ---------- */
@@ -73,7 +86,8 @@ window.__ModuleLoader__.load({
73
86
  transition: width .3s ease; }
74
87
 
75
88
  /* 媒体展示区 */
76
- .agn-shot { border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
89
+ .agn-shot { position: relative; border-radius: 10px;
90
+ border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
77
91
  background: var(--dsw-alias-bg-layer-2, #fafafa); overflow: hidden;
78
92
  display: flex; align-items: center; justify-content: center; min-height: 120px; }
79
93
  .agn-shot img, .agn-shot video { display: block; max-width: 100%; height: auto; }
@@ -82,6 +96,39 @@ window.__ModuleLoader__.load({
82
96
  .agn-shot-msg { padding: 18px; font-size: 12px; text-align: center; line-height: 1.7;
83
97
  color: var(--dsw-alias-label-secondary, #888); }
84
98
 
99
+ /* 悬停浮现的提示词:贴在媒体底部,默认透明,鼠标进来才出现 */
100
+ .agn-prompt { position: absolute; left: 0; right: 0; bottom: 0;
101
+ display: flex; align-items: flex-end; gap: 8px; padding: 8px 10px;
102
+ background: linear-gradient(to top, rgba(0,0,0,.62), rgba(0,0,0,0));
103
+ opacity: 0; transition: opacity .16s ease; pointer-events: none; }
104
+ .agn-shot:hover .agn-prompt { opacity: 1; pointer-events: auto; }
105
+ .agn-prompt-text { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 1.5;
106
+ color: #fff; display: -webkit-box; -webkit-line-clamp: 3;
107
+ -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
108
+ .agn-prompt-copy { flex: none; font: inherit; font-size: 11px; line-height: 1.3;
109
+ padding: 2px 8px; border-radius: 5px; cursor: pointer;
110
+ color: #fff; background: rgba(255,255,255,.18);
111
+ border: 1px solid rgba(255,255,255,.45); }
112
+ .agn-prompt-copy:hover { background: rgba(255,255,255,.3); }
113
+
114
+ /* 输出目录清单 */
115
+ .agn-lib { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
116
+ .agn-lib-tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
117
+ .agn-lib-grid { display: flex; gap: 8px; flex-wrap: wrap;
118
+ max-height: 300px; overflow-y: auto; padding: 2px; }
119
+ .agn-lib-item { position: relative; width: 84px; height: 84px; border-radius: 8px;
120
+ padding: 0; overflow: hidden; cursor: pointer; flex: none;
121
+ border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
122
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
123
+ .agn-lib-item:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
124
+ .agn-lib-item img, .agn-lib-item video { width: 100%; height: 100%;
125
+ object-fit: cover; display: block; pointer-events: none; }
126
+ .agn-lib-badge { position: absolute; left: 4px; bottom: 4px; font-size: 10px;
127
+ line-height: 1.4; padding: 0 5px; border-radius: 4px;
128
+ color: #fff; background: rgba(0,0,0,.55); }
129
+ .agn-lib-empty { font-size: 12px; line-height: 1.6;
130
+ color: var(--dsw-alias-label-secondary, #888); }
131
+
85
132
  /* 历史缩略图 */
86
133
  .agn-hist { display: flex; flex-direction: column; gap: 6px; }
87
134
  .agn-hist-strip { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -90,10 +137,15 @@ window.__ModuleLoader__.load({
90
137
  border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
91
138
  background: var(--dsw-alias-bg-layer-2, #fafafa); }
92
139
  .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; }
140
+ /* 缩略图里放 <video> 取首帧;pointer-events: none 让点击继续落到外层按钮上
141
+ (video 属于可交互内容,套在 button 里会吃掉点击) */
142
+ .agn-thumb img, .agn-thumb video { width: 100%; height: 100%;
143
+ object-fit: cover; display: block; pointer-events: none; }
144
+ /* ▶ 只是角标,压在首帧上;别挡点击,也别把首帧盖住 */
94
145
  .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); }
146
+ justify-content: center; font-size: 20px; line-height: 1;
147
+ color: #fff; text-shadow: 0 1px 4px rgba(0, 0, 0, .75);
148
+ pointer-events: none; background: rgba(0, 0, 0, .12); }
97
149
  .agn-thumb-cap { position: absolute; left: 0; right: 0; bottom: 0; font-size: 10px;
98
150
  text-align: center; padding: 1px 0;
99
151
  color: var(--dsw-alias-label-primary-foreground, #fff);
@@ -144,6 +196,55 @@ window.__ModuleLoader__.load({
144
196
  .agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
145
197
  background: var(--dsw-alias-bg-layer-2, #f1f1f1);
146
198
  color: var(--dsw-alias-label-secondary, #555); }
199
+
200
+ /* 提示词预设 */
201
+ .agn-presets { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
202
+ .agn-preset { font: inherit; font-size: 11px; line-height: 1.4; padding: 2px 9px;
203
+ border-radius: 999px; cursor: pointer;
204
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
205
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
206
+ .agn-preset:hover:not(:disabled) { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
207
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
208
+ .agn-preset:disabled { opacity: .45; cursor: not-allowed; }
209
+
210
+ /* 视频关键帧(图生视频) */
211
+ .agn-frames { display: flex; flex-direction: column; gap: 8px; }
212
+ .agn-frames-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
213
+ .agn-frame { display: flex; flex-direction: column; gap: 4px; flex: none; }
214
+ .agn-frame-cap { font-size: 11px; color: var(--dsw-alias-label-secondary, #888); }
215
+ .agn-frame-box { position: relative; width: 72px; height: 72px; border-radius: 8px;
216
+ overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
217
+ background: var(--dsw-alias-bg-layer-2, #fafafa); }
218
+ .agn-frame-box img { width: 100%; height: 100%; object-fit: cover; display: block; }
219
+ .agn-frame-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer;
220
+ font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
221
+ color: var(--dsw-alias-label-secondary, #666); background: transparent;
222
+ border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
223
+ .agn-frame-add:disabled { opacity: .45; cursor: not-allowed; }
224
+ /* 「+ 结果」:从已生成的图片里挑一张当关键帧 */
225
+ .agn-frame-from { width: 72px; font: inherit; font-size: 11px; line-height: 1.4;
226
+ padding: 2px 0; border-radius: 6px; cursor: pointer;
227
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
228
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
229
+ .agn-frame-from:hover:not(:disabled) {
230
+ border-color: var(--dsw-alias-state-business-primary, #4b7bec);
231
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
232
+ .agn-frame-from:disabled { opacity: .45; cursor: not-allowed; }
233
+ .agn-frame .agn-ref-pick { margin-top: 2px; }
234
+ .agn-seed { width: 72px; }
235
+
236
+ /* 结果收尾动作 */
237
+ .agn-acts { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
238
+ .agn-act { font: inherit; font-size: 11px; line-height: 1.4; padding: 3px 10px;
239
+ border-radius: 6px; cursor: pointer;
240
+ color: var(--dsw-alias-label-secondary, #555); background: transparent;
241
+ border: 1px solid var(--dsw-alias-border-l2, #ddd); }
242
+ .agn-act:hover { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
243
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
244
+ .agn-act-on { border-color: var(--dsw-alias-state-business-primary, #4b7bec);
245
+ color: var(--dsw-alias-state-business-primary, #4b7bec); }
246
+ .agn-notice { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2);
247
+ line-height: 1.6; }
147
248
  `;
148
249
 
149
250
  function ensureCss() {
@@ -279,6 +380,27 @@ window.__ModuleLoader__.load({
279
380
  refsBusy: false,
280
381
  refsError: "",
281
382
  refPickOpen: false,
383
+
384
+ /* ---- 视频关键帧(可选):本地图直接当首帧 / 尾帧 ---- */
385
+ vFirst: null, // { src(dataURI), name, bytes }
386
+ vLast: null,
387
+ vSeed: "", // 空串 = 不传 seed
388
+ frameBusy: false, // 正在压缩关键帧图片
389
+ framePick: "", // '' | 'first' | 'last':本地文件选择器的目标
390
+ framePickFrom: "", // '' | 'first' | 'last':生成结果选择面板的目标
391
+
392
+ /* ---- 历史:宿主落盘,刷新/重启后还在 ---- */
393
+ historyMax: HISTORY_MAX,
394
+ historyLoaded: false,
395
+ notice: "", // 一次性操作反馈(复制路径、打开访达…)
396
+
397
+ /* ---- 输出目录清单:把磁盘上已有的图/视频捞回来看 ---- */
398
+ library: null, // null = 还没加载过;[] = 加载过但是空
399
+ libraryOpen: false,
400
+ libraryTab: "image", // 'image' | 'video'
401
+ libraryBusy: false,
402
+ libraryError: "",
403
+ copiedKey: "", // 刚刚复制了哪一条的提示词(用于显示「已复制」)
282
404
  };
283
405
 
284
406
  const snapshot = () => ({ ...state, history: state.history.slice() });
@@ -312,9 +434,58 @@ window.__ModuleLoader__.load({
312
434
  };
313
435
 
314
436
  const pushResult = (entry) => {
315
- set({ result: entry, history: [entry, ...state.history].slice(0, HISTORY_MAX) });
437
+ set({ result: entry, history: [entry, ...state.history].slice(0, state.historyMax) });
438
+ // 顺手落盘:/agnes/history 是唯一真相,写失败也不影响本次展示
439
+ void appendHistory(entry);
316
440
  };
317
441
 
442
+ /* ---------- 历史(宿主持久化) ---------- */
443
+ async function loadHistory() {
444
+ try {
445
+ const res = await fetch("/agnes/history");
446
+ if (!res.ok) {
447
+ // 旧版宿主没有这条路由:保持空历史即可,不要报错吓人
448
+ set({ historyLoaded: true });
449
+ return;
450
+ }
451
+ const data = await res.json();
452
+ if (!data || data.ok !== true) {
453
+ set({ historyLoaded: true });
454
+ return;
455
+ }
456
+ const entries = Array.isArray(data.entries) ? data.entries : [];
457
+ const max = typeof data.max === "number" && data.max > 0 ? data.max : HISTORY_MAX;
458
+ update((prev) => ({
459
+ history: entries,
460
+ historyMax: max,
461
+ historyLoaded: true,
462
+ // 主预览默认显示最新一条;已经有结果时不动它
463
+ result: prev.result === null ? (entries[0] || null) : prev.result,
464
+ }));
465
+ } catch {
466
+ set({ historyLoaded: true });
467
+ }
468
+ }
469
+
470
+ async function appendHistory(entry) {
471
+ try {
472
+ const res = await fetch("/agnes/history", {
473
+ method: "POST",
474
+ headers: { "content-type": "application/json" },
475
+ body: JSON.stringify(entry),
476
+ });
477
+ if (!res.ok) return;
478
+ const data = await res.json();
479
+ if (!data || data.ok !== true) return;
480
+ // 只有宿主确实回了列表才覆盖本地:宁可用旧列表,也不要把界面清空
481
+ if (!Array.isArray(data.entries)) return;
482
+ update((prev) => ({
483
+ history: data.entries,
484
+ historyMax: typeof data.max === "number" && data.max > 0 ? data.max : prev.historyMax,
485
+ }));
486
+ } catch { /* 落盘失败不影响当前结果展示 */ }
487
+ }
488
+
318
489
  async function loadHealth() {
319
490
  if (state.healthRequested) return;
320
491
  state.healthRequested = true;
@@ -400,7 +571,7 @@ window.__ModuleLoader__.load({
400
571
  const data = await res.json();
401
572
  if (!data.ok) throw new Error(data.error || "生成失败");
402
573
  if (token !== runToken) return;
403
- pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
574
+ pushResult({ ...data, kind: "image", prompt, wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
404
575
  }
405
576
 
406
577
  /* ---------- 参考图(图生图) ---------- */
@@ -519,19 +690,226 @@ window.__ModuleLoader__.load({
519
690
  set({ refPickOpen: !state.refPickOpen, refsError: "" });
520
691
  }
521
692
 
693
+ /* ---------- 视频关键帧(首帧 / 尾帧,本地图直接当帧) ---------- */
694
+ const frameField = (which) => (which === "last" ? "vLast" : "vFirst");
695
+
696
+ function pickFrame(which) {
697
+ if (state.busy || state.frameBusy) return;
698
+ set({ framePick: which, error: "" });
699
+ }
700
+
701
+ async function setFrameFromFile(which, file) {
702
+ set({ frameBusy: true, error: "" });
703
+ try {
704
+ // 和参考图走同一套下采样:长边 1280、PNG 优先,既省上行又不会糊
705
+ const r = await shrinkImage(file);
706
+ set({
707
+ [frameField(which)]: { src: r.src, name: file.name || "关键帧", bytes: r.bytes },
708
+ framePick: "",
709
+ });
710
+ } catch (e) {
711
+ set({ framePick: "", error: e && e.message ? e.message : String(e) });
712
+ } finally {
713
+ set({ frameBusy: false });
714
+ }
715
+ }
716
+
717
+ function clearFrame(which) {
718
+ if (state.busy) return;
719
+ set({ [frameField(which)]: null, framePickFrom: "" });
720
+ }
721
+
722
+ /* 把「生成结果」里的图直接当关键帧:和本地图完全同一条链路(blob → 下采样 → data URI) */
723
+ function toggleFramePick(which) {
724
+ if (state.busy || state.frameBusy) return;
725
+ set({ framePickFrom: state.framePickFrom === which ? "" : which, error: "" });
726
+ }
727
+
728
+ async function setFrameFromResult(which, entry) {
729
+ if (!entry || entry.kind !== "image" || !entry.filename) return;
730
+ if (state.busy || state.frameBusy) return;
731
+ set({ frameBusy: true, error: "" });
732
+ try {
733
+ const res = await fetch(srcOf(entry.filename));
734
+ if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
735
+ const blob = await res.blob();
736
+ const r = await shrinkImage(blob);
737
+ set({
738
+ [frameField(which)]: { src: r.src, name: entry.filename, bytes: r.bytes },
739
+ framePickFrom: "",
740
+ });
741
+ } catch (e) {
742
+ set({ framePickFrom: "", error: e && e.message ? e.message : String(e) });
743
+ } finally {
744
+ set({ frameBusy: false });
745
+ }
746
+ }
747
+
748
+ /* 结果区的一键设置:只在「结果本身是图片」时有意义 */
749
+ function useResultAsFrame(which) {
750
+ const cur = state.result;
751
+ if (cur === null || cur.kind !== "image") return;
752
+ return setFrameFromResult(which, cur);
753
+ }
754
+
755
+ /* ---------- 结果收尾动作:复制路径 / 访达显示 / 下载 ---------- */
756
+ async function copyPath() {
757
+ const cur = state.result;
758
+ if (cur === null) return;
759
+ const text = cur.path || "";
760
+ try {
761
+ await navigator.clipboard.writeText(text);
762
+ set({ notice: "路径已复制" });
763
+ } catch {
764
+ set({ notice: "复制失败,可手动复制:" + text });
765
+ }
766
+ }
767
+
768
+ async function reveal() {
769
+ const cur = state.result;
770
+ if (cur === null) return;
771
+ try {
772
+ const res = await fetch("/agnes/reveal", {
773
+ method: "POST",
774
+ headers: { "content-type": "application/json" },
775
+ body: JSON.stringify({ name: cur.filename }),
776
+ });
777
+ const data = await res.json().catch(() => ({}));
778
+ set({
779
+ notice: data && data.ok === true
780
+ ? "已在访达中显示"
781
+ : "打开失败:" + ((data && data.error) || "HTTP " + res.status),
782
+ });
783
+ } catch (e) {
784
+ set({ notice: "打开失败:" + (e && e.message ? e.message : String(e)) });
785
+ }
786
+ }
787
+
788
+ function downloadResult() {
789
+ const cur = state.result;
790
+ if (cur === null) return;
791
+ const a = document.createElement("a");
792
+ a.href = srcOf(cur.filename);
793
+ a.download = cur.filename;
794
+ document.body.appendChild(a);
795
+ a.click();
796
+ a.remove();
797
+ set({ notice: "已开始下载" });
798
+ }
799
+
800
+ /** 停掉等待:让在途的轮询/请求结果失效(服务端任务本身无法撤回) */
801
+ function cancel() {
802
+ if (!state.busy) return;
803
+ runToken += 1;
804
+ stopTimer();
805
+ set({
806
+ busy: false, vStatus: "", vProgress: 0,
807
+ notice: "已停止等待;服务端任务可能仍在跑",
808
+ });
809
+ }
810
+
811
+ /* ---------- 输出目录:把磁盘上已经有的图/视频捞回来 ---------- */
812
+ async function copyPrompt(text) {
813
+ const t = typeof text === "string" ? text.trim() : "";
814
+ if (t === "") return;
815
+ try {
816
+ await navigator.clipboard.writeText(t);
817
+ set({ notice: "提示词已复制", copiedKey: t });
818
+ } catch {
819
+ set({ notice: "复制失败,可手动选中复制" });
820
+ }
821
+ }
822
+
823
+ async function loadLibrary() {
824
+ if (state.libraryBusy) return;
825
+ set({ libraryBusy: true, libraryError: "" });
826
+ try {
827
+ const res = await fetch("/agnes/library");
828
+ const data = await res.json().catch(() => ({}));
829
+ if (!res.ok || !data || data.ok !== true) {
830
+ throw new Error((data && data.error) || "HTTP " + res.status);
831
+ }
832
+ const files = Array.isArray(data.files) ? data.files : [];
833
+ set({
834
+ library: files,
835
+ libraryOpen: true,
836
+ libraryBusy: false,
837
+ libraryError: "",
838
+ });
839
+ const images = files.filter((f) => f.kind === "image").length;
840
+ if (images === 0) {
841
+ const videos = files.filter((f) => f.kind === "video").length;
842
+ if (videos > 0) set({ libraryTab: "video" });
843
+ }
844
+ } catch (e) {
845
+ set({
846
+ libraryBusy: false,
847
+ libraryOpen: true,
848
+ libraryError: e && e.message ? e.message : String(e),
849
+ });
850
+ }
851
+ }
852
+
853
+ function toggleLibrary() {
854
+ if (state.libraryOpen) {
855
+ set({ libraryOpen: false });
856
+ return;
857
+ }
858
+ // 没加载过就先拉一次;已经有清单则直接展开
859
+ if (state.library === null) return loadLibrary();
860
+ set({ libraryOpen: true });
861
+ }
862
+
863
+ function setLibraryTab(tab) {
864
+ set({ libraryTab: tab === "video" ? "video" : "image" });
865
+ }
866
+
867
+ /** 点磁盘上的文件:直接放进结果区看,不经过生成流程 */
868
+ function showLibraryFile(file) {
869
+ if (!file || !file.name) return;
870
+ if (state.busy) return;
871
+ set({
872
+ result: {
873
+ kind: file.kind === "video" ? "video" : "image",
874
+ filename: file.name,
875
+ path: file.path || "",
876
+ bytes: file.bytes || 0,
877
+ prompt: typeof file.prompt === "string" ? file.prompt : "",
878
+ fromDisk: true,
879
+ },
880
+ error: "",
881
+ notice: "",
882
+ });
883
+ }
884
+
522
885
  /* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
523
886
  async function runVideo(token, prompt) {
524
887
  set({ vStatus: "提交中…", vProgress: 0, videoId: null });
525
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,本地图片不用先传图床
526
896
  const subRes = await fetch("/agnes/video", {
527
897
  method: "POST",
528
898
  headers: { "content-type": "application/json" },
529
- body: JSON.stringify({ prompt, seconds: state.vSeconds, ratio: state.vRatio }),
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
+ }),
530
907
  });
531
908
  const sub = await subRes.json();
532
909
  if (!sub.ok) throw new Error(sub.error || "提交失败");
533
910
  if (token !== runToken) return;
534
911
 
912
+ const usedMode = sub.mode === "keyframe" ? "keyframe" : "text";
535
913
  set({ videoId: sub.videoId, vStatus: "排队中…" });
536
914
  const deadline = Date.now() + VIDEO_DEADLINE_MS;
537
915
 
@@ -580,6 +958,9 @@ window.__ModuleLoader__.load({
580
958
  ratio: state.vRatio,
581
959
  seconds: st.seconds || state.vSeconds,
582
960
  transport: "异步任务",
961
+ mode: usedMode,
962
+ seed: seedValue === null ? undefined : seedValue,
963
+ prompt,
583
964
  wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
584
965
  });
585
966
  return;
@@ -628,6 +1009,7 @@ window.__ModuleLoader__.load({
628
1009
  set,
629
1010
  loadHealth,
630
1011
  loadConfig,
1012
+ loadHistory,
631
1013
  saveConfig,
632
1014
  setCfgDraft(field, value) {
633
1015
  set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
@@ -640,16 +1022,36 @@ window.__ModuleLoader__.load({
640
1022
  removeRef,
641
1023
  clearRefs,
642
1024
  toggleRefPick,
1025
+ /* ---- 视频关键帧 ---- */
1026
+ pickFrame,
1027
+ setFrameFromFile,
1028
+ clearFrame,
1029
+ toggleFramePick,
1030
+ setFrameFromResult,
1031
+ useResultAsFrame,
1032
+ /* ---- 结果收尾 ---- */
1033
+ copyPath,
1034
+ reveal,
1035
+ downloadResult,
1036
+ cancel,
1037
+ copyPrompt,
1038
+ /* ---- 输出目录清单 ---- */
1039
+ loadLibrary,
1040
+ toggleLibrary,
1041
+ setLibraryTab,
1042
+ showLibraryFile,
643
1043
  maxRefs: MAX_REFS,
644
1044
  /** 切换主预览到历史里的某一张 */
645
1045
  show(entry) { set({ result: entry }); },
646
1046
  /**
647
- * 清空界面上的生成结果。只清显示状态:不删磁盘文件、不动提示词和参数。
648
- * 进行中的请求不清(否则结果回来又会写一遍,反而更乱)。
1047
+ * 清空界面上的生成结果。删的是界面状态和宿主持久化的历史记录,
1048
+ * 不删磁盘文件、不动提示词和参数。进行中的请求不清。
649
1049
  */
650
1050
  clear() {
651
1051
  if (state.busy) return;
652
- set({ result: null, history: [], error: "" });
1052
+ set({ result: null, history: [], error: "", notice: "" });
1053
+ // 宿主是历史的真源;这里的 DELETE 失败也不该打断界面
1054
+ void fetch("/agnes/history", { method: "DELETE" }).catch(() => {});
653
1055
  },
654
1056
  dispose() {
655
1057
  stopTimer();
@@ -666,7 +1068,7 @@ window.__ModuleLoader__.load({
666
1068
  }
667
1069
 
668
1070
  /* ---------- 媒体显示:视频直接用 <video>,图片带加载兜底 ---------- */
669
- function MediaBox({ entry, big }) {
1071
+ function MediaBox({ entry, big, onCopyPrompt }) {
670
1072
  const isVideo = entry !== null && entry.kind === "video";
671
1073
  const name = entry === null ? null : entry.filename;
672
1074
 
@@ -715,6 +1117,8 @@ window.__ModuleLoader__.load({
715
1117
  React.createElement("span", { className: "agn-code" }, entry.path || name)));
716
1118
  }
717
1119
 
1120
+ const promptText = typeof entry.prompt === "string" ? entry.prompt.trim() : "";
1121
+
718
1122
  return React.createElement("div", { className: "agn-shot" + (big ? " agn-shot-big" : "") },
719
1123
  isVideo
720
1124
  ? React.createElement("video", {
@@ -724,7 +1128,20 @@ window.__ModuleLoader__.load({
724
1128
  preload: "metadata",
725
1129
  onError: () => setFailed(true),
726
1130
  })
727
- : React.createElement("img", { src, alt: "生成结果", onError })
1131
+ : React.createElement("img", { src, alt: "生成结果", onError }),
1132
+ /* 鼠标移上去才浮出来的一层:显示当初的提示词,带一键复制 */
1133
+ promptText === ""
1134
+ ? null
1135
+ : React.createElement("div", { className: "agn-prompt" },
1136
+ React.createElement("div", { className: "agn-prompt-text", title: promptText }, promptText),
1137
+ React.createElement("button", {
1138
+ type: "button", className: "agn-prompt-copy",
1139
+ title: "复制这段提示词",
1140
+ onClick: (e) => {
1141
+ e.stopPropagation();
1142
+ if (typeof onCopyPrompt === "function") onCopyPrompt(promptText);
1143
+ },
1144
+ }, "复制"))
728
1145
  );
729
1146
  }
730
1147
 
@@ -755,7 +1172,7 @@ window.__ModuleLoader__.load({
755
1172
  return () => { off(); };
756
1173
  }, []);
757
1174
 
758
- React.useEffect(() => { store.loadHealth(); store.loadConfig(); }, []);
1175
+ React.useEffect(() => { store.loadHealth(); store.loadConfig(); store.loadHistory(); }, []);
759
1176
 
760
1177
  const busy = s.busy;
761
1178
  const isVideo = s.mode === "video";
@@ -765,7 +1182,12 @@ window.__ModuleLoader__.load({
765
1182
  const hasRefs = s.refs.length > 0;
766
1183
  const refsFull = s.refs.length >= MAX_REFS;
767
1184
  const imageHistory = s.history.filter((h) => h.kind === "image");
1185
+ const libraryAll = s.library || [];
1186
+ const libraryImages = libraryAll.filter((f) => f.kind !== "video");
1187
+ const libraryVideos = libraryAll.filter((f) => f.kind === "video");
1188
+ const libraryShown = s.libraryTab === "video" ? libraryVideos : libraryImages;
768
1189
  const fileInput = React.useRef(null);
1190
+ const frameInput = React.useRef(null);
769
1191
 
770
1192
  const onKeyDown = (e) => {
771
1193
  if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
@@ -797,6 +1219,13 @@ window.__ModuleLoader__.load({
797
1219
  onChange: (e) => store.set({ vRatio: e.target.value }),
798
1220
  }, VIDEO_RATIOS.map((x) => React.createElement("option", { key: x, value: x }, x))),
799
1221
  React.createElement("span", { key: "m", className: "agn-meta" }, "720P"),
1222
+ React.createElement("span", { key: "l3", className: "agn-label" }, "种子"),
1223
+ React.createElement("input", {
1224
+ key: "s3", type: "text", inputMode: "numeric", className: "agn-seed",
1225
+ value: s.vSeed, disabled: busy, placeholder: "随机",
1226
+ title: "固定 seed 可让同一提示词出片更稳定;留空由服务端随机",
1227
+ onChange: (e) => store.set({ vSeed: e.target.value }),
1228
+ }),
800
1229
  ]
801
1230
  : [
802
1231
  React.createElement("span", { key: "l1", className: "agn-label" }, "尺寸"),
@@ -815,6 +1244,57 @@ window.__ModuleLoader__.load({
815
1244
  ? "生成结果(还没有)"
816
1245
  : (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
817
1246
 
1247
+ /* 视频缩略图:放一个真的 <video>,浏览器会解码出首帧当封面。
1248
+ #t=0.1 是媒体片段定位,避免第一帧偶尔是黑场;muted + playsInline
1249
+ 保证不会被自动播放策略拦掉,也不会突然出声。 */
1250
+ const videoThumb = (name) => React.createElement("video", {
1251
+ src: srcOf(name) + "#t=0.1",
1252
+ preload: "metadata",
1253
+ muted: true,
1254
+ playsInline: true,
1255
+ tabIndex: -1,
1256
+ });
1257
+
1258
+ /* 关键帧小格:有图显示缩略图 + ×,无图显示「+ 本地」;
1259
+ 下方固定一行「+ 结果」,从已经生成过的图片里挑一张 */
1260
+ const frameSlot = (which, label, cur) => React.createElement("div", { className: "agn-frame" },
1261
+ React.createElement("div", { className: "agn-frame-cap" }, label),
1262
+ cur !== null
1263
+ ? React.createElement("div", { className: "agn-frame-box" },
1264
+ React.createElement("img", { src: cur.src, alt: label }),
1265
+ React.createElement("button", {
1266
+ type: "button", className: "agn-ref-del", title: "移除" + label,
1267
+ disabled: busy,
1268
+ onClick: () => store.clearFrame(which),
1269
+ }, "×"))
1270
+ : React.createElement("button", {
1271
+ type: "button", className: "agn-frame-add",
1272
+ disabled: busy || s.frameBusy,
1273
+ title: "从本地磁盘选择" + label + "图片(PNG / JPEG / WebP / GIF)",
1274
+ onClick: () => {
1275
+ store.pickFrame(which);
1276
+ const el = frameInput.current;
1277
+ if (el !== null) el.click();
1278
+ },
1279
+ }, "+ 本地"),
1280
+ React.createElement("button", {
1281
+ type: "button", className: "agn-frame-from",
1282
+ disabled: busy || s.frameBusy || imageHistory.length === 0,
1283
+ title: imageHistory.length === 0
1284
+ ? "还没有生成过图片,先出一张图再来选"
1285
+ : "从生成结果里挑一张当" + label,
1286
+ onClick: () => store.toggleFramePick(which),
1287
+ }, s.framePickFrom === which ? "收起" : "+ 结果"),
1288
+ s.framePickFrom === which
1289
+ ? React.createElement("div", { className: "agn-ref-pick agn-frame-pick" },
1290
+ imageHistory.map((h) => React.createElement("button", {
1291
+ key: h.filename, type: "button", title: h.filename,
1292
+ disabled: busy || s.frameBusy,
1293
+ onClick: () => store.setFrameFromResult(which, h),
1294
+ }, React.createElement("img", { src: srcOf(h.filename), alt: h.filename }))))
1295
+ : null
1296
+ );
1297
+
818
1298
  return React.createElement("div", { className: "agn-root" },
819
1299
  /* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
820
1300
  React.createElement("input", {
@@ -835,6 +1315,22 @@ window.__ModuleLoader__.load({
835
1315
  },
836
1316
  }),
837
1317
 
1318
+ /* 隐藏的关键帧选择器:首帧 / 尾帧共用,靠 store.framePick 区分目标 */
1319
+ React.createElement("input", {
1320
+ ref: frameInput,
1321
+ type: "file",
1322
+ accept: "image/png,image/jpeg,image/webp,image/gif",
1323
+ style: { display: "none" },
1324
+ onChange: (e) => {
1325
+ const el = e.target;
1326
+ const file = el.files && el.files[0];
1327
+ const which = s.framePick === "last" ? "last" : "first";
1328
+ Promise.resolve(store.setFrameFromFile(which, file))
1329
+ .catch(() => {})
1330
+ .finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
1331
+ },
1332
+ }),
1333
+
838
1334
  React.createElement("div", { className: "agn-head" },
839
1335
  React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
840
1336
  React.createElement("span", { className: "agn-meta" },
@@ -849,6 +1345,22 @@ window.__ModuleLoader__.load({
849
1345
  modeBtn("video", "视频")
850
1346
  ),
851
1347
 
1348
+ /* ---- 视频关键帧(图生视频):本地图片直接当首帧/尾帧,留空即纯文生视频 ---- */
1349
+ isVideo
1350
+ ? React.createElement("div", { className: "agn-frames" },
1351
+ React.createElement("div", { className: "agn-ref-head" },
1352
+ React.createElement("span", { className: "agn-label" }, "关键帧"),
1353
+ React.createElement("span", { className: "agn-hint" },
1354
+ s.vFirst !== null || s.vLast !== null ? "图生视频" : "留空 = 纯文生视频"),
1355
+ s.frameBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null
1356
+ ),
1357
+ React.createElement("div", { className: "agn-frames-row" },
1358
+ frameSlot("first", "首帧", s.vFirst),
1359
+ frameSlot("last", "尾帧", s.vLast)
1360
+ )
1361
+ )
1362
+ : null,
1363
+
852
1364
  /* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
853
1365
  !isVideo
854
1366
  ? React.createElement("div", { className: "agn-refs" },
@@ -922,6 +1434,20 @@ window.__ModuleLoader__.load({
922
1434
  : null,
923
1435
 
924
1436
  React.createElement("div", { className: "agn-label" }, "提示词"),
1437
+ React.createElement("div", { className: "agn-presets" },
1438
+ PROMPT_PRESETS.map((preset) => React.createElement("button", {
1439
+ key: preset.label,
1440
+ type: "button",
1441
+ className: "agn-preset",
1442
+ disabled: busy,
1443
+ 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))
1450
+ ),
925
1451
  React.createElement("textarea", {
926
1452
  className: "agn-textarea",
927
1453
  value: s.prompt,
@@ -938,7 +1464,17 @@ window.__ModuleLoader__.load({
938
1464
  className: "agn-btn", type: "button",
939
1465
  disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
940
1466
  onClick: () => store.run(),
941
- }, buttonLabel)
1467
+ }, buttonLabel),
1468
+ /* 视频是异步任务,等待可能很长:给出真正的「停止等待」而不是让用户干等 */
1469
+ busy
1470
+ ? React.createElement("button", {
1471
+ className: "agn-btn-ghost", type: "button",
1472
+ title: isVideo
1473
+ ? "停止等待(服务端任务可能仍在跑,已生成的文件不会丢)"
1474
+ : "放弃这次等待(请求已发出,结果可能仍会落盘)",
1475
+ onClick: () => store.cancel(),
1476
+ }, "取消")
1477
+ : null
942
1478
  ),
943
1479
 
944
1480
  /* 视频是异步任务:给出阶段与百分比,避免只看到一个跳动的秒数 */
@@ -960,6 +1496,7 @@ window.__ModuleLoader__.load({
960
1496
  "未找到 Key:" + (key.file || "") + " —— 把 Key 写进该文件后可立即使用")
961
1497
  : null,
962
1498
  s.error !== "" ? React.createElement("div", { className: "agn-err" }, s.error) : null,
1499
+ s.notice !== "" ? React.createElement("div", { className: "agn-notice" }, s.notice) : null,
963
1500
  busy && !isVideo
964
1501
  ? React.createElement("div", { className: "agn-hint" },
965
1502
  "正在生成:可切到别的面板,回来仍会接着显示结果(请求不会中断)")
@@ -970,6 +1507,12 @@ window.__ModuleLoader__.load({
970
1507
  React.createElement("div", { className: "agn-card" },
971
1508
  React.createElement("div", { className: "agn-card-head" },
972
1509
  React.createElement("div", { className: "agn-label" }, resultLabel),
1510
+ React.createElement("button", {
1511
+ type: "button",
1512
+ className: "agn-btn-ghost",
1513
+ title: "把输出目录里已有的图片 / 视频列出来,点一下就能显示在上方",
1514
+ onClick: () => store.toggleLibrary(),
1515
+ }, s.libraryOpen ? "收起目录" : "输出目录"),
973
1516
  React.createElement("button", {
974
1517
  type: "button",
975
1518
  className: "agn-btn-ghost",
@@ -980,7 +1523,7 @@ window.__ModuleLoader__.load({
980
1523
  ),
981
1524
 
982
1525
  shown !== null
983
- ? React.createElement(MediaBox, { entry: shown, big: true })
1526
+ ? React.createElement(MediaBox, { entry: shown, big: true, onCopyPrompt: (t) => store.copyPrompt(t) })
984
1527
  : React.createElement("div", { className: "agn-shot" },
985
1528
  React.createElement("div", { className: "agn-shot-msg" },
986
1529
  busy ? (isVideo ? "正在生成视频…" : "正在生成图片…")
@@ -989,19 +1532,116 @@ window.__ModuleLoader__.load({
989
1532
  shown !== null
990
1533
  ? React.createElement("div", { className: "agn-hint" },
991
1534
  React.createElement("span", null,
992
- shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
1535
+ (shown.fromDisk ? "来自输出目录 · " : "")
1536
+ + shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
993
1537
  ? (shown.bytes / 1024 / 1024).toFixed(2) + " MB"
994
1538
  : (shown.bytes / 1024).toFixed(0) + " KB")
995
1539
  + " · " + shown.size + "/" + shown.ratio
996
1540
  + (shown.seconds ? " · " + shown.seconds + " 秒" : "")
1541
+ + (shown.mode === "keyframe" ? " · 图生视频" : "")
997
1542
  + (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
998
- + " · 用时 " + shown.elapsedSec + "s"),
1543
+ + (shown.seed !== undefined && shown.seed !== null ? " · seed " + shown.seed : "")
1544
+ + " · 用时 " + (shown.elapsedSec || shown.wallSec) + "s"),
999
1545
  React.createElement("br"),
1000
1546
  React.createElement("span", null, "已保存到 "),
1001
1547
  React.createElement("span", { className: "agn-code" }, shown.path || ""))
1548
+ : null,
1549
+
1550
+ shown !== null
1551
+ ? React.createElement("div", { className: "agn-acts" },
1552
+ React.createElement("button", {
1553
+ type: "button", className: "agn-act",
1554
+ title: "把文件路径复制到剪贴板",
1555
+ onClick: () => store.copyPath(),
1556
+ }, "复制路径"),
1557
+ React.createElement("button", {
1558
+ type: "button", className: "agn-act",
1559
+ title: "在访达中显示这个文件",
1560
+ onClick: () => store.reveal(),
1561
+ }, "在访达中显示"),
1562
+ React.createElement("button", {
1563
+ type: "button", className: "agn-act",
1564
+ title: "另存一份到「下载」目录",
1565
+ onClick: () => store.downloadResult(),
1566
+ }, "下载"),
1567
+ shown.kind === "image"
1568
+ ? React.createElement("button", {
1569
+ type: "button", className: "agn-act",
1570
+ disabled: busy || s.frameBusy,
1571
+ title: "把这张图设为视频首帧(切到视频模式即可看到)",
1572
+ onClick: () => store.useResultAsFrame("first"),
1573
+ }, "设为首帧")
1574
+ : null,
1575
+ shown.kind === "image"
1576
+ ? React.createElement("button", {
1577
+ type: "button", className: "agn-act",
1578
+ disabled: busy || s.frameBusy,
1579
+ title: "把这张图设为视频尾帧(切到视频模式即可看到)",
1580
+ onClick: () => store.useResultAsFrame("last"),
1581
+ }, "设为尾帧")
1582
+ : null)
1002
1583
  : null
1003
1584
  ),
1004
1585
 
1586
+ /* ---- 输出目录:把磁盘上已有的图 / 视频捞回来看 ---- */
1587
+ s.libraryOpen
1588
+ ? React.createElement("div", { className: "agn-card" },
1589
+ React.createElement("div", { className: "agn-card-head" },
1590
+ React.createElement("div", { className: "agn-label" }, "输出目录"),
1591
+ React.createElement("button", {
1592
+ type: "button", className: "agn-btn-ghost",
1593
+ disabled: s.libraryBusy,
1594
+ title: "重新读一遍输出目录(新生成的文件会自动出现在这里)",
1595
+ onClick: () => store.loadLibrary(),
1596
+ }, s.libraryBusy ? "读取中…" : "刷新")
1597
+ ),
1598
+ React.createElement("div", { className: "agn-hint" },
1599
+ "点缩略图会把它显示到上方结果区,并带上当初写进图片里的提示词"),
1600
+ React.createElement("div", { className: "agn-lib" },
1601
+ React.createElement("div", { className: "agn-lib-tabs" },
1602
+ React.createElement("button", {
1603
+ type: "button",
1604
+ className: "agn-act" + (s.libraryTab === "image" ? " agn-act-on" : ""),
1605
+ onClick: () => store.setLibraryTab("image"),
1606
+ }, "图片 " + libraryImages.length),
1607
+ React.createElement("button", {
1608
+ type: "button",
1609
+ className: "agn-act" + (s.libraryTab === "video" ? " agn-act-on" : ""),
1610
+ onClick: () => store.setLibraryTab("video"),
1611
+ }, "视频 " + libraryVideos.length)
1612
+ ),
1613
+ s.libraryError !== ""
1614
+ ? React.createElement("div", { className: "agn-err" }, s.libraryError)
1615
+ : null,
1616
+ libraryShown.length === 0
1617
+ ? React.createElement("div", { className: "agn-lib-empty" },
1618
+ s.libraryBusy
1619
+ ? "正在读取输出目录…"
1620
+ : "这个目录里还没有" + (s.libraryTab === "video" ? "视频" : "图片"))
1621
+ : React.createElement("div", { className: "agn-lib-grid" },
1622
+ libraryShown.map((f) => React.createElement("button", {
1623
+ key: f.name,
1624
+ type: "button",
1625
+ className: "agn-lib-item" + (shown && shown.filename === f.name ? " agn-thumb-on" : ""),
1626
+ title: (f.prompt ? f.prompt + "\n" : "") + f.name,
1627
+ disabled: busy,
1628
+ onClick: () => store.showLibraryFile(f),
1629
+ },
1630
+ f.kind === "video"
1631
+ ? videoThumb(f.name)
1632
+ : React.createElement("img", { src: srcOf(f.name), alt: f.name, loading: "lazy" }),
1633
+ f.kind === "video"
1634
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1635
+ : null,
1636
+ f.kind === "video"
1637
+ ? React.createElement("span", { className: "agn-lib-badge" }, "视频")
1638
+ : null
1639
+ ))
1640
+ )
1641
+ )
1642
+ )
1643
+ : null,
1644
+
1005
1645
  /* ---- 历史 ---- */
1006
1646
  s.history.length > 1
1007
1647
  ? React.createElement("div", { className: "agn-card agn-hist" },
@@ -1015,8 +1655,11 @@ window.__ModuleLoader__.load({
1015
1655
  onClick: () => store.show(h),
1016
1656
  },
1017
1657
  h.kind === "video"
1018
- ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1658
+ ? videoThumb(h.filename)
1019
1659
  : React.createElement("img", { src: srcOf(h.filename), alt: h.filename, loading: "lazy" }),
1660
+ h.kind === "video"
1661
+ ? React.createElement("div", { className: "agn-thumb-play" }, "▶")
1662
+ : null,
1020
1663
  React.createElement("span", { className: "agn-thumb-cap" },
1021
1664
  h.kind === "video"
1022
1665
  ? (h.seconds ? h.seconds + "s" : "视频")
@@ -1091,7 +1734,7 @@ window.__ModuleLoader__.load({
1091
1734
  s.cfgOpen
1092
1735
  ? React.createElement("div", { className: "agn-note" },
1093
1736
  "保存到 ",
1094
- (s.health && s.health.configFile) || "~/.dsh/agnes-image/config.json")
1737
+ (s.health && s.health.configFile) || "~/.dsh/agnes-create/config.json")
1095
1738
  : null
1096
1739
  ),
1097
1740