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/README.md
CHANGED
|
@@ -114,6 +114,20 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
114
114
|
|
|
115
115
|
> 关键帧只在视频模式出现;图片模式用的是上面的「参考图」。
|
|
116
116
|
|
|
117
|
+
### 参考生视频(参考图,最多 5 张)
|
|
118
|
+
|
|
119
|
+
视频模式还有一栏「参考图」,用来给上游参考**风格、主体或构图**,而不是指定起止画面。最多 5 张,来源同样是「+ 本地」和「+ 结果」。
|
|
120
|
+
|
|
121
|
+
| 给了什么 | 实际行为 |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| 只有参考图 | 按参考图的风格 / 主体 / 构图生成新视频(`mode: reference`) |
|
|
124
|
+
| 参考图 + 首帧或尾帧 | 按关键帧生成,**参考图本次不生效**(面板会提示) |
|
|
125
|
+
| 什么都不给 | 纯文生视频 |
|
|
126
|
+
|
|
127
|
+
同样以 data URI 直接发给上游,不需要图床。结果信息里会标注「参考生视频」,和「图生视频」「纯文生视频」区分开。
|
|
128
|
+
|
|
129
|
+
> 三种模式的优先级:关键帧 > 参考图 > 纯文本。
|
|
130
|
+
|
|
117
131
|
### 提示词随图保存
|
|
118
132
|
|
|
119
133
|
出图时会把这次的提示词写进图片文件本身(PNG 用 iTXt 块、JPEG 用 COM 段),所以:
|
|
@@ -130,6 +144,26 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
130
144
|
- 点任意一张就把它显示到上方结果区,图片会连带读回当初写在文件里的提示词;
|
|
131
145
|
- 只读不写,不会改动或删除你的文件;「刷新」用于同步新生成的文件。
|
|
132
146
|
|
|
147
|
+
### 顶部直接切换模型
|
|
148
|
+
|
|
149
|
+
标题栏「Agnes 创作」右边的模型名可以点开:图片模式列图片模型、视频模式列视频模型,选中后**立刻保存**,不用进设置。
|
|
150
|
+
|
|
151
|
+
- 选项优先用「设置 → 测试连接」探测到的上游真实清单(更可信),没测过时用内置清单
|
|
152
|
+
- 内置清单:图片 `agnes-image-2.5-flash` / `agnes-image-2.1-flash`,视频 `agnes-video-2.5-flash` / `agnes-video-2.5`
|
|
153
|
+
- 只有 1 个可选时仍然显示为纯文本,不弹空下拉
|
|
154
|
+
|
|
155
|
+
### 在对话里直接出图 / 出视频
|
|
156
|
+
|
|
157
|
+
除了侧边栏面板,插件还把三个模型工具交给宿主,所以你直接对 agent 说「画一只坐在窗台上的橘猫」它就能出图:
|
|
158
|
+
|
|
159
|
+
| 工具 | 作用 | 主要参数 |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `agnes_image` | 出图,等结果落盘后返回路径 | `prompt`、`size`(1K–4K)、`ratio`、`images`(≤8,URL 或 data URI) |
|
|
162
|
+
| `agnes_video` | 提交视频任务,**立刻返回任务号**(不阻塞) | `prompt`、`seconds`(4–12)、`ratio`、`seed`、`first_frame`、`last_frame`、`reference_images`(≤5) |
|
|
163
|
+
| `agnes_video_status` | 查视频任务进度 / 取结果路径 | `task_id`(留空则列出最近的几笔) |
|
|
164
|
+
|
|
165
|
+
视频任务由宿主推进,工具只负责提交与查询,所以 agent 不必一直挂着等。宿主没有工具服务时,这三个工具会自动不注册,面板照常可用。
|
|
166
|
+
|
|
133
167
|
## 关于费用
|
|
134
168
|
|
|
135
169
|
插件的费用由 Agnes AI 账号结算,本插件不涉及任何计费。
|
package/lib/agnes.js
CHANGED
|
@@ -19,6 +19,11 @@ import { join, basename } from 'node:path';
|
|
|
19
19
|
export const MODEL = 'agnes-image-2.5-flash';
|
|
20
20
|
export const DEFAULT_BASE = 'https://api.agnes-ai.cn/v1'; // 中国站
|
|
21
21
|
|
|
22
|
+
/** 画图模型候选:上游 /models 目前只列出这两个图像模型(2.0 已下架)。 */
|
|
23
|
+
export const IMAGE_MODELS = ['agnes-image-2.5-flash', 'agnes-image-2.1-flash'];
|
|
24
|
+
/** 视频模型候选。 */
|
|
25
|
+
export const VIDEO_MODELS = ['agnes-video-2.5-flash', 'agnes-video-2.5'];
|
|
26
|
+
|
|
22
27
|
/* ==========================================================================
|
|
23
28
|
可配置路径:Key 文件位置 / 输出目录
|
|
24
29
|
--------------------------------------------------------------------------
|
|
@@ -55,11 +60,11 @@ const DATA_URI_RE = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i
|
|
|
55
60
|
* 文档要求 extra_body.image 里是「公共图像 URL 或 Data URI Base64」,
|
|
56
61
|
* 所以这里只放行 http(s) 与 data:image 两种,别的直接报错而不是透传给上游。
|
|
57
62
|
*/
|
|
58
|
-
export function normalizeRefs(images) {
|
|
63
|
+
export function normalizeRefs(images, limit = MAX_REF_IMAGES) {
|
|
59
64
|
if (images === undefined || images === null) return [];
|
|
60
65
|
if (!Array.isArray(images)) throw new Error('参考图必须是数组');
|
|
61
|
-
if (images.length >
|
|
62
|
-
throw new Error(`参考图最多 ${
|
|
66
|
+
if (images.length > limit) {
|
|
67
|
+
throw new Error(`参考图最多 ${limit} 张,收到 ${images.length} 张`);
|
|
63
68
|
}
|
|
64
69
|
return images.map((value, i) => {
|
|
65
70
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
@@ -81,6 +86,13 @@ export function expandHome(p) {
|
|
|
81
86
|
return s;
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/** 只接受候选表里的模型名:配置里写了乱七八糟的值就当没写,回退默认。 */
|
|
90
|
+
function pickModel(value, choices) {
|
|
91
|
+
if (typeof value !== 'string') return null;
|
|
92
|
+
const name = value.trim();
|
|
93
|
+
return choices.includes(name) ? name : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
/**
|
|
85
97
|
* 解析生效配置。
|
|
86
98
|
* 配置文件不存在或内容损坏时一律退回默认值 —— 配置坏了不该把插件拖死。
|
|
@@ -98,10 +110,15 @@ export async function resolvePaths() {
|
|
|
98
110
|
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
99
111
|
stored = raw;
|
|
100
112
|
configFile = CONFIG_FILE;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
// 直接写文件,别调 saveConfig():saveConfig 内部会先 resolvePaths(),
|
|
114
|
+
// 而此刻新配置还没落盘,于是又走到这里 —— 无限递归。
|
|
115
|
+
const migrated = {};
|
|
116
|
+
if (typeof raw.keyFile === 'string' && raw.keyFile.trim() !== '') migrated.keyFile = raw.keyFile.trim();
|
|
117
|
+
if (typeof raw.outDir === 'string' && raw.outDir.trim() !== '') migrated.outDir = raw.outDir.trim();
|
|
118
|
+
if (typeof raw.imageModel === 'string') migrated.imageModel = raw.imageModel;
|
|
119
|
+
if (typeof raw.videoModel === 'string') migrated.videoModel = raw.videoModel;
|
|
120
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
121
|
+
await writeFile(CONFIG_FILE, JSON.stringify(migrated, null, 2) + '\n', 'utf8');
|
|
105
122
|
}
|
|
106
123
|
} catch { /* 旧配置也没有 → 用默认 */ }
|
|
107
124
|
}
|
|
@@ -111,14 +128,29 @@ export async function resolvePaths() {
|
|
|
111
128
|
const storedOut = typeof stored.outDir === 'string' && stored.outDir.trim() !== ''
|
|
112
129
|
? expandHome(stored.outDir) : null;
|
|
113
130
|
|
|
131
|
+
const storedImageModel = pickModel(stored.imageModel, IMAGE_MODELS);
|
|
132
|
+
const storedVideoModel = pickModel(stored.videoModel, VIDEO_MODELS);
|
|
133
|
+
|
|
114
134
|
return {
|
|
115
135
|
keyFile: storedKey ?? (process.env.AGNES_KEY_FILE || DEFAULT_KEY_FILE),
|
|
116
136
|
outDir: storedOut ?? (process.env.AGNES_OUT_DIR || DEFAULT_OUT_DIR),
|
|
137
|
+
imageModel: storedImageModel ?? MODEL,
|
|
138
|
+
videoModel: storedVideoModel ?? VIDEO_MODEL,
|
|
117
139
|
configFile,
|
|
118
140
|
legacyConfigFile: LEGACY_CONFIG_FILE,
|
|
119
141
|
historyFile: HISTORY_FILE,
|
|
120
|
-
defaults: {
|
|
121
|
-
|
|
142
|
+
defaults: {
|
|
143
|
+
keyFile: DEFAULT_KEY_FILE,
|
|
144
|
+
outDir: DEFAULT_OUT_DIR,
|
|
145
|
+
imageModel: MODEL,
|
|
146
|
+
videoModel: VIDEO_MODEL,
|
|
147
|
+
},
|
|
148
|
+
stored: {
|
|
149
|
+
keyFile: storedKey,
|
|
150
|
+
outDir: storedOut,
|
|
151
|
+
imageModel: storedImageModel,
|
|
152
|
+
videoModel: storedVideoModel,
|
|
153
|
+
},
|
|
122
154
|
};
|
|
123
155
|
}
|
|
124
156
|
|
|
@@ -133,8 +165,22 @@ export async function saveConfig(patch = {}) {
|
|
|
133
165
|
const next = {
|
|
134
166
|
keyFile: patch.keyFile === undefined ? current.stored.keyFile : patch.keyFile,
|
|
135
167
|
outDir: patch.outDir === undefined ? current.stored.outDir : patch.outDir,
|
|
168
|
+
imageModel: patch.imageModel === undefined ? current.stored.imageModel : patch.imageModel,
|
|
169
|
+
videoModel: patch.videoModel === undefined ? current.stored.videoModel : patch.videoModel,
|
|
136
170
|
};
|
|
137
171
|
|
|
172
|
+
for (const [field, label, choices] of [
|
|
173
|
+
['imageModel', '图片模型', IMAGE_MODELS],
|
|
174
|
+
['videoModel', '视频模型', VIDEO_MODELS],
|
|
175
|
+
]) {
|
|
176
|
+
const v = next[field];
|
|
177
|
+
if (v === null) continue; // 清空 = 回退默认
|
|
178
|
+
if (typeof v !== 'string' || !choices.includes(v.trim())) {
|
|
179
|
+
throw new Error(`${label}不在可选范围内:${choices.join(' / ')}`);
|
|
180
|
+
}
|
|
181
|
+
next[field] = v.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
for (const [field, label] of [['keyFile', 'Key 文件路径'], ['outDir', '输出目录']]) {
|
|
139
185
|
const v = next[field];
|
|
140
186
|
if (v === null) continue; // 允许清空回默认
|
|
@@ -149,6 +195,8 @@ export async function saveConfig(patch = {}) {
|
|
|
149
195
|
const payload = {};
|
|
150
196
|
if (typeof next.keyFile === 'string') payload.keyFile = next.keyFile;
|
|
151
197
|
if (typeof next.outDir === 'string') payload.outDir = next.outDir;
|
|
198
|
+
if (typeof next.imageModel === 'string') payload.imageModel = next.imageModel;
|
|
199
|
+
if (typeof next.videoModel === 'string') payload.videoModel = next.videoModel;
|
|
152
200
|
|
|
153
201
|
await mkdir(CONFIG_DIR, { recursive: true });
|
|
154
202
|
await writeFile(CONFIG_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
@@ -218,7 +266,96 @@ export async function clearHistory() {
|
|
|
218
266
|
return { entries: [], file: HISTORY_FILE };
|
|
219
267
|
}
|
|
220
268
|
|
|
269
|
+
/* ==========================================================================
|
|
270
|
+
视频任务(落盘 + 宿主侧轮询)
|
|
271
|
+
--------------------------------------------------------------------------
|
|
272
|
+
从前轮询在前端,刷新 / 关面板 / 崩溃就丢;现在任务表在宿主进程里,
|
|
273
|
+
写进 tasks.json,应用重启后把「排队中 / 生成中」的任务重新接管。
|
|
274
|
+
========================================================================== */
|
|
275
|
+
|
|
276
|
+
export const TASKS_FILE = join(CONFIG_DIR, 'tasks.json');
|
|
277
|
+
export const TASK_MAX = 50;
|
|
278
|
+
|
|
279
|
+
/** 还在跑、需要继续轮询的状态。 */
|
|
280
|
+
export const TASK_ACTIVE = ['queued', 'running'];
|
|
281
|
+
|
|
282
|
+
const TASK_STATUS = ['queued', 'running', 'completed', 'failed', 'cancelled'];
|
|
283
|
+
|
|
284
|
+
function sanitizeTask(raw) {
|
|
285
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
286
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
287
|
+
if (id === '') return null;
|
|
288
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
289
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
290
|
+
const status = TASK_STATUS.includes(raw.status) ? raw.status : 'queued';
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
kind: 'video',
|
|
294
|
+
status,
|
|
295
|
+
progress: num(raw.progress) ?? 0,
|
|
296
|
+
videoId: str(raw.videoId),
|
|
297
|
+
mode: raw.mode === 'keyframe' ? 'keyframe' : 'text',
|
|
298
|
+
prompt: str(raw.prompt)?.slice(0, 500) ?? null,
|
|
299
|
+
seconds: str(raw.seconds),
|
|
300
|
+
ratio: str(raw.ratio),
|
|
301
|
+
seed: num(raw.seed),
|
|
302
|
+
filename: str(raw.filename),
|
|
303
|
+
path: str(raw.path),
|
|
304
|
+
bytes: num(raw.bytes),
|
|
305
|
+
error: str(raw.error),
|
|
306
|
+
createdAt: str(raw.createdAt) ?? new Date().toISOString(),
|
|
307
|
+
updatedAt: str(raw.updatedAt) ?? new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function readTasks() {
|
|
312
|
+
try {
|
|
313
|
+
const raw = JSON.parse(await readFile(TASKS_FILE, 'utf8'));
|
|
314
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.tasks) ? raw.tasks : []);
|
|
315
|
+
return list.map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
316
|
+
} catch {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function writeTasks(tasks) {
|
|
322
|
+
const list = (Array.isArray(tasks) ? tasks : []).map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
323
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
324
|
+
await writeFile(TASKS_FILE, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
325
|
+
return list;
|
|
326
|
+
}
|
|
327
|
+
|
|
221
328
|
/** 读 Key。不传 file 则按当前配置解析。 */
|
|
329
|
+
/**
|
|
330
|
+
* 问上游要模型清单,用来做「测试连接」和填下拉选项。
|
|
331
|
+
* 顺带区分三种失败:Key 不对(401/403)、额度/限流(429)、网络或服务异常。
|
|
332
|
+
*/
|
|
333
|
+
export async function listModels({ baseUrl = DEFAULT_BASE, key } = {}) {
|
|
334
|
+
const k = key ?? (await readKey());
|
|
335
|
+
if (!k) throw new Error('没有找到 API Key,先确认 Key 文件路径');
|
|
336
|
+
let res;
|
|
337
|
+
try {
|
|
338
|
+
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`, {
|
|
339
|
+
headers: { Authorization: `Bearer ${k}` },
|
|
340
|
+
signal: AbortSignal.timeout(20_000),
|
|
341
|
+
});
|
|
342
|
+
} catch (e) {
|
|
343
|
+
throw new Error(`连不上上游:${e && e.message ? e.message : String(e)}`);
|
|
344
|
+
}
|
|
345
|
+
if (res.status === 401 || res.status === 403) throw new Error('Key 无效或已过期(HTTP ' + res.status + ')');
|
|
346
|
+
if (res.status === 429) throw new Error('上游限流(HTTP 429),稍后再试');
|
|
347
|
+
if (!res.ok) throw new Error('上游返回 HTTP ' + res.status);
|
|
348
|
+
|
|
349
|
+
const payload = await res.json().catch(() => null);
|
|
350
|
+
const rows = payload && Array.isArray(payload.data) ? payload.data : [];
|
|
351
|
+
const ids = rows.map((r) => (r && typeof r.id === 'string' ? r.id : '')).filter(Boolean);
|
|
352
|
+
return {
|
|
353
|
+
models: ids,
|
|
354
|
+
image: ids.filter((id) => id.startsWith('agnes-image-')),
|
|
355
|
+
video: ids.filter((id) => id.startsWith('agnes-video-')),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
222
359
|
export async function readKey(file) {
|
|
223
360
|
const target = file ?? (await resolvePaths()).keyFile;
|
|
224
361
|
let raw;
|
|
@@ -696,6 +833,9 @@ export const VIDEO_MODEL = 'agnes-video-2.5-flash';
|
|
|
696
833
|
export const VIDEO_SECONDS = ['4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
|
697
834
|
export const VIDEO_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
|
698
835
|
export const VIDEO_SIZE = '720P';
|
|
836
|
+
|
|
837
|
+
/** 视频 reference 模式的参考图上限(上游文档:images 最多 5 张)。 */
|
|
838
|
+
export const MAX_VIDEO_REF_IMAGES = 5;
|
|
699
839
|
export const VIDEO_TIMEOUT_MS = 600_000;
|
|
700
840
|
|
|
701
841
|
/** 从 baseUrl 剥出域名根:/agnesapi 不在 /v1 下。 */
|
|
@@ -722,13 +862,14 @@ export async function submitVideo({
|
|
|
722
862
|
seed = null,
|
|
723
863
|
firstFrame = null,
|
|
724
864
|
lastFrame = null,
|
|
865
|
+
referenceImages = [],
|
|
725
866
|
retries = 3,
|
|
726
867
|
retryDelayMs = 15_000,
|
|
727
868
|
} = {}) {
|
|
728
869
|
let lastError = null;
|
|
729
870
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
730
871
|
try {
|
|
731
|
-
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame });
|
|
872
|
+
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame, referenceImages });
|
|
732
873
|
} catch (e) {
|
|
733
874
|
lastError = e;
|
|
734
875
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -757,7 +898,17 @@ function normalizeSeed(seed) {
|
|
|
757
898
|
return n;
|
|
758
899
|
}
|
|
759
900
|
|
|
760
|
-
async function submitVideoOnce({
|
|
901
|
+
async function submitVideoOnce({
|
|
902
|
+
prompt,
|
|
903
|
+
seconds,
|
|
904
|
+
ratio,
|
|
905
|
+
model,
|
|
906
|
+
baseUrl,
|
|
907
|
+
seed,
|
|
908
|
+
firstFrame,
|
|
909
|
+
lastFrame,
|
|
910
|
+
referenceImages = [],
|
|
911
|
+
}) {
|
|
761
912
|
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
762
913
|
const sec = String(seconds);
|
|
763
914
|
if (!VIDEO_SECONDS.includes(sec)) {
|
|
@@ -767,7 +918,9 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, f
|
|
|
767
918
|
|
|
768
919
|
const first = normalizeFrame(firstFrame, '首帧图');
|
|
769
920
|
const last = normalizeFrame(lastFrame, '尾帧图');
|
|
770
|
-
const
|
|
921
|
+
const refs = normalizeRefs(referenceImages, MAX_VIDEO_REF_IMAGES);
|
|
922
|
+
// 关键帧优先:给了首/尾帧就是 keyframe;否则有参考图走 reference;都没有才是纯文本
|
|
923
|
+
const mode = first !== null || last !== null ? 'keyframe' : refs.length > 0 ? 'reference' : 'text';
|
|
771
924
|
const seedValue = normalizeSeed(seed);
|
|
772
925
|
|
|
773
926
|
const key = await readKey();
|
|
@@ -784,6 +937,7 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, f
|
|
|
784
937
|
if (first !== null) body.first_frame = first;
|
|
785
938
|
if (last !== null) body.last_frame = last;
|
|
786
939
|
}
|
|
940
|
+
if (mode === 'reference') body.images = refs;
|
|
787
941
|
if (seedValue !== null) body.seed = seedValue;
|
|
788
942
|
|
|
789
943
|
let res;
|