@mokuyoaxis/dsh-iris 0.1.1 → 0.1.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/CHANGELOG.md +75 -1
- package/README.en.md +259 -0
- package/README.md +70 -7
- package/bin/dsh-iris.js +23 -0
- package/cordis.patch.yml +1 -1
- package/docs/ARCHITECTURE.md +81 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +25 -8
- package/docs/SECURITY.md +53 -0
- package/docs/TASK_SEMANTICS.md +147 -0
- package/docs/assets/diagrams/iris-architecture.drawio +1 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +63 -0
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +1 -0
- package/docs/assets/diagrams/iris-task-lifecycle.png +0 -0
- package/docs/assets/diagrams/iris-task-lifecycle.svg +64 -0
- package/docs/assets/diagrams/iris-workflow-compose.drawio +1 -0
- package/docs/assets/diagrams/iris-workflow-compose.png +0 -0
- package/docs/assets/diagrams/iris-workflow-compose.svg +41 -0
- package/docs/assets/examples/iris-flower-generated.webp +0 -0
- package/docs/assets/screenshots/image-task-succeeded.webp +0 -0
- package/docs/assets/screenshots/prompt-general-after.webp +0 -0
- package/docs/assets/screenshots/prompt-general-before.webp +0 -0
- package/docs/assets/screenshots/prompt-general-preview.webp +0 -0
- package/docs/assets/screenshots/prompt-image-before.webp +0 -0
- package/docs/assets/screenshots/prompt-image-preview.webp +0 -0
- package/docs/releases/0.1.2.md +26 -0
- package/docs/releases/0.1.3.md +30 -0
- package/docs/screenshots.md +34 -0
- package/lib/actions.js +609 -175
- package/lib/api.js +122 -18
- package/lib/bundled-skills.js +62 -0
- package/lib/client.js +460 -22
- package/lib/doctor.js +243 -0
- package/lib/index.js +57 -46
- package/lib/prompt-optimizer-config.js +170 -0
- package/lib/prompt-optimizer.js +244 -0
- package/lib/provider-contract.js +234 -0
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +473 -29
- package/package.json +13 -4
- package/screenshots.json +6 -0
- package/user_guide.md +84 -3
package/lib/actions.js
CHANGED
|
@@ -26,6 +26,8 @@ import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
|
26
26
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
27
27
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
28
28
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
29
|
+
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
30
|
+
import { deriveUserState } from './task-semantics.js';
|
|
29
31
|
|
|
30
32
|
const MEDIA_TYPES = {
|
|
31
33
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
@@ -66,7 +68,7 @@ function throwIfAborted(signal) {
|
|
|
66
68
|
* 生成类 failover 只覆盖“上传/提交/同步生成”阶段。远端一旦受理即绑定该任务,
|
|
67
69
|
* 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
|
|
68
70
|
*/
|
|
69
|
-
|
|
71
|
+
function selectProviders(capability, label, requestedModelRef) {
|
|
70
72
|
let providers = store.pickAllFor(capability);
|
|
71
73
|
const parsedRef = models.parseModelRef(String(requestedModelRef || '').trim());
|
|
72
74
|
if (parsedRef) {
|
|
@@ -75,25 +77,193 @@ async function submitWithFailover(capability, label, signal, requestedModelRef,
|
|
|
75
77
|
if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
|
|
76
78
|
}
|
|
77
79
|
if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
return providers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function imageRoute(provider, args) {
|
|
84
|
+
const protocol = provider.mediaProtocol || inferMediaProtocol(provider.baseUrl);
|
|
85
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
86
|
+
const model = args.model
|
|
87
|
+
? (parsed?.modelId || String(args.model))
|
|
88
|
+
: (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
|
|
89
|
+
return { provider, protocol, model, modelRef: models.modelRef(provider.id, model) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function imageSubmissionError(error) {
|
|
93
|
+
const status = Number(error && error.status) || undefined;
|
|
94
|
+
// 408/409/425/499 可能来自中间层或冲突恢复,不能证明请求未被受理。
|
|
95
|
+
const explicitRejection = Boolean(status && status >= 400 && status < 500 && ![408, 409, 425, 499].includes(status));
|
|
96
|
+
let category = 'provider';
|
|
97
|
+
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) category = 'aborted';
|
|
98
|
+
else if (error && (error.category === 'network' || error instanceof TypeError)) category = 'network';
|
|
99
|
+
else if (status === 401 || status === 403) category = 'authentication';
|
|
100
|
+
else if (status === 429 && error && error.category === 'quota') category = 'quota';
|
|
101
|
+
else if (status === 429) category = 'rate_limit';
|
|
102
|
+
else if (status && status >= 400 && status < 500) category = 'invalid_request';
|
|
103
|
+
else if (!status) category = 'protocol';
|
|
104
|
+
return new ProviderContractError(String((error && error.message) || error || '提交失败'), {
|
|
105
|
+
stage: 'submit',
|
|
106
|
+
category,
|
|
107
|
+
acceptance: explicitRejection ? 'not_accepted' : 'unknown',
|
|
108
|
+
retryable: status === 429 || Boolean(status && status >= 500) || category === 'network',
|
|
109
|
+
...(status ? { httpStatus: status } : {})
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function taskActionError(taskId, message, details) {
|
|
114
|
+
const error = new Error('iris: ' + message + '(task: ' + taskId + ')');
|
|
115
|
+
error.taskId = taskId;
|
|
116
|
+
if (details) error.details = details;
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function deliverCompletedImage(taskId, value, signal) {
|
|
121
|
+
const names = [];
|
|
122
|
+
try {
|
|
123
|
+
tasks.beginDelivery(taskId);
|
|
124
|
+
if (value && value.kind === 'urls') {
|
|
125
|
+
for (const url of value.items || []) {
|
|
126
|
+
const name = taskId + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
127
|
+
await adapters.downloadTo(url, path.join(outputDir(), name), { signal });
|
|
128
|
+
names.push(name);
|
|
129
|
+
}
|
|
130
|
+
} else if (value && value.kind === 'openai') {
|
|
131
|
+
for (const item of value.items || []) {
|
|
132
|
+
const name = taskId + '-' + names.length + '.png';
|
|
133
|
+
const target = path.join(outputDir(), name);
|
|
134
|
+
if (item && item.b64) writePrivateFile(target, Buffer.from(item.b64, 'base64'));
|
|
135
|
+
else if (item && item.url) await adapters.downloadTo(item.url, target, { signal });
|
|
136
|
+
else throw new Error('同步图片结果缺少 b64 或 URL');
|
|
137
|
+
names.push(name);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
throw new Error('未知同步图片结果结构');
|
|
141
|
+
}
|
|
142
|
+
if (!names.length) throw new Error('生成已成功,但没有可交付的图片');
|
|
143
|
+
for (const name of names) registerMedia(taskId, path.join(outputDir(), name));
|
|
144
|
+
tasks.completeDelivery(taskId, names);
|
|
145
|
+
return tasks.get(taskId);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
let recorded;
|
|
81
148
|
try {
|
|
82
|
-
|
|
83
|
-
} catch (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
errors.push({
|
|
87
|
-
providerId: provider.id,
|
|
88
|
-
modelRef: field && provider[field] ? models.modelRef(provider.id, provider[field]) : provider.id,
|
|
89
|
-
message: String((err && err.message) || err)
|
|
149
|
+
recorded = tasks.failDelivery(taskId, error, names);
|
|
150
|
+
} catch (persistError) {
|
|
151
|
+
throw taskActionError(taskId, '图像已生成,但交付状态无法可靠落盘;禁止重新生成', {
|
|
152
|
+
message: String((persistError && persistError.message) || persistError)
|
|
90
153
|
});
|
|
91
154
|
}
|
|
155
|
+
throw taskActionError(taskId, '图像已生成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** v0.1.3 图片能力统一链路:异步、同步及混合候选共用一个 Task。 */
|
|
160
|
+
async function submitImageV2(routes, prompt, args, signal) {
|
|
161
|
+
const task = tasks.createV2({
|
|
162
|
+
cap: 'image',
|
|
163
|
+
prompt: prompt.slice(0, 4000),
|
|
164
|
+
size: args.size || '',
|
|
165
|
+
count: Number(args.n || 1),
|
|
166
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {}),
|
|
167
|
+
...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
|
|
168
|
+
});
|
|
169
|
+
const candidates = routes.map((route) => ({
|
|
170
|
+
id: route.provider.id,
|
|
171
|
+
model: route.modelRef,
|
|
172
|
+
route,
|
|
173
|
+
submit: async () => {
|
|
174
|
+
if (signal && signal.aborted) {
|
|
175
|
+
throw new ProviderContractError('提交前收到取消信号', {
|
|
176
|
+
stage: 'submit', category: 'aborted', acceptance: 'unknown'
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
if (route.protocol === 'dashscope') {
|
|
181
|
+
if (adapters.dashscopeImageMode(route.model) === 'legacy-async') {
|
|
182
|
+
const remoteTaskId = await adapters.submitImage({
|
|
183
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
184
|
+
model: route.model, prompt, size: args.size, n: args.n, signal
|
|
185
|
+
});
|
|
186
|
+
return { kind: 'accepted', remoteTaskId };
|
|
187
|
+
}
|
|
188
|
+
const urls = await adapters.generateImageMultimodal({
|
|
189
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
190
|
+
model: route.model, prompt, size: args.size, n: args.n, signal
|
|
191
|
+
});
|
|
192
|
+
return { kind: 'completed', value: { kind: 'urls', items: urls } };
|
|
193
|
+
}
|
|
194
|
+
const items = await adapters.openAiGenerateImage({
|
|
195
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
196
|
+
model: route.model, prompt, size: args.size, n: args.n, signal
|
|
197
|
+
});
|
|
198
|
+
return { kind: 'completed', value: { kind: 'openai', items } };
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw imageSubmissionError(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'image' }, {
|
|
206
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
207
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
208
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
209
|
+
return tasks.beginAttempt(task.id, {
|
|
210
|
+
providerId,
|
|
211
|
+
providerName: route.provider.name,
|
|
212
|
+
model,
|
|
213
|
+
protocol: route.protocol
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (submitted.localError) {
|
|
220
|
+
throw taskActionError(task.id, '提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
221
|
+
}
|
|
222
|
+
if (!submitted.candidate) {
|
|
223
|
+
const lastError = submitted.result && submitted.result.error;
|
|
224
|
+
tasks.finalizeNoAcceptance(task.id, lastError || '所有候选均明确未受理');
|
|
225
|
+
throw taskActionError(task.id, '所有候选均明确未受理', submitted.attempts);
|
|
92
226
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
227
|
+
if (submitted.result.kind === 'acceptance_unknown') {
|
|
228
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
229
|
+
throw taskActionError(task.id, '供应商受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const route = submitted.candidate.route;
|
|
233
|
+
if (submitted.result.kind === 'completed') {
|
|
234
|
+
const delivered = await deliverCompletedImage(task.id, submitted.result.value, signal);
|
|
235
|
+
return { task: delivered, provider: route.provider, model: route.model, remoteTaskId: null };
|
|
236
|
+
}
|
|
237
|
+
if (submitted.result.kind !== 'accepted') {
|
|
238
|
+
throw taskActionError(task.id, '图片提交结果无法识别,已停止后续候选', submitted.result);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
tasks.activateWatch(task.id);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
throw taskActionError(task.id, '远端已受理,但本地盯守状态落盘失败;禁止自动重提', { message: String(error.message || error) });
|
|
245
|
+
}
|
|
246
|
+
tasks.watch(tasks.get(task.id), {
|
|
247
|
+
key: () => route.provider.apiKey,
|
|
248
|
+
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId }),
|
|
249
|
+
intervalMs: 2500,
|
|
250
|
+
onSuccess: async (record, result) => {
|
|
251
|
+
const names = [];
|
|
252
|
+
for (const url of result.urls || []) {
|
|
253
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
254
|
+
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
255
|
+
names.push(name);
|
|
256
|
+
}
|
|
257
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
258
|
+
return names;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
task: tasks.get(task.id),
|
|
263
|
+
provider: route.provider,
|
|
264
|
+
model: route.model,
|
|
265
|
+
remoteTaskId: submitted.result.remoteTaskId
|
|
266
|
+
};
|
|
97
267
|
}
|
|
98
268
|
|
|
99
269
|
function findGeneratedAttachment(attachmentId) {
|
|
@@ -129,6 +299,313 @@ function fileDataUrl(file) {
|
|
|
129
299
|
return 'data:' + file.mediaType + ';base64,' + fs.readFileSync(file.absPath).toString('base64');
|
|
130
300
|
}
|
|
131
301
|
|
|
302
|
+
function videoRoute(provider, args, frame) {
|
|
303
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
304
|
+
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider.videoModel || 'wan2.2-t2v-flash');
|
|
305
|
+
const isS2V = /s2v/i.test(model);
|
|
306
|
+
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
307
|
+
return { provider, model, modelRef: models.modelRef(provider.id, model), isS2V, mode, protocol: 'dashscope' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function beforeGenerationError(error, stage) {
|
|
311
|
+
const category = error && (error.category === 'network' || error instanceof TypeError)
|
|
312
|
+
? 'network'
|
|
313
|
+
: (stage === 'validate' ? 'invalid_request' : 'local_io');
|
|
314
|
+
return new ProviderContractError(String((error && error.message) || error || '提交前准备失败'), {
|
|
315
|
+
stage,
|
|
316
|
+
category,
|
|
317
|
+
acceptance: 'not_accepted',
|
|
318
|
+
retryable: category === 'network'
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
323
|
+
const task = tasks.createV2({
|
|
324
|
+
cap: 'video', prompt: prompt.slice(0, 4000),
|
|
325
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {}),
|
|
326
|
+
...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
|
|
327
|
+
});
|
|
328
|
+
const candidates = routes.map((route) => ({
|
|
329
|
+
id: route.provider.id,
|
|
330
|
+
model: route.modelRef,
|
|
331
|
+
route,
|
|
332
|
+
submit: async (_input, context) => {
|
|
333
|
+
const attemptId = context.attempt.id;
|
|
334
|
+
let submitArgs;
|
|
335
|
+
if (route.isS2V) {
|
|
336
|
+
if (!frame) throw beforeGenerationError(new Error('iris: s2v 数字人需要首帧'), 'validate');
|
|
337
|
+
const audioPath = String(args.audio_path || '').trim();
|
|
338
|
+
if (!audioPath || !path.isAbsolute(audioPath)) {
|
|
339
|
+
throw beforeGenerationError(new Error('iris: s2v 的 audio_path 必须是绝对路径'), 'validate');
|
|
340
|
+
}
|
|
341
|
+
if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
|
|
342
|
+
tasks.markAttemptStage(task.id, attemptId, 'upload');
|
|
343
|
+
try {
|
|
344
|
+
const imageUrl = await adapters.uploadTempFile({
|
|
345
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
346
|
+
model: route.model, filePath: frame.absPath, signal
|
|
347
|
+
});
|
|
348
|
+
const audioUrl = await adapters.uploadTempFile({
|
|
349
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
350
|
+
model: route.model, filePath: audioPath, signal
|
|
351
|
+
});
|
|
352
|
+
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
throw beforeGenerationError(error, 'upload');
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
|
|
358
|
+
tasks.markAttemptStage(task.id, attemptId, 'prepare');
|
|
359
|
+
try {
|
|
360
|
+
submitArgs = {
|
|
361
|
+
prompt,
|
|
362
|
+
imgDataUrl: frame ? fileDataUrl(frame) : undefined,
|
|
363
|
+
size: args.size || '1280*720',
|
|
364
|
+
duration: args.duration ? Number(args.duration) : undefined
|
|
365
|
+
};
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw beforeGenerationError(error, 'prepare');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
tasks.markAttemptStage(task.id, attemptId, 'submit');
|
|
371
|
+
try {
|
|
372
|
+
const remoteTaskId = await adapters.submitVideo({
|
|
373
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
374
|
+
model: route.model, ...submitArgs, signal
|
|
375
|
+
});
|
|
376
|
+
return { kind: 'accepted', remoteTaskId };
|
|
377
|
+
} catch (error) {
|
|
378
|
+
throw imageSubmissionError(error);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
|
|
383
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'video' }, {
|
|
384
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
385
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
386
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
387
|
+
const attempt = tasks.beginAttempt(task.id, {
|
|
388
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
389
|
+
});
|
|
390
|
+
tasks.update(task.id, {
|
|
391
|
+
mode: route.mode,
|
|
392
|
+
...(route.isS2V ? { resolution: args.resolution || '480P' } : {
|
|
393
|
+
size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
|
|
394
|
+
})
|
|
395
|
+
});
|
|
396
|
+
return attempt;
|
|
397
|
+
},
|
|
398
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
if (submitted.localError) {
|
|
402
|
+
throw taskActionError(task.id, '视频提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
403
|
+
}
|
|
404
|
+
if (!submitted.candidate) {
|
|
405
|
+
const lastError = submitted.result && submitted.result.error;
|
|
406
|
+
tasks.finalizeNoAcceptance(task.id, lastError || '所有视频候选均明确未受理');
|
|
407
|
+
throw taskActionError(task.id, '所有视频候选均明确未受理', submitted.attempts);
|
|
408
|
+
}
|
|
409
|
+
if (submitted.result.kind !== 'accepted') {
|
|
410
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
411
|
+
throw taskActionError(task.id, '视频受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const route = submitted.candidate.route;
|
|
415
|
+
tasks.activateWatch(task.id);
|
|
416
|
+
tasks.watch(tasks.get(task.id), {
|
|
417
|
+
key: () => route.provider.apiKey,
|
|
418
|
+
poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId: id }),
|
|
419
|
+
intervalMs: 6000,
|
|
420
|
+
onSuccess: async (record, result) => {
|
|
421
|
+
const names = [];
|
|
422
|
+
for (const url of result.urls || []) {
|
|
423
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
|
|
424
|
+
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
425
|
+
names.push(name);
|
|
426
|
+
}
|
|
427
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
428
|
+
return names;
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function simpleModelRoute(provider, args, field, fallback, protocol = 'dashscope') {
|
|
435
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
436
|
+
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider[field] || fallback);
|
|
437
|
+
return { provider, model, modelRef: models.modelRef(provider.id, model), protocol };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
441
|
+
const task = tasks.createV2({
|
|
442
|
+
cap: 'transcribe', prompt: 'audio transcribe: ' + path.basename(audioPath),
|
|
443
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
444
|
+
});
|
|
445
|
+
const candidates = routes.map((route) => ({
|
|
446
|
+
id: route.provider.id,
|
|
447
|
+
model: route.modelRef,
|
|
448
|
+
route,
|
|
449
|
+
submit: async (_input, context) => {
|
|
450
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'upload');
|
|
451
|
+
let audioUrl;
|
|
452
|
+
try {
|
|
453
|
+
audioUrl = await adapters.uploadTempFile({
|
|
454
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
455
|
+
model: route.model, filePath: audioPath, signal
|
|
456
|
+
});
|
|
457
|
+
} catch (error) {
|
|
458
|
+
throw beforeGenerationError(error, 'upload');
|
|
459
|
+
}
|
|
460
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
461
|
+
try {
|
|
462
|
+
const remoteTaskId = await adapters.submitTranscription({
|
|
463
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
464
|
+
model: route.model, audioUrl, signal
|
|
465
|
+
});
|
|
466
|
+
return { kind: 'accepted', remoteTaskId };
|
|
467
|
+
} catch (error) {
|
|
468
|
+
throw imageSubmissionError(error);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}));
|
|
472
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'transcribe' }, {
|
|
473
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
474
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
475
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
476
|
+
return tasks.beginAttempt(task.id, {
|
|
477
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
481
|
+
});
|
|
482
|
+
if (submitted.localError) throw taskActionError(task.id, '转写提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
483
|
+
if (!submitted.candidate) {
|
|
484
|
+
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有转写候选均明确未受理');
|
|
485
|
+
throw taskActionError(task.id, '所有转写候选均明确未受理', submitted.attempts);
|
|
486
|
+
}
|
|
487
|
+
if (submitted.result.kind !== 'accepted') {
|
|
488
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
489
|
+
throw taskActionError(task.id, '转写受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
490
|
+
}
|
|
491
|
+
const route = submitted.candidate.route;
|
|
492
|
+
tasks.activateWatch(task.id);
|
|
493
|
+
tasks.watch(tasks.get(task.id), {
|
|
494
|
+
key: () => route.provider.apiKey,
|
|
495
|
+
poll: async ({ key, remoteTaskId: id }) => {
|
|
496
|
+
const result = await adapters.pollTranscriptionTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId: id });
|
|
497
|
+
return { done: result.done, ok: result.ok, urls: result.text ? [result.text] : [], message: result.message };
|
|
498
|
+
},
|
|
499
|
+
intervalMs: 2500,
|
|
500
|
+
allowEmptyResult: true,
|
|
501
|
+
onSuccess: async (record, result) => {
|
|
502
|
+
tasks.update(record.id, { transcribeText: (result.urls && result.urls.join('')) || '' });
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function submitTtsV2(routes, text, args, signal) {
|
|
510
|
+
const task = tasks.createV2({
|
|
511
|
+
cap: 'tts', prompt: text.slice(0, 500),
|
|
512
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
513
|
+
});
|
|
514
|
+
const candidates = routes.map((route) => ({
|
|
515
|
+
id: route.provider.id,
|
|
516
|
+
model: route.modelRef,
|
|
517
|
+
route,
|
|
518
|
+
submit: async (_input, context) => {
|
|
519
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
520
|
+
try {
|
|
521
|
+
const audio = await adapters.synthesizeTts({
|
|
522
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
523
|
+
model: route.model, text, voice: route.voice, signal
|
|
524
|
+
});
|
|
525
|
+
return { kind: 'completed', value: audio };
|
|
526
|
+
} catch (error) {
|
|
527
|
+
throw imageSubmissionError(error);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}));
|
|
531
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'tts' }, {
|
|
532
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
533
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
534
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
535
|
+
const attempt = tasks.beginAttempt(task.id, {
|
|
536
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
537
|
+
});
|
|
538
|
+
tasks.update(task.id, { voice: route.voice });
|
|
539
|
+
return attempt;
|
|
540
|
+
},
|
|
541
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
542
|
+
});
|
|
543
|
+
if (submitted.localError) throw taskActionError(task.id, '语音合成结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
544
|
+
if (!submitted.candidate) {
|
|
545
|
+
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有语音候选均明确未受理');
|
|
546
|
+
throw taskActionError(task.id, '所有语音候选均明确未受理', submitted.attempts);
|
|
547
|
+
}
|
|
548
|
+
if (submitted.result.kind !== 'completed') {
|
|
549
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
550
|
+
throw taskActionError(task.id, '语音合成受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
551
|
+
}
|
|
552
|
+
const route = submitted.candidate.route;
|
|
553
|
+
const audio = submitted.result.value || {};
|
|
554
|
+
const names = [];
|
|
555
|
+
try {
|
|
556
|
+
tasks.beginDelivery(task.id);
|
|
557
|
+
const name = task.id + '.' + (audio.audioUrl ? adapters.extFromUrl(audio.audioUrl, 'wav') : 'wav');
|
|
558
|
+
const target = path.join(outputDir(), name);
|
|
559
|
+
if (audio.audioUrl) await adapters.downloadTo(audio.audioUrl, target, { signal });
|
|
560
|
+
else if (audio.audioB64) writePrivateFile(target, Buffer.from(audio.audioB64, 'base64'));
|
|
561
|
+
else throw new Error('语音合成成功响应缺少音频数据');
|
|
562
|
+
names.push(name);
|
|
563
|
+
registerMedia(task.id, target);
|
|
564
|
+
tasks.completeDelivery(task.id, names);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const recorded = tasks.failDelivery(task.id, error, names);
|
|
567
|
+
throw taskActionError(task.id, '语音已合成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
568
|
+
}
|
|
569
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** 重新观察/交付与启动恢复共享的远端查询和本地落盘依赖。 */
|
|
573
|
+
export function taskPollDeps(provider, task) {
|
|
574
|
+
if (!provider || !task) return null;
|
|
575
|
+
if (task.cap === 'transcribe') {
|
|
576
|
+
return {
|
|
577
|
+
key: () => provider.apiKey,
|
|
578
|
+
poll: async ({ key, remoteTaskId }) => {
|
|
579
|
+
const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId });
|
|
580
|
+
return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
|
|
581
|
+
},
|
|
582
|
+
intervalMs: 2500,
|
|
583
|
+
allowEmptyResult: true,
|
|
584
|
+
onSuccess: async (record, result) => {
|
|
585
|
+
tasks.update(record.id, { transcribeText: (result.urls && result.urls.join('')) || '' });
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (task.cap !== 'image' && task.cap !== 'video') return null;
|
|
591
|
+
return {
|
|
592
|
+
key: () => provider.apiKey,
|
|
593
|
+
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
|
|
594
|
+
intervalMs: task.cap === 'video' ? 6000 : 2500,
|
|
595
|
+
onSuccess: async (record, result) => {
|
|
596
|
+
const names = [];
|
|
597
|
+
for (const url of result.urls || []) {
|
|
598
|
+
const fallback = task.cap === 'video' ? 'mp4' : 'png';
|
|
599
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, fallback);
|
|
600
|
+
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
601
|
+
names.push(name);
|
|
602
|
+
}
|
|
603
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
604
|
+
return names;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
132
609
|
/* ---------------- 动作注册表 ---------------- */
|
|
133
610
|
|
|
134
611
|
const actions = {};
|
|
@@ -160,75 +637,16 @@ export async function runAction(ctx, name, args, { signal } = {}) {
|
|
|
160
637
|
register('image', async (ctx, args, { signal }) => {
|
|
161
638
|
const prompt = String(args.prompt || '').trim();
|
|
162
639
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
cap: 'image', providerId: provider.id, providerName: provider.name,
|
|
168
|
-
model, protocol, prompt: prompt.slice(0, 4000)
|
|
169
|
-
});
|
|
170
|
-
if (protocol === 'dashscope') {
|
|
171
|
-
const started = await tasks.submitGuard(task, () => adapters.startImageGeneration({
|
|
172
|
-
key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
|
|
173
|
-
}));
|
|
174
|
-
const remoteTaskId = started.remoteTaskId;
|
|
175
|
-
if (!remoteTaskId) {
|
|
176
|
-
try {
|
|
177
|
-
const names = [];
|
|
178
|
-
for (const url of started.urls || []) {
|
|
179
|
-
const name = `${task.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
|
|
180
|
-
await adapters.downloadTo(url, path.join(outputDir(), name), { signal });
|
|
181
|
-
names.push(name);
|
|
182
|
-
}
|
|
183
|
-
tasks.update(task.id, { status: 'succeeded', files: names, finishedAt: new Date().toISOString() });
|
|
184
|
-
for (const name of names) registerMedia(task.id, path.join(outputDir(), name));
|
|
185
|
-
} catch (err) {
|
|
186
|
-
// 模型已经同步生成成功,转存失败不能再向下一供应商重复计费。
|
|
187
|
-
tasks.update(task.id, { status: 'failed', error: `图片转存失败:${String(err.message || err)}`, finishedAt: new Date().toISOString() });
|
|
188
|
-
}
|
|
189
|
-
return { task: tasks.get(task.id), provider, model, remoteTaskId: null };
|
|
190
|
-
}
|
|
191
|
-
tasks.update(task.id, { remoteTaskId });
|
|
192
|
-
tasks.watch(tasks.get(task.id), {
|
|
193
|
-
key: () => provider.apiKey,
|
|
194
|
-
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
|
|
195
|
-
intervalMs: 2500,
|
|
196
|
-
onSuccess: async (t, r) => {
|
|
197
|
-
const names = [];
|
|
198
|
-
for (const url of r.urls || []) {
|
|
199
|
-
const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
|
|
200
|
-
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
201
|
-
names.push(name);
|
|
202
|
-
}
|
|
203
|
-
for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
|
|
204
|
-
return names;
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
return { task, provider, model, remoteTaskId };
|
|
208
|
-
}
|
|
209
|
-
const files = await tasks.submitGuard(task, async () => {
|
|
210
|
-
const outs = await adapters.openAiGenerateImage({
|
|
211
|
-
key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
|
|
212
|
-
});
|
|
213
|
-
const written = [];
|
|
214
|
-
for (let i = 0; i < outs.length; i++) {
|
|
215
|
-
const p = path.join(outputDir(), `${task.id}-${i}.png`);
|
|
216
|
-
if (outs[i].b64) writePrivateFile(p, Buffer.from(outs[i].b64, 'base64'));
|
|
217
|
-
else await adapters.downloadTo(outs[i].url, p, { signal });
|
|
218
|
-
written.push(p);
|
|
219
|
-
}
|
|
220
|
-
return written;
|
|
221
|
-
});
|
|
222
|
-
tasks.update(task.id, { status: 'succeeded', files: files.map((f) => path.basename(f)), finishedAt: new Date().toISOString() });
|
|
223
|
-
for (const f of files) registerMedia(task.id, f);
|
|
224
|
-
return { task, provider, model, remoteTaskId: null };
|
|
225
|
-
});
|
|
640
|
+
|
|
641
|
+
const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
|
|
642
|
+
const routes = imageProviders.map((provider) => imageRoute(provider, args));
|
|
643
|
+
const submitted = await submitImageV2(routes, prompt, args, signal);
|
|
226
644
|
return {
|
|
227
645
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
228
646
|
remoteTaskId: submitted.remoteTaskId,
|
|
229
647
|
text: submitted.remoteTaskId
|
|
230
|
-
?
|
|
231
|
-
:
|
|
648
|
+
? '[iris] 图像任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
649
|
+
: '[iris] 图像任务已完成(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
232
650
|
};
|
|
233
651
|
});
|
|
234
652
|
|
|
@@ -237,60 +655,13 @@ register('video', async (ctx, args, { signal }) => {
|
|
|
237
655
|
const prompt = String(args.prompt || '').trim();
|
|
238
656
|
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
239
657
|
const frame = resolveFirstFrameFile(args);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (!isS2V && !prompt) throw new Error('iris: 文生/图生视频需要 prompt');
|
|
244
|
-
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
245
|
-
const task = tasks.create({
|
|
246
|
-
cap: 'video', providerId: provider.id, providerName: provider.name, model, mode,
|
|
247
|
-
...(isS2V ? { resolution: args.resolution || '480P' } : {
|
|
248
|
-
size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
|
|
249
|
-
}),
|
|
250
|
-
prompt: prompt.slice(0, 4000)
|
|
251
|
-
});
|
|
252
|
-
const remoteTaskId = await tasks.submitGuard(task, async () => {
|
|
253
|
-
let submitArgs;
|
|
254
|
-
if (isS2V) {
|
|
255
|
-
if (!frame) throw new Error('iris: s2v 数字人需要首帧');
|
|
256
|
-
const audioPath = String(args.audio_path || '').trim();
|
|
257
|
-
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: s2v 的 audio_path 必须是绝对路径');
|
|
258
|
-
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在:' + audioPath);
|
|
259
|
-
const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: frame.absPath, signal });
|
|
260
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
261
|
-
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
262
|
-
} else {
|
|
263
|
-
submitArgs = {
|
|
264
|
-
prompt,
|
|
265
|
-
imgDataUrl: frame ? fileDataUrl(frame) : undefined,
|
|
266
|
-
size: args.size || '1280*720',
|
|
267
|
-
duration: args.duration ? Number(args.duration) : undefined
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
return adapters.submitVideo({ key: provider.apiKey, baseUrl: provider.baseUrl, model, ...submitArgs, signal });
|
|
271
|
-
});
|
|
272
|
-
tasks.update(task.id, { remoteTaskId });
|
|
273
|
-
tasks.watch(tasks.get(task.id), {
|
|
274
|
-
key: () => provider.apiKey,
|
|
275
|
-
poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id }),
|
|
276
|
-
intervalMs: 6000,
|
|
277
|
-
onSuccess: async (t, r) => {
|
|
278
|
-
const names = [];
|
|
279
|
-
for (const url of r.urls || []) {
|
|
280
|
-
const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'mp4')}`;
|
|
281
|
-
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
282
|
-
names.push(name);
|
|
283
|
-
}
|
|
284
|
-
for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
|
|
285
|
-
return names;
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
return { task, provider, model, remoteTaskId };
|
|
289
|
-
});
|
|
658
|
+
const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
|
|
659
|
+
const routes = providers.map((provider) => videoRoute(provider, args, frame));
|
|
660
|
+
const submitted = await submitVideoV2(routes, frame, prompt, args, signal);
|
|
290
661
|
return {
|
|
291
662
|
ok: true, taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
|
|
292
663
|
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
293
|
-
text:
|
|
664
|
+
text: '[iris] 视频任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
294
665
|
};
|
|
295
666
|
});
|
|
296
667
|
|
|
@@ -298,25 +669,15 @@ register('video', async (ctx, args, { signal }) => {
|
|
|
298
669
|
register('tts', async (ctx, args, { signal }) => {
|
|
299
670
|
const text = String(args.text || '').trim();
|
|
300
671
|
if (!text) throw new Error('iris: text 不能为空');
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const name = `iris-tts-${Date.now()}.${r.audioUrl ? adapters.extFromUrl(r.audioUrl, 'wav') : 'wav'}`;
|
|
308
|
-
const p = path.join(outputDir(), name);
|
|
309
|
-
if (r.audioUrl) await adapters.downloadTo(r.audioUrl, p, { signal });
|
|
310
|
-
else writePrivateFile(p, Buffer.from(r.audioB64, 'base64'));
|
|
311
|
-
return name;
|
|
312
|
-
});
|
|
313
|
-
tasks.update(task.id, { status: 'succeeded', files: [fileName], finishedAt: new Date().toISOString() });
|
|
314
|
-
registerMedia(task.id, path.join(outputDir(), fileName));
|
|
315
|
-
return { task, provider, model };
|
|
316
|
-
});
|
|
672
|
+
const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
|
|
673
|
+
const routes = providers.map((provider) => ({
|
|
674
|
+
...simpleModelRoute(provider, args, 'ttsModel', 'qwen-tts-latest'),
|
|
675
|
+
voice: args.voice || provider.ttsVoice || 'Cherry'
|
|
676
|
+
}));
|
|
677
|
+
const submitted = await submitTtsV2(routes, text, args, signal);
|
|
317
678
|
return {
|
|
318
679
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
319
|
-
text:
|
|
680
|
+
text: '[iris] 语音已合成(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
320
681
|
};
|
|
321
682
|
});
|
|
322
683
|
|
|
@@ -414,6 +775,99 @@ register('status', async (ctx, args, { signal }) => {
|
|
|
414
775
|
return { ok: true, text: recent.map((t) => `[${t.cap}] ${t.id} · ${t.status} · ${t.model}`).join('\n') };
|
|
415
776
|
});
|
|
416
777
|
|
|
778
|
+
/* ---------- Task v2 人工接管 ---------- */
|
|
779
|
+
register('task_reobserve', async (ctx, args) => {
|
|
780
|
+
const id = String(args.task_id || '').trim();
|
|
781
|
+
const task = tasks.get(id);
|
|
782
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可接管的 Task v2');
|
|
783
|
+
if (task.acceptance !== 'accepted' || !task.remoteTaskId) throw new Error('iris: 缺少已受理证据或远端任务 ID,不能重新观察');
|
|
784
|
+
if (task.outcome === 'succeeded') throw new Error('iris: 生成已经成功,请使用重新交付');
|
|
785
|
+
if (task.outcome === 'failed' || task.outcome === 'canceled') throw new Error('iris: 任务已有明确终态,不能重新观察');
|
|
786
|
+
const provider = store.providerById(task.providerId);
|
|
787
|
+
const deps = provider && taskPollDeps(provider, task);
|
|
788
|
+
if (!deps) throw new Error('iris: 对应供应商或轮询协议不可用');
|
|
789
|
+
tasks.watch(task, deps);
|
|
790
|
+
return { ok: true, taskId: id, text: '[iris] 已重新观察远端任务;不会重新提交生成请求。' };
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
register('task_redeliver', async (ctx, args, { signal }) => {
|
|
794
|
+
const id = String(args.task_id || '').trim();
|
|
795
|
+
const task = tasks.get(id);
|
|
796
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重新交付的 Task v2');
|
|
797
|
+
if (task.outcome !== 'succeeded' || task.deliveryState !== 'failed') throw new Error('iris: 任务不处于生成成功但交付失败状态');
|
|
798
|
+
if (!task.remoteTaskId) throw new Error('iris: 该同步结果没有可重新获取的远端任务 ID,请重新从原始输入发起');
|
|
799
|
+
const provider = store.providerById(task.providerId);
|
|
800
|
+
const deps = provider && taskPollDeps(provider, task);
|
|
801
|
+
if (!deps || task.cap === 'transcribe') throw new Error('iris: 对应供应商或产物交付协议不可用');
|
|
802
|
+
tasks.prepareRedelivery(id);
|
|
803
|
+
try {
|
|
804
|
+
tasks.beginDelivery(id);
|
|
805
|
+
const result = await deps.poll({ key: deps.key(), remoteTaskId: task.remoteTaskId, signal });
|
|
806
|
+
if (!result.done) throw new Error('远端结果暂未就绪,请先重新观察');
|
|
807
|
+
if (!result.ok) throw new Error(result.message || '供应商当前无法返回成功产物');
|
|
808
|
+
if (!(result.urls || []).length) throw new Error('供应商未返回可交付的结果 URL');
|
|
809
|
+
const files = await deps.onSuccess(tasks.get(id), result);
|
|
810
|
+
tasks.completeDelivery(id, files);
|
|
811
|
+
return { ok: true, taskId: id, files, text: '[iris] 产物已重新交付;未重新生成。' };
|
|
812
|
+
} catch (error) {
|
|
813
|
+
const current = tasks.get(id);
|
|
814
|
+
if (current && current.outcome === 'succeeded' && current.deliveryState !== 'failed') tasks.failDelivery(id, error);
|
|
815
|
+
throw new Error('iris: 重新交付失败:' + String((error && error.message) || error));
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
register('task_ack_attention', async (_ctx, args) => {
|
|
820
|
+
const id = String(args.task_id || '').trim();
|
|
821
|
+
const task = tasks.get(id);
|
|
822
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可标为已读的 Task v2');
|
|
823
|
+
tasks.acknowledgeAttention(id, 'read');
|
|
824
|
+
return { ok: true, taskId: id, text: '[iris] 提醒已标为已读;任务事实未改变,可在历史详情中恢复提醒。' };
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
register('task_restore_attention', async (_ctx, args) => {
|
|
828
|
+
const id = String(args.task_id || '').trim();
|
|
829
|
+
const task = tasks.get(id);
|
|
830
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可恢复提醒的 Task v2');
|
|
831
|
+
tasks.restoreAttention(id);
|
|
832
|
+
return { ok: true, taskId: id, text: '[iris] 已恢复任务提醒。' };
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
register('task_manual_retry', async (ctx, args, { signal }) => {
|
|
836
|
+
const id = String(args.task_id || '').trim();
|
|
837
|
+
if (args.confirm_duplicate_charge !== true) throw new Error('iris: 必须明确确认本次操作可能产生重复任务或重复费用');
|
|
838
|
+
const task = tasks.get(id);
|
|
839
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重试的 Task v2');
|
|
840
|
+
const state = deriveUserState(task);
|
|
841
|
+
if (!['needs_attention', 'failed', 'canceled'].includes(state)) throw new Error('iris: 当前状态不需要重新生成');
|
|
842
|
+
if (tasks.attentionDisposition(task).status === 'acknowledged') {
|
|
843
|
+
throw new Error('iris: 该提醒已经处理;如需再次操作,请先恢复提醒');
|
|
844
|
+
}
|
|
845
|
+
let action;
|
|
846
|
+
let retryArgs;
|
|
847
|
+
if (task.cap === 'image' && task.prompt) {
|
|
848
|
+
action = 'image';
|
|
849
|
+
retryArgs = { prompt: task.prompt, size: task.size || undefined, n: task.count || 1 };
|
|
850
|
+
} else if (task.cap === 'video' && task.mode === 't2v' && task.prompt) {
|
|
851
|
+
action = 'video';
|
|
852
|
+
retryArgs = { prompt: task.prompt, size: task.size || undefined, duration: task.duration || undefined };
|
|
853
|
+
} else {
|
|
854
|
+
throw new Error('iris: 该任务的原始输入无法无损重建,请从原始文件或文本手动发起新任务');
|
|
855
|
+
}
|
|
856
|
+
if (task.modelRef) retryArgs.model = task.modelRef;
|
|
857
|
+
retryArgs._retryOf = id;
|
|
858
|
+
try {
|
|
859
|
+
const result = await runAction(ctx, action, retryArgs, { signal });
|
|
860
|
+
if (result.taskId) tasks.linkManualRetry(id, result.taskId);
|
|
861
|
+
return { ...result, retryOf: id, text: (result.text || '[iris] 已创建新任务')
|
|
862
|
+
+ '\n[iris] 原任务提醒已归档,并保留新旧任务关联;本次新任务可能与远端未知任务重复。' };
|
|
863
|
+
} catch (error) {
|
|
864
|
+
if (error && error.taskId) {
|
|
865
|
+
try { tasks.linkManualRetry(id, error.taskId); } catch (_) {}
|
|
866
|
+
}
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
417
871
|
/* ---------- 🧹 任务清理(阶段 4 续:元数据删除,产物文件默认保留) ---------- */
|
|
418
872
|
|
|
419
873
|
/** 扫描 outputs/ 里不被任何任务记录引用的孤儿文件(files[] + media[].file 并集) */
|
|
@@ -849,35 +1303,15 @@ register('transcribe', async (ctx, args, { signal }) => {
|
|
|
849
1303
|
const audioPath = String(args.audio_path || '').trim();
|
|
850
1304
|
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: audio_path 必须是绝对路径');
|
|
851
1305
|
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在');
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
});
|
|
858
|
-
const remoteTaskId = await tasks.submitGuard(task, async () => {
|
|
859
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
860
|
-
return adapters.submitTranscription({ key: provider.apiKey, baseUrl: provider.baseUrl, model, audioUrl, signal });
|
|
861
|
-
});
|
|
862
|
-
tasks.update(task.id, { remoteTaskId });
|
|
863
|
-
tasks.watch(tasks.get(task.id), {
|
|
864
|
-
key: () => provider.apiKey,
|
|
865
|
-
poll: async ({ key, remoteTaskId: id }) => {
|
|
866
|
-
const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id });
|
|
867
|
-
return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
|
|
868
|
-
},
|
|
869
|
-
intervalMs: 2500,
|
|
870
|
-
onSuccess: async (t, r) => {
|
|
871
|
-
tasks.update(t.id, { transcribeText: (r.urls && r.urls.join('')) || '' });
|
|
872
|
-
return [];
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
return { task, provider, model, remoteTaskId };
|
|
876
|
-
});
|
|
1306
|
+
const providers = selectProviders(cap.CAPABILITIES.TRANSCRIBE, '转写', args.model);
|
|
1307
|
+
const routes = providers.map((provider) =>
|
|
1308
|
+
simpleModelRoute(provider, args, 'transcribeModel', 'qwen-audio-3.0-asr-flash-filetrans')
|
|
1309
|
+
);
|
|
1310
|
+
const submitted = await submitTranscriptionV2(routes, audioPath, args, signal);
|
|
877
1311
|
return {
|
|
878
1312
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
879
1313
|
remoteTaskId: submitted.remoteTaskId,
|
|
880
|
-
text:
|
|
1314
|
+
text: '[iris] 音频转写任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
881
1315
|
};
|
|
882
1316
|
});
|
|
883
1317
|
|