dsh-agnes-create 0.7.0 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/lib/agnes.js +166 -12
- package/lib/client.js +530 -71
- package/lib/index.js +496 -10
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -33,11 +33,13 @@ import { basename, join, resolve as resolvePath } from 'node:path';
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
|
|
36
|
-
DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES, HISTORY_MAX,
|
|
37
|
-
generate, keyStatus, readMedia, resolvePaths, saveConfig, safeName,
|
|
36
|
+
DEFAULT_BASE, SIZES, RATIOS, MAX_REF_IMAGES, MAX_VIDEO_REF_IMAGES, HISTORY_MAX,
|
|
37
|
+
generate, keyStatus, readKey, readMedia, resolvePaths, saveConfig, safeName,
|
|
38
38
|
submitVideo, queryVideo, saveVideo,
|
|
39
39
|
readHistory, appendHistory, clearHistory,
|
|
40
40
|
listLibrary, fileExists,
|
|
41
|
+
listModels, IMAGE_MODELS, VIDEO_MODELS,
|
|
42
|
+
readTasks, writeTasks, TASK_ACTIVE, TASK_MAX,
|
|
41
43
|
} from './agnes.js';
|
|
42
44
|
|
|
43
45
|
export const name = 'dsh-agnes-create';
|
|
@@ -145,15 +147,370 @@ export function apply(ctx) {
|
|
|
145
147
|
routeDisposers.push(webServer.register(route));
|
|
146
148
|
};
|
|
147
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
|
+
|
|
148
508
|
const registerRoutes = (value) => {
|
|
149
509
|
if (registered) return;
|
|
150
510
|
const webServer = value ?? ctx.reflect.get('webServer', false);
|
|
151
511
|
if (webServer === undefined) return;
|
|
152
512
|
registered = true;
|
|
153
513
|
|
|
154
|
-
/** videoId → 已下载的本地结果,避免每次轮询重复下载几十 MB */
|
|
155
|
-
const downloaded = new Map();
|
|
156
|
-
|
|
157
514
|
addRoute(webServer, {
|
|
158
515
|
kind: 'exact',
|
|
159
516
|
path: '/agnes/health',
|
|
@@ -163,8 +520,13 @@ export function apply(ctx) {
|
|
|
163
520
|
const k = await keyStatus(cfg.keyFile);
|
|
164
521
|
sendJson(res, {
|
|
165
522
|
ok: true,
|
|
166
|
-
model:
|
|
167
|
-
videoModel:
|
|
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
|
+
},
|
|
168
530
|
endpoint: `${DEFAULT_BASE}/images/generations`,
|
|
169
531
|
videoEndpoint: `${DEFAULT_BASE}/videos`,
|
|
170
532
|
keyFile: cfg.keyFile,
|
|
@@ -195,7 +557,7 @@ export function apply(ctx) {
|
|
|
195
557
|
const cfg = await resolvePaths();
|
|
196
558
|
return sendJson(res, {
|
|
197
559
|
ok: true,
|
|
198
|
-
config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
|
|
560
|
+
config: { keyFile: cfg.keyFile, outDir: cfg.outDir, imageModel: cfg.imageModel, videoModel: cfg.videoModel },
|
|
199
561
|
stored: cfg.stored,
|
|
200
562
|
defaults: cfg.defaults,
|
|
201
563
|
configFile: cfg.configFile,
|
|
@@ -213,11 +575,16 @@ export function apply(ctx) {
|
|
|
213
575
|
if (Object.prototype.hasOwnProperty.call(body, 'outDir')) {
|
|
214
576
|
patch.outDir = typeof body.outDir === 'string' && body.outDir.trim() === '' ? null : body.outDir;
|
|
215
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
|
+
}
|
|
216
583
|
|
|
217
584
|
const cfg = await saveConfig(patch);
|
|
218
585
|
sendJson(res, {
|
|
219
586
|
ok: true,
|
|
220
|
-
config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
|
|
587
|
+
config: { keyFile: cfg.keyFile, outDir: cfg.outDir, imageModel: cfg.imageModel, videoModel: cfg.videoModel },
|
|
221
588
|
stored: cfg.stored,
|
|
222
589
|
defaults: cfg.defaults,
|
|
223
590
|
configFile: cfg.configFile,
|
|
@@ -230,6 +597,40 @@ export function apply(ctx) {
|
|
|
230
597
|
},
|
|
231
598
|
});
|
|
232
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
|
+
|
|
233
634
|
/* ---------- 图片 ---------- */
|
|
234
635
|
addRoute(webServer, {
|
|
235
636
|
kind: 'exact',
|
|
@@ -241,11 +642,12 @@ export function apply(ctx) {
|
|
|
241
642
|
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
242
643
|
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
243
644
|
}
|
|
645
|
+
const cfg = await resolvePaths();
|
|
244
646
|
const result = await generate({
|
|
245
647
|
prompt: body.prompt,
|
|
246
648
|
size: body.size,
|
|
247
649
|
ratio: body.ratio,
|
|
248
|
-
model: body.model,
|
|
650
|
+
model: body.model || cfg.imageModel,
|
|
249
651
|
// 图生图 / 多图合成:参考图数组(data URI 或 http(s) 链接)。
|
|
250
652
|
// 校验在内核 normalizeRefs 里做,这里只负责透传。
|
|
251
653
|
images: body.images,
|
|
@@ -275,14 +677,18 @@ export function apply(ctx) {
|
|
|
275
677
|
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
276
678
|
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
277
679
|
}
|
|
680
|
+
const cfg = await resolvePaths();
|
|
278
681
|
const sub = await submitVideo({
|
|
279
682
|
prompt: body.prompt,
|
|
280
683
|
seconds: body.seconds,
|
|
281
684
|
ratio: body.ratio,
|
|
685
|
+
model: cfg.videoModel,
|
|
282
686
|
// 关键帧 / 种子:有就传,没有内核会退回纯文生视频
|
|
283
687
|
seed: body.seed,
|
|
284
688
|
firstFrame: body.firstFrame,
|
|
285
689
|
lastFrame: body.lastFrame,
|
|
690
|
+
// 参考图(mode:reference):最多 5 张,本地图同样以 data URI 直传
|
|
691
|
+
referenceImages: body.referenceImages,
|
|
286
692
|
});
|
|
287
693
|
return sendJson(res, {
|
|
288
694
|
ok: true,
|
|
@@ -335,6 +741,86 @@ export function apply(ctx) {
|
|
|
335
741
|
},
|
|
336
742
|
});
|
|
337
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
|
+
|
|
338
824
|
/* ---------- 历史记录(落盘,跨会话保留) ---------- */
|
|
339
825
|
/* 历史里可能留着指向旧输出目录的记录(比如曾经把 outDir 改到别处)。
|
|
340
826
|
这里只把「文件此刻确实还在当前输出目录里」的条目交给界面,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agnes-create",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agnes 创作:在 DeepSeek Harness
|
|
3
|
+
"version": "0.10.3",
|
|
4
|
+
"description": "Agnes 创作:在 DeepSeek Harness 里出图、出视频,也能让 agent 在对话里直接调用。图片支持文生图 / 图生图 / 多图合成(agnes-image-2.5-flash);视频支持文生 / 首尾帧关键帧 / 参考图三种模式与 seed(agnes-video-2.5-flash,720P,4–12 秒)。侧边栏面板 + 模型工具双通道,视频任务由宿主推进、刷新或重启可接回;提示词写进图片(PNG iTXt / JPEG COM)悬停可看可复制;可浏览输出目录、历史 24 条、模型可切换并测试连接;标题栏的模型名可直接点开切换。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|