agens-studio 0.1.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-CLI.md +167 -0
- package/README.md +173 -0
- package/cli.js +978 -0
- package/config.js +146 -0
- package/package.json +35 -0
- package/server.js +74 -0
- package/src/routes/assets.js +146 -0
- package/src/routes/generate.js +64 -0
- package/src/routes/prompt.js +279 -0
- package/src/routes/task.js +19 -0
- package/src/services/agensClient.js +257 -0
- package/src/services/assetStore.js +224 -0
- package/src/services/mimoClient.js +567 -0
- package/src/services/mimoVision.js +296 -0
- package/src/services/promptMemory.js +276 -0
- package/src/services/taskManager.js +246 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { config } from '../../config.js';
|
|
3
|
+
import * as agens from './agensClient.js';
|
|
4
|
+
import { downloadAndRegister } from './assetStore.js';
|
|
5
|
+
import { reviewImage } from './mimoVision.js';
|
|
6
|
+
import { addSample } from './promptMemory.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 异步任务管理器
|
|
10
|
+
* - generate 接口提交后立即返回本地 taskId
|
|
11
|
+
* - 后台轮询远端状态,状态机:pending → processing → success / failed
|
|
12
|
+
* - 成功后自动下载生成物并登记到资产库
|
|
13
|
+
*
|
|
14
|
+
* 本地单机:用内存 Map 存任务。重启会丢,但够用。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const tasks = new Map(); // localTaskId -> task object
|
|
18
|
+
|
|
19
|
+
// 任务队列只保留最近 N 条(前端展示上限),超出的自动丢弃
|
|
20
|
+
const MAX_TASKS_KEPT = 4;
|
|
21
|
+
|
|
22
|
+
// 生成任务并发限制:agnes 同时只允许 1 个任务(视频报 Service busy (tasks:1))
|
|
23
|
+
// 用简单的"等待 + 重试"机制,而不是阻塞式 promise 链(避免卡死)
|
|
24
|
+
const AGNES_MAX_CONCURRENT = 1;
|
|
25
|
+
let agnesRunning = 0;
|
|
26
|
+
const agnesWaitQueue = [];
|
|
27
|
+
|
|
28
|
+
/** 获取 agnes 调用权(信号量),释放时调 release */
|
|
29
|
+
async function acquireAgnes() {
|
|
30
|
+
if (agnesRunning < AGNES_MAX_CONCURRENT) {
|
|
31
|
+
agnesRunning++;
|
|
32
|
+
return () => { agnesRunning--; drainAgnesQueue(); };
|
|
33
|
+
}
|
|
34
|
+
// 排队等待
|
|
35
|
+
await new Promise((resolve) => agnesWaitQueue.push(resolve));
|
|
36
|
+
agnesRunning++;
|
|
37
|
+
return () => { agnesRunning--; drainAgnesQueue(); };
|
|
38
|
+
}
|
|
39
|
+
function drainAgnesQueue() {
|
|
40
|
+
if (agnesRunning < AGNES_MAX_CONCURRENT && agnesWaitQueue.length) {
|
|
41
|
+
const next = agnesWaitQueue.shift();
|
|
42
|
+
next();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createTask({ type, mode, prompt, params, imageUrls }) {
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
const task = {
|
|
49
|
+
id,
|
|
50
|
+
type, // 'image' | 'video'
|
|
51
|
+
mode, // 视频:text2video/image2video/multi2video/keyframe;生图:'image'
|
|
52
|
+
prompt,
|
|
53
|
+
params,
|
|
54
|
+
imageUrls,
|
|
55
|
+
status: 'pending', // pending / processing / success / failed
|
|
56
|
+
progress: 0,
|
|
57
|
+
remoteTaskId: null,
|
|
58
|
+
resultAssetId: null, // 成功后对应资产 id
|
|
59
|
+
resultUrl: null, // 原始远端 URL(下载前)
|
|
60
|
+
error: null,
|
|
61
|
+
createdAt: Date.now(),
|
|
62
|
+
updatedAt: Date.now(),
|
|
63
|
+
};
|
|
64
|
+
tasks.set(id, task);
|
|
65
|
+
pruneOldTasks();
|
|
66
|
+
return task;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 保留最近 MAX_TASKS_KEPT 条任务,其余从内存移除 */
|
|
70
|
+
function pruneOldTasks() {
|
|
71
|
+
if (tasks.size <= MAX_TASKS_KEPT) return;
|
|
72
|
+
// 按 createdAt 倒序,保留最新的 MAX_TASKS_KEPT 条
|
|
73
|
+
const sorted = Array.from(tasks.values()).sort((a, b) => b.createdAt - a.createdAt);
|
|
74
|
+
const keep = new Set(sorted.slice(0, MAX_TASKS_KEPT).map((t) => t.id));
|
|
75
|
+
for (const id of tasks.keys()) {
|
|
76
|
+
if (!keep.has(id)) tasks.delete(id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 提交一个生成任务
|
|
82
|
+
*/
|
|
83
|
+
export async function submitGenerateJob({ type, mode, prompt, params = {}, imageUrls = [] }) {
|
|
84
|
+
const task = createTask({ type, mode, prompt, params, imageUrls });
|
|
85
|
+
|
|
86
|
+
// 异步执行(不阻塞请求),内部用信号量限制 agnes 并发
|
|
87
|
+
runJob(task).catch((err) => {
|
|
88
|
+
task.status = 'failed';
|
|
89
|
+
task.error = err?.message || String(err);
|
|
90
|
+
task.updatedAt = Date.now();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return task;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function runJob(task) {
|
|
97
|
+
// 等待 agnes 调用权(排队期间是 pending,获取到才变 processing)
|
|
98
|
+
const release = await acquireAgnes();
|
|
99
|
+
let released = false;
|
|
100
|
+
const releaseOnce = () => { if (!released) { released = true; release(); } };
|
|
101
|
+
task.status = 'processing';
|
|
102
|
+
task.updatedAt = Date.now();
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
if (task.type === 'image') {
|
|
106
|
+
const { url, mime, meta } = await agens.generateImage({
|
|
107
|
+
prompt: task.prompt,
|
|
108
|
+
params: { ...task.params, imageUrls: task.imageUrls },
|
|
109
|
+
});
|
|
110
|
+
const asset = await downloadAndRegister({
|
|
111
|
+
url, kind: 'image', mime,
|
|
112
|
+
meta: { prompt: task.prompt, mode: task.mode, params: task.params },
|
|
113
|
+
});
|
|
114
|
+
task.status = 'success';
|
|
115
|
+
task.resultUrl = url;
|
|
116
|
+
task.resultAssetId = asset.id;
|
|
117
|
+
task.updatedAt = Date.now();
|
|
118
|
+
// 生图 agnes 调用结束,释放信号量(复看不占 agnes)
|
|
119
|
+
releaseOnce();
|
|
120
|
+
task.reviewStatus = 'pending';
|
|
121
|
+
reviewImage(asset.path, task.prompt, task.mode)
|
|
122
|
+
.then(async (review) => {
|
|
123
|
+
if (review.improvedPrompt) {
|
|
124
|
+
try {
|
|
125
|
+
const sample = await addSample({ kind: 'positive', original: task.prompt, optimized: review.improvedPrompt, mode: task.mode, imageDescription: '' });
|
|
126
|
+
review.sampleId = sample.id;
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
task.review = review;
|
|
130
|
+
task.reviewStatus = 'done';
|
|
131
|
+
task.updatedAt = Date.now();
|
|
132
|
+
})
|
|
133
|
+
.catch((e) => { task.review = { error: e.message }; task.reviewStatus = 'failed'; task.updatedAt = Date.now(); });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 视频:信号量保持到整个任务结束
|
|
138
|
+
const { videoId } = await agens.generateVideo({ mode: task.mode, prompt: task.prompt, imageUrls: task.imageUrls, params: task.params });
|
|
139
|
+
task.remoteTaskId = videoId;
|
|
140
|
+
task.updatedAt = Date.now();
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < config.agens.pollMaxAttempts; i++) {
|
|
143
|
+
await sleep(config.agens.pollInterval);
|
|
144
|
+
let info;
|
|
145
|
+
try { info = await agens.getTaskStatus(videoId); }
|
|
146
|
+
catch (err) { task.error = `查询失败(重试中):${err.message}`; task.updatedAt = Date.now(); continue; }
|
|
147
|
+
task.error = null;
|
|
148
|
+
task.updatedAt = Date.now();
|
|
149
|
+
if (typeof info.progress === 'number' && info.progress > 0) task.progress = Math.min(99, info.progress);
|
|
150
|
+
if (info.status === 'processing') { if (typeof info.progress !== 'number') task.progress = Math.min(95, 10 + i * 3); continue; }
|
|
151
|
+
if (info.status === 'pending') continue;
|
|
152
|
+
if (info.status === 'failed') { task.status = 'failed'; task.error = info.result?.error || '生成失败'; task.updatedAt = Date.now(); return; }
|
|
153
|
+
if (info.status === 'success') {
|
|
154
|
+
task.progress = 100;
|
|
155
|
+
const v = info.result?.videoUrl;
|
|
156
|
+
if (!v) { task.status = 'failed'; task.error = '成功但未返回视频 URL'; task.updatedAt = Date.now(); return; }
|
|
157
|
+
task.resultUrl = v;
|
|
158
|
+
let vasset;
|
|
159
|
+
try {
|
|
160
|
+
vasset = await downloadAndRegister({ url: v, kind: 'video', mime: 'video/mp4', meta: { prompt: task.prompt, mode: task.mode, params: { ...task.params, duration: info.result?.duration }, taskId: task.id } });
|
|
161
|
+
task.resultAssetId = vasset.id;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// 下载失败:标记失败(而不是 success 但没资产)
|
|
164
|
+
task.status = 'failed';
|
|
165
|
+
task.error = `视频已生成但下载失败:${e.message}`;
|
|
166
|
+
task.updatedAt = Date.now();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
task.status = 'success';
|
|
170
|
+
task.updatedAt = Date.now();
|
|
171
|
+
// 视频也触发 AI 复看(mimo-v2.5 能看视频)
|
|
172
|
+
releaseOnce(); // 视频任务结束,释放信号量
|
|
173
|
+
task.reviewStatus = 'pending';
|
|
174
|
+
task.updatedAt = Date.now();
|
|
175
|
+
if (vasset) {
|
|
176
|
+
reviewImage(vasset.path, task.prompt, task.mode)
|
|
177
|
+
.then(async (review) => {
|
|
178
|
+
if (review.improvedPrompt) {
|
|
179
|
+
try {
|
|
180
|
+
const sample = await addSample({ kind: 'positive', original: task.prompt, optimized: review.improvedPrompt, mode: task.mode, imageDescription: '' });
|
|
181
|
+
review.sampleId = sample.id;
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
task.review = review;
|
|
185
|
+
task.reviewStatus = 'done';
|
|
186
|
+
task.updatedAt = Date.now();
|
|
187
|
+
})
|
|
188
|
+
.catch((e) => { task.review = { error: e.message }; task.reviewStatus = 'failed'; task.updatedAt = Date.now(); });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
task.status = 'failed';
|
|
194
|
+
task.error = '轮询超时';
|
|
195
|
+
task.updatedAt = Date.now();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
task.status = 'failed';
|
|
198
|
+
task.error = err?.message || String(err);
|
|
199
|
+
task.updatedAt = Date.now();
|
|
200
|
+
} finally {
|
|
201
|
+
releaseOnce();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 查询任务(对外给前端,已脱敏) */
|
|
206
|
+
export function getTask(localId) {
|
|
207
|
+
const t = tasks.get(localId);
|
|
208
|
+
if (!t) return null;
|
|
209
|
+
return {
|
|
210
|
+
id: t.id,
|
|
211
|
+
type: t.type,
|
|
212
|
+
mode: t.mode,
|
|
213
|
+
prompt: t.prompt,
|
|
214
|
+
status: t.status,
|
|
215
|
+
progress: t.progress,
|
|
216
|
+
resultAssetId: t.resultAssetId,
|
|
217
|
+
resultUrl: t.resultUrl,
|
|
218
|
+
error: t.error,
|
|
219
|
+
reviewStatus: t.reviewStatus || null,
|
|
220
|
+
review: t.review || null,
|
|
221
|
+
createdAt: t.createdAt,
|
|
222
|
+
updatedAt: t.updatedAt,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 最近 N 条任务(默认与 MAX_TASKS_KEPT 对齐) */
|
|
227
|
+
export function recentTasks(limit = MAX_TASKS_KEPT) {
|
|
228
|
+
return Array.from(tasks.values())
|
|
229
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
230
|
+
.slice(0, limit)
|
|
231
|
+
.map((t) => ({
|
|
232
|
+
id: t.id,
|
|
233
|
+
type: t.type,
|
|
234
|
+
mode: t.mode,
|
|
235
|
+
prompt: t.prompt,
|
|
236
|
+
status: t.status,
|
|
237
|
+
progress: t.progress,
|
|
238
|
+
resultAssetId: t.resultAssetId,
|
|
239
|
+
error: t.error,
|
|
240
|
+
createdAt: t.createdAt,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sleep(ms) {
|
|
245
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
246
|
+
}
|