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 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 > MAX_REF_IMAGES) {
62
- throw new Error(`参考图最多 ${MAX_REF_IMAGES} 张,收到 ${images.length} 张`);
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
- await saveConfig({
102
- keyFile: typeof raw.keyFile === 'string' ? raw.keyFile : null,
103
- outDir: typeof raw.outDir === 'string' ? raw.outDir : null,
104
- }).catch(() => { /* 迁移失败不影响本次读取 */ });
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: { keyFile: DEFAULT_KEY_FILE, outDir: DEFAULT_OUT_DIR },
121
- stored: { keyFile: storedKey, outDir: storedOut },
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({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame }) {
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 mode = first !== null || last !== null ? 'keyframe' : 'text';
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;