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/README.md
CHANGED
|
@@ -53,10 +53,10 @@ Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认
|
|
|
53
53
|
|
|
54
54
|
1. 侧边栏点 **Agnes创作**
|
|
55
55
|
2. 顶部选择 **图片** 或 **视频**
|
|
56
|
-
3. 输入提示词,`Cmd/Ctrl + Enter` 或点按钮生成;提示词上方有 6
|
|
56
|
+
3. 输入提示词,`Cmd/Ctrl + Enter` 或点按钮生成;提示词上方有 6 个内置风格预设(写实摄影 / 水彩 / 3D 渲染 / 海报 / 夜景霓虹 / 极简),点一下追加到末尾,不会冲掉已写的主体;也可以把自己的常用提示词存成预设,或者点 **扩写** 让模型把那句简短描述补全
|
|
57
57
|
4. 想放弃等待就点生成按钮旁的 **取消**(只是停止本地等待,服务端任务可能仍在跑)
|
|
58
58
|
5. 结果直接显示在面板里;图片用 `<img>`、视频用 `<video>` 播放(支持拖动进度条)
|
|
59
|
-
6.
|
|
59
|
+
6. 结果下方四个按钮:**复制路径**(写到剪贴板)、**在访达中显示**(定位到文件)、**下载**(另存一份到「下载」目录)、**新窗口打开**(在浏览器新标签页里看原图 / 播放原视频);如果结果本身是图片,还会多出 **设为首帧** / **设为尾帧**,一键把它变成下一段视频的关键帧
|
|
60
60
|
7. 生成过的内容记在面板底部「最近生成」里,最多 24 条,落盘到 `~/.dsh/agnes-create/history.json`,重启应用也不丢
|
|
61
61
|
|
|
62
62
|
提示词建议按「主体 + 场景 + 动作/镜头 + 光线 + 风格 + 构图」组织,例如:
|
|
@@ -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,37 @@ 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
|
+
提示词上方那排 chip 里,除了 6 个内置风格,还能放自己的:
|
|
158
|
+
|
|
159
|
+
- **+ 存为预设** —— 把当前输入的整段提示词存下来。会展开一个输入框让你取名字(留空则取提示词前 10 个字),回车或点「保存」即存,存完立刻出现在 chip 里
|
|
160
|
+
- 点自己的预设 = 追加到当前提示词末尾(和内置预设的拼接规则一样:先去掉尾部标点,再用「,」连接),不会覆盖已经写好的主体
|
|
161
|
+
- 预设 chip 右侧的 **×** 删除它,点了就生效,没有二次确认
|
|
162
|
+
- **扩写** —— 把一句话变成一段完整的画面描述:图片模式补主体外观、环境、光线、镜头与构图;视频模式补动作推进、镜头运动、景别与氛围。扩写结果直接替换输入框内容,多花一次文本模型的调用(约 160 tokens,几分钱)
|
|
163
|
+
|
|
164
|
+
预设落盘到 `~/.dsh/agnes-create/presets.json`,重启应用不丢;最多 40 条,名称截到 20 字、内容截到 600 字,同名再存会覆盖并置顶。
|
|
165
|
+
|
|
166
|
+
### 在对话里直接出图 / 出视频
|
|
167
|
+
|
|
168
|
+
除了侧边栏面板,插件还把三个模型工具交给宿主,所以你直接对 agent 说「画一只坐在窗台上的橘猫」它就能出图:
|
|
169
|
+
|
|
170
|
+
| 工具 | 作用 | 主要参数 |
|
|
171
|
+
| --- | --- | --- |
|
|
172
|
+
| `agnes_image` | 出图,等结果落盘后返回路径 | `prompt`、`size`(1K–4K)、`ratio`、`images`(≤8,URL 或 data URI) |
|
|
173
|
+
| `agnes_video` | 提交视频任务,**立刻返回任务号**(不阻塞) | `prompt`、`seconds`(4–12)、`ratio`、`seed`、`first_frame`、`last_frame`、`reference_images`(≤5) |
|
|
174
|
+
| `agnes_video_status` | 查视频任务进度 / 取结果路径 | `task_id`(留空则列出最近的几笔) |
|
|
175
|
+
|
|
176
|
+
视频任务由宿主推进,工具只负责提交与查询,所以 agent 不必一直挂着等。宿主没有工具服务时,这三个工具会自动不注册,面板照常可用。
|
|
177
|
+
|
|
133
178
|
## 关于费用
|
|
134
179
|
|
|
135
180
|
插件的费用由 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,229 @@ export async function clearHistory() {
|
|
|
218
266
|
return { entries: [], file: HISTORY_FILE };
|
|
219
267
|
}
|
|
220
268
|
|
|
269
|
+
/* ==========================================================================
|
|
270
|
+
自定义提示词预设
|
|
271
|
+
--------------------------------------------------------------------------
|
|
272
|
+
内置的 6 个风格 chip 写死在客户端(纯展示,不用存);用户自己存的走这里,
|
|
273
|
+
落到 ~/.dsh/agnes-create/presets.json —— 跨会话、重启都还在。
|
|
274
|
+
同名视为覆盖(存两次同一名字不会出现两条)。
|
|
275
|
+
========================================================================== */
|
|
276
|
+
|
|
277
|
+
export const PRESETS_FILE = join(CONFIG_DIR, 'presets.json');
|
|
278
|
+
export const PRESET_MAX = 40;
|
|
279
|
+
export const PRESET_LABEL_MAX = 20;
|
|
280
|
+
export const PRESET_TEXT_MAX = 600;
|
|
281
|
+
|
|
282
|
+
function sanitizePreset(raw) {
|
|
283
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
284
|
+
const label = typeof raw.label === 'string' ? raw.label.trim().replace(/\s+/g, ' ') : '';
|
|
285
|
+
const text = typeof raw.text === 'string' ? raw.text.trim() : '';
|
|
286
|
+
if (label === '' || text === '') return null;
|
|
287
|
+
return { label: label.slice(0, PRESET_LABEL_MAX), text: text.slice(0, PRESET_TEXT_MAX) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function readPresets() {
|
|
291
|
+
try {
|
|
292
|
+
const raw = JSON.parse(await readFile(PRESETS_FILE, 'utf8'));
|
|
293
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.presets) ? raw.presets : []);
|
|
294
|
+
const seen = new Set();
|
|
295
|
+
const presets = [];
|
|
296
|
+
for (const item of list) {
|
|
297
|
+
const clean = sanitizePreset(item);
|
|
298
|
+
if (clean === null || seen.has(clean.label)) continue;
|
|
299
|
+
seen.add(clean.label);
|
|
300
|
+
presets.push(clean);
|
|
301
|
+
}
|
|
302
|
+
return { presets: presets.slice(0, PRESET_MAX), file: PRESETS_FILE };
|
|
303
|
+
} catch {
|
|
304
|
+
return { presets: [], file: PRESETS_FILE };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 存一条预设(同名覆盖并置顶)。返回最新列表。 */
|
|
309
|
+
export async function savePreset(entry) {
|
|
310
|
+
const clean = sanitizePreset(entry);
|
|
311
|
+
if (clean === null) throw new Error('预设需要名称和内容');
|
|
312
|
+
const { presets } = await readPresets();
|
|
313
|
+
const next = [clean, ...presets.filter((p) => p.label !== clean.label)].slice(0, PRESET_MAX);
|
|
314
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
315
|
+
await writeFile(PRESETS_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
316
|
+
return { presets: next, file: PRESETS_FILE };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 删一条预设。名字不存在时也返回成功(幂等),列表不变。 */
|
|
320
|
+
export async function removePreset(label) {
|
|
321
|
+
const name = typeof label === 'string' ? label.trim() : '';
|
|
322
|
+
if (name === '') throw new Error('缺少预设名称');
|
|
323
|
+
const { presets } = await readPresets();
|
|
324
|
+
const next = presets.filter((p) => p.label !== name);
|
|
325
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
326
|
+
await writeFile(PRESETS_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
327
|
+
return { presets: next, file: PRESETS_FILE };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ==========================================================================
|
|
331
|
+
视频任务(落盘 + 宿主侧轮询)
|
|
332
|
+
--------------------------------------------------------------------------
|
|
333
|
+
从前轮询在前端,刷新 / 关面板 / 崩溃就丢;现在任务表在宿主进程里,
|
|
334
|
+
写进 tasks.json,应用重启后把「排队中 / 生成中」的任务重新接管。
|
|
335
|
+
========================================================================== */
|
|
336
|
+
|
|
337
|
+
export const TASKS_FILE = join(CONFIG_DIR, 'tasks.json');
|
|
338
|
+
export const TASK_MAX = 50;
|
|
339
|
+
|
|
340
|
+
/** 还在跑、需要继续轮询的状态。 */
|
|
341
|
+
export const TASK_ACTIVE = ['queued', 'running'];
|
|
342
|
+
|
|
343
|
+
const TASK_STATUS = ['queued', 'running', 'completed', 'failed', 'cancelled'];
|
|
344
|
+
|
|
345
|
+
function sanitizeTask(raw) {
|
|
346
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
347
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
348
|
+
if (id === '') return null;
|
|
349
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
350
|
+
const str = (v) => (typeof v === 'string' && v !== '' ? v : null);
|
|
351
|
+
const status = TASK_STATUS.includes(raw.status) ? raw.status : 'queued';
|
|
352
|
+
return {
|
|
353
|
+
id,
|
|
354
|
+
kind: 'video',
|
|
355
|
+
status,
|
|
356
|
+
progress: num(raw.progress) ?? 0,
|
|
357
|
+
videoId: str(raw.videoId),
|
|
358
|
+
mode: raw.mode === 'keyframe' ? 'keyframe' : 'text',
|
|
359
|
+
prompt: str(raw.prompt)?.slice(0, 500) ?? null,
|
|
360
|
+
seconds: str(raw.seconds),
|
|
361
|
+
ratio: str(raw.ratio),
|
|
362
|
+
seed: num(raw.seed),
|
|
363
|
+
filename: str(raw.filename),
|
|
364
|
+
path: str(raw.path),
|
|
365
|
+
bytes: num(raw.bytes),
|
|
366
|
+
error: str(raw.error),
|
|
367
|
+
createdAt: str(raw.createdAt) ?? new Date().toISOString(),
|
|
368
|
+
updatedAt: str(raw.updatedAt) ?? new Date().toISOString(),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function readTasks() {
|
|
373
|
+
try {
|
|
374
|
+
const raw = JSON.parse(await readFile(TASKS_FILE, 'utf8'));
|
|
375
|
+
const list = Array.isArray(raw) ? raw : (Array.isArray(raw?.tasks) ? raw.tasks : []);
|
|
376
|
+
return list.map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
377
|
+
} catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function writeTasks(tasks) {
|
|
383
|
+
const list = (Array.isArray(tasks) ? tasks : []).map(sanitizeTask).filter((x) => x !== null).slice(0, TASK_MAX);
|
|
384
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
385
|
+
await writeFile(TASKS_FILE, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
386
|
+
return list;
|
|
387
|
+
}
|
|
388
|
+
|
|
221
389
|
/** 读 Key。不传 file 则按当前配置解析。 */
|
|
390
|
+
/**
|
|
391
|
+
* 问上游要模型清单,用来做「测试连接」和填下拉选项。
|
|
392
|
+
* 顺带区分三种失败:Key 不对(401/403)、额度/限流(429)、网络或服务异常。
|
|
393
|
+
*/
|
|
394
|
+
export async function listModels({ baseUrl = DEFAULT_BASE, key } = {}) {
|
|
395
|
+
const k = key ?? (await readKey());
|
|
396
|
+
if (!k) throw new Error('没有找到 API Key,先确认 Key 文件路径');
|
|
397
|
+
let res;
|
|
398
|
+
try {
|
|
399
|
+
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/models`, {
|
|
400
|
+
headers: { Authorization: `Bearer ${k}` },
|
|
401
|
+
signal: AbortSignal.timeout(20_000),
|
|
402
|
+
});
|
|
403
|
+
} catch (e) {
|
|
404
|
+
throw new Error(`连不上上游:${e && e.message ? e.message : String(e)}`);
|
|
405
|
+
}
|
|
406
|
+
if (res.status === 401 || res.status === 403) throw new Error('Key 无效或已过期(HTTP ' + res.status + ')');
|
|
407
|
+
if (res.status === 429) throw new Error('上游限流(HTTP 429),稍后再试');
|
|
408
|
+
if (!res.ok) throw new Error('上游返回 HTTP ' + res.status);
|
|
409
|
+
|
|
410
|
+
const payload = await res.json().catch(() => null);
|
|
411
|
+
const rows = payload && Array.isArray(payload.data) ? payload.data : [];
|
|
412
|
+
const ids = rows.map((r) => (r && typeof r.id === 'string' ? r.id : '')).filter(Boolean);
|
|
413
|
+
return {
|
|
414
|
+
models: ids,
|
|
415
|
+
image: ids.filter((id) => id.startsWith('agnes-image-')),
|
|
416
|
+
video: ids.filter((id) => id.startsWith('agnes-video-')),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* ==========================================================================
|
|
421
|
+
提示词扩写(借上游文本模型)
|
|
422
|
+
--------------------------------------------------------------------------
|
|
423
|
+
实测契约(2026-11-12):POST /v1/chat/completions,OpenAI 兼容;
|
|
424
|
+
model=agnes-2.5-flash 可用,正文在 choices[0].message.content,约 160 tokens/次。
|
|
425
|
+
图片与视频的扩写侧重不同,所以按 kind 换系统提示。
|
|
426
|
+
========================================================================== */
|
|
427
|
+
|
|
428
|
+
export const EXPAND_MODEL = 'agnes-2.5-flash';
|
|
429
|
+
export const EXPAND_MAX_INPUT = 1000;
|
|
430
|
+
export const EXPAND_MAX_OUTPUT = 1200;
|
|
431
|
+
export const EXPAND_TIMEOUT_MS = 60_000;
|
|
432
|
+
|
|
433
|
+
const EXPAND_SYSTEM_IMAGE =
|
|
434
|
+
'你是图像提示词工程师。把用户给的简短想法扩写成一段具体的画面描述:'
|
|
435
|
+
+ '补上主体外观与动作、环境细节、光线方向与质感、镜头视角与构图。'
|
|
436
|
+
+ '只输出这一段中文描述,不要解释,不要分点,不要加引号,不要写「提示词:」这类前缀。';
|
|
437
|
+
|
|
438
|
+
const EXPAND_SYSTEM_VIDEO =
|
|
439
|
+
'你是视频提示词工程师。把用户给的简短想法扩写成一段可执行的视频描述:'
|
|
440
|
+
+ '包含主体动作的推进、镜头运动(推拉摇移或固定)、景别变化、光线与氛围,'
|
|
441
|
+
+ '时长按 5 秒左右的节奏来写,不要出现分镜编号或时间码。'
|
|
442
|
+
+ '只输出这一段中文描述,不要解释,不要分点,不要加引号。';
|
|
443
|
+
|
|
444
|
+
/** 把一句话扩写成一段提示词。kind: 'image' | 'video'。 */
|
|
445
|
+
export async function expandPrompt(prompt, { kind = 'image', model = EXPAND_MODEL, key, baseUrl = DEFAULT_BASE } = {}) {
|
|
446
|
+
const text = typeof prompt === 'string' ? prompt.trim() : '';
|
|
447
|
+
if (text === '') throw new Error('先写一句想画什么,再来扩写');
|
|
448
|
+
if (text.length > EXPAND_MAX_INPUT) throw new Error(`提示词太长(上限 ${EXPAND_MAX_INPUT} 字)`);
|
|
449
|
+
const k = key ?? (await readKey());
|
|
450
|
+
if (!k) throw new Error('没有找到 API Key,先确认 Key 文件路径');
|
|
451
|
+
|
|
452
|
+
const system = kind === 'video' ? EXPAND_SYSTEM_VIDEO : EXPAND_SYSTEM_IMAGE;
|
|
453
|
+
let res;
|
|
454
|
+
try {
|
|
455
|
+
res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
456
|
+
method: 'POST',
|
|
457
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${k}` },
|
|
458
|
+
body: JSON.stringify({
|
|
459
|
+
model,
|
|
460
|
+
messages: [
|
|
461
|
+
{ role: 'system', content: system },
|
|
462
|
+
{ role: 'user', content: text },
|
|
463
|
+
],
|
|
464
|
+
max_tokens: 400,
|
|
465
|
+
}),
|
|
466
|
+
signal: AbortSignal.timeout(EXPAND_TIMEOUT_MS),
|
|
467
|
+
});
|
|
468
|
+
} catch (e) {
|
|
469
|
+
throw new Error(`连不上上游:${e && e.message ? e.message : String(e)}`);
|
|
470
|
+
}
|
|
471
|
+
if (res.status === 401 || res.status === 403) throw new Error('Key 无效或已过期(HTTP ' + res.status + ')');
|
|
472
|
+
if (res.status === 429) throw new Error('上游限流(HTTP 429),稍后再试');
|
|
473
|
+
if (!res.ok) {
|
|
474
|
+
const body = await res.text().catch(() => '');
|
|
475
|
+
throw new Error(`上游返回 HTTP ${res.status}${body ? ':' + body.slice(0, 120) : ''}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const payload = await res.json().catch(() => null);
|
|
479
|
+
const content = payload && payload.choices && payload.choices[0]
|
|
480
|
+
? payload.choices[0].message && payload.choices[0].message.content
|
|
481
|
+
: '';
|
|
482
|
+
// 有的模型会自带一对引号或收尾换行,剥掉再返回
|
|
483
|
+
const out = (typeof content === 'string' ? content : '')
|
|
484
|
+
.trim()
|
|
485
|
+
.replace(/^[「“"'`]+/, '')
|
|
486
|
+
.replace(/[」”"'`]+$/, '')
|
|
487
|
+
.trim();
|
|
488
|
+
if (out === '') throw new Error('上游没有返回内容,再试一次');
|
|
489
|
+
return { prompt: out.slice(0, EXPAND_MAX_OUTPUT), model, usage: (payload && payload.usage) || null };
|
|
490
|
+
}
|
|
491
|
+
|
|
222
492
|
export async function readKey(file) {
|
|
223
493
|
const target = file ?? (await resolvePaths()).keyFile;
|
|
224
494
|
let raw;
|
|
@@ -696,6 +966,9 @@ export const VIDEO_MODEL = 'agnes-video-2.5-flash';
|
|
|
696
966
|
export const VIDEO_SECONDS = ['4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
|
697
967
|
export const VIDEO_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
|
698
968
|
export const VIDEO_SIZE = '720P';
|
|
969
|
+
|
|
970
|
+
/** 视频 reference 模式的参考图上限(上游文档:images 最多 5 张)。 */
|
|
971
|
+
export const MAX_VIDEO_REF_IMAGES = 5;
|
|
699
972
|
export const VIDEO_TIMEOUT_MS = 600_000;
|
|
700
973
|
|
|
701
974
|
/** 从 baseUrl 剥出域名根:/agnesapi 不在 /v1 下。 */
|
|
@@ -722,13 +995,14 @@ export async function submitVideo({
|
|
|
722
995
|
seed = null,
|
|
723
996
|
firstFrame = null,
|
|
724
997
|
lastFrame = null,
|
|
998
|
+
referenceImages = [],
|
|
725
999
|
retries = 3,
|
|
726
1000
|
retryDelayMs = 15_000,
|
|
727
1001
|
} = {}) {
|
|
728
1002
|
let lastError = null;
|
|
729
1003
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
730
1004
|
try {
|
|
731
|
-
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame });
|
|
1005
|
+
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, firstFrame, lastFrame, referenceImages });
|
|
732
1006
|
} catch (e) {
|
|
733
1007
|
lastError = e;
|
|
734
1008
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -757,7 +1031,17 @@ function normalizeSeed(seed) {
|
|
|
757
1031
|
return n;
|
|
758
1032
|
}
|
|
759
1033
|
|
|
760
|
-
async function submitVideoOnce({
|
|
1034
|
+
async function submitVideoOnce({
|
|
1035
|
+
prompt,
|
|
1036
|
+
seconds,
|
|
1037
|
+
ratio,
|
|
1038
|
+
model,
|
|
1039
|
+
baseUrl,
|
|
1040
|
+
seed,
|
|
1041
|
+
firstFrame,
|
|
1042
|
+
lastFrame,
|
|
1043
|
+
referenceImages = [],
|
|
1044
|
+
}) {
|
|
761
1045
|
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
762
1046
|
const sec = String(seconds);
|
|
763
1047
|
if (!VIDEO_SECONDS.includes(sec)) {
|
|
@@ -767,7 +1051,9 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, f
|
|
|
767
1051
|
|
|
768
1052
|
const first = normalizeFrame(firstFrame, '首帧图');
|
|
769
1053
|
const last = normalizeFrame(lastFrame, '尾帧图');
|
|
770
|
-
const
|
|
1054
|
+
const refs = normalizeRefs(referenceImages, MAX_VIDEO_REF_IMAGES);
|
|
1055
|
+
// 关键帧优先:给了首/尾帧就是 keyframe;否则有参考图走 reference;都没有才是纯文本
|
|
1056
|
+
const mode = first !== null || last !== null ? 'keyframe' : refs.length > 0 ? 'reference' : 'text';
|
|
771
1057
|
const seedValue = normalizeSeed(seed);
|
|
772
1058
|
|
|
773
1059
|
const key = await readKey();
|
|
@@ -784,6 +1070,7 @@ async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl, seed, f
|
|
|
784
1070
|
if (first !== null) body.first_frame = first;
|
|
785
1071
|
if (last !== null) body.last_frame = last;
|
|
786
1072
|
}
|
|
1073
|
+
if (mode === 'reference') body.images = refs;
|
|
787
1074
|
if (seedValue !== null) body.seed = seedValue;
|
|
788
1075
|
|
|
789
1076
|
let res;
|