dsh-agnes-create 0.0.0-stage → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -2
- package/cordis.patch.yml +5 -0
- package/lib/agnes.js +525 -0
- package/lib/client.js +1136 -0
- package/lib/index.js +340 -0
- package/package.json +61 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-agnes-create",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
|
|
9
|
+
const ID = "dsh-agnes-create";
|
|
10
|
+
const CSS_ID = "dsh-agnes-create";
|
|
11
|
+
const SIZE_OPTIONS = ["1K", "2K", "3K", "4K"];
|
|
12
|
+
const RATIO_OPTIONS = ["1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"];
|
|
13
|
+
// 视频:Flash 固定 720P,seconds 是字符串 "4"–"12"
|
|
14
|
+
const VIDEO_SECONDS = ["4", "5", "6", "7", "8", "9", "10", "11", "12"];
|
|
15
|
+
const VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
|
|
16
|
+
const HISTORY_MAX = 8;
|
|
17
|
+
const POLL_MS = 6000;
|
|
18
|
+
const VIDEO_DEADLINE_MS = 15 * 60 * 1000;
|
|
19
|
+
// 图生图:一次最多 8 张参考图(与宿主 MAX_REF_IMAGES 保持一致)
|
|
20
|
+
const MAX_REFS = 8;
|
|
21
|
+
// 发给上游前先下采样:长边 1280 足够模型识别,base64 也压得住
|
|
22
|
+
const REF_MAX_EDGE = 1280;
|
|
23
|
+
const REF_QUALITY = 0.88;
|
|
24
|
+
const REF_PNG_KEEP_BYTES = 800 * 1024;
|
|
25
|
+
|
|
26
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
|
|
28
|
+
/* ---------- 样式 ---------- */
|
|
29
|
+
const CSS = `
|
|
30
|
+
.agn-root { display: flex; flex-direction: column; gap: 12px; padding: 20px 24px; height: 100%;
|
|
31
|
+
box-sizing: border-box; overflow: auto; font-size: 13px; }
|
|
32
|
+
.agn-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
33
|
+
.agn-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, inherit); }
|
|
34
|
+
.agn-meta { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); }
|
|
35
|
+
.agn-card { border: 1px solid var(--dsw-alias-border-l2, #e3e3e3); border-radius: 10px;
|
|
36
|
+
padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
37
|
+
.agn-label { font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-secondary, #666); }
|
|
38
|
+
.agn-textarea { width: 100%; box-sizing: border-box; min-height: 84px; resize: vertical;
|
|
39
|
+
font: inherit; line-height: 1.6; padding: 8px 10px; border-radius: 8px;
|
|
40
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
41
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa);
|
|
42
|
+
border: 1px solid var(--dsw-alias-border-l2, #ddd); outline: none; }
|
|
43
|
+
.agn-textarea:focus { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
|
|
44
|
+
.agn-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
45
|
+
.agn-select { font: inherit; padding: 5px 8px; border-radius: 8px; cursor: pointer;
|
|
46
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
47
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa);
|
|
48
|
+
border: 1px solid var(--dsw-alias-border-l2, #ddd); }
|
|
49
|
+
.agn-btn { font: inherit; font-weight: 600; padding: 7px 18px; border-radius: 8px; cursor: pointer;
|
|
50
|
+
border: 1px solid var(--dsw-alias-button-primary-fill, #4b7bec);
|
|
51
|
+
background: var(--dsw-alias-button-primary-fill, #4b7bec);
|
|
52
|
+
color: var(--dsw-alias-label-primary-foreground, #fff); }
|
|
53
|
+
.agn-btn:disabled { opacity: .45; cursor: not-allowed; }
|
|
54
|
+
.agn-err { color: var(--dsw-alias-state-error-primary, #d33); white-space: pre-wrap;
|
|
55
|
+
word-break: break-word; font-size: 12px; }
|
|
56
|
+
.agn-ok { color: var(--dsw-alias-state-success-primary, #2a2); font-size: 12px; }
|
|
57
|
+
|
|
58
|
+
/* 图片/视频 模式切换 */
|
|
59
|
+
.agn-modes { display: flex; gap: 6px; }
|
|
60
|
+
.agn-mode { font: inherit; font-size: 12px; padding: 4px 14px; border-radius: 999px;
|
|
61
|
+
cursor: pointer; border: 1px solid var(--dsw-alias-border-l2, #ddd);
|
|
62
|
+
background: transparent; color: var(--dsw-alias-label-secondary, #666); }
|
|
63
|
+
.agn-mode-on { background: var(--dsw-alias-button-primary-fill, #4b7bec);
|
|
64
|
+
border-color: transparent;
|
|
65
|
+
color: var(--dsw-alias-label-primary-foreground, #fff); }
|
|
66
|
+
.agn-mode:disabled { opacity: .45; cursor: not-allowed; }
|
|
67
|
+
|
|
68
|
+
/* 进度条 */
|
|
69
|
+
.agn-bar { height: 6px; border-radius: 3px; overflow: hidden;
|
|
70
|
+
background: var(--dsw-alias-bg-layer-2, #eee); }
|
|
71
|
+
.agn-bar-fill { height: 100%; width: 0;
|
|
72
|
+
background: var(--dsw-alias-button-primary-fill, #4b7bec);
|
|
73
|
+
transition: width .3s ease; }
|
|
74
|
+
|
|
75
|
+
/* 媒体展示区 */
|
|
76
|
+
.agn-shot { border-radius: 10px; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
77
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); overflow: hidden;
|
|
78
|
+
display: flex; align-items: center; justify-content: center; min-height: 120px; }
|
|
79
|
+
.agn-shot img, .agn-shot video { display: block; max-width: 100%; height: auto; }
|
|
80
|
+
.agn-shot-big { max-height: 62vh; }
|
|
81
|
+
.agn-shot-big img, .agn-shot-big video { max-height: 62vh; object-fit: contain; }
|
|
82
|
+
.agn-shot-msg { padding: 18px; font-size: 12px; text-align: center; line-height: 1.7;
|
|
83
|
+
color: var(--dsw-alias-label-secondary, #888); }
|
|
84
|
+
|
|
85
|
+
/* 历史缩略图 */
|
|
86
|
+
.agn-hist { display: flex; flex-direction: column; gap: 6px; }
|
|
87
|
+
.agn-hist-strip { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
88
|
+
.agn-thumb { position: relative; width: 84px; height: 84px; border-radius: 8px; padding: 0;
|
|
89
|
+
overflow: hidden; cursor: pointer; flex: none;
|
|
90
|
+
border: 2px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
91
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); }
|
|
92
|
+
.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; }
|
|
94
|
+
.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); }
|
|
97
|
+
.agn-thumb-cap { position: absolute; left: 0; right: 0; bottom: 0; font-size: 10px;
|
|
98
|
+
text-align: center; padding: 1px 0;
|
|
99
|
+
color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
100
|
+
background: rgba(0, 0, 0, .45); }
|
|
101
|
+
|
|
102
|
+
/* 设置卡片 */
|
|
103
|
+
.agn-field { display: flex; flex-direction: column; gap: 4px; }
|
|
104
|
+
.agn-input { font: inherit; font-size: 12px; padding: 6px 9px; border-radius: 8px;
|
|
105
|
+
box-sizing: border-box; width: 100%; outline: none;
|
|
106
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
107
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa);
|
|
108
|
+
border: 1px solid var(--dsw-alias-border-l2, #ddd);
|
|
109
|
+
font-family: var(--ds-font-family-code, ui-monospace, monospace); }
|
|
110
|
+
.agn-input:focus { border-color: var(--dsw-alias-state-business-primary, #4b7bec); }
|
|
111
|
+
.agn-input:disabled { opacity: .5; }
|
|
112
|
+
.agn-note { font-size: 11px; color: var(--dsw-alias-label-secondary, #888); }
|
|
113
|
+
.agn-okline { font-size: 12px; color: var(--dsw-alias-state-success-primary, #2a2); }
|
|
114
|
+
|
|
115
|
+
.agn-hint { font-size: 12px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.6; }
|
|
116
|
+
.agn-code { font-family: var(--ds-font-family-code, ui-monospace, monospace); font-size: 11px;
|
|
117
|
+
word-break: break-all; }
|
|
118
|
+
|
|
119
|
+
/* 参考图(图生图 / 多图合成) */
|
|
120
|
+
.agn-refs { display: flex; flex-direction: column; gap: 8px; }
|
|
121
|
+
.agn-ref-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
122
|
+
.agn-ref-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
123
|
+
.agn-ref { position: relative; width: 72px; height: 72px; border-radius: 8px; overflow: hidden;
|
|
124
|
+
flex: none; border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
125
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); }
|
|
126
|
+
.agn-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
127
|
+
.agn-ref-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
|
|
128
|
+
border: none; border-radius: 50%; cursor: pointer; padding: 0;
|
|
129
|
+
font-size: 11px; line-height: 18px; text-align: center;
|
|
130
|
+
color: #fff; background: rgba(0, 0, 0, .6); }
|
|
131
|
+
.agn-ref-del:hover { background: rgba(0, 0, 0, .8); }
|
|
132
|
+
.agn-ref-add { width: 72px; height: 72px; border-radius: 8px; cursor: pointer; flex: none;
|
|
133
|
+
font: inherit; font-size: 11px; line-height: 1.35; padding: 4px;
|
|
134
|
+
color: var(--dsw-alias-label-secondary, #666); background: transparent;
|
|
135
|
+
border: 1px dashed var(--dsw-alias-border-l2, #ccc); }
|
|
136
|
+
.agn-ref-add:disabled { opacity: .45; cursor: not-allowed; }
|
|
137
|
+
.agn-ref-pick { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px; border-radius: 8px;
|
|
138
|
+
border: 1px solid var(--dsw-alias-border-l2, #e3e3e3);
|
|
139
|
+
background: var(--dsw-alias-bg-layer-2, #fafafa); }
|
|
140
|
+
.agn-ref-pick button { width: 56px; height: 56px; padding: 0; border-radius: 6px;
|
|
141
|
+
overflow: hidden; cursor: pointer; flex: none;
|
|
142
|
+
border: 1px solid var(--dsw-alias-border-l2, #ddd); background: transparent; }
|
|
143
|
+
.agn-ref-pick img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
144
|
+
.agn-tag { font-size: 11px; padding: 2px 8px; border-radius: 999px;
|
|
145
|
+
background: var(--dsw-alias-bg-layer-2, #f1f1f1);
|
|
146
|
+
color: var(--dsw-alias-label-secondary, #555); }
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
function ensureCss() {
|
|
150
|
+
const tags = document.querySelectorAll('style[data-plugin-css="' + CSS_ID + '"]');
|
|
151
|
+
const style = tags[0] || document.createElement("style");
|
|
152
|
+
for (let i = 1; i < tags.length; i += 1) tags[i].remove();
|
|
153
|
+
style.dataset.plugin = "dsh-agnes-create";
|
|
154
|
+
style.dataset.pluginCss = CSS_ID;
|
|
155
|
+
style.textContent = CSS;
|
|
156
|
+
if (!style.isConnected) document.head.appendChild(style);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function srcOf(name) {
|
|
160
|
+
return "/agnes/media?name=" + encodeURIComponent(name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ==========================================================================
|
|
164
|
+
参考图预处理(图生图)
|
|
165
|
+
--------------------------------------------------------------------------
|
|
166
|
+
两个来源最终都归一成 data URI:
|
|
167
|
+
· 本地文件 —— <input type="file"> 给的 File
|
|
168
|
+
· 生成结果 —— /agnes/media 取回 Blob
|
|
169
|
+
先按长边 1280 下采样再编码,否则一张 4K PNG 的 base64 就有十几 MB,
|
|
170
|
+
宿主请求体和上游都要跟着炸。
|
|
171
|
+
========================================================================== */
|
|
172
|
+
|
|
173
|
+
let refSeq = 0;
|
|
174
|
+
const nextRefId = () => "ref-" + (refSeq += 1) + "-" + Date.now();
|
|
175
|
+
|
|
176
|
+
function blobToDataUrl(blob) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const fr = new FileReader();
|
|
179
|
+
fr.onload = () => resolve(String(fr.result));
|
|
180
|
+
fr.onerror = () => reject(new Error("读取图片失败"));
|
|
181
|
+
fr.readAsDataURL(blob);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 编码一次 canvas;toBlob 回调可能是 null(极端尺寸/内存不足) */
|
|
186
|
+
function canvasToBlob(canvas, type, quality) {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
try {
|
|
189
|
+
canvas.toBlob((b) => resolve(b), type, quality);
|
|
190
|
+
} catch {
|
|
191
|
+
resolve(null);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 把任意图片 Blob 压成适合上传的 data URI。
|
|
198
|
+
* PNG 优先保留(多图合成常带透明通道),但超过阈值就退回 JPEG。
|
|
199
|
+
*/
|
|
200
|
+
async function shrinkImage(blob) {
|
|
201
|
+
if (!(blob instanceof Blob) || blob.size === 0) throw new Error("图片是空的");
|
|
202
|
+
|
|
203
|
+
let bitmap;
|
|
204
|
+
try {
|
|
205
|
+
bitmap = await createImageBitmap(blob);
|
|
206
|
+
} catch {
|
|
207
|
+
// 极少数格式解码不了:小图原样上传,大图明确报错而不是偷偷传十几 MB
|
|
208
|
+
if (blob.size <= REF_PNG_KEEP_BYTES) {
|
|
209
|
+
return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
|
|
210
|
+
}
|
|
211
|
+
throw new Error("无法解码这张图片(试试 PNG/JPEG/WebP)");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const longest = Math.max(bitmap.width, bitmap.height) || 1;
|
|
215
|
+
const scale = Math.min(1, REF_MAX_EDGE / longest);
|
|
216
|
+
const w = Math.max(1, Math.round(bitmap.width * scale));
|
|
217
|
+
const h = Math.max(1, Math.round(bitmap.height * scale));
|
|
218
|
+
|
|
219
|
+
const canvas = document.createElement("canvas");
|
|
220
|
+
canvas.width = w;
|
|
221
|
+
canvas.height = h;
|
|
222
|
+
const ctx = canvas.getContext("2d");
|
|
223
|
+
if (ctx === null) {
|
|
224
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
225
|
+
return { src: await blobToDataUrl(blob), bytes: blob.size, shrunk: false };
|
|
226
|
+
}
|
|
227
|
+
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
228
|
+
if (typeof bitmap.close === "function") bitmap.close();
|
|
229
|
+
|
|
230
|
+
const wantPng = blob.type === "image/png";
|
|
231
|
+
let out = await canvasToBlob(canvas, wantPng ? "image/png" : "image/jpeg", REF_QUALITY);
|
|
232
|
+
if (out === null) throw new Error("图片编码失败");
|
|
233
|
+
if (wantPng && out.size > REF_PNG_KEEP_BYTES) {
|
|
234
|
+
const jpeg = await canvasToBlob(canvas, "image/jpeg", REF_QUALITY);
|
|
235
|
+
if (jpeg !== null && jpeg.size < out.size) out = jpeg;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { src: await blobToDataUrl(out), bytes: out.size, shrunk: scale < 1 };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ==========================================================================
|
|
242
|
+
状态仓库(放组件外面:main slot 切换会卸载组件,状态必须独立存活)
|
|
243
|
+
========================================================================== */
|
|
244
|
+
const GLOBAL_KEY = "__dshAgnesCreateStore";
|
|
245
|
+
|
|
246
|
+
function createStore() {
|
|
247
|
+
const listeners = new Set();
|
|
248
|
+
const state = {
|
|
249
|
+
prompt: "",
|
|
250
|
+
mode: "image", // image | video
|
|
251
|
+
size: "1K",
|
|
252
|
+
ratio: "1:1",
|
|
253
|
+
vSeconds: "5",
|
|
254
|
+
vRatio: "16:9",
|
|
255
|
+
busy: false,
|
|
256
|
+
error: "",
|
|
257
|
+
result: null,
|
|
258
|
+
history: [], // 最新在前,元素带 kind: 'image' | 'video'
|
|
259
|
+
startedAt: 0,
|
|
260
|
+
elapsed: 0,
|
|
261
|
+
vProgress: 0,
|
|
262
|
+
vStatus: "",
|
|
263
|
+
videoId: null,
|
|
264
|
+
health: null,
|
|
265
|
+
healthRequested: false,
|
|
266
|
+
|
|
267
|
+
/* ---- 可配置项:Key 文件路径 / 输出目录 ---- */
|
|
268
|
+
cfg: null, // 生效配置 { keyFile, outDir }
|
|
269
|
+
cfgDefaults: null,
|
|
270
|
+
cfgStored: null,
|
|
271
|
+
cfgDraft: { keyFile: "", outDir: "" },
|
|
272
|
+
cfgOpen: false,
|
|
273
|
+
cfgSaving: false,
|
|
274
|
+
cfgError: "",
|
|
275
|
+
cfgNotice: "",
|
|
276
|
+
|
|
277
|
+
/* ---- 图生图参考图 ---- */
|
|
278
|
+
refs: [], // { id, name, src(dataURI), bytes, from: 'local' | 'result' }
|
|
279
|
+
refsBusy: false,
|
|
280
|
+
refsError: "",
|
|
281
|
+
refPickOpen: false,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const snapshot = () => ({ ...state, history: state.history.slice() });
|
|
285
|
+
const notify = () => {
|
|
286
|
+
for (const fn of Array.from(listeners)) {
|
|
287
|
+
try { fn(); } catch { /* 单个订阅者出错不拖垮其它 */ }
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const set = (patch) => {
|
|
291
|
+
Object.assign(state, patch);
|
|
292
|
+
notify();
|
|
293
|
+
};
|
|
294
|
+
/** 基于当前状态计算补丁:避免「读旧值再写回」导致的覆盖。 */
|
|
295
|
+
const update = (fn) => {
|
|
296
|
+
const patch = fn(state);
|
|
297
|
+
if (patch !== null && patch !== undefined) Object.assign(state, patch);
|
|
298
|
+
notify();
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
let timer = null;
|
|
302
|
+
let runToken = 0;
|
|
303
|
+
|
|
304
|
+
const stopTimer = () => {
|
|
305
|
+
if (timer !== null) { clearInterval(timer); timer = null; }
|
|
306
|
+
};
|
|
307
|
+
const startTimer = () => {
|
|
308
|
+
stopTimer();
|
|
309
|
+
timer = setInterval(() => {
|
|
310
|
+
if (state.busy) set({ elapsed: (Date.now() - state.startedAt) / 1000 });
|
|
311
|
+
}, 1000);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const pushResult = (entry) => {
|
|
315
|
+
set({ result: entry, history: [entry, ...state.history].slice(0, HISTORY_MAX) });
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
async function loadHealth() {
|
|
319
|
+
if (state.healthRequested) return;
|
|
320
|
+
state.healthRequested = true;
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch("/agnes/health");
|
|
323
|
+
set({ health: await res.json() });
|
|
324
|
+
} catch {
|
|
325
|
+
set({ health: { ok: false, error: "宿主路由不可达" } });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ---------- 配置读写 ---------- */
|
|
330
|
+
async function loadConfig() {
|
|
331
|
+
try {
|
|
332
|
+
const res = await fetch("/agnes/config");
|
|
333
|
+
// 宿主半还没重启时这条路由不存在:给出可操作的提示,而不是一句“读取失败”
|
|
334
|
+
if (res.status === 404) {
|
|
335
|
+
set({ cfgError: "配置接口尚未就绪:需要完全重启应用(只重载窗口不够)" });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const data = await res.json();
|
|
339
|
+
if (!data.ok) throw new Error(data.error || "读取配置失败");
|
|
340
|
+
set({
|
|
341
|
+
cfg: data.config,
|
|
342
|
+
cfgDefaults: data.defaults,
|
|
343
|
+
cfgStored: data.stored,
|
|
344
|
+
cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
|
|
345
|
+
cfgError: "",
|
|
346
|
+
});
|
|
347
|
+
} catch (e) {
|
|
348
|
+
set({ cfgError: e && e.message ? e.message : String(e) });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function saveConfig() {
|
|
353
|
+
if (state.cfgSaving) return;
|
|
354
|
+
set({ cfgSaving: true, cfgError: "", cfgNotice: "" });
|
|
355
|
+
try {
|
|
356
|
+
const res = await fetch("/agnes/config", {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: { "content-type": "application/json" },
|
|
359
|
+
body: JSON.stringify({
|
|
360
|
+
keyFile: state.cfgDraft.keyFile,
|
|
361
|
+
outDir: state.cfgDraft.outDir,
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
if (res.status === 404) throw new Error("配置接口尚未就绪:需要完全重启应用(只重载窗口不够)");
|
|
365
|
+
const data = await res.json();
|
|
366
|
+
if (!data.ok) throw new Error(data.error || "保存失败");
|
|
367
|
+
set({
|
|
368
|
+
cfg: data.config,
|
|
369
|
+
cfgDefaults: data.defaults,
|
|
370
|
+
cfgStored: data.stored,
|
|
371
|
+
cfgDraft: { keyFile: data.config.keyFile, outDir: data.config.outDir },
|
|
372
|
+
cfgNotice: "已保存",
|
|
373
|
+
});
|
|
374
|
+
// 路径变了,key 是否存在/目录是否可写都要重新算
|
|
375
|
+
try {
|
|
376
|
+
set({ health: await (await fetch("/agnes/health")).json() });
|
|
377
|
+
} catch { /* 健康信息刷新失败不影响保存结果 */ }
|
|
378
|
+
} catch (e) {
|
|
379
|
+
set({ cfgError: e && e.message ? e.message : String(e) });
|
|
380
|
+
} finally {
|
|
381
|
+
set({ cfgSaving: false });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ---------- 图片:一次请求就出结果 ---------- */
|
|
386
|
+
async function runImage(token, prompt) {
|
|
387
|
+
const refs = state.refs;
|
|
388
|
+
const payload = {
|
|
389
|
+
prompt,
|
|
390
|
+
size: state.size,
|
|
391
|
+
ratio: state.ratio,
|
|
392
|
+
// 有参考图就是图生图 / 多图合成;宿主原样透传给上游的 extra_body.image
|
|
393
|
+
...(refs.length > 0 ? { images: refs.map((r) => r.src) } : {}),
|
|
394
|
+
};
|
|
395
|
+
const res = await fetch("/agnes/generate", {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "content-type": "application/json" },
|
|
398
|
+
body: JSON.stringify(payload),
|
|
399
|
+
});
|
|
400
|
+
const data = await res.json();
|
|
401
|
+
if (!data.ok) throw new Error(data.error || "生成失败");
|
|
402
|
+
if (token !== runToken) return;
|
|
403
|
+
pushResult({ ...data, kind: "image", wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1) });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ---------- 参考图(图生图) ---------- */
|
|
407
|
+
|
|
408
|
+
const refsFull = () => state.refs.length >= MAX_REFS;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 参考图操作串行化。
|
|
412
|
+
*
|
|
413
|
+
* 之前用 `if (state.refsBusy) return` 做互斥:一旦某个操作正在跑
|
|
414
|
+
* (读生成结果要 fetch + 解码,可能几百毫秒),这期间用户的另一次添加
|
|
415
|
+
* 会被**静默丢弃**——点了没反应,看起来就像"刚加的图不见了"。
|
|
416
|
+
* 现在改成排队执行:不丢单,也不会出现两个操作互相覆盖 refs。
|
|
417
|
+
*/
|
|
418
|
+
let refQueue = Promise.resolve();
|
|
419
|
+
function queueRefOp(task) {
|
|
420
|
+
const run = refQueue.then(task, task);
|
|
421
|
+
// 单个操作失败不能卡死后面的队列
|
|
422
|
+
refQueue = run.catch(() => {});
|
|
423
|
+
return run;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 来源一:本地磁盘文件(可多选)。返回 Promise,调用方可等它读完再清空 input。 */
|
|
427
|
+
function addRefsFromFiles(files) {
|
|
428
|
+
const list = Array.from(files || []).filter(
|
|
429
|
+
(f) => f && String(f.type || "").startsWith("image/")
|
|
430
|
+
);
|
|
431
|
+
if (list.length === 0) {
|
|
432
|
+
set({ refsError: "没有可用的图片文件(需要 PNG / JPEG / WebP / GIF)" });
|
|
433
|
+
return Promise.resolve();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return queueRefOp(async () => {
|
|
437
|
+
set({ refsBusy: true, refsError: "" });
|
|
438
|
+
const added = [];
|
|
439
|
+
const failed = [];
|
|
440
|
+
try {
|
|
441
|
+
for (const file of list) {
|
|
442
|
+
if (state.refs.length + added.length >= MAX_REFS) {
|
|
443
|
+
failed.push("超出 " + MAX_REFS + " 张上限");
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
// 先把文件读进内存再继续:即使 input.value 被清空,读也不会断
|
|
448
|
+
const r = await shrinkImage(file);
|
|
449
|
+
added.push({
|
|
450
|
+
id: nextRefId(),
|
|
451
|
+
name: file.name || "本地图片",
|
|
452
|
+
src: r.src,
|
|
453
|
+
bytes: r.bytes,
|
|
454
|
+
from: "local",
|
|
455
|
+
});
|
|
456
|
+
} catch (e) {
|
|
457
|
+
failed.push((file.name || "图片") + ":" + (e && e.message ? e.message : String(e)));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (added.length > 0) {
|
|
461
|
+
// 用 update 基于最新状态追加,杜绝覆盖别的来源刚加进来的图
|
|
462
|
+
update((prev) => ({ refs: prev.refs.concat(added) }));
|
|
463
|
+
}
|
|
464
|
+
set({
|
|
465
|
+
refsError: failed.length > 0 ? failed.join(";") : "",
|
|
466
|
+
refPickOpen: false,
|
|
467
|
+
});
|
|
468
|
+
} finally {
|
|
469
|
+
set({ refsBusy: false });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 来源二:生成结果里的图片(通过 /agnes/media 取回再下采样) */
|
|
475
|
+
function addRefFromResult(entry) {
|
|
476
|
+
if (!entry || entry.kind !== "image" || !entry.filename) return Promise.resolve();
|
|
477
|
+
if (refsFull()) {
|
|
478
|
+
set({ refsError: "参考图最多 " + MAX_REFS + " 张" });
|
|
479
|
+
return Promise.resolve();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return queueRefOp(async () => {
|
|
483
|
+
set({ refsBusy: true, refsError: "" });
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(srcOf(entry.filename));
|
|
486
|
+
if (!res.ok) throw new Error("读取生成结果失败:HTTP " + res.status);
|
|
487
|
+
const blob = await res.blob();
|
|
488
|
+
const r = await shrinkImage(blob);
|
|
489
|
+
update((prev) => ({
|
|
490
|
+
refs: prev.refs.concat([{
|
|
491
|
+
id: nextRefId(),
|
|
492
|
+
name: entry.filename,
|
|
493
|
+
src: r.src,
|
|
494
|
+
bytes: r.bytes,
|
|
495
|
+
from: "result",
|
|
496
|
+
}]),
|
|
497
|
+
refPickOpen: false,
|
|
498
|
+
}));
|
|
499
|
+
} catch (e) {
|
|
500
|
+
set({ refsError: e && e.message ? e.message : String(e) });
|
|
501
|
+
} finally {
|
|
502
|
+
set({ refsBusy: false });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function removeRef(id) {
|
|
508
|
+
if (state.busy) return;
|
|
509
|
+
update((prev) => ({ refs: prev.refs.filter((r) => r.id !== id), refsError: "" }));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function clearRefs() {
|
|
513
|
+
if (state.busy) return;
|
|
514
|
+
set({ refs: [], refsError: "", refPickOpen: false });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function toggleRefPick() {
|
|
518
|
+
if (state.busy) return;
|
|
519
|
+
set({ refPickOpen: !state.refPickOpen, refsError: "" });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/* ---------- 视频:提交 → 轮询 → 下载完成 ---------- */
|
|
523
|
+
async function runVideo(token, prompt) {
|
|
524
|
+
set({ vStatus: "提交中…", vProgress: 0, videoId: null });
|
|
525
|
+
|
|
526
|
+
const subRes = await fetch("/agnes/video", {
|
|
527
|
+
method: "POST",
|
|
528
|
+
headers: { "content-type": "application/json" },
|
|
529
|
+
body: JSON.stringify({ prompt, seconds: state.vSeconds, ratio: state.vRatio }),
|
|
530
|
+
});
|
|
531
|
+
const sub = await subRes.json();
|
|
532
|
+
if (!sub.ok) throw new Error(sub.error || "提交失败");
|
|
533
|
+
if (token !== runToken) return;
|
|
534
|
+
|
|
535
|
+
set({ videoId: sub.videoId, vStatus: "排队中…" });
|
|
536
|
+
const deadline = Date.now() + VIDEO_DEADLINE_MS;
|
|
537
|
+
|
|
538
|
+
let transientFails = 0;
|
|
539
|
+
for (;;) {
|
|
540
|
+
await sleep(POLL_MS);
|
|
541
|
+
if (token !== runToken) return;
|
|
542
|
+
|
|
543
|
+
let st;
|
|
544
|
+
try {
|
|
545
|
+
const r = await fetch("/agnes/video?id=" + encodeURIComponent(sub.videoId));
|
|
546
|
+
st = await r.json();
|
|
547
|
+
} catch (e) {
|
|
548
|
+
st = { ok: false, error: "网络错误:" + (e && e.message ? e.message : String(e)) };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (!st.ok) {
|
|
552
|
+
const msg = String(st.error || "查询任务失败");
|
|
553
|
+
// 429/503 只是限流,任务还在服务端跑:退避后继续,别把整个任务判死。
|
|
554
|
+
// 实测提交后立刻查询就会 429,这条路径是必需的。
|
|
555
|
+
if (/\b429\b|\b503\b|频繁|队列已满|网络错误|timeout/i.test(msg)) {
|
|
556
|
+
transientFails += 1;
|
|
557
|
+
if (transientFails > 8) throw new Error(msg);
|
|
558
|
+
set({ vStatus: "限流重试中…" });
|
|
559
|
+
await sleep(Math.min(30000, 6000 * transientFails));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
throw new Error(msg);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
transientFails = 0;
|
|
566
|
+
if (token !== runToken) return;
|
|
567
|
+
|
|
568
|
+
set({
|
|
569
|
+
vStatus: st.status,
|
|
570
|
+
vProgress: typeof st.progress === "number" ? st.progress : 0,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
if (st.status === "completed") {
|
|
574
|
+
pushResult({
|
|
575
|
+
kind: "video",
|
|
576
|
+
filename: st.filename,
|
|
577
|
+
path: st.path,
|
|
578
|
+
bytes: st.bytes,
|
|
579
|
+
size: "720P",
|
|
580
|
+
ratio: state.vRatio,
|
|
581
|
+
seconds: st.seconds || state.vSeconds,
|
|
582
|
+
transport: "异步任务",
|
|
583
|
+
wallSec: ((Date.now() - state.startedAt) / 1000).toFixed(1),
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (st.status === "failed") {
|
|
588
|
+
const err = st.error;
|
|
589
|
+
throw new Error(typeof err === "string" ? err : (err && err.message) || "视频生成失败");
|
|
590
|
+
}
|
|
591
|
+
if (Date.now() > deadline) throw new Error("等待超时(15 分钟)");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function run() {
|
|
596
|
+
if (state.busy || state.refsBusy) return;
|
|
597
|
+
const prompt = state.prompt.trim();
|
|
598
|
+
if (prompt === "") return;
|
|
599
|
+
|
|
600
|
+
const token = ++runToken;
|
|
601
|
+
const mode = state.mode;
|
|
602
|
+
set({
|
|
603
|
+
busy: true, error: "", startedAt: Date.now(), elapsed: 0,
|
|
604
|
+
vProgress: 0, vStatus: mode === "video" ? "提交中…" : "",
|
|
605
|
+
});
|
|
606
|
+
startTimer();
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
if (mode === "video") await runVideo(token, prompt);
|
|
610
|
+
else await runImage(token, prompt);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
if (token === runToken) set({ error: e && e.message ? e.message : String(e) });
|
|
613
|
+
} finally {
|
|
614
|
+
if (token === runToken) {
|
|
615
|
+
stopTimer();
|
|
616
|
+
set({ busy: false, elapsed: (Date.now() - state.startedAt) / 1000 });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
get: snapshot,
|
|
623
|
+
raw: () => state,
|
|
624
|
+
subscribe(fn) {
|
|
625
|
+
listeners.add(fn);
|
|
626
|
+
return () => listeners.delete(fn);
|
|
627
|
+
},
|
|
628
|
+
set,
|
|
629
|
+
loadHealth,
|
|
630
|
+
loadConfig,
|
|
631
|
+
saveConfig,
|
|
632
|
+
setCfgDraft(field, value) {
|
|
633
|
+
set({ cfgDraft: { ...state.cfgDraft, [field]: value }, cfgNotice: "" });
|
|
634
|
+
},
|
|
635
|
+
toggleCfg() { set({ cfgOpen: !state.cfgOpen }); },
|
|
636
|
+
run,
|
|
637
|
+
/* ---- 参考图动作 ---- */
|
|
638
|
+
addRefsFromFiles,
|
|
639
|
+
addRefFromResult,
|
|
640
|
+
removeRef,
|
|
641
|
+
clearRefs,
|
|
642
|
+
toggleRefPick,
|
|
643
|
+
maxRefs: MAX_REFS,
|
|
644
|
+
/** 切换主预览到历史里的某一张 */
|
|
645
|
+
show(entry) { set({ result: entry }); },
|
|
646
|
+
/**
|
|
647
|
+
* 清空界面上的生成结果。只清显示状态:不删磁盘文件、不动提示词和参数。
|
|
648
|
+
* 进行中的请求不清(否则结果回来又会写一遍,反而更乱)。
|
|
649
|
+
*/
|
|
650
|
+
clear() {
|
|
651
|
+
if (state.busy) return;
|
|
652
|
+
set({ result: null, history: [], error: "" });
|
|
653
|
+
},
|
|
654
|
+
dispose() {
|
|
655
|
+
stopTimer();
|
|
656
|
+
runToken += 1;
|
|
657
|
+
listeners.clear();
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function getStore() {
|
|
663
|
+
let s = globalThis[GLOBAL_KEY];
|
|
664
|
+
if (!s) { s = createStore(); globalThis[GLOBAL_KEY] = s; }
|
|
665
|
+
return s;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* ---------- 媒体显示:视频直接用 <video>,图片带加载兜底 ---------- */
|
|
669
|
+
function MediaBox({ entry, big }) {
|
|
670
|
+
const isVideo = entry !== null && entry.kind === "video";
|
|
671
|
+
const name = entry === null ? null : entry.filename;
|
|
672
|
+
|
|
673
|
+
const [src, setSrc] = React.useState(name === null ? null : srcOf(name));
|
|
674
|
+
const [failed, setFailed] = React.useState(false);
|
|
675
|
+
const [triedBlob, setTriedBlob] = React.useState(false);
|
|
676
|
+
const objUrlRef = React.useRef(null);
|
|
677
|
+
|
|
678
|
+
React.useEffect(() => {
|
|
679
|
+
setSrc(name === null ? null : srcOf(name));
|
|
680
|
+
setFailed(false);
|
|
681
|
+
setTriedBlob(false);
|
|
682
|
+
return () => {
|
|
683
|
+
if (objUrlRef.current !== null) {
|
|
684
|
+
try { URL.revokeObjectURL(objUrlRef.current); } catch { /* ignore */ }
|
|
685
|
+
objUrlRef.current = null;
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}, [name]);
|
|
689
|
+
|
|
690
|
+
if (entry === null || name === null) {
|
|
691
|
+
return React.createElement("div", { className: "agn-shot" },
|
|
692
|
+
React.createElement("div", { className: "agn-shot-msg" }, "还没有生成结果"));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const onError = async () => {
|
|
696
|
+
if (isVideo || triedBlob) { setFailed(true); return; }
|
|
697
|
+
setTriedBlob(true);
|
|
698
|
+
try {
|
|
699
|
+
const res = await fetch(srcOf(name));
|
|
700
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
701
|
+
const buf = await res.arrayBuffer();
|
|
702
|
+
if (buf.byteLength === 0) throw new Error("0 字节");
|
|
703
|
+
const url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
|
|
704
|
+
objUrlRef.current = url;
|
|
705
|
+
setSrc(url);
|
|
706
|
+
} catch {
|
|
707
|
+
setFailed(true);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
if (failed) {
|
|
712
|
+
return React.createElement("div", { className: "agn-shot" },
|
|
713
|
+
React.createElement("div", { className: "agn-shot-msg" },
|
|
714
|
+
"已生成,但界面加载失败。\n文件路径:",
|
|
715
|
+
React.createElement("span", { className: "agn-code" }, entry.path || name)));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return React.createElement("div", { className: "agn-shot" + (big ? " agn-shot-big" : "") },
|
|
719
|
+
isVideo
|
|
720
|
+
? React.createElement("video", {
|
|
721
|
+
src,
|
|
722
|
+
controls: true,
|
|
723
|
+
playsInline: true,
|
|
724
|
+
preload: "metadata",
|
|
725
|
+
onError: () => setFailed(true),
|
|
726
|
+
})
|
|
727
|
+
: React.createElement("img", { src, alt: "生成结果", onError })
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/* ---------- 侧栏图标 ---------- */
|
|
732
|
+
function Glyph(props) {
|
|
733
|
+
const size = typeof props.size === "number" ? props.size : 18;
|
|
734
|
+
return React.createElement(
|
|
735
|
+
"svg",
|
|
736
|
+
{
|
|
737
|
+
width: size, height: size, viewBox: "0 0 24 24", fill: "none",
|
|
738
|
+
stroke: "currentColor", strokeWidth: props.active ? 2 : 1.7,
|
|
739
|
+
strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
|
|
740
|
+
},
|
|
741
|
+
React.createElement("rect", { key: "b", x: 2.5, y: 6.5, width: 19, height: 14, rx: 3 }),
|
|
742
|
+
React.createElement("circle", { key: "c", cx: 12, cy: 13.5, r: 4 }),
|
|
743
|
+
React.createElement("path", { key: "t", d: "M8.5 6.5 9.6 4.2h4.8l1.1 2.3" })
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/* ---------- 主页面 ---------- */
|
|
748
|
+
function Panel() {
|
|
749
|
+
const store = getStore();
|
|
750
|
+
const [s, setSnap] = React.useState(() => store.get());
|
|
751
|
+
|
|
752
|
+
React.useEffect(() => {
|
|
753
|
+
setSnap(store.get());
|
|
754
|
+
const off = store.subscribe(() => setSnap(store.get()));
|
|
755
|
+
return () => { off(); };
|
|
756
|
+
}, []);
|
|
757
|
+
|
|
758
|
+
React.useEffect(() => { store.loadHealth(); store.loadConfig(); }, []);
|
|
759
|
+
|
|
760
|
+
const busy = s.busy;
|
|
761
|
+
const isVideo = s.mode === "video";
|
|
762
|
+
const key = s.health && s.health.key ? s.health.key : null;
|
|
763
|
+
const keyBad = key !== null && key.present !== true;
|
|
764
|
+
const shown = s.result;
|
|
765
|
+
const hasRefs = s.refs.length > 0;
|
|
766
|
+
const refsFull = s.refs.length >= MAX_REFS;
|
|
767
|
+
const imageHistory = s.history.filter((h) => h.kind === "image");
|
|
768
|
+
const fileInput = React.useRef(null);
|
|
769
|
+
|
|
770
|
+
const onKeyDown = (e) => {
|
|
771
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); store.run(); }
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const buttonLabel = busy
|
|
775
|
+
? (isVideo ? "生成视频… " : (hasRefs ? "图生图中… " : "生成中… ")) + (s.elapsed > 0 ? s.elapsed.toFixed(0) + "s" : "")
|
|
776
|
+
: (isVideo ? "生成视频" : (hasRefs ? "图生图" : "生成"));
|
|
777
|
+
|
|
778
|
+
const modeBtn = (mode, text) => React.createElement("button", {
|
|
779
|
+
key: mode,
|
|
780
|
+
type: "button",
|
|
781
|
+
className: "agn-mode" + (s.mode === mode ? " agn-mode-on" : ""),
|
|
782
|
+
disabled: busy,
|
|
783
|
+
onClick: () => store.set({ mode }),
|
|
784
|
+
}, text);
|
|
785
|
+
|
|
786
|
+
/* ---- 参数区:图片走档位+宽高比,视频走秒数+画幅(720P 固定) ---- */
|
|
787
|
+
const params = isVideo
|
|
788
|
+
? [
|
|
789
|
+
React.createElement("span", { key: "l1", className: "agn-label" }, "时长"),
|
|
790
|
+
React.createElement("select", {
|
|
791
|
+
key: "s1", className: "agn-select", value: s.vSeconds, disabled: busy,
|
|
792
|
+
onChange: (e) => store.set({ vSeconds: e.target.value }),
|
|
793
|
+
}, VIDEO_SECONDS.map((x) => React.createElement("option", { key: x, value: x }, x + " 秒"))),
|
|
794
|
+
React.createElement("span", { key: "l2", className: "agn-label" }, "画幅"),
|
|
795
|
+
React.createElement("select", {
|
|
796
|
+
key: "s2", className: "agn-select", value: s.vRatio, disabled: busy,
|
|
797
|
+
onChange: (e) => store.set({ vRatio: e.target.value }),
|
|
798
|
+
}, VIDEO_RATIOS.map((x) => React.createElement("option", { key: x, value: x }, x))),
|
|
799
|
+
React.createElement("span", { key: "m", className: "agn-meta" }, "720P"),
|
|
800
|
+
]
|
|
801
|
+
: [
|
|
802
|
+
React.createElement("span", { key: "l1", className: "agn-label" }, "尺寸"),
|
|
803
|
+
React.createElement("select", {
|
|
804
|
+
key: "s1", className: "agn-select", value: s.size, disabled: busy,
|
|
805
|
+
onChange: (e) => store.set({ size: e.target.value }),
|
|
806
|
+
}, SIZE_OPTIONS.map((x) => React.createElement("option", { key: x, value: x }, x))),
|
|
807
|
+
React.createElement("span", { key: "l2", className: "agn-label" }, "比例"),
|
|
808
|
+
React.createElement("select", {
|
|
809
|
+
key: "s2", className: "agn-select", value: s.ratio, disabled: busy,
|
|
810
|
+
onChange: (e) => store.set({ ratio: e.target.value }),
|
|
811
|
+
}, RATIO_OPTIONS.map((x) => React.createElement("option", { key: x, value: x }, x))),
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
const resultLabel = shown === null
|
|
815
|
+
? "生成结果(还没有)"
|
|
816
|
+
: (shown.kind === "video" ? "生成结果 · 视频" : "生成结果 · 图片");
|
|
817
|
+
|
|
818
|
+
return React.createElement("div", { className: "agn-root" },
|
|
819
|
+
/* 隐藏的文件选择器:由「+ 本地图片」按钮触发,支持多选 */
|
|
820
|
+
React.createElement("input", {
|
|
821
|
+
ref: fileInput,
|
|
822
|
+
type: "file",
|
|
823
|
+
accept: "image/png,image/jpeg,image/webp,image/gif",
|
|
824
|
+
multiple: true,
|
|
825
|
+
style: { display: "none" },
|
|
826
|
+
onChange: (e) => {
|
|
827
|
+
const el = e.target;
|
|
828
|
+
const files = el.files;
|
|
829
|
+
// 关键:等文件真正读完再清空 input。
|
|
830
|
+
// 之前是「发起读取后立刻 el.value = ''」,Chromium 下会把刚选的文件
|
|
831
|
+
// 的底层数据一起释放,读取中途失败 → 这一次添加被整单丢掉。
|
|
832
|
+
Promise.resolve(store.addRefsFromFiles(files))
|
|
833
|
+
.catch(() => {})
|
|
834
|
+
.finally(() => { try { el.value = ""; } catch { /* input 可能已卸载 */ } });
|
|
835
|
+
},
|
|
836
|
+
}),
|
|
837
|
+
|
|
838
|
+
React.createElement("div", { className: "agn-head" },
|
|
839
|
+
React.createElement("span", { className: "agn-title" }, "Agnes 创作"),
|
|
840
|
+
React.createElement("span", { className: "agn-meta" },
|
|
841
|
+
isVideo
|
|
842
|
+
? (s.health && s.health.videoModel ? s.health.videoModel : "agnes-video-2.5-flash")
|
|
843
|
+
: (s.health && s.health.model ? s.health.model : "agnes-image-2.5-flash"))
|
|
844
|
+
),
|
|
845
|
+
|
|
846
|
+
React.createElement("div", { className: "agn-card" },
|
|
847
|
+
React.createElement("div", { className: "agn-modes" },
|
|
848
|
+
modeBtn("image", "图片"),
|
|
849
|
+
modeBtn("video", "视频")
|
|
850
|
+
),
|
|
851
|
+
|
|
852
|
+
/* ---- 参考图(图生图 / 多图合成):本地文件 + 生成结果两种来源 ---- */
|
|
853
|
+
!isVideo
|
|
854
|
+
? React.createElement("div", { className: "agn-refs" },
|
|
855
|
+
React.createElement("div", { className: "agn-ref-head" },
|
|
856
|
+
React.createElement("span", { className: "agn-label" }, "参考图"),
|
|
857
|
+
React.createElement("span", { className: "agn-tag" },
|
|
858
|
+
s.refs.length + " / " + MAX_REFS),
|
|
859
|
+
s.refsBusy ? React.createElement("span", { className: "agn-hint" }, "处理中…") : null,
|
|
860
|
+
s.refs.length > 0
|
|
861
|
+
? React.createElement("button", {
|
|
862
|
+
type: "button", className: "agn-btn-ghost", disabled: busy,
|
|
863
|
+
onClick: () => store.clearRefs(),
|
|
864
|
+
}, "清空参考图")
|
|
865
|
+
: null
|
|
866
|
+
),
|
|
867
|
+
React.createElement("div", { className: "agn-ref-strip" },
|
|
868
|
+
s.refs.map((r) => React.createElement("div", {
|
|
869
|
+
key: r.id,
|
|
870
|
+
className: "agn-ref",
|
|
871
|
+
title: r.name + " · " + (r.bytes / 1024).toFixed(0) + " KB · " +
|
|
872
|
+
(r.from === "result" ? "来自生成结果" : "来自本地"),
|
|
873
|
+
},
|
|
874
|
+
React.createElement("img", { src: r.src, alt: r.name }),
|
|
875
|
+
React.createElement("button", {
|
|
876
|
+
type: "button", className: "agn-ref-del",
|
|
877
|
+
title: "移除这张参考图",
|
|
878
|
+
disabled: busy,
|
|
879
|
+
onClick: () => store.removeRef(r.id),
|
|
880
|
+
}, "×")
|
|
881
|
+
)),
|
|
882
|
+
React.createElement("button", {
|
|
883
|
+
type: "button", className: "agn-ref-add",
|
|
884
|
+
disabled: busy || refsFull,
|
|
885
|
+
title: "从本地磁盘选择一个或多个图片(PNG / JPEG / WebP / GIF)",
|
|
886
|
+
onClick: () => {
|
|
887
|
+
const el = fileInput.current;
|
|
888
|
+
if (el !== null) el.click();
|
|
889
|
+
},
|
|
890
|
+
}, "+ 本地图片"),
|
|
891
|
+
React.createElement("button", {
|
|
892
|
+
type: "button", className: "agn-ref-add",
|
|
893
|
+
disabled: busy || refsFull || imageHistory.length === 0,
|
|
894
|
+
title: imageHistory.length === 0 ? "还没有生成过图片" : "从「生成结果」里挑一张",
|
|
895
|
+
onClick: () => store.toggleRefPick(),
|
|
896
|
+
}, "+ 生成结果")
|
|
897
|
+
),
|
|
898
|
+
|
|
899
|
+
/* 从生成结果挑图:只列图片(视频不能当参考图) */
|
|
900
|
+
s.refPickOpen
|
|
901
|
+
? React.createElement("div", { className: "agn-ref-pick" },
|
|
902
|
+
imageHistory.length === 0
|
|
903
|
+
? React.createElement("span", { className: "agn-hint" }, "还没有生成过图片")
|
|
904
|
+
: imageHistory.map((h) => React.createElement("button", {
|
|
905
|
+
key: "pick-" + h.filename,
|
|
906
|
+
type: "button",
|
|
907
|
+
title: h.filename,
|
|
908
|
+
onClick: () => store.addRefFromResult(h),
|
|
909
|
+
}, React.createElement("img", {
|
|
910
|
+
src: srcOf(h.filename), alt: h.filename, loading: "lazy",
|
|
911
|
+
})))
|
|
912
|
+
)
|
|
913
|
+
: null,
|
|
914
|
+
|
|
915
|
+
React.createElement("div", { className: "agn-note" },
|
|
916
|
+
hasRefs
|
|
917
|
+
? "将按参考图重绘(" + s.refs.length + " 张" +
|
|
918
|
+
(s.refs.length > 1 ? ",多图合成" : "") + ")——提示词写「想怎么改」"
|
|
919
|
+
: "不加参考图 = 纯文生图;加 1 张 = 图生图;加多张 = 多图合成"),
|
|
920
|
+
s.refsError !== "" ? React.createElement("div", { className: "agn-err" }, s.refsError) : null
|
|
921
|
+
)
|
|
922
|
+
: null,
|
|
923
|
+
|
|
924
|
+
React.createElement("div", { className: "agn-label" }, "提示词"),
|
|
925
|
+
React.createElement("textarea", {
|
|
926
|
+
className: "agn-textarea",
|
|
927
|
+
value: s.prompt,
|
|
928
|
+
placeholder: isVideo
|
|
929
|
+
? "描述镜头与动作:主体 + 动作 + 镜头运动 + 光线氛围…\n(Cmd/Ctrl + Enter 生成)"
|
|
930
|
+
: "描述你想要的画面:主体 + 场景 + 风格 + 光照 + 构图 + 质量…\n(Cmd/Ctrl + Enter 生成)",
|
|
931
|
+
onChange: (e) => store.set({ prompt: e.target.value }),
|
|
932
|
+
onKeyDown,
|
|
933
|
+
disabled: busy,
|
|
934
|
+
}),
|
|
935
|
+
React.createElement("div", { className: "agn-row" },
|
|
936
|
+
params,
|
|
937
|
+
React.createElement("button", {
|
|
938
|
+
className: "agn-btn", type: "button",
|
|
939
|
+
disabled: busy || s.refsBusy || s.prompt.trim() === "" || keyBad,
|
|
940
|
+
onClick: () => store.run(),
|
|
941
|
+
}, buttonLabel)
|
|
942
|
+
),
|
|
943
|
+
|
|
944
|
+
/* 视频是异步任务:给出阶段与百分比,避免只看到一个跳动的秒数 */
|
|
945
|
+
busy && isVideo
|
|
946
|
+
? React.createElement("div", null,
|
|
947
|
+
React.createElement("div", { className: "agn-bar" },
|
|
948
|
+
React.createElement("div", {
|
|
949
|
+
className: "agn-bar-fill",
|
|
950
|
+
style: { width: Math.max(3, Math.min(100, s.vProgress)) + "%" },
|
|
951
|
+
})),
|
|
952
|
+
React.createElement("div", { className: "agn-hint" },
|
|
953
|
+
(s.vStatus || "排队中…") + " " + Math.max(0, Math.round(s.vProgress)) + "%"
|
|
954
|
+
+ (s.videoId ? " 任务 " + String(s.videoId).slice(0, 18) : ""))
|
|
955
|
+
)
|
|
956
|
+
: null,
|
|
957
|
+
|
|
958
|
+
keyBad
|
|
959
|
+
? React.createElement("div", { className: "agn-err" },
|
|
960
|
+
"未找到 Key:" + (key.file || "") + " —— 把 Key 写进该文件后可立即使用")
|
|
961
|
+
: null,
|
|
962
|
+
s.error !== "" ? React.createElement("div", { className: "agn-err" }, s.error) : null,
|
|
963
|
+
busy && !isVideo
|
|
964
|
+
? React.createElement("div", { className: "agn-hint" },
|
|
965
|
+
"正在生成:可切到别的面板,回来仍会接着显示结果(请求不会中断)")
|
|
966
|
+
: null
|
|
967
|
+
),
|
|
968
|
+
|
|
969
|
+
/* ---- 结果区 ---- */
|
|
970
|
+
React.createElement("div", { className: "agn-card" },
|
|
971
|
+
React.createElement("div", { className: "agn-card-head" },
|
|
972
|
+
React.createElement("div", { className: "agn-label" }, resultLabel),
|
|
973
|
+
React.createElement("button", {
|
|
974
|
+
type: "button",
|
|
975
|
+
className: "agn-btn-ghost",
|
|
976
|
+
disabled: busy || (s.result === null && s.history.length === 0),
|
|
977
|
+
title: "清空界面上的生成结果显示(不会删除已保存到桌面的文件)",
|
|
978
|
+
onClick: () => store.clear(),
|
|
979
|
+
}, "清空")
|
|
980
|
+
),
|
|
981
|
+
|
|
982
|
+
shown !== null
|
|
983
|
+
? React.createElement(MediaBox, { entry: shown, big: true })
|
|
984
|
+
: React.createElement("div", { className: "agn-shot" },
|
|
985
|
+
React.createElement("div", { className: "agn-shot-msg" },
|
|
986
|
+
busy ? (isVideo ? "正在生成视频…" : "正在生成图片…")
|
|
987
|
+
: "输入提示词后点「生成」,结果会显示在这里")),
|
|
988
|
+
|
|
989
|
+
shown !== null
|
|
990
|
+
? React.createElement("div", { className: "agn-hint" },
|
|
991
|
+
React.createElement("span", null,
|
|
992
|
+
shown.filename + " · " + (shown.bytes / 1024 / 1024 >= 1
|
|
993
|
+
? (shown.bytes / 1024 / 1024).toFixed(2) + " MB"
|
|
994
|
+
: (shown.bytes / 1024).toFixed(0) + " KB")
|
|
995
|
+
+ " · " + shown.size + "/" + shown.ratio
|
|
996
|
+
+ (shown.seconds ? " · " + shown.seconds + " 秒" : "")
|
|
997
|
+
+ (shown.mode === "img2img" ? " · 图生图 " + (shown.refCount || 0) + " 张参考" : "")
|
|
998
|
+
+ " · 用时 " + shown.elapsedSec + "s"),
|
|
999
|
+
React.createElement("br"),
|
|
1000
|
+
React.createElement("span", null, "已保存到 "),
|
|
1001
|
+
React.createElement("span", { className: "agn-code" }, shown.path || ""))
|
|
1002
|
+
: null
|
|
1003
|
+
),
|
|
1004
|
+
|
|
1005
|
+
/* ---- 历史 ---- */
|
|
1006
|
+
s.history.length > 1
|
|
1007
|
+
? React.createElement("div", { className: "agn-card agn-hist" },
|
|
1008
|
+
React.createElement("div", { className: "agn-label" }, "最近生成"),
|
|
1009
|
+
React.createElement("div", { className: "agn-hist-strip" },
|
|
1010
|
+
s.history.map((h, i) => React.createElement("button", {
|
|
1011
|
+
key: h.filename + "-" + i,
|
|
1012
|
+
type: "button",
|
|
1013
|
+
className: "agn-thumb" + (shown && shown.filename === h.filename ? " agn-thumb-on" : ""),
|
|
1014
|
+
title: h.filename,
|
|
1015
|
+
onClick: () => store.show(h),
|
|
1016
|
+
},
|
|
1017
|
+
h.kind === "video"
|
|
1018
|
+
? React.createElement("div", { className: "agn-thumb-play" }, "▶")
|
|
1019
|
+
: React.createElement("img", { src: srcOf(h.filename), alt: h.filename, loading: "lazy" }),
|
|
1020
|
+
React.createElement("span", { className: "agn-thumb-cap" },
|
|
1021
|
+
h.kind === "video"
|
|
1022
|
+
? (h.seconds ? h.seconds + "s" : "视频")
|
|
1023
|
+
: (h.bytes / 1024).toFixed(0) + "KB")
|
|
1024
|
+
))
|
|
1025
|
+
)
|
|
1026
|
+
)
|
|
1027
|
+
: null,
|
|
1028
|
+
|
|
1029
|
+
/* ---- 设置:Key 文件路径 / 输出目录 ---- */
|
|
1030
|
+
React.createElement("div", { className: "agn-card" },
|
|
1031
|
+
React.createElement("div", { className: "agn-card-head" },
|
|
1032
|
+
React.createElement("div", { className: "agn-label" }, "设置"),
|
|
1033
|
+
React.createElement("button", {
|
|
1034
|
+
type: "button", className: "agn-btn-ghost", disabled: s.cfgSaving,
|
|
1035
|
+
onClick: () => store.toggleCfg(),
|
|
1036
|
+
}, s.cfgOpen ? "收起" : "展开")
|
|
1037
|
+
),
|
|
1038
|
+
|
|
1039
|
+
s.cfgOpen
|
|
1040
|
+
? React.createElement("div", { className: "agn-field" },
|
|
1041
|
+
React.createElement("div", { className: "agn-label" }, "Key 文件路径"),
|
|
1042
|
+
React.createElement("input", {
|
|
1043
|
+
className: "agn-input", type: "text", spellCheck: false,
|
|
1044
|
+
value: s.cfgDraft.keyFile,
|
|
1045
|
+
placeholder: s.cfgDefaults ? s.cfgDefaults.keyFile : "~/.agnes-key.txt",
|
|
1046
|
+
disabled: s.cfgSaving,
|
|
1047
|
+
onChange: (e) => store.setCfgDraft("keyFile", e.target.value),
|
|
1048
|
+
}),
|
|
1049
|
+
React.createElement("div", { className: "agn-note" },
|
|
1050
|
+
"只放 key 本身,别带 Bearer 前缀。留空 = 默认 ",
|
|
1051
|
+
s.cfgDefaults ? s.cfgDefaults.keyFile : "~/.agnes-key.txt")
|
|
1052
|
+
)
|
|
1053
|
+
: null,
|
|
1054
|
+
|
|
1055
|
+
s.cfgOpen
|
|
1056
|
+
? React.createElement("div", { className: "agn-field" },
|
|
1057
|
+
React.createElement("div", { className: "agn-label" }, "输出目录"),
|
|
1058
|
+
React.createElement("input", {
|
|
1059
|
+
className: "agn-input", type: "text", spellCheck: false,
|
|
1060
|
+
value: s.cfgDraft.outDir,
|
|
1061
|
+
placeholder: s.cfgDefaults ? s.cfgDefaults.outDir : "~/Desktop/Agnes出图",
|
|
1062
|
+
disabled: s.cfgSaving,
|
|
1063
|
+
onChange: (e) => store.setCfgDraft("outDir", e.target.value),
|
|
1064
|
+
}),
|
|
1065
|
+
React.createElement("div", { className: "agn-note" },
|
|
1066
|
+
"图片和视频都存这里,目录不存在会自动创建。留空 = 默认 ",
|
|
1067
|
+
s.cfgDefaults ? s.cfgDefaults.outDir : "~/Desktop/Agnes出图")
|
|
1068
|
+
)
|
|
1069
|
+
: null,
|
|
1070
|
+
|
|
1071
|
+
s.cfgOpen
|
|
1072
|
+
? React.createElement("div", { className: "agn-row" },
|
|
1073
|
+
React.createElement("button", {
|
|
1074
|
+
type: "button", className: "agn-btn", disabled: s.cfgSaving,
|
|
1075
|
+
onClick: () => store.saveConfig(),
|
|
1076
|
+
}, s.cfgSaving ? "保存中…" : "保存"),
|
|
1077
|
+
React.createElement("button", {
|
|
1078
|
+
type: "button", className: "agn-btn-ghost", disabled: s.cfgSaving,
|
|
1079
|
+
title: "把两个输入框清空,保存后即回退到默认路径",
|
|
1080
|
+
onClick: () => {
|
|
1081
|
+
store.setCfgDraft("keyFile", "");
|
|
1082
|
+
store.setCfgDraft("outDir", "");
|
|
1083
|
+
},
|
|
1084
|
+
}, "填回默认"),
|
|
1085
|
+
s.cfgNotice !== "" ? React.createElement("span", { className: "agn-okline" }, s.cfgNotice) : null
|
|
1086
|
+
)
|
|
1087
|
+
: null,
|
|
1088
|
+
|
|
1089
|
+
s.cfgError !== "" ? React.createElement("div", { className: "agn-err" }, s.cfgError) : null,
|
|
1090
|
+
|
|
1091
|
+
s.cfgOpen
|
|
1092
|
+
? React.createElement("div", { className: "agn-note" },
|
|
1093
|
+
"保存到 ",
|
|
1094
|
+
(s.health && s.health.configFile) || "~/.dsh/agnes-image/config.json")
|
|
1095
|
+
: null
|
|
1096
|
+
),
|
|
1097
|
+
|
|
1098
|
+
React.createElement("div", { className: "agn-hint" },
|
|
1099
|
+
s.health === null
|
|
1100
|
+
? "正在读取插件状态…"
|
|
1101
|
+
: s.health.ok
|
|
1102
|
+
? React.createElement("span", null,
|
|
1103
|
+
"输出目录 ",
|
|
1104
|
+
React.createElement("span", { className: "agn-code" }, s.health.outDir || ""),
|
|
1105
|
+
" Key:",
|
|
1106
|
+
React.createElement("span", { className: "agn-code" },
|
|
1107
|
+
key && key.present ? key.masked : "未就绪"))
|
|
1108
|
+
: "插件宿主半未就绪:" + (s.health.error || "未知错误")
|
|
1109
|
+
)
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/* ---------- 注册 ---------- */
|
|
1114
|
+
function apply(ctx) {
|
|
1115
|
+
ensureCss();
|
|
1116
|
+
|
|
1117
|
+
const slots = ctx.get("slots");
|
|
1118
|
+
if (slots === undefined) return;
|
|
1119
|
+
|
|
1120
|
+
slots.inject("main", () => slots.register(
|
|
1121
|
+
{ name: "main", key: ID },
|
|
1122
|
+
() => React.createElement(Panel)
|
|
1123
|
+
));
|
|
1124
|
+
|
|
1125
|
+
slots.inject("sidebar.panellist", () => slots.register(
|
|
1126
|
+
{ name: "sidebar.panellist", id: ID, order: 25, label: () => "Agnes创作" },
|
|
1127
|
+
(props) => React.createElement(Glyph, props)
|
|
1128
|
+
));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
exports.apply = apply;
|
|
1132
|
+
exports.inject = ["slots"];
|
|
1133
|
+
exports.__internals = { getStore, createStore, srcOf, shrinkImage, MAX_REFS };
|
|
1134
|
+
return module.exports;
|
|
1135
|
+
},
|
|
1136
|
+
});
|