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