dsh-agnes-create 0.4.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -6,10 +6,20 @@
6
6
  * POST /agnes/generate { prompt, size, ratio } → 生成并落盘
7
7
  *
8
8
  * 视频(异步任务制)
9
- * POST /agnes/video { prompt, seconds, ratio } → 提交,立刻返回 { videoId }
9
+ * POST /agnes/video { prompt, seconds, ratio, seed?, firstFrame?, lastFrame? }
10
+ * 提交,立刻返回 { videoId }
11
+ * 给了 firstFrame/lastFrame 就自动走 keyframe 模式
10
12
  * GET /agnes/video?id=… 查询一次状态;completed 时顺带下载落盘并返回 filename
11
13
  * (下载结果按 videoId 缓存,反复轮询不会重复下载)
12
14
  *
15
+ * 历史(宿主侧持久化,跨会话/重启都还在)
16
+ * GET /agnes/history 读回最近若干条
17
+ * POST /agnes/history 追加一条(生成完成后由前端调用)
18
+ * DELETE /agnes/history 清空
19
+ *
20
+ * 结果收尾
21
+ * POST /agnes/reveal { name } → 在访达里显示该文件(只能是输出目录内的文件)
22
+ *
13
23
  * 媒体
14
24
  * GET /agnes/media?name=… 把已生成的图片/视频流回浏览器,视频支持 Range
15
25
  * GET /agnes/image?name=… 兼容旧链接,等价于 /agnes/media
@@ -18,11 +28,18 @@
18
28
  * 也监听 internal/service 事件。
19
29
  */
20
30
 
31
+ import { execFile } from 'node:child_process';
32
+ import { basename, join, resolve as resolvePath } from 'node:path';
33
+
21
34
  import {
22
35
  MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
23
- DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES,
24
- generate, keyStatus, readMedia, resolvePaths, saveConfig,
36
+ DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES, MAX_VIDEO_REF_IMAGES, HISTORY_MAX,
37
+ generate, keyStatus, readKey, readMedia, resolvePaths, saveConfig, safeName,
25
38
  submitVideo, queryVideo, saveVideo,
39
+ readHistory, appendHistory, clearHistory,
40
+ listLibrary, fileExists,
41
+ listModels, IMAGE_MODELS, VIDEO_MODELS,
42
+ readTasks, writeTasks, TASK_ACTIVE, TASK_MAX,
26
43
  } from './agnes.js';
27
44
 
28
45
  export const name = 'dsh-agnes-create';
@@ -130,15 +147,370 @@ export function apply(ctx) {
130
147
  routeDisposers.push(webServer.register(route));
131
148
  };
132
149
 
150
+ /* ==========================================================================
151
+ 视频任务引擎
152
+ --------------------------------------------------------------------------
153
+ 以前是前端每 6 秒问一次上游;刷新、切面板、崩掉、重启都会丢进度。
154
+ 现在任务表在宿主进程里,落盘到 ~/.dsh/agnes-create/tasks.json,
155
+ 应用重启后把「排队中 / 生成中」的任务重新接管继续轮询。
156
+ ========================================================================== */
157
+
158
+ /** videoId → 已下载的本地结果,避免每次轮询重复下载几十 MB */
159
+ const downloaded = new Map();
160
+
161
+ const POLL_INTERVAL_MS = 6000;
162
+ /** 单个任务最多跟踪 40 分钟,超了就判失败,免得永远挂着 */
163
+ const TASK_DEADLINE_MS = 40 * 60 * 1000;
164
+
165
+ /** id → 任务。内存是权威副本,磁盘只是持久化镜像。 */
166
+ const tasks = new Map();
167
+ /** 正在查询中的 id:防止上一轮还没回来就又发一轮 */
168
+ const inflight = new Set();
169
+ let ticker = null;
170
+ let engineStarted = false;
171
+
172
+ const nowIso = () => new Date().toISOString();
173
+
174
+ const persistTasks = async () => {
175
+ try {
176
+ const list = [...tasks.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
177
+ await writeTasks(list);
178
+ } catch {
179
+ // 落盘失败不影响内存里的任务,下一轮会再试
180
+ }
181
+ };
182
+
183
+ const patchTask = (id, patch) => {
184
+ const cur = tasks.get(id);
185
+ if (cur === undefined) return null;
186
+ const next = { ...cur, ...patch, updatedAt: nowIso() };
187
+ tasks.set(id, next);
188
+ return next;
189
+ };
190
+
191
+ const isActiveTask = (task) => TASK_ACTIVE.includes(task.status);
192
+
193
+ /** 查一次上游,把结果写回任务;限流 / 网络错误不判死,留给下一轮。 */
194
+ const pollTaskOnce = async (task) => {
195
+ if (task.videoId === null || inflight.has(task.id)) return;
196
+ inflight.add(task.id);
197
+ try {
198
+ const st = await queryVideo({ videoId: task.videoId });
199
+
200
+ if (st.status === 'completed') {
201
+ let saved = downloaded.get(task.videoId);
202
+ if (saved === undefined) {
203
+ if (st.url === null) throw new Error('任务已完成但没有返回视频地址');
204
+ saved = await saveVideo({ url: st.url });
205
+ downloaded.set(task.videoId, saved);
206
+ }
207
+ patchTask(task.id, {
208
+ status: 'completed',
209
+ progress: 100,
210
+ filename: saved.filename,
211
+ path: saved.path,
212
+ bytes: saved.bytes,
213
+ error: null,
214
+ });
215
+ // 生成完顺手记一条历史,和面板里的记录保持一致
216
+ try {
217
+ await appendHistory({
218
+ filename: saved.filename,
219
+ kind: 'video',
220
+ bytes: saved.bytes,
221
+ path: saved.path,
222
+ size: VIDEO_SIZE,
223
+ ratio: task.ratio,
224
+ seconds: st.seconds ?? task.seconds,
225
+ mode: task.mode,
226
+ prompt: task.prompt,
227
+ createdAt: nowIso(),
228
+ });
229
+ } catch {
230
+ // 历史写失败不影响任务本身
231
+ }
232
+ } else if (st.status === 'failed') {
233
+ patchTask(task.id, { status: 'failed', error: st.error ?? '生成失败' });
234
+ } else if (Date.now() - Date.parse(task.createdAt) > TASK_DEADLINE_MS) {
235
+ patchTask(task.id, { status: 'failed', error: '等待超时(40 分钟)' });
236
+ } else {
237
+ patchTask(task.id, {
238
+ status: 'running',
239
+ progress: typeof st.progress === 'number' ? st.progress : task.progress,
240
+ });
241
+ }
242
+
243
+ await persistTasks();
244
+ } catch {
245
+ // 单次查询失败(429 / 503 / 网络抖动)保持原状,下一轮继续
246
+ } finally {
247
+ inflight.delete(task.id);
248
+ }
249
+ };
250
+
251
+ const tickAll = async () => {
252
+ const active = [...tasks.values()].filter(isActiveTask);
253
+ for (const task of active) {
254
+ await pollTaskOnce(task);
255
+ }
256
+ };
257
+
258
+ const startEngine = () => {
259
+ if (engineStarted) return;
260
+ engineStarted = true;
261
+ void (async () => {
262
+ try {
263
+ const saved = await readTasks();
264
+ for (const task of saved) {
265
+ if (tasks.has(task.id)) continue;
266
+ // 重启前还在跑的,标记成 running 继续跟踪
267
+ tasks.set(task.id, isActiveTask(task) ? { ...task, status: 'running' } : task);
268
+ }
269
+ if (saved.length > 0) await persistTasks();
270
+ } catch {
271
+ // 读不回来就当没有旧任务
272
+ }
273
+ ticker = setInterval(() => {
274
+ void tickAll();
275
+ }, POLL_INTERVAL_MS);
276
+ void tickAll();
277
+ })();
278
+ };
279
+
280
+ ctx.effect(
281
+ () => () => {
282
+ if (ticker !== null) {
283
+ clearInterval(ticker);
284
+ ticker = null;
285
+ }
286
+ engineStarted = false;
287
+ },
288
+ 'dsh-agnes-create: task engine',
289
+ );
290
+
291
+ startEngine();
292
+
293
+ /**
294
+ * 新建一个视频任务:提交给上游 + 记进任务表。
295
+ * HTTP 路由和模型工具走同一条路径,免得两边的字段慢慢长歪。
296
+ */
297
+ const enqueueVideoTask = async (params) => {
298
+ const cfg = await resolvePaths();
299
+ const sub = await submitVideo({
300
+ prompt: params.prompt,
301
+ seconds: params.seconds,
302
+ ratio: params.ratio,
303
+ model: params.model ?? cfg.videoModel,
304
+ seed: params.seed,
305
+ firstFrame: params.firstFrame,
306
+ lastFrame: params.lastFrame,
307
+ referenceImages: params.referenceImages,
308
+ });
309
+ const id = 'v' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
310
+ const task = {
311
+ id,
312
+ kind: 'video',
313
+ status: 'queued',
314
+ progress: 0,
315
+ videoId: sub.videoId,
316
+ mode: sub.mode,
317
+ prompt: params.prompt,
318
+ seconds: typeof params.seconds === 'string' ? params.seconds : null,
319
+ ratio: typeof params.ratio === 'string' ? params.ratio : null,
320
+ seed: typeof params.seed === 'number' ? params.seed : null,
321
+ filename: null,
322
+ path: null,
323
+ bytes: null,
324
+ error: null,
325
+ createdAt: nowIso(),
326
+ updatedAt: nowIso(),
327
+ };
328
+ tasks.set(id, task);
329
+ // 只留最近 TASK_MAX 条:先扔最老的、已经结束的
330
+ if (tasks.size > TASK_MAX) {
331
+ const done = [...tasks.values()]
332
+ .filter((t) => !isActiveTask(t))
333
+ .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
334
+ while (tasks.size > TASK_MAX && done.length > 0) {
335
+ const victim = done.shift();
336
+ tasks.delete(victim.id);
337
+ }
338
+ }
339
+ await persistTasks();
340
+ return task;
341
+ };
342
+
343
+ /* ==========================================================================
344
+ 模型工具:让 agent 在对话里直接出图 / 出视频
345
+ --------------------------------------------------------------------------
346
+ `tools` 是可选服务(纯终端 profile 没有),所以走 ctx.inject 条件注入;
347
+ SDK 用动态 import —— 宿主没提供 @deepseek-ai/dsh-tools 时只是少这组工具,
348
+ 面板和路由照常工作,插件不会因此激活失败。
349
+ ========================================================================== */
350
+ const registerAgentTools = () => {
351
+ // 老宿主 / 测试桩可能没有 ctx.inject:没有就不注册,面板照常可用
352
+ if (typeof ctx.inject !== 'function') return;
353
+ ctx.inject(['tools'], (sub) => {
354
+ if (sub === null || sub === undefined || sub.tools === undefined) return;
355
+ let disposed = false;
356
+ ctx.effect(() => () => {
357
+ disposed = true;
358
+ });
359
+
360
+ void import('@deepseek-ai/dsh-tools')
361
+ .then(({ defineTool }) => {
362
+ if (disposed) return;
363
+ const textOutput = {
364
+ schema: { type: 'string' },
365
+ render: (_args, value) => [{ type: 'text', text: value }],
366
+ };
367
+
368
+ const disposables = [
369
+ sub.tools.register(
370
+ defineTool({
371
+ name: 'agnes_image',
372
+ description:
373
+ '用 Agnes 图像模型(agnes-image-2.5-flash)生成或改写一张图。支持文生图与图生图/多图合成:给 images 就是参考图(最多 8 张,http(s) 链接或 data:image/…;base64)。图片保存在本机输出目录,返回文件路径、大小与生成方式。',
374
+ parameters: {
375
+ prompt: { type: 'string', required: true, description: '画面描述,越具体越好(主体、风格、光线、构图)。' },
376
+ size: { type: 'string', enum: SIZES, description: '分辨率档位,默认 1K。' },
377
+ ratio: { type: 'string', enum: RATIOS, description: '画幅比例,默认 1:1。' },
378
+ images: {
379
+ type: 'array',
380
+ items: { type: 'string' },
381
+ description: `参考图,最多 ${MAX_REF_IMAGES} 张。只接受 http(s) 链接或 data:image/…;base64,本地图请先转成 data URI。`,
382
+ },
383
+ },
384
+ output: textOutput,
385
+ timeoutMs: 360_000,
386
+ async execute(args) {
387
+ const cfg = await resolvePaths();
388
+ const r = await generate({
389
+ prompt: args.prompt,
390
+ size: args.size,
391
+ ratio: args.ratio,
392
+ images: args.images,
393
+ model: cfg.imageModel,
394
+ });
395
+ return [
396
+ `已生成图片:${r.path}`,
397
+ `文件:${r.filename}(${Math.round(r.bytes / 1024)} KB)`,
398
+ `模式:${r.mode}${r.refCount > 0 ? `(参考图 ${r.refCount} 张)` : ''}`,
399
+ `耗时:${r.elapsedSec}s`,
400
+ ].join('\n');
401
+ },
402
+ }),
403
+ ),
404
+
405
+ sub.tools.register(
406
+ defineTool({
407
+ name: 'agnes_video',
408
+ description:
409
+ '提交一个 Agnes 视频生成任务(agnes-video-2.5-flash,720P,4–12 秒)。立刻返回任务号,不会阻塞等待;进度用 agnes_video_status 查。三种模式自动选择:给 first_frame / last_frame 走关键帧(图生视频),只给 reference_images 走参考生视频(最多 5 张),都不给是纯文生视频。图片同样接受 http(s) 与 data:image/…;base64。',
410
+ parameters: {
411
+ prompt: { type: 'string', required: true, description: '视频内容描述,包含镜头运动与氛围更好。' },
412
+ seconds: { type: 'string', enum: VIDEO_SECONDS, description: '时长(秒),默认 5。' },
413
+ ratio: { type: 'string', enum: VIDEO_RATIOS, description: '画幅比例,默认 16:9。' },
414
+ seed: { type: 'number', description: '可选的非负整数种子,同提示词 + 同 seed 更容易复现。' },
415
+ first_frame: { type: 'string', description: '首帧图片(http(s) 或 data:image/…;base64)。给了就走图生视频。' },
416
+ last_frame: { type: 'string', description: '尾帧图片,与 first_frame 至少给一个才走关键帧模式。' },
417
+ reference_images: {
418
+ type: 'array',
419
+ items: { type: 'string' },
420
+ description: `参考图,最多 ${MAX_VIDEO_REF_IMAGES} 张(参考风格 / 主体 / 构图,不是起止画面)。已给关键帧时不生效。`,
421
+ },
422
+ },
423
+ output: textOutput,
424
+ timeoutMs: 120_000,
425
+ async execute(args) {
426
+ const task = await enqueueVideoTask({
427
+ prompt: args.prompt,
428
+ seconds: args.seconds,
429
+ ratio: args.ratio,
430
+ seed: args.seed,
431
+ firstFrame: args.first_frame,
432
+ lastFrame: args.last_frame,
433
+ referenceImages: args.reference_images,
434
+ });
435
+ return [
436
+ `已提交视频任务:${task.id}`,
437
+ `上游任务:${task.videoId}`,
438
+ `模式:${task.mode}`,
439
+ '宿主会在后台轮询并在完成后自动下载到输出目录;用 agnes_video_status 查进度。',
440
+ ].join('\n');
441
+ },
442
+ }),
443
+ ),
444
+
445
+ sub.tools.register(
446
+ defineTool({
447
+ name: 'agnes_video_status',
448
+ description:
449
+ '查询 Agnes 视频任务进度。给 task_id 查单个任务(含完成后的文件路径);不给就列出最近的任务与正在跑的数量。',
450
+ parameters: {
451
+ task_id: { type: 'string', description: 'agnes_video 返回的任务号,例如 vmwdik0rakuix。' },
452
+ },
453
+ output: textOutput,
454
+ timeoutMs: 30_000,
455
+ async execute(args) {
456
+ if (typeof args.task_id === 'string' && args.task_id !== '') {
457
+ let task = tasks.get(args.task_id);
458
+ if (task === undefined) {
459
+ // 引擎还没回灌完时,退回去读一次磁盘
460
+ const saved = await readTasks().catch(() => []);
461
+ task = saved.find((t) => t.id === args.task_id);
462
+ }
463
+ if (task === undefined) return `没有这个任务:${args.task_id}`;
464
+ const lines = [
465
+ `任务:${task.id}`,
466
+ `状态:${task.status}(${task.progress ?? 0}%)`,
467
+ `模式:${task.mode}`,
468
+ `提示词:${task.prompt}`,
469
+ ];
470
+ if (task.filename !== null && task.filename !== undefined) {
471
+ lines.push(`文件:${task.path ?? task.filename}`);
472
+ }
473
+ if (task.error !== null && task.error !== undefined) lines.push(`错误:${task.error}`);
474
+ return lines.join('\n');
475
+ }
476
+ const list = [...tasks.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
477
+ if (list.length === 0) return '还没有视频任务。';
478
+ const active = list.filter(isActiveTask).length;
479
+ return [
480
+ `共 ${list.length} 个任务,其中 ${active} 个正在跑:`,
481
+ ...list
482
+ .slice(0, 10)
483
+ .map((t) => `- ${t.id} ${t.status} ${t.progress ?? 0}% ${t.prompt.slice(0, 40)}`),
484
+ ].join('\n');
485
+ },
486
+ }),
487
+ ),
488
+ ];
489
+
490
+ ctx.effect(() => () => {
491
+ for (const dispose of disposables) {
492
+ try {
493
+ dispose();
494
+ } catch {
495
+ // 已经卸载过就算了
496
+ }
497
+ }
498
+ });
499
+ })
500
+ .catch(() => {
501
+ // 宿主没有 @deepseek-ai/dsh-tools:模型侧少了三个工具,面板不受影响
502
+ });
503
+ });
504
+ };
505
+
506
+ registerAgentTools();
507
+
133
508
  const registerRoutes = (value) => {
134
509
  if (registered) return;
135
510
  const webServer = value ?? ctx.reflect.get('webServer', false);
136
511
  if (webServer === undefined) return;
137
512
  registered = true;
138
513
 
139
- /** videoId → 已下载的本地结果,避免每次轮询重复下载几十 MB */
140
- const downloaded = new Map();
141
-
142
514
  addRoute(webServer, {
143
515
  kind: 'exact',
144
516
  path: '/agnes/health',
@@ -148,8 +520,13 @@ export function apply(ctx) {
148
520
  const k = await keyStatus(cfg.keyFile);
149
521
  sendJson(res, {
150
522
  ok: true,
151
- model: MODEL,
152
- videoModel: VIDEO_MODEL,
523
+ model: cfg.imageModel,
524
+ videoModel: cfg.videoModel,
525
+ models: {
526
+ image: IMAGE_MODELS,
527
+ video: VIDEO_MODELS,
528
+ current: { image: cfg.imageModel, video: cfg.videoModel },
529
+ },
153
530
  endpoint: `${DEFAULT_BASE}/images/generations`,
154
531
  videoEndpoint: `${DEFAULT_BASE}/videos`,
155
532
  keyFile: cfg.keyFile,
@@ -180,7 +557,7 @@ export function apply(ctx) {
180
557
  const cfg = await resolvePaths();
181
558
  return sendJson(res, {
182
559
  ok: true,
183
- config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
560
+ config: { keyFile: cfg.keyFile, outDir: cfg.outDir, imageModel: cfg.imageModel, videoModel: cfg.videoModel },
184
561
  stored: cfg.stored,
185
562
  defaults: cfg.defaults,
186
563
  configFile: cfg.configFile,
@@ -198,11 +575,16 @@ export function apply(ctx) {
198
575
  if (Object.prototype.hasOwnProperty.call(body, 'outDir')) {
199
576
  patch.outDir = typeof body.outDir === 'string' && body.outDir.trim() === '' ? null : body.outDir;
200
577
  }
578
+ for (const field of ['imageModel', 'videoModel']) {
579
+ if (Object.prototype.hasOwnProperty.call(body, field)) {
580
+ patch[field] = typeof body[field] === 'string' && body[field].trim() === '' ? null : body[field];
581
+ }
582
+ }
201
583
 
202
584
  const cfg = await saveConfig(patch);
203
585
  sendJson(res, {
204
586
  ok: true,
205
- config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
587
+ config: { keyFile: cfg.keyFile, outDir: cfg.outDir, imageModel: cfg.imageModel, videoModel: cfg.videoModel },
206
588
  stored: cfg.stored,
207
589
  defaults: cfg.defaults,
208
590
  configFile: cfg.configFile,
@@ -215,6 +597,40 @@ export function apply(ctx) {
215
597
  },
216
598
  });
217
599
 
600
+ /* ---------- 模型清单 / 连通性测试 ---------- */
601
+ addRoute(webServer, {
602
+ kind: 'exact',
603
+ path: '/agnes/models',
604
+ handler: async (req, res) => {
605
+ try {
606
+ if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET' }, 405);
607
+ const cfg = await resolvePaths();
608
+ const k = await keyStatus(cfg.keyFile);
609
+ if (!k.present) {
610
+ return sendJson(res, {
611
+ ok: false,
612
+ error: `没有找到 Key 文件:${cfg.keyFile}`,
613
+ key: k,
614
+ configured: { image: cfg.imageModel, video: cfg.videoModel },
615
+ fallback: { image: IMAGE_MODELS, video: VIDEO_MODELS },
616
+ }, 400);
617
+ }
618
+ const live = await listModels({ baseUrl: DEFAULT_BASE, key: await readKey(cfg.keyFile) });
619
+ sendJson(res, {
620
+ ok: true,
621
+ key: k,
622
+ upstream: live,
623
+ configured: { image: cfg.imageModel, video: cfg.videoModel },
624
+ fallback: { image: IMAGE_MODELS, video: VIDEO_MODELS },
625
+ });
626
+ } catch (e) {
627
+ const msg = errMessage(e);
628
+ const status = /Key 无效|没有找到|连不上/.test(msg) ? 400 : 500;
629
+ sendJson(res, { ok: false, error: msg }, status);
630
+ }
631
+ },
632
+ });
633
+
218
634
  /* ---------- 图片 ---------- */
219
635
  addRoute(webServer, {
220
636
  kind: 'exact',
@@ -226,11 +642,12 @@ export function apply(ctx) {
226
642
  if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
227
643
  return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
228
644
  }
645
+ const cfg = await resolvePaths();
229
646
  const result = await generate({
230
647
  prompt: body.prompt,
231
648
  size: body.size,
232
649
  ratio: body.ratio,
233
- model: body.model,
650
+ model: body.model || cfg.imageModel,
234
651
  // 图生图 / 多图合成:参考图数组(data URI 或 http(s) 链接)。
235
652
  // 校验在内核 normalizeRefs 里做,这里只负责透传。
236
653
  images: body.images,
@@ -255,16 +672,31 @@ export function apply(ctx) {
255
672
 
256
673
  // POST = 提交任务
257
674
  if (req.method === 'POST') {
258
- const body = await readJson(req);
675
+ // 关键帧是 base64 data URI,1 MB 装不下,所以和参考图共用 32 MB 上限
676
+ const body = await readJson(req, REF_BODY_LIMIT);
259
677
  if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
260
678
  return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
261
679
  }
680
+ const cfg = await resolvePaths();
262
681
  const sub = await submitVideo({
263
682
  prompt: body.prompt,
264
683
  seconds: body.seconds,
265
684
  ratio: body.ratio,
685
+ model: cfg.videoModel,
686
+ // 关键帧 / 种子:有就传,没有内核会退回纯文生视频
687
+ seed: body.seed,
688
+ firstFrame: body.firstFrame,
689
+ lastFrame: body.lastFrame,
690
+ // 参考图(mode:reference):最多 5 张,本地图同样以 data URI 直传
691
+ referenceImages: body.referenceImages,
692
+ });
693
+ return sendJson(res, {
694
+ ok: true,
695
+ videoId: sub.videoId,
696
+ taskId: sub.taskId,
697
+ model: sub.model,
698
+ mode: sub.mode,
266
699
  });
267
- return sendJson(res, { ok: true, videoId: sub.videoId, taskId: sub.taskId, model: sub.model });
268
700
  }
269
701
 
270
702
  // GET = 查询一次状态
@@ -309,6 +741,186 @@ export function apply(ctx) {
309
741
  },
310
742
  });
311
743
 
744
+ /* ---------- 视频任务:提交 / 列表 / 单个 / 取消 ---------- */
745
+ addRoute(webServer, {
746
+ kind: 'exact',
747
+ path: '/agnes/tasks',
748
+ handler: async (req, res) => {
749
+ try {
750
+ // POST = 新建任务:提交给上游后立刻返回,后续由宿主轮询
751
+ if (req.method === 'POST') {
752
+ const body = await readJson(req, REF_BODY_LIMIT);
753
+ if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
754
+ return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
755
+ }
756
+ const cfg = await resolvePaths();
757
+ void cfg;
758
+ const task = await enqueueVideoTask({
759
+ prompt: body.prompt,
760
+ seconds: body.seconds,
761
+ ratio: body.ratio,
762
+ model: cfg.videoModel,
763
+ seed: body.seed,
764
+ firstFrame: body.firstFrame,
765
+ lastFrame: body.lastFrame,
766
+ // 参考图(mode:reference):最多 5 张,本地图同样以 data URI 直传
767
+ referenceImages: body.referenceImages,
768
+ });
769
+ return sendJson(res, { ok: true, task });
770
+ }
771
+
772
+ if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET 或 POST' }, 405);
773
+
774
+ const list = [...tasks.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
775
+ return sendJson(res, {
776
+ ok: true,
777
+ tasks: list,
778
+ active: list.filter((t) => isActiveTask(t)).length,
779
+ max: TASK_MAX,
780
+ });
781
+ } catch (e) {
782
+ sendJson(res, { ok: false, error: errMessage(e) }, 500);
783
+ }
784
+ },
785
+ });
786
+
787
+ addRoute(webServer, {
788
+ kind: 'exact',
789
+ path: '/agnes/task',
790
+ handler: async (req, res) => {
791
+ try {
792
+ if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET' }, 405);
793
+ const url = new URL(req.url, 'http://dsh.local');
794
+ const id = url.searchParams.get('id') || '';
795
+ const task = tasks.get(id);
796
+ if (task === undefined) return sendJson(res, { ok: false, error: '任务不存在' }, 404);
797
+ sendJson(res, { ok: true, task });
798
+ } catch (e) {
799
+ sendJson(res, { ok: false, error: errMessage(e) }, 500);
800
+ }
801
+ },
802
+ });
803
+
804
+ addRoute(webServer, {
805
+ kind: 'exact',
806
+ path: '/agnes/task/cancel',
807
+ handler: async (req, res) => {
808
+ try {
809
+ if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
810
+ const body = await readJson(req);
811
+ const id = typeof body.id === 'string' ? body.id : '';
812
+ const task = tasks.get(id);
813
+ if (task === undefined) return sendJson(res, { ok: false, error: '任务不存在' }, 404);
814
+ // 上游没有取消接口,这里只停本地跟踪:已生成的文件不会丢,额度照样算
815
+ const next = patchTask(id, { status: 'cancelled', error: null });
816
+ await persistTasks();
817
+ sendJson(res, { ok: true, task: next });
818
+ } catch (e) {
819
+ sendJson(res, { ok: false, error: errMessage(e) }, 500);
820
+ }
821
+ },
822
+ });
823
+
824
+ /* ---------- 历史记录(落盘,跨会话保留) ---------- */
825
+ /* 历史里可能留着指向旧输出目录的记录(比如曾经把 outDir 改到别处)。
826
+ 这里只把「文件此刻确实还在当前输出目录里」的条目交给界面,
827
+ 顺手把过滤后的列表写回磁盘,免得陈旧记录一直躺在文件里。 */
828
+ const liveHistory = async () => {
829
+ const { entries } = await readHistory();
830
+ const { outDir } = await resolvePaths();
831
+ const keep = [];
832
+ for (const entry of entries) {
833
+ if (await fileExists(join(outDir, entry.filename))) keep.push(entry);
834
+ }
835
+ if (keep.length !== entries.length) {
836
+ try {
837
+ await clearHistory();
838
+ // appendHistory 是置顶语义,倒序写回才能保持原有顺序
839
+ for (let i = keep.length - 1; i >= 0; i -= 1) await appendHistory(keep[i]);
840
+ } catch {
841
+ // 写回失败不影响本次返回
842
+ }
843
+ }
844
+ return keep;
845
+ };
846
+
847
+ addRoute(webServer, {
848
+ kind: 'exact',
849
+ path: '/agnes/history',
850
+ handler: async (req, res) => {
851
+ try {
852
+ if (req.method === 'GET') {
853
+ const entries = await liveHistory();
854
+ const { file } = await readHistory();
855
+ return sendJson(res, { ok: true, entries, file, max: HISTORY_MAX });
856
+ }
857
+ if (req.method === 'POST') {
858
+ // 单条追加:前端每生成一张图/一段视频就来记一笔
859
+ const body = await readJson(req);
860
+ await appendHistory(body);
861
+ const entries = await liveHistory();
862
+ return sendJson(res, { ok: true, entries, max: HISTORY_MAX });
863
+ }
864
+ if (req.method === 'DELETE') {
865
+ await clearHistory();
866
+ return sendJson(res, { ok: true, entries: [], max: HISTORY_MAX });
867
+ }
868
+ return sendJson(res, { ok: false, error: '需要 GET / POST / DELETE' }, 405);
869
+ } catch (e) {
870
+ sendJson(res, { ok: false, error: errMessage(e) }, 400);
871
+ }
872
+ },
873
+ });
874
+
875
+ /* ---------- 输出目录清单:把已经存在的图/视频捞回界面 ---------- */
876
+ addRoute(webServer, {
877
+ kind: 'exact',
878
+ path: '/agnes/library',
879
+ handler: async (req, res) => {
880
+ try {
881
+ if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET' }, 405);
882
+ const { outDir } = await resolvePaths();
883
+ const files = await listLibrary(outDir);
884
+ sendJson(res, {
885
+ ok: true,
886
+ outDir,
887
+ files,
888
+ images: files.filter((f) => f.kind === 'image').length,
889
+ videos: files.filter((f) => f.kind === 'video').length,
890
+ });
891
+ } catch (e) {
892
+ sendJson(res, { ok: false, error: errMessage(e) }, 500);
893
+ }
894
+ },
895
+ });
896
+
897
+ /* ---------- 在访达里显示结果文件 ---------- */
898
+ addRoute(webServer, {
899
+ kind: 'exact',
900
+ path: '/agnes/reveal',
901
+ handler: async (req, res) => {
902
+ try {
903
+ if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
904
+ const body = await readJson(req);
905
+ // safeName 只放行 [A-Za-z0-9._-],顺带挡住路径穿越
906
+ const safe = safeName(typeof body.name === 'string' ? body.name : '');
907
+ if (safe === null) return sendJson(res, { ok: false, error: '非法文件名' }, 400);
908
+ const { outDir } = await resolvePaths();
909
+ const abs = resolvePath(outDir, safe);
910
+ // 双保险:解析后必须仍在输出目录里
911
+ if (!abs.startsWith(resolvePath(outDir) + '/')) {
912
+ return sendJson(res, { ok: false, error: '文件不在输出目录内' }, 400);
913
+ }
914
+ await new Promise((resolve, reject) => {
915
+ execFile('open', ['-R', abs], (err) => (err ? reject(err) : resolve()));
916
+ });
917
+ sendJson(res, { ok: true, path: abs });
918
+ } catch (e) {
919
+ sendJson(res, { ok: false, error: errMessage(e) }, 500);
920
+ }
921
+ },
922
+ });
923
+
312
924
  /* ---------- 媒体(图片 + 视频) ---------- */
313
925
  const mediaHandler = async (req, res) => {
314
926
  try {