dsh-agnes-create 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/README.md +126 -2
- package/cordis.patch.yml +5 -0
- package/lib/agnes.js +525 -0
- package/lib/client.js +1136 -0
- package/lib/index.js +340 -0
- package/package.json +61 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-agnes-create contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-agnes-create
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DeepSeek Harness(dsh)里直接**出图**和**出视频**的侧边栏插件。侧边栏点一下「Agnes创作」,输入提示词就能出片,结果自动保存到本地并可在面板里回看。
|
|
4
|
+
|
|
5
|
+
> 仓库:<https://github.com/zxygithub/dsh-agnes-create> · npm:`dsh-agnes-create`
|
|
6
|
+
|
|
7
|
+
- 出图:`agnes-image-2.5-flash` — 支持 `1K`/`2K`/`3K`/`4K` 与 8 种宽高比
|
|
8
|
+
- 出视频:`agnes-video-2.5-flash` — 720P,4–12 秒,异步任务带百分比进度
|
|
9
|
+
- Key 文件位置、输出目录都能在界面里改,改完即时生效
|
|
10
|
+
- 最近 8 条生成结果可回看,一键清空界面(不删文件)
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
在插件市场里搜 `dsh-agnes-create` 安装,或命令行:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
dsh plugin --profile <profile> add dsh-agnes-create
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
装好后面板出现在左侧边栏,标签为 **Agnes创作**。
|
|
21
|
+
|
|
22
|
+
## 准备 API Key
|
|
23
|
+
|
|
24
|
+
插件从文件读取 Key,**不接受把 Key 写在配置里或代码里**。默认路径:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
~/.agnes-key.txt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
文件里只放 Key 本身,不要带 `Bearer ` 前缀、不要加引号:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
sk-xxxxxxxxxxxxxxxxxxxxxxxx
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Key 从 [Agnes AI 平台](https://platform.agnes-ai.com) 获取。端点默认走中国站 `https://api.agnes-ai.cn/v1`。
|
|
37
|
+
|
|
38
|
+
## 配置
|
|
39
|
+
|
|
40
|
+
面板底部的「设置」卡片可以改两项,保存后立即生效(不用重启):
|
|
41
|
+
|
|
42
|
+
| 配置项 | 默认值 | 说明 |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Key 文件路径 | `~/.agnes-key.txt` | 支持 `~/` 开头或绝对路径 |
|
|
45
|
+
| 输出目录 | `~/Desktop/Agnes出图` | 图片和视频都存这里,不存在会自动创建 |
|
|
46
|
+
|
|
47
|
+
- 输入框留空并保存 = 回退到默认值
|
|
48
|
+
- 配置存在 `~/.dsh/agnes-image/config.json`
|
|
49
|
+
- 优先级:显式传参 > 已保存配置 > 环境变量(`AGNES_KEY_FILE` / `AGNES_OUT_DIR`)> 默认值
|
|
50
|
+
- 配置文件损坏时会静默回退默认值,不会让插件加载失败
|
|
51
|
+
|
|
52
|
+
## 用法
|
|
53
|
+
|
|
54
|
+
1. 侧边栏点 **Agnes创作**
|
|
55
|
+
2. 顶部选择 **图片** 或 **视频**
|
|
56
|
+
3. 输入提示词,`Cmd/Ctrl + Enter` 或点按钮生成
|
|
57
|
+
4. 结果直接显示在面板里;图片用 `<img>`、视频用 `<video>` 播放(支持拖动进度条)
|
|
58
|
+
|
|
59
|
+
提示词建议按「主体 + 场景 + 动作/镜头 + 光线 + 风格 + 构图」组织,例如:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
一只胖乎乎的橘色虎斑猫坐在木质窗台上,午后暖阳斜射,窗外虚化的街景,浅景深,写实摄影,中景构图,高细节
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
参数范围:
|
|
66
|
+
|
|
67
|
+
| 类型 | 尺寸 | 宽高比 | 其它 |
|
|
68
|
+
| --- | --- | --- | --- |
|
|
69
|
+
| 图片 | `1K` `2K` `3K` `4K` | `1:1` `3:4` `4:3` `16:9` `9:16` `2:3` `3:2` `21:9` | 可选 1–8 张参考图(图生图 / 多图合成) |
|
|
70
|
+
| 视频 | 固定 `720P` | `21:9` `16:9` `4:3` `1:1` `3:4` `9:16` | 时长 4–12 秒 |
|
|
71
|
+
|
|
72
|
+
### 图生图 / 多图合成
|
|
73
|
+
|
|
74
|
+
「参考图」区有两种来源,可以混着加,最多 8 张:
|
|
75
|
+
|
|
76
|
+
- **+ 本地图片** —— 从磁盘选一个或多个文件(PNG / JPEG / WebP / GIF)
|
|
77
|
+
- **+ 生成结果** —— 从下方「最近生成」里挑一张(视频不能当参考图)
|
|
78
|
+
|
|
79
|
+
加好之后提示词写「想怎么改」,按钮会变成 **图生图**:
|
|
80
|
+
|
|
81
|
+
| 参考图数量 | 实际行为 |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 0 张 | 纯文生图 |
|
|
84
|
+
| 1 张 | 图生图(按参考图重绘 / 改风格 / 换背景) |
|
|
85
|
+
| 2–8 张 | 多图合成(把多张图的元素 / 配色融合) |
|
|
86
|
+
|
|
87
|
+
实现细节:
|
|
88
|
+
|
|
89
|
+
- 图片在**浏览器侧先下采样到长边 1280** 再编码成 data URI(PNG 优先保留透明通道,超过 800 KB 自动转 JPEG),所以一张 4K 原图发出的请求也只有几百 KB
|
|
90
|
+
- 参考图走接口的 `extra_body.image`;多图就是往这个数组里放多条
|
|
91
|
+
- 校验在前端和内核各做一次:只接受 `http(s)` 或 `data:image/…;base64`,超过 8 张、空串、非图片类型都会直接报错,不会把脏数据发给上游
|
|
92
|
+
- 本地文件路径**不会**被读取或上传到别处:文件内容只在内存里转成 base64 随请求发给 Agnes
|
|
93
|
+
|
|
94
|
+
## 关于费用
|
|
95
|
+
|
|
96
|
+
插件的费用由 Agnes AI 账号结算,本插件不涉及任何计费。
|
|
97
|
+
|
|
98
|
+
- 图片:1K 刊例价约 `$0.010/张`,2K 约 `$0.018/张`
|
|
99
|
+
- 视频:`$0.025/秒`(4 秒约 `$0.10`)
|
|
100
|
+
|
|
101
|
+
官方文档标注当前为体验价(`$0`)。具体以平台公告为准。
|
|
102
|
+
|
|
103
|
+
## 已知限制
|
|
104
|
+
|
|
105
|
+
- 视频为**异步任务**:提交后轮询,通常需要 1–4 分钟;队列繁忙时提交会返回 `503`,插件会自动重试
|
|
106
|
+
- 查询接口有频率限制,触发 `429` 时面板会显示「限流重试中…」并退避,任务不会因此失败
|
|
107
|
+
- 视频只支持**文生视频**:暂无首尾帧、参考音频与视频输入(Flash 版接口本身不支持这些)
|
|
108
|
+
- 参考图最多 8 张;单张在浏览器侧压到长边 1280,细节极多的小字参考图重绘后可能看不清
|
|
109
|
+
- 生成结果只保留最近 8 条(本次会话内),刷新页面后历史清空;文件本身在输出目录里不会丢
|
|
110
|
+
|
|
111
|
+
## 开发
|
|
112
|
+
|
|
113
|
+
包内是纯 ESM,**没有构建步骤**:`lib/client.js` 是手写的客户端 bundle(`window.__ModuleLoader__.load` 包装),宿主侧 `lib/index.js` 注册 HTTP 路由,`lib/agnes.js` 是与命令行共用的内核。
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
lib/
|
|
117
|
+
├── agnes.js 内核:请求构造、视频任务轮询、落盘、配置解析
|
|
118
|
+
├── index.js 宿主半:/agnes/health /agnes/config /agnes/generate /agnes/video /agnes/media
|
|
119
|
+
└── client.js 客户端半:侧边栏入口 + 面板 UI
|
|
120
|
+
cordis.patch.yml 把插件行插入 profile
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
改动后无需构建,重载窗口即可生效;宿主路由的改动需要重启应用。
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/agnes.js
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agnes 出图内核 —— 被宿主插件(lib/index.js)和 CLI(agnes-image.mjs)共用。
|
|
3
|
+
*
|
|
4
|
+
* 文档:https://www.agnes-ai.com/zh-Hans/docs/agnes-image-25-flash
|
|
5
|
+
*
|
|
6
|
+
* 硬约束(别改错,都是文档里写死的坑):
|
|
7
|
+
* · POST {base}/images/generations
|
|
8
|
+
* · 文生图必填 model / prompt / size
|
|
9
|
+
* · response_format 只能放 extra_body 内,放顶层会报错
|
|
10
|
+
* · 图生图输入放 extra_body.image(URL 或 data URI)
|
|
11
|
+
*
|
|
12
|
+
* API Key 永远从文件读,不进代码、不进日志。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join, basename } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export const MODEL = 'agnes-image-2.5-flash';
|
|
20
|
+
export const DEFAULT_BASE = 'https://api.agnes-ai.cn/v1'; // 中国站
|
|
21
|
+
|
|
22
|
+
/* ==========================================================================
|
|
23
|
+
可配置路径:Key 文件位置 / 输出目录
|
|
24
|
+
--------------------------------------------------------------------------
|
|
25
|
+
优先级:显式传参 > 已保存配置 > 环境变量 > 内置默认。
|
|
26
|
+
配置存于 ~/.dsh/agnes-image/config.json,由面板里的「设置」卡片写入。
|
|
27
|
+
========================================================================== */
|
|
28
|
+
|
|
29
|
+
export const CONFIG_DIR = join(homedir(), '.dsh', 'agnes-image');
|
|
30
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
31
|
+
export const DEFAULT_KEY_FILE = join(homedir(), '.agnes-key.txt');
|
|
32
|
+
export const DEFAULT_OUT_DIR = join(homedir(), 'Desktop', 'Agnes出图');
|
|
33
|
+
|
|
34
|
+
/** 兼容旧引用(skill / CLI 用它展示默认值)。生效值请用 resolvePaths()。 */
|
|
35
|
+
export const KEY_FILE = process.env.AGNES_KEY_FILE || DEFAULT_KEY_FILE;
|
|
36
|
+
export const OUT_DIR = process.env.AGNES_OUT_DIR || DEFAULT_OUT_DIR;
|
|
37
|
+
|
|
38
|
+
export const TIMEOUT_MS = 300_000; // 文档建议 60s–360s
|
|
39
|
+
|
|
40
|
+
export const SIZES = ['1K', '2K', '3K', '4K'];
|
|
41
|
+
export const RATIOS = ['1:1', '3:4', '4:3', '16:9', '9:16', '2:3', '3:2', '21:9'];
|
|
42
|
+
|
|
43
|
+
/** 图生图 / 多图合成:一次最多几张参考图。文档没写上限,8 张是面板与请求体的安全值。 */
|
|
44
|
+
export const MAX_REF_IMAGES = 8;
|
|
45
|
+
|
|
46
|
+
/** data URI 只接受图片 MIME,其余一律拒绝(防止把任意 base64 塞进上游请求)。 */
|
|
47
|
+
const DATA_URI_RE = /^data:image\/(png|jpe?g|webp|gif);base64,[A-Za-z0-9+/=]+$/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 校验并规整参考图数组。
|
|
51
|
+
* 文档要求 extra_body.image 里是「公共图像 URL 或 Data URI Base64」,
|
|
52
|
+
* 所以这里只放行 http(s) 与 data:image 两种,别的直接报错而不是透传给上游。
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeRefs(images) {
|
|
55
|
+
if (images === undefined || images === null) return [];
|
|
56
|
+
if (!Array.isArray(images)) throw new Error('参考图必须是数组');
|
|
57
|
+
if (images.length > MAX_REF_IMAGES) {
|
|
58
|
+
throw new Error(`参考图最多 ${MAX_REF_IMAGES} 张,收到 ${images.length} 张`);
|
|
59
|
+
}
|
|
60
|
+
return images.map((value, i) => {
|
|
61
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
62
|
+
throw new Error(`第 ${i + 1} 张参考图不是有效字符串`);
|
|
63
|
+
}
|
|
64
|
+
const s = value.trim();
|
|
65
|
+
if (/^https?:\/\//i.test(s)) return s;
|
|
66
|
+
if (DATA_URI_RE.test(s)) return s;
|
|
67
|
+
const kind = s.slice(0, 30).replace(/\s+/g, ' ');
|
|
68
|
+
throw new Error(`第 ${i + 1} 张参考图只接受 http(s) 链接或 data:image/…;base64,收到:${kind}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 把开头的 ~ 展开成家目录;其余原样返回。 */
|
|
73
|
+
export function expandHome(p) {
|
|
74
|
+
const s = String(p ?? '').trim();
|
|
75
|
+
if (s === '~') return homedir();
|
|
76
|
+
if (s.startsWith('~/')) return join(homedir(), s.slice(2));
|
|
77
|
+
return s;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 解析生效配置。
|
|
82
|
+
* 配置文件不存在或内容损坏时一律退回默认值 —— 配置坏了不该把插件拖死。
|
|
83
|
+
*/
|
|
84
|
+
export async function resolvePaths() {
|
|
85
|
+
let stored = {};
|
|
86
|
+
try {
|
|
87
|
+
const raw = JSON.parse(await readFile(CONFIG_FILE, 'utf8'));
|
|
88
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) stored = raw;
|
|
89
|
+
} catch { /* 无配置文件或解析失败 → 用默认 */ }
|
|
90
|
+
|
|
91
|
+
const storedKey = typeof stored.keyFile === 'string' && stored.keyFile.trim() !== ''
|
|
92
|
+
? expandHome(stored.keyFile) : null;
|
|
93
|
+
const storedOut = typeof stored.outDir === 'string' && stored.outDir.trim() !== ''
|
|
94
|
+
? expandHome(stored.outDir) : null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
keyFile: storedKey ?? (process.env.AGNES_KEY_FILE || DEFAULT_KEY_FILE),
|
|
98
|
+
outDir: storedOut ?? (process.env.AGNES_OUT_DIR || DEFAULT_OUT_DIR),
|
|
99
|
+
configFile: CONFIG_FILE,
|
|
100
|
+
defaults: { keyFile: DEFAULT_KEY_FILE, outDir: DEFAULT_OUT_DIR },
|
|
101
|
+
stored: { keyFile: storedKey, outDir: storedOut },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 保存配置,只覆盖传进来的字段;传 null 表示清除该字段、回退默认。
|
|
107
|
+
* 校验:必须是绝对路径;输出目录立刻创建 —— 写不进去现在报错,
|
|
108
|
+
* 而不是等到出图时才失败。
|
|
109
|
+
*/
|
|
110
|
+
export async function saveConfig(patch = {}) {
|
|
111
|
+
const current = await resolvePaths();
|
|
112
|
+
|
|
113
|
+
const next = {
|
|
114
|
+
keyFile: patch.keyFile === undefined ? current.stored.keyFile : patch.keyFile,
|
|
115
|
+
outDir: patch.outDir === undefined ? current.stored.outDir : patch.outDir,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
for (const [field, label] of [['keyFile', 'Key 文件路径'], ['outDir', '输出目录']]) {
|
|
119
|
+
const v = next[field];
|
|
120
|
+
if (v === null) continue; // 允许清空回默认
|
|
121
|
+
if (typeof v !== 'string' || v.trim() === '') throw new Error(`${label}不能为空`);
|
|
122
|
+
const expanded = expandHome(v);
|
|
123
|
+
if (!expanded.startsWith('/')) throw new Error(`${label}必须是绝对路径(或以 ~/ 开头)`);
|
|
124
|
+
next[field] = expanded;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await mkdir(next.outDir ?? current.outDir, { recursive: true });
|
|
128
|
+
|
|
129
|
+
const payload = {};
|
|
130
|
+
if (typeof next.keyFile === 'string') payload.keyFile = next.keyFile;
|
|
131
|
+
if (typeof next.outDir === 'string') payload.outDir = next.outDir;
|
|
132
|
+
|
|
133
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
134
|
+
await writeFile(CONFIG_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
135
|
+
|
|
136
|
+
return resolvePaths();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 读 Key。不传 file 则按当前配置解析。 */
|
|
140
|
+
export async function readKey(file) {
|
|
141
|
+
const target = file ?? (await resolvePaths()).keyFile;
|
|
142
|
+
let raw;
|
|
143
|
+
try {
|
|
144
|
+
raw = await readFile(target, 'utf8');
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e.code === 'ENOENT') {
|
|
147
|
+
throw new Error(`找不到 key 文件:${target}。把 Key 写进该文件(只放 key 本身,不要带 "Bearer")。`);
|
|
148
|
+
}
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
const key = raw.trim();
|
|
152
|
+
if (!key) throw new Error(`key 文件是空的:${target}`);
|
|
153
|
+
if (/^bearer\s+/i.test(key)) throw new Error('key 文件里不要带 "Bearer " 前缀');
|
|
154
|
+
return key;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function keyStatus(file) {
|
|
158
|
+
const target = file ?? (await resolvePaths()).keyFile;
|
|
159
|
+
try {
|
|
160
|
+
const key = await readKey(target);
|
|
161
|
+
return { present: true, file: target, length: key.length, masked: key.slice(0, 3) + '***' + key.slice(-3) };
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return { present: false, file: target, error: e.message };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildBody({ prompt, size = '1K', ratio = '1:1', model = MODEL, fmt = 'url', images = [] }) {
|
|
168
|
+
const body = {
|
|
169
|
+
model,
|
|
170
|
+
prompt,
|
|
171
|
+
size,
|
|
172
|
+
ratio,
|
|
173
|
+
// 文档明确要求:response_format 只能在 extra_body 内
|
|
174
|
+
extra_body: { response_format: fmt === 'b64' ? 'b64_json' : 'url' },
|
|
175
|
+
};
|
|
176
|
+
if (images.length > 0) body.extra_body.image = images;
|
|
177
|
+
return body;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function post({ baseUrl, key, body }) {
|
|
181
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/images/generations`;
|
|
182
|
+
let res;
|
|
183
|
+
try {
|
|
184
|
+
res = await fetch(url, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
187
|
+
body: JSON.stringify(body),
|
|
188
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
189
|
+
});
|
|
190
|
+
} catch (e) {
|
|
191
|
+
const detail = e.name === 'TimeoutError' ? `超时(${TIMEOUT_MS / 1000}s)` : `${e.name}: ${e.message}`;
|
|
192
|
+
throw new Error(`请求失败 — ${detail}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const text = await res.text();
|
|
196
|
+
let payload;
|
|
197
|
+
try {
|
|
198
|
+
payload = JSON.parse(text);
|
|
199
|
+
} catch {
|
|
200
|
+
throw new Error(`HTTP ${res.status},响应非 JSON:${text.slice(0, 300)}`);
|
|
201
|
+
}
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
const msg = payload?.error?.message || payload?.message || text.slice(0, 300);
|
|
204
|
+
throw new Error(`HTTP ${res.status} — ${msg}`);
|
|
205
|
+
}
|
|
206
|
+
const item = payload?.data?.[0];
|
|
207
|
+
if (!item) throw new Error(`响应缺少 data[0]:${text.slice(0, 300)}`);
|
|
208
|
+
return { payload, item };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function stamp() {
|
|
212
|
+
return new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 只允许安全的文件名,防止路径穿越。 */
|
|
216
|
+
export function safeName(name) {
|
|
217
|
+
const b = basename(String(name || ''));
|
|
218
|
+
return /^[A-Za-z0-9._-]+$/.test(b) ? b : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 生成一张图并落盘。
|
|
223
|
+
* @returns {Promise<{ok:true, filename:string, path:string, bytes:number, transport:string,
|
|
224
|
+
* elapsedSec:number, size:string, ratio:string, model:string,
|
|
225
|
+
* revisedPrompt:string|null, remoteUrl:string|null}>}
|
|
226
|
+
*/
|
|
227
|
+
export async function generate({
|
|
228
|
+
prompt,
|
|
229
|
+
size = '1K',
|
|
230
|
+
ratio = '1:1',
|
|
231
|
+
model = MODEL,
|
|
232
|
+
baseUrl = DEFAULT_BASE,
|
|
233
|
+
fmt = 'url',
|
|
234
|
+
images = [],
|
|
235
|
+
outDir,
|
|
236
|
+
filename,
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
239
|
+
if (!SIZES.includes(size)) throw new Error(`size 必须是 ${SIZES.join(' / ')}`);
|
|
240
|
+
if (!RATIOS.includes(ratio)) throw new Error(`ratio 必须是 ${RATIOS.join(' / ')}`);
|
|
241
|
+
|
|
242
|
+
// 有参考图 → 图生图 / 多图合成;没有 → 纯文生图。校验在发请求之前做完。
|
|
243
|
+
const refs = normalizeRefs(images);
|
|
244
|
+
|
|
245
|
+
const key = await readKey();
|
|
246
|
+
const started = Date.now();
|
|
247
|
+
|
|
248
|
+
let { payload, item } = await post({ baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt, images: refs }) });
|
|
249
|
+
|
|
250
|
+
const dir = outDir ?? (await resolvePaths()).outDir;
|
|
251
|
+
const name = filename || `agnes-${stamp()}.png`;
|
|
252
|
+
const path = join(dir, name);
|
|
253
|
+
await mkdir(dir, { recursive: true });
|
|
254
|
+
|
|
255
|
+
let buf;
|
|
256
|
+
let transport;
|
|
257
|
+
let remoteUrl = item.url || null;
|
|
258
|
+
|
|
259
|
+
if (fmt === 'url' && item.url) {
|
|
260
|
+
try {
|
|
261
|
+
const r = await fetch(item.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
262
|
+
if (!r.ok) throw new Error(`下载返回 HTTP ${r.status}`);
|
|
263
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
264
|
+
if (buf.length === 0) throw new Error('下载到 0 字节');
|
|
265
|
+
transport = 'url → 下载';
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// 存储域名不可达时自动回退 base64,避免白跑一次
|
|
268
|
+
({ payload, item } = await post({
|
|
269
|
+
baseUrl, key, body: buildBody({ prompt, size, ratio, model, fmt: 'b64', images }),
|
|
270
|
+
}));
|
|
271
|
+
if (!item.b64_json) throw new Error(`回退 b64 后仍无 b64_json(原下载错误:${e.message})`);
|
|
272
|
+
buf = Buffer.from(item.b64_json, 'base64');
|
|
273
|
+
transport = `url 失败(${e.message}) → b64 回退`;
|
|
274
|
+
remoteUrl = null;
|
|
275
|
+
}
|
|
276
|
+
} else if (item.b64_json) {
|
|
277
|
+
buf = Buffer.from(item.b64_json, 'base64');
|
|
278
|
+
transport = item.url ? 'b64(服务端附带 url)' : 'b64';
|
|
279
|
+
} else if (item.url) {
|
|
280
|
+
const r = await fetch(item.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
281
|
+
if (!r.ok) throw new Error(`下载返回 HTTP ${r.status}`);
|
|
282
|
+
buf = Buffer.from(await r.arrayBuffer());
|
|
283
|
+
transport = 'url → 下载';
|
|
284
|
+
} else {
|
|
285
|
+
throw new Error('响应里既没有 url 也没有 b64_json');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!buf || buf.length === 0) throw new Error('图片数据为空');
|
|
289
|
+
await writeFile(path, buf);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
filename: name,
|
|
294
|
+
path,
|
|
295
|
+
bytes: buf.length,
|
|
296
|
+
transport,
|
|
297
|
+
elapsedSec: Number(((Date.now() - started) / 1000).toFixed(1)),
|
|
298
|
+
size,
|
|
299
|
+
ratio,
|
|
300
|
+
model,
|
|
301
|
+
// 有参考图 = 图生图 / 多图合成;没有 = 文生图。面板据此显示模式。
|
|
302
|
+
mode: refs.length > 0 ? 'img2img' : 'text2img',
|
|
303
|
+
refCount: refs.length,
|
|
304
|
+
revisedPrompt: item.revised_prompt ?? null,
|
|
305
|
+
remoteUrl,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ==========================================================================
|
|
310
|
+
媒体读取(图片 + 视频)
|
|
311
|
+
========================================================================== */
|
|
312
|
+
const MIME = {
|
|
313
|
+
'.png': 'image/png',
|
|
314
|
+
'.jpg': 'image/jpeg',
|
|
315
|
+
'.jpeg': 'image/jpeg',
|
|
316
|
+
'.webp': 'image/webp',
|
|
317
|
+
'.gif': 'image/gif',
|
|
318
|
+
'.mp4': 'video/mp4',
|
|
319
|
+
'.webm': 'video/webm',
|
|
320
|
+
'.mov': 'video/quicktime',
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export async function readMedia(name, outDir) {
|
|
324
|
+
const safe = safeName(name);
|
|
325
|
+
if (safe === null) throw new Error('非法文件名');
|
|
326
|
+
const dir = outDir ?? (await resolvePaths()).outDir;
|
|
327
|
+
const path = join(dir, safe);
|
|
328
|
+
const buf = await readFile(path);
|
|
329
|
+
const dot = safe.lastIndexOf('.');
|
|
330
|
+
const ext = dot < 0 ? '' : safe.slice(dot).toLowerCase();
|
|
331
|
+
return { path, buf, mime: MIME[ext] ?? 'application/octet-stream' };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 兼容旧调用:只取字节。 */
|
|
335
|
+
export async function readImage(name, outDir) {
|
|
336
|
+
const { path, buf } = await readMedia(name, outDir);
|
|
337
|
+
return { path, buf };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* ==========================================================================
|
|
341
|
+
视频(agnes-video-2.5-flash)
|
|
342
|
+
--------------------------------------------------------------------------
|
|
343
|
+
和图片的「一次请求就出结果」完全不同,这是异步任务制:
|
|
344
|
+
|
|
345
|
+
POST {base}/videos → 提交,拿 video_id
|
|
346
|
+
GET {origin}/agnesapi?video_id=... → 轮询,status=completed 时取顶层 url
|
|
347
|
+
|
|
348
|
+
⚠ /agnesapi 挂在域名根上,不在 /v1 之下,所以必须从 baseUrl 剥出 origin。
|
|
349
|
+
|
|
350
|
+
Flash 专属硬约束(违反直接 400,且不产生费用):
|
|
351
|
+
· size 只能是字符串 "720P"
|
|
352
|
+
· seconds 是字符串 "4"–"12"
|
|
353
|
+
· 不支持 videos 输入
|
|
354
|
+
========================================================================== */
|
|
355
|
+
|
|
356
|
+
export const VIDEO_MODEL = 'agnes-video-2.5-flash';
|
|
357
|
+
export const VIDEO_SECONDS = ['4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
|
358
|
+
export const VIDEO_RATIOS = ['21:9', '16:9', '4:3', '1:1', '3:4', '9:16'];
|
|
359
|
+
export const VIDEO_SIZE = '720P';
|
|
360
|
+
export const VIDEO_TIMEOUT_MS = 600_000;
|
|
361
|
+
|
|
362
|
+
/** 从 baseUrl 剥出域名根:/agnesapi 不在 /v1 下。 */
|
|
363
|
+
export function apiOrigin(baseUrl = DEFAULT_BASE) {
|
|
364
|
+
return new URL(baseUrl).origin;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 提交文生视频任务,返回 video_id。队列满(503)/限流(429) 会自动重试。 */
|
|
368
|
+
export async function submitVideo({
|
|
369
|
+
prompt,
|
|
370
|
+
seconds = '5',
|
|
371
|
+
ratio = '16:9',
|
|
372
|
+
model = VIDEO_MODEL,
|
|
373
|
+
baseUrl = DEFAULT_BASE,
|
|
374
|
+
retries = 3,
|
|
375
|
+
retryDelayMs = 15_000,
|
|
376
|
+
} = {}) {
|
|
377
|
+
let lastError = null;
|
|
378
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
379
|
+
try {
|
|
380
|
+
return await submitVideoOnce({ prompt, seconds, ratio, model, baseUrl });
|
|
381
|
+
} catch (e) {
|
|
382
|
+
lastError = e;
|
|
383
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
384
|
+
// 只有队列满/限流才值得重试;参数错、鉴权错重试无用
|
|
385
|
+
const retriable = /\b503\b|\b429\b|队列已满|rate limit/i.test(msg);
|
|
386
|
+
if (!retriable || attempt === retries) throw e;
|
|
387
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
throw lastError ?? new Error('提交任务失败');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function submitVideoOnce({ prompt, seconds, ratio, model, baseUrl }) {
|
|
394
|
+
if (typeof prompt !== 'string' || prompt.trim() === '') throw new Error('prompt 不能为空');
|
|
395
|
+
const sec = String(seconds);
|
|
396
|
+
if (!VIDEO_SECONDS.includes(sec)) {
|
|
397
|
+
throw new Error(`seconds 必须是字符串 "${VIDEO_SECONDS[0]}"–"${VIDEO_SECONDS[VIDEO_SECONDS.length - 1]}"`);
|
|
398
|
+
}
|
|
399
|
+
if (!VIDEO_RATIOS.includes(ratio)) throw new Error(`aspect_ratio 必须是 ${VIDEO_RATIOS.join(' / ')}`);
|
|
400
|
+
|
|
401
|
+
const key = await readKey();
|
|
402
|
+
const url = `${baseUrl.replace(/\/+$/, '')}/videos`;
|
|
403
|
+
const body = {
|
|
404
|
+
model,
|
|
405
|
+
prompt,
|
|
406
|
+
seconds: sec,
|
|
407
|
+
mode: 'text',
|
|
408
|
+
size: VIDEO_SIZE, // Flash 只认 720P
|
|
409
|
+
aspect_ratio: ratio,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
let res;
|
|
413
|
+
try {
|
|
414
|
+
res = await fetch(url, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
417
|
+
body: JSON.stringify(body),
|
|
418
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
419
|
+
});
|
|
420
|
+
} catch (e) {
|
|
421
|
+
const detail = e.name === 'TimeoutError' ? `超时(${TIMEOUT_MS / 1000}s)` : `${e.name}: ${e.message}`;
|
|
422
|
+
throw new Error(`提交任务失败 — ${detail}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const text = await res.text();
|
|
426
|
+
let payload;
|
|
427
|
+
try {
|
|
428
|
+
payload = JSON.parse(text);
|
|
429
|
+
} catch {
|
|
430
|
+
throw new Error(`HTTP ${res.status},响应非 JSON:${text.slice(0, 300)}`);
|
|
431
|
+
}
|
|
432
|
+
if (!res.ok) {
|
|
433
|
+
const msg = payload?.detail || payload?.error?.message || payload?.message || text.slice(0, 300);
|
|
434
|
+
throw new Error(`HTTP ${res.status} — ${msg}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 文档:id / task_id 是任务 ID,video_id 才是查询用的
|
|
438
|
+
const videoId = payload?.video_id ?? payload?.id ?? payload?.task_id;
|
|
439
|
+
if (typeof videoId !== 'string' || videoId === '') {
|
|
440
|
+
throw new Error(`响应里没有 video_id:${text.slice(0, 300)}`);
|
|
441
|
+
}
|
|
442
|
+
return { videoId, taskId: payload?.task_id ?? null, model, baseUrl };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 查询一次任务状态。429/503 属于瞬时限流,自动退避重试。 */
|
|
446
|
+
export async function queryVideo({
|
|
447
|
+
videoId,
|
|
448
|
+
model = VIDEO_MODEL,
|
|
449
|
+
baseUrl = DEFAULT_BASE,
|
|
450
|
+
key,
|
|
451
|
+
retries = 2,
|
|
452
|
+
retryDelayMs = 4_000,
|
|
453
|
+
} = {}) {
|
|
454
|
+
let lastError = null;
|
|
455
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
456
|
+
try {
|
|
457
|
+
return await queryVideoOnce({ videoId, model, baseUrl, key });
|
|
458
|
+
} catch (e) {
|
|
459
|
+
lastError = e;
|
|
460
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
461
|
+
// 实测查询接口会返回 429「查询过于频繁」,这里的重试是必须的
|
|
462
|
+
const retriable = /\b429\b|\b503\b|频繁|队列已满|rate limit/i.test(msg);
|
|
463
|
+
if (!retriable || attempt === retries) throw e;
|
|
464
|
+
await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
throw lastError ?? new Error('查询任务失败');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function queryVideoOnce({ videoId, model, baseUrl, key }) {
|
|
471
|
+
if (typeof videoId !== 'string' || videoId === '') throw new Error('缺少 videoId');
|
|
472
|
+
const k = key ?? (await readKey());
|
|
473
|
+
|
|
474
|
+
const url = new URL(`${apiOrigin(baseUrl)}/agnesapi`);
|
|
475
|
+
url.searchParams.set('video_id', videoId);
|
|
476
|
+
url.searchParams.set('model_name', model);
|
|
477
|
+
|
|
478
|
+
let res;
|
|
479
|
+
try {
|
|
480
|
+
res = await fetch(url, {
|
|
481
|
+
headers: { Authorization: `Bearer ${k}` },
|
|
482
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
483
|
+
});
|
|
484
|
+
} catch (e) {
|
|
485
|
+
const detail = e.name === 'TimeoutError' ? `超时(${TIMEOUT_MS / 1000}s)` : `${e.name}: ${e.message}`;
|
|
486
|
+
throw new Error(`查询任务失败 — ${detail}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const text = await res.text();
|
|
490
|
+
let payload;
|
|
491
|
+
try {
|
|
492
|
+
payload = JSON.parse(text);
|
|
493
|
+
} catch {
|
|
494
|
+
throw new Error(`HTTP ${res.status},响应非 JSON:${text.slice(0, 300)}`);
|
|
495
|
+
}
|
|
496
|
+
if (!res.ok) {
|
|
497
|
+
const msg = payload?.detail || payload?.error?.message || text.slice(0, 300);
|
|
498
|
+
throw new Error(`HTTP ${res.status} — ${msg}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
status: String(payload?.status ?? 'pending'),
|
|
503
|
+
progress: Number(payload?.progress ?? 0) || 0,
|
|
504
|
+
url: typeof payload?.url === 'string' && payload.url !== '' ? payload.url : null,
|
|
505
|
+
error: payload?.error ?? null,
|
|
506
|
+
seconds: payload?.seconds ?? null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 把已完成的视频下载到本地。 */
|
|
511
|
+
export async function saveVideo({ url, outDir, filename } = {}) {
|
|
512
|
+
if (typeof url !== 'string' || url === '') throw new Error('缺少视频地址');
|
|
513
|
+
const dir = outDir ?? (await resolvePaths()).outDir;
|
|
514
|
+
const name = filename || `agnes-video-${stamp()}.mp4`;
|
|
515
|
+
const path = join(dir, name);
|
|
516
|
+
await mkdir(dir, { recursive: true });
|
|
517
|
+
|
|
518
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(VIDEO_TIMEOUT_MS) });
|
|
519
|
+
if (!res.ok) throw new Error(`下载视频返回 HTTP ${res.status}`);
|
|
520
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
521
|
+
if (buf.length === 0) throw new Error('视频下载到 0 字节');
|
|
522
|
+
await writeFile(path, buf);
|
|
523
|
+
|
|
524
|
+
return { filename: name, path, bytes: buf.length };
|
|
525
|
+
}
|