dsh-agnes-create 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +103 -2
- package/cordis.patch.yml +5 -0
- package/lib/agnes.js +490 -0
- package/lib/client.js +783 -0
- package/lib/index.js +322 -0
- package/package.json +58 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主半:注册 JSON 路由给浏览器端 fetch。
|
|
3
|
+
*
|
|
4
|
+
* 图片(同步)
|
|
5
|
+
* GET /agnes/health 健康检查(模型、端点、key 状态、输出目录)
|
|
6
|
+
* POST /agnes/generate { prompt, size, ratio } → 生成并落盘
|
|
7
|
+
*
|
|
8
|
+
* 视频(异步任务制)
|
|
9
|
+
* POST /agnes/video { prompt, seconds, ratio } → 提交,立刻返回 { videoId }
|
|
10
|
+
* GET /agnes/video?id=… 查询一次状态;completed 时顺带下载落盘并返回 filename
|
|
11
|
+
* (下载结果按 videoId 缓存,反复轮询不会重复下载)
|
|
12
|
+
*
|
|
13
|
+
* 媒体
|
|
14
|
+
* GET /agnes/media?name=… 把已生成的图片/视频流回浏览器,视频支持 Range
|
|
15
|
+
* GET /agnes/image?name=… 兼容旧链接,等价于 /agnes/media
|
|
16
|
+
*
|
|
17
|
+
* 路由注册方式:webServer 可能晚于本插件就绪,所以既立刻试、
|
|
18
|
+
* 也监听 internal/service 事件。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
MODEL, VIDEO_MODEL, VIDEO_SECONDS, VIDEO_RATIOS, VIDEO_SIZE,
|
|
23
|
+
DEFAULT_BASE, SIZES, RATIOS,
|
|
24
|
+
generate, keyStatus, readMedia, resolvePaths, saveConfig,
|
|
25
|
+
submitVideo, queryVideo, saveVideo,
|
|
26
|
+
} from './agnes.js';
|
|
27
|
+
|
|
28
|
+
export const name = 'dsh-agnes-create';
|
|
29
|
+
|
|
30
|
+
function sendJson(res, data, status = 200) {
|
|
31
|
+
res.writeHead(status, {
|
|
32
|
+
'content-type': 'application/json; charset=utf-8',
|
|
33
|
+
'cache-control': 'no-store',
|
|
34
|
+
});
|
|
35
|
+
res.end(JSON.stringify(data));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let size = 0;
|
|
42
|
+
req.on('data', (c) => {
|
|
43
|
+
size += c.length;
|
|
44
|
+
if (size > 1_000_000) {
|
|
45
|
+
reject(new Error('请求体过大'));
|
|
46
|
+
req.destroy();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
chunks.push(c);
|
|
50
|
+
});
|
|
51
|
+
req.on('end', () => {
|
|
52
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
53
|
+
if (text.trim() === '') return resolve({});
|
|
54
|
+
try {
|
|
55
|
+
resolve(JSON.parse(text));
|
|
56
|
+
} catch {
|
|
57
|
+
reject(new Error('请求体不是合法 JSON'));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
req.on('error', reject);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errMessage(e) {
|
|
65
|
+
return e instanceof Error ? e.message : String(e);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 支持 Range 的媒体响应,视频拖动进度条需要它。 */
|
|
69
|
+
function sendMedia(req, res, buf, mime) {
|
|
70
|
+
const range = req.headers.range;
|
|
71
|
+
const m = typeof range === 'string' ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
|
|
72
|
+
|
|
73
|
+
if (m === null) {
|
|
74
|
+
res.writeHead(200, {
|
|
75
|
+
'content-type': mime,
|
|
76
|
+
'content-length': String(buf.length),
|
|
77
|
+
'accept-ranges': 'bytes',
|
|
78
|
+
'cache-control': 'no-store',
|
|
79
|
+
});
|
|
80
|
+
res.end(req.method === 'HEAD' ? undefined : buf);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const total = buf.length;
|
|
85
|
+
let start = m[1] === '' ? null : Number(m[1]);
|
|
86
|
+
let end = m[2] === '' ? null : Number(m[2]);
|
|
87
|
+
if (start === null) {
|
|
88
|
+
// bytes=-N:最后 N 字节
|
|
89
|
+
start = Math.max(0, total - (end ?? 0));
|
|
90
|
+
end = total - 1;
|
|
91
|
+
} else if (end === null) {
|
|
92
|
+
end = total - 1;
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= total) {
|
|
95
|
+
res.writeHead(416, { 'content-range': `bytes */${total}` });
|
|
96
|
+
res.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
end = Math.min(end, total - 1);
|
|
100
|
+
const slice = buf.subarray(start, end + 1);
|
|
101
|
+
res.writeHead(206, {
|
|
102
|
+
'content-type': mime,
|
|
103
|
+
'content-length': String(slice.length),
|
|
104
|
+
'content-range': `bytes ${start}-${end}/${total}`,
|
|
105
|
+
'accept-ranges': 'bytes',
|
|
106
|
+
'cache-control': 'no-store',
|
|
107
|
+
});
|
|
108
|
+
res.end(req.method === 'HEAD' ? undefined : slice);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function apply(ctx) {
|
|
112
|
+
let registered = false;
|
|
113
|
+
/** webServer.register 的返回值是「移除该路由」的 disposer,必须收好:
|
|
114
|
+
* 插件卸载/热重载时若不清理,路由会留在服务器表里,重新启用就会
|
|
115
|
+
* 「duplicate exact route」而激活失败(实测踩过)。 */
|
|
116
|
+
const routeDisposers = [];
|
|
117
|
+
|
|
118
|
+
const addRoute = (webServer, route) => {
|
|
119
|
+
routeDisposers.push(webServer.register(route));
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const registerRoutes = (value) => {
|
|
123
|
+
if (registered) return;
|
|
124
|
+
const webServer = value ?? ctx.reflect.get('webServer', false);
|
|
125
|
+
if (webServer === undefined) return;
|
|
126
|
+
registered = true;
|
|
127
|
+
|
|
128
|
+
/** videoId → 已下载的本地结果,避免每次轮询重复下载几十 MB */
|
|
129
|
+
const downloaded = new Map();
|
|
130
|
+
|
|
131
|
+
addRoute(webServer, {
|
|
132
|
+
kind: 'exact',
|
|
133
|
+
path: '/agnes/health',
|
|
134
|
+
handler: async (req, res) => {
|
|
135
|
+
try {
|
|
136
|
+
const cfg = await resolvePaths();
|
|
137
|
+
const k = await keyStatus(cfg.keyFile);
|
|
138
|
+
sendJson(res, {
|
|
139
|
+
ok: true,
|
|
140
|
+
model: MODEL,
|
|
141
|
+
videoModel: VIDEO_MODEL,
|
|
142
|
+
endpoint: `${DEFAULT_BASE}/images/generations`,
|
|
143
|
+
videoEndpoint: `${DEFAULT_BASE}/videos`,
|
|
144
|
+
keyFile: cfg.keyFile,
|
|
145
|
+
key: k, // 只有 present/length/masked,没有明文
|
|
146
|
+
outDir: cfg.outDir,
|
|
147
|
+
configFile: cfg.configFile,
|
|
148
|
+
defaults: cfg.defaults,
|
|
149
|
+
sizes: SIZES,
|
|
150
|
+
ratios: RATIOS,
|
|
151
|
+
videoSizes: [VIDEO_SIZE],
|
|
152
|
+
videoSeconds: VIDEO_SECONDS,
|
|
153
|
+
videoRatios: VIDEO_RATIOS,
|
|
154
|
+
});
|
|
155
|
+
} catch (e) {
|
|
156
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 500);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/* ---------- 配置:Key 文件路径 / 输出目录 ---------- */
|
|
162
|
+
addRoute(webServer, {
|
|
163
|
+
kind: 'exact',
|
|
164
|
+
path: '/agnes/config',
|
|
165
|
+
handler: async (req, res) => {
|
|
166
|
+
try {
|
|
167
|
+
if (req.method === 'GET') {
|
|
168
|
+
const cfg = await resolvePaths();
|
|
169
|
+
return sendJson(res, {
|
|
170
|
+
ok: true,
|
|
171
|
+
config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
|
|
172
|
+
stored: cfg.stored,
|
|
173
|
+
defaults: cfg.defaults,
|
|
174
|
+
configFile: cfg.configFile,
|
|
175
|
+
key: await keyStatus(cfg.keyFile),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 GET 或 POST' }, 405);
|
|
179
|
+
|
|
180
|
+
const body = await readJson(req);
|
|
181
|
+
const patch = {};
|
|
182
|
+
// 空字符串 = 清除该项、回退默认
|
|
183
|
+
if (Object.prototype.hasOwnProperty.call(body, 'keyFile')) {
|
|
184
|
+
patch.keyFile = typeof body.keyFile === 'string' && body.keyFile.trim() === '' ? null : body.keyFile;
|
|
185
|
+
}
|
|
186
|
+
if (Object.prototype.hasOwnProperty.call(body, 'outDir')) {
|
|
187
|
+
patch.outDir = typeof body.outDir === 'string' && body.outDir.trim() === '' ? null : body.outDir;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cfg = await saveConfig(patch);
|
|
191
|
+
sendJson(res, {
|
|
192
|
+
ok: true,
|
|
193
|
+
config: { keyFile: cfg.keyFile, outDir: cfg.outDir },
|
|
194
|
+
stored: cfg.stored,
|
|
195
|
+
defaults: cfg.defaults,
|
|
196
|
+
configFile: cfg.configFile,
|
|
197
|
+
key: await keyStatus(cfg.keyFile),
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
// 校验失败属于用户输入问题,用 400 区分于 500 的服务端异常
|
|
201
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 400);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/* ---------- 图片 ---------- */
|
|
207
|
+
addRoute(webServer, {
|
|
208
|
+
kind: 'exact',
|
|
209
|
+
path: '/agnes/generate',
|
|
210
|
+
handler: async (req, res) => {
|
|
211
|
+
if (req.method !== 'POST') return sendJson(res, { ok: false, error: '需要 POST' }, 405);
|
|
212
|
+
try {
|
|
213
|
+
const body = await readJson(req);
|
|
214
|
+
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
215
|
+
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
216
|
+
}
|
|
217
|
+
const result = await generate({
|
|
218
|
+
prompt: body.prompt,
|
|
219
|
+
size: body.size,
|
|
220
|
+
ratio: body.ratio,
|
|
221
|
+
model: body.model,
|
|
222
|
+
});
|
|
223
|
+
sendJson(res, { ...result, kind: 'image' });
|
|
224
|
+
} catch (e) {
|
|
225
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 500);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/* ---------- 视频:提交 ---------- */
|
|
231
|
+
addRoute(webServer, {
|
|
232
|
+
kind: 'exact',
|
|
233
|
+
path: '/agnes/video',
|
|
234
|
+
handler: async (req, res) => {
|
|
235
|
+
try {
|
|
236
|
+
const url = new URL(req.url, 'http://dsh.local');
|
|
237
|
+
|
|
238
|
+
// POST = 提交任务
|
|
239
|
+
if (req.method === 'POST') {
|
|
240
|
+
const body = await readJson(req);
|
|
241
|
+
if (typeof body.prompt !== 'string' || body.prompt.trim() === '') {
|
|
242
|
+
return sendJson(res, { ok: false, error: '提示词不能为空' }, 400);
|
|
243
|
+
}
|
|
244
|
+
const sub = await submitVideo({
|
|
245
|
+
prompt: body.prompt,
|
|
246
|
+
seconds: body.seconds,
|
|
247
|
+
ratio: body.ratio,
|
|
248
|
+
});
|
|
249
|
+
return sendJson(res, { ok: true, videoId: sub.videoId, taskId: sub.taskId, model: sub.model });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// GET = 查询一次状态
|
|
253
|
+
if (req.method !== 'GET') return sendJson(res, { ok: false, error: '需要 GET 或 POST' }, 405);
|
|
254
|
+
|
|
255
|
+
const videoId = url.searchParams.get('id') || '';
|
|
256
|
+
if (videoId === '') return sendJson(res, { ok: false, error: '缺少 id' }, 400);
|
|
257
|
+
|
|
258
|
+
const st = await queryVideo({ videoId });
|
|
259
|
+
|
|
260
|
+
if (st.status !== 'completed') {
|
|
261
|
+
return sendJson(res, {
|
|
262
|
+
ok: true,
|
|
263
|
+
status: st.status,
|
|
264
|
+
progress: st.progress,
|
|
265
|
+
error: st.error,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 已完成:下载一次并记住
|
|
270
|
+
let saved = downloaded.get(videoId);
|
|
271
|
+
if (saved === undefined) {
|
|
272
|
+
if (st.url === null) throw new Error('任务已完成但没有返回视频地址');
|
|
273
|
+
saved = await saveVideo({ url: st.url });
|
|
274
|
+
downloaded.set(videoId, saved);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
sendJson(res, {
|
|
278
|
+
ok: true,
|
|
279
|
+
kind: 'video',
|
|
280
|
+
status: 'completed',
|
|
281
|
+
progress: 100,
|
|
282
|
+
videoId,
|
|
283
|
+
filename: saved.filename,
|
|
284
|
+
path: saved.path,
|
|
285
|
+
bytes: saved.bytes,
|
|
286
|
+
seconds: st.seconds,
|
|
287
|
+
});
|
|
288
|
+
} catch (e) {
|
|
289
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 500);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/* ---------- 媒体(图片 + 视频) ---------- */
|
|
295
|
+
const mediaHandler = async (req, res) => {
|
|
296
|
+
try {
|
|
297
|
+
const name = new URL(req.url, 'http://dsh.local').searchParams.get('name') || '';
|
|
298
|
+
const { buf, mime } = await readMedia(name);
|
|
299
|
+
sendMedia(req, res, buf, mime);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
sendJson(res, { ok: false, error: errMessage(e) }, 404);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
addRoute(webServer, { kind: 'exact', path: '/agnes/media', handler: mediaHandler });
|
|
306
|
+
addRoute(webServer, { kind: 'exact', path: '/agnes/image', handler: mediaHandler });
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
registerRoutes(undefined);
|
|
310
|
+
ctx.on('internal/service', (svc, value) => {
|
|
311
|
+
if (svc === 'webServer') registerRoutes(value);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// 卸载/热重载时把路由摘掉。不摘的话路由会留在服务器表里,
|
|
315
|
+
// 重新启用时会撞 "duplicate exact route" 而激活失败。
|
|
316
|
+
ctx.effect(() => () => {
|
|
317
|
+
for (const dispose of routeDisposers.splice(0)) {
|
|
318
|
+
try { dispose(); } catch { /* 路由可能已被移除 */ }
|
|
319
|
+
}
|
|
320
|
+
registered = false;
|
|
321
|
+
}, 'dsh-agnes-create: route cleanup');
|
|
322
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agnes-create",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Agnes 创作:在 DeepSeek Harness 里用 agnes-image-2.5-flash 出图、agnes-video-2.5-flash 出视频。侧边栏面板 + 提示词输入框,Key 与输出目录可在界面里配置。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/zxygithub/dsh-agnes-create.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/zxygithub/dsh-agnes-create#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/zxygithub/dsh-agnes-create/issues"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org/",
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"dsh",
|
|
30
|
+
"dsh-plugin",
|
|
31
|
+
"deepseek-harness",
|
|
32
|
+
"agnes",
|
|
33
|
+
"image-generation",
|
|
34
|
+
"video-generation"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"dsh": ">=0.2.2-rc.1"
|
|
38
|
+
},
|
|
39
|
+
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"platform": "web",
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-renderer"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"compatibility": {
|
|
50
|
+
"dshReleases": {
|
|
51
|
+
"0.2.2-rc.1+": "compatible"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-renderer": ">=0.2.2-rc.1"
|
|
58
|
+
},
|
|
59
|
+
"license": "MIT"
|
|
60
|
+
}
|