@mokuyoaxis/dsh-iris 0.1.4 → 0.2.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/CHANGELOG.md +54 -1
- package/README.en.md +72 -15
- package/README.md +72 -15
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +80 -64
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
- package/docs/HOST_DOCTOR.md +2 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
- package/docs/ROADMAP.md +42 -86
- package/docs/SECURITY.md +4 -3
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +374 -362
- package/lib/adapters.js +52 -15
- package/lib/api.js +152 -5
- package/lib/client.js +763 -48
- package/lib/command-service.js +420 -0
- package/lib/config.js +56 -43
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/index.js +159 -54
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +84 -21
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/tasks.js +16 -4
- package/lib/vision.js +28 -7
- package/package.json +8 -8
- package/user_guide.md +27 -12
package/lib/actions.js
CHANGED
|
@@ -20,17 +20,20 @@ import * as artifacts from './artifacts.js';
|
|
|
20
20
|
import { registerMedia, mediaLinksOf, webBase } from './media.js';
|
|
21
21
|
import { buildVisionBackendsFromHost, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
|
|
22
22
|
import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
|
|
23
|
-
import {
|
|
23
|
+
import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
24
|
+
import { imageDimensions } from './pixels.js';
|
|
24
25
|
import { locateObject } from './locate.js';
|
|
25
26
|
import { longOcr } from './ocr.js';
|
|
26
27
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
27
28
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
28
29
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
29
|
-
import {
|
|
30
|
+
import { selectMediaProtocol } from './provider-protocol.js';
|
|
31
|
+
import { providerTaskBinding } from './provider-catalog.js';
|
|
30
32
|
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
31
|
-
import { invokeProviderOperation, providerPollTaskView } from './provider-adapter.js';
|
|
33
|
+
import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
|
|
32
34
|
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
33
35
|
import { deriveUserState } from './task-semantics.js';
|
|
36
|
+
import { normalizeGenerationInput } from './generation-input.js';
|
|
34
37
|
|
|
35
38
|
const MEDIA_TYPES = {
|
|
36
39
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
@@ -53,6 +56,9 @@ function actionHost(value) {
|
|
|
53
56
|
throw new TypeError('Iris Action 只接受有效的 Host Adapter,不接受原始 ctx');
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// 工作台清空可选文本框会发送空字符串;在 Host 边界解释为未提供,保持默认值。
|
|
60
|
+
function optionalActionText(value) { return value === '' ? undefined : value; }
|
|
61
|
+
|
|
56
62
|
/* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
|
|
57
63
|
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
58
64
|
try {
|
|
@@ -89,24 +95,43 @@ function throwIfAborted(signal) {
|
|
|
89
95
|
*/
|
|
90
96
|
function selectProviders(capability, label, requestedModelRef) {
|
|
91
97
|
let providers = store.pickAllFor(capability);
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
98
|
+
const requested = String(requestedModelRef || '').trim();
|
|
99
|
+
if (requested) {
|
|
100
|
+
const pool = models.modelPool(store.providers());
|
|
101
|
+
const selected = models.resolvePoolModel(pool, requested, capability);
|
|
94
102
|
const field = models.CAP_FIELD[capability];
|
|
95
|
-
providers = providers.filter((p) => p.id ===
|
|
103
|
+
providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
|
|
96
104
|
if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
|
|
97
105
|
}
|
|
98
106
|
if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
|
|
99
107
|
return providers;
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
function configuredModel(provider, capability, requested) {
|
|
111
|
+
const ref = models.resolveModelRef(provider, capability, requested);
|
|
112
|
+
if (!ref) throw new Error('iris: 未配置可用模型,请发现或手工添加并标注对应能力');
|
|
113
|
+
return models.parseModelRef(ref).modelId;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function routeSelectionReason(provider, capability, requested) {
|
|
117
|
+
if (String(requested || '').trim()) return 'explicit';
|
|
118
|
+
const field = models.CAP_FIELD[capability];
|
|
119
|
+
const selectedRef = field && provider?.[field]
|
|
120
|
+
? models.modelRef(provider.id, provider[field]) : '';
|
|
121
|
+
return models.selectionReasonForModel(
|
|
122
|
+
models.modelPool(store.providers()),
|
|
123
|
+
capability,
|
|
124
|
+
store.assignments()[capability],
|
|
125
|
+
selectedRef
|
|
126
|
+
) || 'pool';
|
|
127
|
+
}
|
|
128
|
+
|
|
102
129
|
function imageRoute(provider, args) {
|
|
103
|
-
const protocol = provider.mediaProtocol
|
|
104
|
-
const
|
|
105
|
-
const model = args.model
|
|
106
|
-
? (parsed?.modelId || String(args.model))
|
|
107
|
-
: (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
|
|
130
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
131
|
+
const model = configuredModel(provider, cap.CAPABILITIES.IMAGE, args.model);
|
|
108
132
|
return {
|
|
109
133
|
provider, protocol, model, modelRef: models.modelRef(provider.id, model),
|
|
134
|
+
selectionReason: routeSelectionReason(provider, cap.CAPABILITIES.IMAGE, args.model),
|
|
110
135
|
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
111
136
|
};
|
|
112
137
|
}
|
|
@@ -184,148 +209,97 @@ async function askWithHealth(backends, request) {
|
|
|
184
209
|
}
|
|
185
210
|
}
|
|
186
211
|
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const name = taskId + '-' + names.length + '.png';
|
|
202
|
-
const target = path.join(outputDir(), name);
|
|
203
|
-
if (item && item.b64) writePrivateFile(target, Buffer.from(item.b64, 'base64'));
|
|
204
|
-
else if (item && item.url) await invokeProviderOperation(providerAdapter, 'download', {
|
|
205
|
-
artifact: { kind: 'remote-url', url: item.url }, targetPath: target, signal
|
|
206
|
-
});
|
|
207
|
-
else throw new Error('同步图片结果缺少 b64 或 URL');
|
|
208
|
-
names.push(name);
|
|
209
|
-
}
|
|
210
|
-
} else {
|
|
211
|
-
throw new Error('未知同步图片结果结构');
|
|
212
|
-
}
|
|
213
|
-
if (!names.length) throw new Error('生成已成功,但没有可交付的图片');
|
|
214
|
-
for (const name of names) registerMedia(taskId, path.join(outputDir(), name));
|
|
215
|
-
tasks.completeDelivery(taskId, names);
|
|
216
|
-
return tasks.get(taskId);
|
|
217
|
-
} catch (error) {
|
|
218
|
-
let recorded;
|
|
219
|
-
try {
|
|
220
|
-
recorded = tasks.failDelivery(taskId, error, names);
|
|
221
|
-
} catch (persistError) {
|
|
222
|
-
throw taskActionError(taskId, '图像已生成,但交付状态无法可靠落盘;禁止重新生成', {
|
|
223
|
-
message: String((persistError && persistError.message) || persistError)
|
|
212
|
+
function recordCoreTaskAttemptHealth(task, capability) {
|
|
213
|
+
const label = capability === cap.CAPABILITIES.VIDEO ? '视频' : '图片';
|
|
214
|
+
for (const attempt of task?.attempts || []) {
|
|
215
|
+
const parsed = models.parseModelRef(attempt.model);
|
|
216
|
+
if (!parsed) continue;
|
|
217
|
+
if (attempt.acceptance === 'accepted') {
|
|
218
|
+
recordProviderHealthSafe(attempt.providerId, parsed.modelId, capability, {
|
|
219
|
+
ok: true, category: 'success',
|
|
220
|
+
note: attempt.resultKind === 'completed' ? `同步${label}生成成功` : `${label}任务已受理`
|
|
221
|
+
});
|
|
222
|
+
} else if (attempt.error) {
|
|
223
|
+
recordProviderHealthSafe(attempt.providerId, parsed.modelId, capability, {
|
|
224
|
+
ok: false, category: attempt.error.category, httpStatus: attempt.error.httpStatus,
|
|
225
|
+
note: attempt.error.safeMessage
|
|
224
226
|
});
|
|
225
227
|
}
|
|
226
|
-
throw taskActionError(taskId, '图像已生成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
227
228
|
}
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
/**
|
|
231
|
-
async function
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
model: route.modelRef,
|
|
243
|
-
route,
|
|
244
|
-
submit: async () => {
|
|
245
|
-
if (signal && signal.aborted) {
|
|
246
|
-
throw new ProviderContractError('提交前收到取消信号', {
|
|
247
|
-
stage: 'submit', category: 'aborted', acceptance: 'unknown'
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
return observeProviderCapability(route, cap.CAPABILITIES.IMAGE, () =>
|
|
251
|
-
invokeProviderOperation(route.adapter, 'submit', {
|
|
252
|
-
capability: 'image',
|
|
253
|
-
model: route.model,
|
|
254
|
-
input: { prompt, size: args.size, n: args.n },
|
|
255
|
-
signal
|
|
256
|
-
}));
|
|
257
|
-
}
|
|
258
|
-
}));
|
|
259
|
-
|
|
260
|
-
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'image' }, {
|
|
261
|
-
beforeAttempt: ({ providerId, model }) => {
|
|
262
|
-
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
263
|
-
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
264
|
-
return tasks.beginAttempt(task.id, {
|
|
265
|
-
providerId,
|
|
266
|
-
providerName: route.provider.name,
|
|
267
|
-
model,
|
|
268
|
-
protocol: route.protocol
|
|
269
|
-
});
|
|
270
|
-
},
|
|
271
|
-
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
231
|
+
/** DSH 图片入口:同步和异步候选共用一份 Core Task/Attempt/Artifact 事实。 */
|
|
232
|
+
async function submitCoreImage(routes, providerInput, signal) {
|
|
233
|
+
const submitted = await submitProviderTaskForDsh({
|
|
234
|
+
capability: 'image',
|
|
235
|
+
candidates: routes.map((route) => ({
|
|
236
|
+
adapter: route.adapter,
|
|
237
|
+
model: route.modelRef,
|
|
238
|
+
selectionReason: route.selectionReason,
|
|
239
|
+
providerBinding: providerTaskBinding(route.provider)
|
|
240
|
+
})),
|
|
241
|
+
providerInput,
|
|
242
|
+
signal
|
|
272
243
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
throw taskActionError(task.id, '所有候选均明确未受理', submitted.attempts);
|
|
244
|
+
const task = submitted.task;
|
|
245
|
+
recordCoreTaskAttemptHealth(task, cap.CAPABILITIES.IMAGE);
|
|
246
|
+
if (submitted.localError) throw taskActionError(task.id, 'Core 提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
247
|
+
if (task.acceptance === 'unknown') throw taskActionError(task.id, '供应商受理状态未知,禁止自动重提', task.lastError);
|
|
248
|
+
if (task.acceptance === 'not_accepted') throw taskActionError(task.id, '所有候选均明确未受理', task.attempts);
|
|
249
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
250
|
+
throw taskActionError(task.id, '图像已生成,但 Core Artifact 交付失败;禁止重新生成', task.lastError);
|
|
281
251
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
252
|
+
const selected = routes.find((route) => route.provider.id === task.providerId && route.modelRef === task.modelRef);
|
|
253
|
+
if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
|
|
254
|
+
if (task.acceptance === 'accepted' && task.remoteTaskId
|
|
255
|
+
&& ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
|
|
256
|
+
await watchProviderTaskForDsh({ taskId: task.id, signal });
|
|
257
|
+
} else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
|
|
258
|
+
throw taskActionError(task.id, '图片路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
|
|
285
259
|
}
|
|
260
|
+
return { task, provider: selected.provider, model: selected.model };
|
|
261
|
+
}
|
|
286
262
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
263
|
+
/**
|
|
264
|
+
* DSH 视频入口(E 阶段):t2v/i2v 共用 Core Task/Attempt/Artifact 事实轴,
|
|
265
|
+
* 不共用图片状态机——视频走自己的交付 Profile(video/mp4 / generated-video)。
|
|
266
|
+
* s2v 数字人(需先上传首帧与音频到临时存储)仍走 legacy 链路,见 register('video')。
|
|
267
|
+
*/
|
|
268
|
+
async function submitCoreVideo(routes, frame, prompt, args, signal) {
|
|
269
|
+
const { providerInput } = normalizeGenerationInput('video', {
|
|
270
|
+
prompt,
|
|
271
|
+
img_data_url: frame ? fileDataUrl(frame) : undefined,
|
|
272
|
+
size: optionalActionText(args.size),
|
|
273
|
+
duration: args.duration
|
|
274
|
+
});
|
|
275
|
+
const submitted = await submitProviderTaskForDsh({
|
|
276
|
+
capability: 'video',
|
|
277
|
+
candidates: routes.map((route) => ({
|
|
278
|
+
adapter: route.adapter,
|
|
279
|
+
model: route.modelRef,
|
|
280
|
+
selectionReason: route.selectionReason,
|
|
281
|
+
providerBinding: providerTaskBinding(route.provider)
|
|
282
|
+
})),
|
|
283
|
+
providerInput,
|
|
284
|
+
signal
|
|
285
|
+
});
|
|
286
|
+
const task = submitted.task;
|
|
287
|
+
recordCoreTaskAttemptHealth(task, cap.CAPABILITIES.VIDEO);
|
|
288
|
+
if (submitted.localError) throw taskActionError(task.id, 'Core 提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
289
|
+
if (task.acceptance === 'unknown') throw taskActionError(task.id, '供应商受理状态未知,禁止自动重提', task.lastError);
|
|
290
|
+
if (task.acceptance === 'not_accepted') throw taskActionError(task.id, '所有候选均明确未受理', task.attempts);
|
|
291
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
292
|
+
throw taskActionError(task.id, '视频已生成,但 Core Artifact 交付失败;禁止重新生成', task.lastError);
|
|
294
293
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
294
|
+
const selected = routes.find((route) => route.provider.id === task.providerId && route.modelRef === task.modelRef);
|
|
295
|
+
if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
|
|
296
|
+
if (task.acceptance === 'accepted' && task.remoteTaskId
|
|
297
|
+
&& ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
|
|
298
|
+
await watchProviderTaskForDsh({ taskId: task.id, signal });
|
|
299
|
+
} else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
|
|
300
|
+
throw taskActionError(task.id, '视频路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
|
|
300
301
|
}
|
|
301
|
-
|
|
302
|
-
poll: ({ remoteTaskId, signal: pollSignal }) => observeProviderCapability(
|
|
303
|
-
route, cap.CAPABILITIES.IMAGE,
|
|
304
|
-
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
305
|
-
capability: 'image', remoteTaskId, signal: pollSignal
|
|
306
|
-
}),
|
|
307
|
-
{ recordSuccess: false }
|
|
308
|
-
),
|
|
309
|
-
intervalMs: 2500,
|
|
310
|
-
onSuccess: async (record, result) => {
|
|
311
|
-
const names = [];
|
|
312
|
-
for (const url of result.urls || []) {
|
|
313
|
-
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
314
|
-
await invokeProviderOperation(route.adapter, 'download', {
|
|
315
|
-
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
316
|
-
});
|
|
317
|
-
names.push(name);
|
|
318
|
-
}
|
|
319
|
-
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
320
|
-
return names;
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
return {
|
|
324
|
-
task: tasks.get(task.id),
|
|
325
|
-
provider: route.provider,
|
|
326
|
-
model: route.model,
|
|
327
|
-
remoteTaskId: submitted.result.remoteTaskId
|
|
328
|
-
};
|
|
302
|
+
return { task, provider: selected.provider, model: selected.model, mode: selected.mode };
|
|
329
303
|
}
|
|
330
304
|
|
|
331
305
|
function findGeneratedAttachment(attachmentId) {
|
|
@@ -362,13 +336,19 @@ function fileDataUrl(file) {
|
|
|
362
336
|
}
|
|
363
337
|
|
|
364
338
|
function videoRoute(provider, args, frame) {
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
|
|
339
|
+
const model = configuredModel(provider, cap.CAPABILITIES.VIDEO, args.model);
|
|
340
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
341
|
+
// B-7:必须区分两件事——
|
|
342
|
+
// s2vModel : 该模型【本身】是否是数字人模型(决定它能否接受 audio_path)
|
|
343
|
+
// isS2V : 本次【请求】是否应走 s2v 链路(显式 audio_path 意图 或 模型本身是 s2v)
|
|
344
|
+
// 旧实现只有 isS2V 且只看模型名,导致 audio_path + 非 s2v 模型被静默降级为 t2v。
|
|
345
|
+
const s2vModel = /s2v/i.test(model);
|
|
346
|
+
const isS2V = Boolean(String(args.audio_path || '').trim()) || s2vModel;
|
|
368
347
|
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
369
348
|
return {
|
|
370
|
-
provider, model, modelRef: models.modelRef(provider.id, model), isS2V, mode, protocol
|
|
371
|
-
adapter: createConfiguredProviderAdapter(provider, { protocol
|
|
349
|
+
provider, model, modelRef: models.modelRef(provider.id, model), isS2V, s2vModel, mode, protocol,
|
|
350
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol }),
|
|
351
|
+
selectionReason: routeSelectionReason(provider, cap.CAPABILITIES.VIDEO, args.model)
|
|
372
352
|
};
|
|
373
353
|
}
|
|
374
354
|
|
|
@@ -405,19 +385,13 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
405
385
|
}
|
|
406
386
|
if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
|
|
407
387
|
tasks.markAttemptStage(task.id, attemptId, 'upload');
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
model: route.model, filePath: audioPath, signal
|
|
416
|
-
});
|
|
417
|
-
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
418
|
-
} catch (error) {
|
|
419
|
-
throw beforeGenerationError(error, 'upload');
|
|
420
|
-
}
|
|
388
|
+
const { url: imageUrl } = await prepareProviderInput(route.adapter, {
|
|
389
|
+
model: route.model, filePath: frame.absPath, signal
|
|
390
|
+
});
|
|
391
|
+
const { url: audioUrl } = await prepareProviderInput(route.adapter, {
|
|
392
|
+
model: route.model, filePath: audioPath, signal
|
|
393
|
+
});
|
|
394
|
+
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
421
395
|
} else {
|
|
422
396
|
if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
|
|
423
397
|
tasks.markAttemptStage(task.id, attemptId, 'prepare');
|
|
@@ -498,147 +472,24 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
498
472
|
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
499
473
|
}
|
|
500
474
|
|
|
501
|
-
function simpleModelRoute(provider, args,
|
|
502
|
-
const
|
|
503
|
-
const model =
|
|
475
|
+
function simpleModelRoute(provider, args, capability) {
|
|
476
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
477
|
+
const model = configuredModel(provider, capability, args.model);
|
|
504
478
|
return {
|
|
505
479
|
provider, model, modelRef: models.modelRef(provider.id, model), protocol,
|
|
506
|
-
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
480
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol }),
|
|
481
|
+
selectionReason: routeSelectionReason(provider, capability, args.model)
|
|
507
482
|
};
|
|
508
483
|
}
|
|
509
484
|
|
|
510
|
-
async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
511
|
-
const task = tasks.createV2({
|
|
512
|
-
cap: 'transcribe', prompt: 'audio transcribe: ' + path.basename(audioPath),
|
|
513
|
-
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
514
|
-
});
|
|
515
|
-
const candidates = routes.map((route) => ({
|
|
516
|
-
id: route.provider.id,
|
|
517
|
-
model: route.modelRef,
|
|
518
|
-
route,
|
|
519
|
-
submit: async (_input, context) => {
|
|
520
|
-
tasks.markAttemptStage(task.id, context.attempt.id, 'upload');
|
|
521
|
-
let audioUrl;
|
|
522
|
-
try {
|
|
523
|
-
audioUrl = await adapters.uploadTempFile({
|
|
524
|
-
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
525
|
-
model: route.model, filePath: audioPath, signal
|
|
526
|
-
});
|
|
527
|
-
} catch (error) {
|
|
528
|
-
throw beforeGenerationError(error, 'upload');
|
|
529
|
-
}
|
|
530
|
-
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
531
|
-
return observeProviderCapability(route, cap.CAPABILITIES.TRANSCRIBE, () =>
|
|
532
|
-
invokeProviderOperation(route.adapter, 'submit', {
|
|
533
|
-
capability: 'transcribe', model: route.model, input: { audioUrl }, signal
|
|
534
|
-
}));
|
|
535
|
-
}
|
|
536
|
-
}));
|
|
537
|
-
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'transcribe' }, {
|
|
538
|
-
beforeAttempt: ({ providerId, model }) => {
|
|
539
|
-
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
540
|
-
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
541
|
-
return tasks.beginAttempt(task.id, {
|
|
542
|
-
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
543
|
-
});
|
|
544
|
-
},
|
|
545
|
-
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
546
|
-
});
|
|
547
|
-
if (submitted.localError) throw taskActionError(task.id, '转写提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
548
|
-
if (!submitted.candidate) {
|
|
549
|
-
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有转写候选均明确未受理');
|
|
550
|
-
throw taskActionError(task.id, '所有转写候选均明确未受理', submitted.attempts);
|
|
551
|
-
}
|
|
552
|
-
if (submitted.result.kind !== 'accepted') {
|
|
553
|
-
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
554
|
-
throw taskActionError(task.id, '转写受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
555
|
-
}
|
|
556
|
-
const route = submitted.candidate.route;
|
|
557
|
-
tasks.activateWatch(task.id);
|
|
558
|
-
tasks.watch(tasks.get(task.id), {
|
|
559
|
-
poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
|
|
560
|
-
route, cap.CAPABILITIES.TRANSCRIBE,
|
|
561
|
-
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
562
|
-
capability: 'transcribe', remoteTaskId: id, signal: pollSignal
|
|
563
|
-
}),
|
|
564
|
-
{ recordSuccess: false }
|
|
565
|
-
),
|
|
566
|
-
intervalMs: 2500,
|
|
567
|
-
allowEmptyResult: true,
|
|
568
|
-
onSuccess: async (record, result) => {
|
|
569
|
-
tasks.update(record.id, { transcribeText: (result.urls && result.urls.join('')) || '' });
|
|
570
|
-
return [];
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
574
|
-
}
|
|
575
485
|
|
|
576
|
-
async function submitTtsV2(routes, text, args, signal) {
|
|
577
|
-
const task = tasks.createV2({
|
|
578
|
-
cap: 'tts', prompt: text.slice(0, 500),
|
|
579
|
-
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
580
|
-
});
|
|
581
|
-
const candidates = routes.map((route) => ({
|
|
582
|
-
id: route.provider.id,
|
|
583
|
-
model: route.modelRef,
|
|
584
|
-
route,
|
|
585
|
-
submit: async (_input, context) => {
|
|
586
|
-
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
587
|
-
return observeProviderCapability(route, cap.CAPABILITIES.TTS, () =>
|
|
588
|
-
invokeProviderOperation(route.adapter, 'submit', {
|
|
589
|
-
capability: 'tts', model: route.model, input: { text, voice: route.voice }, signal
|
|
590
|
-
}));
|
|
591
|
-
}
|
|
592
|
-
}));
|
|
593
|
-
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'tts' }, {
|
|
594
|
-
beforeAttempt: ({ providerId, model }) => {
|
|
595
|
-
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
596
|
-
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
597
|
-
const attempt = tasks.beginAttempt(task.id, {
|
|
598
|
-
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
599
|
-
});
|
|
600
|
-
tasks.update(task.id, { voice: route.voice });
|
|
601
|
-
return attempt;
|
|
602
|
-
},
|
|
603
|
-
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
604
|
-
});
|
|
605
|
-
if (submitted.localError) throw taskActionError(task.id, '语音合成结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
606
|
-
if (!submitted.candidate) {
|
|
607
|
-
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有语音候选均明确未受理');
|
|
608
|
-
throw taskActionError(task.id, '所有语音候选均明确未受理', submitted.attempts);
|
|
609
|
-
}
|
|
610
|
-
if (submitted.result.kind !== 'completed') {
|
|
611
|
-
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
612
|
-
throw taskActionError(task.id, '语音合成受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
613
|
-
}
|
|
614
|
-
const route = submitted.candidate.route;
|
|
615
|
-
const audio = submitted.result.value || {};
|
|
616
|
-
const names = [];
|
|
617
|
-
try {
|
|
618
|
-
tasks.beginDelivery(task.id);
|
|
619
|
-
const name = task.id + '.' + (audio.audioUrl ? adapters.extFromUrl(audio.audioUrl, 'wav') : 'wav');
|
|
620
|
-
const target = path.join(outputDir(), name);
|
|
621
|
-
if (audio.audioUrl) await invokeProviderOperation(route.adapter, 'download', {
|
|
622
|
-
artifact: { kind: 'remote-url', url: audio.audioUrl }, targetPath: target, signal
|
|
623
|
-
});
|
|
624
|
-
else if (audio.audioB64) writePrivateFile(target, Buffer.from(audio.audioB64, 'base64'));
|
|
625
|
-
else throw new Error('语音合成成功响应缺少音频数据');
|
|
626
|
-
names.push(name);
|
|
627
|
-
registerMedia(task.id, target);
|
|
628
|
-
tasks.completeDelivery(task.id, names);
|
|
629
|
-
} catch (error) {
|
|
630
|
-
const recorded = tasks.failDelivery(task.id, error, names);
|
|
631
|
-
throw taskActionError(task.id, '语音已合成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
632
|
-
}
|
|
633
|
-
return { task: tasks.get(task.id), provider: route.provider, model: route.model };
|
|
634
|
-
}
|
|
635
486
|
|
|
636
487
|
/** 重新观察/交付与启动恢复共享的远端查询和本地落盘依赖。 */
|
|
637
488
|
export function taskPollDeps(provider, task) {
|
|
638
489
|
if (!provider || !task) return null;
|
|
639
490
|
if (!['image', 'video', 'transcribe'].includes(task.cap)) return null;
|
|
640
491
|
const protocol = task.protocol || (task.cap === 'image'
|
|
641
|
-
? (provider.mediaProtocol
|
|
492
|
+
? selectMediaProtocol(provider).mediaProtocol
|
|
642
493
|
: 'dashscope');
|
|
643
494
|
let providerAdapter;
|
|
644
495
|
try {
|
|
@@ -725,52 +576,173 @@ export async function runAction(host, name, args, { signal } = {}) {
|
|
|
725
576
|
|
|
726
577
|
/* ---------- 🖼️ 画图 ---------- */
|
|
727
578
|
register('image', async (host, args, { signal }) => {
|
|
728
|
-
const
|
|
729
|
-
|
|
579
|
+
const { providerInput } = normalizeGenerationInput('image', {
|
|
580
|
+
prompt: args.prompt, size: optionalActionText(args.size), n: args.n
|
|
581
|
+
});
|
|
730
582
|
|
|
731
583
|
const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
|
|
732
584
|
const routes = imageProviders.map((provider) => imageRoute(provider, args));
|
|
733
|
-
const submitted = await
|
|
585
|
+
const submitted = await submitCoreImage(routes, providerInput, signal);
|
|
586
|
+
const firstArtifactId = submitted.task.artifactIds[0];
|
|
587
|
+
const ready = submitted.task.outcome === 'succeeded'
|
|
588
|
+
&& submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
|
|
734
589
|
return {
|
|
735
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
736
|
-
remoteTaskId: submitted.remoteTaskId,
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
590
|
+
ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
|
|
591
|
+
providerId: submitted.provider.id, remoteTaskId: submitted.task.remoteTaskId || null,
|
|
592
|
+
artifactIds: submitted.task.artifactIds,
|
|
593
|
+
...(ready ? { imageUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media' } : {}),
|
|
594
|
+
text: ready
|
|
595
|
+
? '[iris] 图像任务已完成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
596
|
+
: '[iris] 图像任务已提交并由 Core 后台观察(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
740
597
|
};
|
|
741
598
|
});
|
|
742
599
|
|
|
743
600
|
/* ---------- 🎬 视频生成 ---------- */
|
|
744
601
|
register('video', async (host, args, { signal }) => {
|
|
745
602
|
const prompt = String(args.prompt || '').trim();
|
|
746
|
-
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
747
603
|
const frame = resolveFirstFrameFile(args);
|
|
748
604
|
const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
|
|
749
605
|
const routes = providers.map((provider) => videoRoute(provider, args, frame));
|
|
750
|
-
|
|
606
|
+
// s2v 数字人需要在受理边界前按候选上传首帧+音频,暂留 legacy 链路(见 DSH_CORE_MIGRATION)。
|
|
607
|
+
// 判定看显式意图(audio_path 或 model 指定 s2v),不看候选池——池里登记 s2v 模型
|
|
608
|
+
// 不应把 t2v/i2v 请求挡回 legacy。
|
|
609
|
+
const hasAudioPath = Boolean(String(args.audio_path || '').trim());
|
|
610
|
+
const s2v = hasAudioPath || (args.model && /s2v/i.test(String(args.model)));
|
|
611
|
+
// B-7:audio_path 与选中模型必须语义一致。若用户提供了音频却选中非 s2v 模型,
|
|
612
|
+
// 旧实现会落入 else 分支静默丢弃 audio_path 并生成普通视频(可能产生非预期计费)。
|
|
613
|
+
// 这里在受理边界与任何网络调用之前明确拒绝,不猜测用户意图。
|
|
614
|
+
if (hasAudioPath && routes.some((route) => !route.s2vModel)) {
|
|
615
|
+
const mismatched = routes.filter((route) => !route.s2vModel).map((route) => route.model).join(', ');
|
|
616
|
+
throw new Error('iris: audio_path 只能配合支持 s2v 数字人的模型;当前候选 ' + mismatched
|
|
617
|
+
+ ' 不是 s2v 模型。请改用 s2v 模型(如 wan2.2-s2v-flash),或移除 audio_path 走文生/图生视频。');
|
|
618
|
+
}
|
|
619
|
+
if (s2v) {
|
|
620
|
+
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
621
|
+
const submitted = await submitVideoV2(routes, frame, prompt, args, signal);
|
|
622
|
+
return {
|
|
623
|
+
ok: true, storage: 'legacy', taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
|
|
624
|
+
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
625
|
+
text: '[iris] 视频任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
const coreRoutes = routes.filter((route) => !route.isS2V);
|
|
629
|
+
if (!coreRoutes.length) {
|
|
630
|
+
throw new Error('iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
|
|
631
|
+
}
|
|
632
|
+
const submitted = await submitCoreVideo(coreRoutes, frame, prompt, args, signal);
|
|
633
|
+
const firstArtifactId = submitted.task.artifactIds[0];
|
|
634
|
+
const ready = submitted.task.outcome === 'succeeded'
|
|
635
|
+
&& submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
|
|
751
636
|
return {
|
|
752
|
-
ok: true, taskId: submitted.task.id, model: submitted.model, mode: submitted.
|
|
753
|
-
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
754
|
-
|
|
637
|
+
ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model, mode: submitted.mode,
|
|
638
|
+
providerId: submitted.provider.id, remoteTaskId: submitted.task.remoteTaskId || null,
|
|
639
|
+
artifactIds: submitted.task.artifactIds,
|
|
640
|
+
...(ready ? { videoUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media' } : {}),
|
|
641
|
+
text: ready
|
|
642
|
+
? '[iris] 视频任务已完成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
643
|
+
: '[iris] 视频任务已提交并由 Core 后台观察(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
755
644
|
};
|
|
756
645
|
});
|
|
757
646
|
|
|
647
|
+
/**
|
|
648
|
+
* DSH 语音合成入口(E2):TTS 是同步完成型——submit 内 completed,同一次调用
|
|
649
|
+
* 直接交付 generated-audio Artifact(无 remote 长轮询)。整条候选链共用一个
|
|
650
|
+
* voice(Core providerInput 静态;legacy 曾按候选各自取 provider.ttsVoice)。
|
|
651
|
+
*/
|
|
652
|
+
async function submitCoreTts(routes, normalizedInput, signal) {
|
|
653
|
+
const voice = normalizedInput.voice || String(routes[0].provider.ttsVoice || '').trim();
|
|
654
|
+
const { providerInput } = normalizeGenerationInput('tts', {
|
|
655
|
+
text: normalizedInput.text, ...(voice ? { voice } : {})
|
|
656
|
+
});
|
|
657
|
+
const submitted = await submitProviderTaskForDsh({
|
|
658
|
+
capability: 'tts',
|
|
659
|
+
candidates: routes.map((route) => ({
|
|
660
|
+
adapter: route.adapter,
|
|
661
|
+
model: route.modelRef,
|
|
662
|
+
selectionReason: route.selectionReason,
|
|
663
|
+
providerBinding: providerTaskBinding(route.provider)
|
|
664
|
+
})),
|
|
665
|
+
providerInput,
|
|
666
|
+
signal
|
|
667
|
+
});
|
|
668
|
+
const task = submitted.task;
|
|
669
|
+
recordCoreTaskAttemptHealth(task, cap.CAPABILITIES.TTS);
|
|
670
|
+
if (submitted.localError) throw taskActionError(task.id, 'Core 提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
671
|
+
if (task.acceptance === 'unknown') throw taskActionError(task.id, '语音合成受理状态未知,禁止自动重提', task.lastError);
|
|
672
|
+
if (task.acceptance === 'not_accepted') throw taskActionError(task.id, '所有候选均明确未受理', task.attempts);
|
|
673
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
674
|
+
throw taskActionError(task.id, '语音已合成,但 Core Artifact 交付失败;禁止重新生成', task.lastError);
|
|
675
|
+
}
|
|
676
|
+
if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
|
|
677
|
+
throw taskActionError(task.id, '语音合成没有得到可交付产物;事实已保留在 Core,未回退重提', task);
|
|
678
|
+
}
|
|
679
|
+
const selected = routes.find((route) => route.provider.id === task.providerId && route.modelRef === task.modelRef);
|
|
680
|
+
if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
|
|
681
|
+
return { task, provider: selected.provider, model: selected.model };
|
|
682
|
+
}
|
|
683
|
+
|
|
758
684
|
/* ---------- 🔊 语音合成 ---------- */
|
|
759
685
|
register('tts', async (host, args, { signal }) => {
|
|
760
|
-
const
|
|
761
|
-
|
|
686
|
+
const { providerInput } = normalizeGenerationInput('tts', {
|
|
687
|
+
text: args.text, voice: optionalActionText(args.voice)
|
|
688
|
+
});
|
|
762
689
|
const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
|
|
763
|
-
const routes = providers.map((provider) => (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}));
|
|
767
|
-
const submitted = await submitTtsV2(routes, text, args, signal);
|
|
690
|
+
const routes = providers.map((provider) => simpleModelRoute(provider, args, cap.CAPABILITIES.TTS));
|
|
691
|
+
const submitted = await submitCoreTts(routes, providerInput, signal);
|
|
692
|
+
const firstArtifactId = submitted.task.artifactIds[0];
|
|
768
693
|
return {
|
|
769
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
770
|
-
|
|
694
|
+
ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
|
|
695
|
+
providerId: submitted.provider.id,
|
|
696
|
+
artifactIds: submitted.task.artifactIds,
|
|
697
|
+
audioUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media',
|
|
698
|
+
text: '[iris] 语音已合成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
771
699
|
};
|
|
772
700
|
});
|
|
773
701
|
|
|
702
|
+
/**
|
|
703
|
+
* DSH 转写入口(E3):上传型异步——音频先在 Host 边界上传到首选候选的临时存储
|
|
704
|
+
* (签名 URL 只进 providerInput,绝不落 Core 记录),再经受理边界提交;观察/接管
|
|
705
|
+
* 与人工控制面复用同一 Task/Attempt 轴;正文物化为 text/plain transcript Artifact。
|
|
706
|
+
* 已知边界:临时音频 URL 属于首选候选的账号,failover 到不同账号时如实记录失败。
|
|
707
|
+
*/
|
|
708
|
+
async function submitCoreTranscribe(routes, normalized, signal) {
|
|
709
|
+
let providerInput = normalized.providerInput;
|
|
710
|
+
if (normalized.audioPath) {
|
|
711
|
+
const prepared = await prepareProviderInput(routes[0].adapter, {
|
|
712
|
+
model: routes[0].model, filePath: normalized.audioPath, signal
|
|
713
|
+
});
|
|
714
|
+
providerInput = normalizeGenerationInput('transcribe', { audio_url: prepared.url }).providerInput;
|
|
715
|
+
}
|
|
716
|
+
const submitted = await submitProviderTaskForDsh({
|
|
717
|
+
capability: 'transcribe',
|
|
718
|
+
candidates: routes.map((route) => ({
|
|
719
|
+
adapter: route.adapter,
|
|
720
|
+
model: route.modelRef,
|
|
721
|
+
selectionReason: route.selectionReason,
|
|
722
|
+
providerBinding: providerTaskBinding(route.provider)
|
|
723
|
+
})),
|
|
724
|
+
providerInput,
|
|
725
|
+
signal
|
|
726
|
+
});
|
|
727
|
+
const task = submitted.task;
|
|
728
|
+
recordCoreTaskAttemptHealth(task, cap.CAPABILITIES.TRANSCRIBE);
|
|
729
|
+
if (submitted.localError) throw taskActionError(task.id, 'Core 提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
730
|
+
if (task.acceptance === 'unknown') throw taskActionError(task.id, '转写受理状态未知,禁止自动重提', task.lastError);
|
|
731
|
+
if (task.acceptance === 'not_accepted') throw taskActionError(task.id, '所有候选均明确未受理', task.attempts);
|
|
732
|
+
if (task.outcome === 'succeeded' && task.deliveryState === 'failed') {
|
|
733
|
+
throw taskActionError(task.id, '转写已完成,但 Core Artifact 交付失败;禁止重新生成', task.lastError);
|
|
734
|
+
}
|
|
735
|
+
const selected = routes.find((route) => route.provider.id === task.providerId && route.modelRef === task.modelRef);
|
|
736
|
+
if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
|
|
737
|
+
if (task.acceptance === 'accepted' && task.remoteTaskId
|
|
738
|
+
&& ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
|
|
739
|
+
await watchProviderTaskForDsh({ taskId: task.id, signal });
|
|
740
|
+
} else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
|
|
741
|
+
throw taskActionError(task.id, '转写路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
|
|
742
|
+
}
|
|
743
|
+
return { task, provider: selected.provider, model: selected.model };
|
|
744
|
+
}
|
|
745
|
+
|
|
774
746
|
/* ---------- 👁 看图 ---------- */
|
|
775
747
|
register('look', async (host, args, { signal }) => {
|
|
776
748
|
const p = String(args.image_path || '').trim();
|
|
@@ -790,23 +762,28 @@ register('crop', async (host, args, { signal }) => {
|
|
|
790
762
|
const p = String(args.image_path || '').trim();
|
|
791
763
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
792
764
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
765
|
+
const result = await cropForDsh({
|
|
766
|
+
imagePath: p, left: args.left, top: args.top, width: args.width, height: args.height, signal
|
|
767
|
+
});
|
|
768
|
+
const b64 = result.bytes.toString('base64');
|
|
769
|
+
return { ok: true, text: `[iris] 裁剪完成:${result.width}x${result.height}(原区域 ${args.left},${args.top},${args.width},${args.height})`, imageDataUrl: `data:${result.mediaType};base64,${b64}` };
|
|
796
770
|
});
|
|
797
771
|
|
|
798
772
|
/* ---------- 📷 像素 diff ---------- */
|
|
799
773
|
register('diff', async (host, args, { signal }) => {
|
|
800
774
|
if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
|
|
801
|
-
const
|
|
802
|
-
|
|
775
|
+
const result = await diffForDsh({
|
|
776
|
+
imageA: { imagePath: args.image_a_path }, imageB: { imagePath: args.image_b_path },
|
|
777
|
+
grid: args.grid, topRegions: args.top_regions, signal
|
|
803
778
|
});
|
|
779
|
+
const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
|
|
804
780
|
const pct = (ratio * 100).toFixed(2);
|
|
805
781
|
const regions = worstRegions.map((r) => `(col ${r.col},row ${r.row}) ${(r.score * 100).toFixed(1)}%`).join(' ');
|
|
806
782
|
return {
|
|
807
783
|
ok: true,
|
|
784
|
+
artifactIds: [result.artifact.id],
|
|
808
785
|
text: `[iris] 像素差异 ${pct}%(${diffPixels}/${totalPixels}px,归一化 ${width}x${height})\n最差区域(${worstRegions.length} 格): ${regions}`,
|
|
809
|
-
imageDataUrl: `data:${
|
|
786
|
+
imageDataUrl: `data:${result.mediaType};base64,${result.bytes.toString('base64')}`
|
|
810
787
|
};
|
|
811
788
|
});
|
|
812
789
|
|
|
@@ -849,20 +826,49 @@ register('ocr', async (host, args, { signal }) => {
|
|
|
849
826
|
|
|
850
827
|
/* ---------- 📋 任务查询 ---------- */
|
|
851
828
|
register('status', async (host, args, { signal }) => {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
829
|
+
const requestedId = String(args.task_id || '').trim();
|
|
830
|
+
if (requestedId) {
|
|
831
|
+
const legacy = tasks.get(requestedId);
|
|
832
|
+
if (legacy) {
|
|
833
|
+
const lines = [`[${legacy.cap}] ${legacy.id} · ${legacy.status}${legacy.progress ? ' · ' + legacy.progress : ''} · ${legacy.model}`];
|
|
834
|
+
if (legacy.prompt) lines.push('prompt: ' + String(legacy.prompt).slice(0, 120));
|
|
835
|
+
if (legacy.error) lines.push('error: ' + legacy.error);
|
|
836
|
+
if ((legacy.files || []).length) lines.push('files:\n' + legacy.files.map((f) => path.join(outputDir(), f)).join('\n'));
|
|
837
|
+
const links = mediaLinksOf(legacy);
|
|
838
|
+
if (links.length) lines.push('links:\n' + links.join('\n'));
|
|
839
|
+
return { ok: true, storage: 'legacy', taskId: legacy.id, text: lines.join('\n') };
|
|
840
|
+
}
|
|
841
|
+
if (/^task_[a-f0-9]{24}$/.test(requestedId)) {
|
|
842
|
+
try {
|
|
843
|
+
const core = await inspectProviderTaskForDsh(requestedId, { signal });
|
|
844
|
+
const lines = [`[${core.capability}] ${core.id} · Core ${core.phase} · ${core.outcome} / ${core.deliveryState} · ${core.modelRef || '未选择模型'}`];
|
|
845
|
+
if (core.lastError) lines.push('error: ' + core.lastError.safeMessage);
|
|
846
|
+
if (core.remoteTaskId) lines.push('remoteTask: ' + core.remoteTaskId);
|
|
847
|
+
if (core.artifactIds.length) {
|
|
848
|
+
lines.push('artifacts:\n' + core.artifactIds.map((id) => id + ' · /iris/api/core/artifact/' + encodeURIComponent(id) + '/media').join('\n'));
|
|
849
|
+
}
|
|
850
|
+
return { ok: true, storage: 'core', taskId: core.id, artifactIds: core.artifactIds, text: lines.join('\n') };
|
|
851
|
+
} catch (_) { /* 对外统一为不存在,不暴露 Core 数据根 */ }
|
|
852
|
+
}
|
|
853
|
+
throw new Error('iris: 没有这个任务:' + args.task_id);
|
|
862
854
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
855
|
+
|
|
856
|
+
const legacyRows = tasks.list().slice(0, 5).map((task) => ({
|
|
857
|
+
at: task.createdAt,
|
|
858
|
+
text: `[${task.cap}] ${task.id} · ${task.status} · ${task.model}`
|
|
859
|
+
}));
|
|
860
|
+
let coreRows = [];
|
|
861
|
+
try {
|
|
862
|
+
const snapshot = await coreSnapshotForDsh({ limit: 5, signal });
|
|
863
|
+
coreRows = (snapshot.tasks.recent || []).map((task) => ({
|
|
864
|
+
at: task.updatedAt,
|
|
865
|
+
text: `[${task.capability}] ${task.id} · Core ${task.phase} · ${task.outcome} / ${task.deliveryState} · ${task.modelRef || '未选择模型'}`
|
|
866
|
+
}));
|
|
867
|
+
} catch (_) { /* legacy 查询仍然可用 */ }
|
|
868
|
+
const rows = legacyRows.concat(coreRows)
|
|
869
|
+
.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
|
|
870
|
+
.slice(0, 5);
|
|
871
|
+
return { ok: true, text: rows.length ? rows.map((item) => item.text).join('\n') : '[iris] 还没有生成任务记录。' };
|
|
866
872
|
});
|
|
867
873
|
|
|
868
874
|
/* ---------- Task v2 人工接管 ---------- */
|
|
@@ -1096,8 +1102,10 @@ register('providers_list', async (host, args) => {
|
|
|
1096
1102
|
name: p.name || p.id,
|
|
1097
1103
|
type: p.type || 'openai',
|
|
1098
1104
|
baseUrl: p.baseUrl,
|
|
1105
|
+
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
1099
1106
|
enabled: p.enabled !== false,
|
|
1100
|
-
|
|
1107
|
+
auth: p.auth === 'none' ? 'none' : 'bearer',
|
|
1108
|
+
...selectMediaProtocol(p),
|
|
1101
1109
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
1102
1110
|
models: models.providerModels(p).map((m) => ({
|
|
1103
1111
|
id: m.id,
|
|
@@ -1117,7 +1125,7 @@ register('providers_list', async (host, args) => {
|
|
|
1117
1125
|
register('providers_upsert', async (host, args) => {
|
|
1118
1126
|
const id = args.id || undefined;
|
|
1119
1127
|
const patch = { id };
|
|
1120
|
-
const allowed = ['name', 'type', 'baseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
1128
|
+
const allowed = ['auth', 'name', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
1121
1129
|
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'models'];
|
|
1122
1130
|
for (const key of allowed) {
|
|
1123
1131
|
if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
|
|
@@ -1141,30 +1149,30 @@ register('providers_set_models', async (host, args) => {
|
|
|
1141
1149
|
});
|
|
1142
1150
|
|
|
1143
1151
|
/**
|
|
1144
|
-
*
|
|
1152
|
+
* 模型发现:读取供应商目录 → 官方能力元数据优先、名称规则兜底 → 写入显式池。
|
|
1145
1153
|
* 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
|
|
1146
1154
|
*/
|
|
1147
1155
|
register('providers_discover', async (host, args, { signal }) => {
|
|
1148
1156
|
const p = store.providerById(String(args.id || ''));
|
|
1149
1157
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1150
|
-
let
|
|
1158
|
+
let discoveredModels;
|
|
1151
1159
|
try {
|
|
1152
1160
|
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1153
1161
|
const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
|
|
1154
|
-
|
|
1162
|
+
discoveredModels = discovered.models;
|
|
1155
1163
|
} catch (err) {
|
|
1156
1164
|
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
1157
1165
|
}
|
|
1158
1166
|
const media = [];
|
|
1159
|
-
for (const
|
|
1160
|
-
const caps = models.
|
|
1161
|
-
if (caps.length) media.push({ id, capabilities: caps });
|
|
1167
|
+
for (const model of discoveredModels) {
|
|
1168
|
+
const caps = models.capabilitiesOfDiscoveredModel(model);
|
|
1169
|
+
if (caps.length) media.push({ id: model.id, capabilities: caps });
|
|
1162
1170
|
}
|
|
1163
1171
|
if (!media.length) {
|
|
1164
|
-
return { ok: true, total:
|
|
1172
|
+
return { ok: true, total: discoveredModels.length, media: 0, text: `[iris] ${p.name}:拉到 ${discoveredModels.length} 个模型,无媒体能力匹配——可在模型池区手动添加并标能力` };
|
|
1165
1173
|
}
|
|
1166
|
-
store.
|
|
1167
|
-
return { ok: true, total:
|
|
1174
|
+
store.mergeDiscoveredModels(p.id, media);
|
|
1175
|
+
return { ok: true, total: discoveredModels.length, media: media.length, text: `[iris] ${p.name}:发现 ${discoveredModels.length} 个模型,${media.length} 个媒体能力模型已入池(官方元数据优先)` };
|
|
1168
1176
|
});
|
|
1169
1177
|
|
|
1170
1178
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
@@ -1213,9 +1221,9 @@ async function probeModel(host, p, modelId, capability, signal) {
|
|
|
1213
1221
|
return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
|
|
1214
1222
|
}
|
|
1215
1223
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
1216
|
-
const providerAdapter = createConfiguredProviderAdapter(p
|
|
1224
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1217
1225
|
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1218
|
-
capability: 'tts', model: modelId, input: { text: '你好'
|
|
1226
|
+
capability: 'tts', model: modelId, input: { text: '你好' }, signal
|
|
1219
1227
|
});
|
|
1220
1228
|
const value = submitted.kind === 'completed' ? submitted.value : null;
|
|
1221
1229
|
const ok = Boolean(value && (value.audioUrl || value.audioB64));
|
|
@@ -1436,41 +1444,45 @@ register('attachment_export', async (host, args, { signal }) => {
|
|
|
1436
1444
|
/* ============ 音频转写(阶段 7.2) ============ */
|
|
1437
1445
|
|
|
1438
1446
|
register('transcribe', async (host, args, { signal }) => {
|
|
1439
|
-
const
|
|
1440
|
-
|
|
1441
|
-
|
|
1447
|
+
const normalized = normalizeGenerationInput('transcribe', {
|
|
1448
|
+
audio_url: args.audio_url, audio_path: args.audio_path
|
|
1449
|
+
}, { allowAudioPath: true });
|
|
1450
|
+
if (normalized.audioPath && !fs.existsSync(normalized.audioPath)) throw new Error('iris: 音频文件不存在');
|
|
1442
1451
|
const providers = selectProviders(cap.CAPABILITIES.TRANSCRIBE, '转写', args.model);
|
|
1443
1452
|
const routes = providers.map((provider) =>
|
|
1444
|
-
simpleModelRoute(provider, args,
|
|
1453
|
+
simpleModelRoute(provider, args, cap.CAPABILITIES.TRANSCRIBE)
|
|
1445
1454
|
);
|
|
1446
|
-
const submitted = await
|
|
1455
|
+
const submitted = await submitCoreTranscribe(routes, normalized, signal);
|
|
1456
|
+
const firstArtifactId = submitted.task.artifactIds[0];
|
|
1457
|
+
const ready = submitted.task.outcome === 'succeeded'
|
|
1458
|
+
&& submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
|
|
1447
1459
|
return {
|
|
1448
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
1449
|
-
remoteTaskId: submitted.remoteTaskId,
|
|
1450
|
-
|
|
1460
|
+
ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
|
|
1461
|
+
providerId: submitted.provider.id, remoteTaskId: submitted.task.remoteTaskId || null,
|
|
1462
|
+
artifactIds: submitted.task.artifactIds,
|
|
1463
|
+
...(ready ? { transcriptUrl: '/iris/api/core/artifact/' + encodeURIComponent(firstArtifactId) + '/media' } : {}),
|
|
1464
|
+
text: ready
|
|
1465
|
+
? '[iris] 音频转写已完成(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
1466
|
+
: '[iris] 音频转写任务已提交并由 Core 后台观察(' + submitted.model + ',Core task: ' + submitted.task.id + ')'
|
|
1451
1467
|
};
|
|
1452
1468
|
});
|
|
1453
1469
|
|
|
1454
1470
|
/* ============ 视频抽帧(阶段 7.1) ============ */
|
|
1455
1471
|
|
|
1456
1472
|
register('video_frames', async (host, args, { signal }) => {
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法抽帧(可选安装 ffmpeg 后启用)');
|
|
1461
|
-
const meta = probeVideo(p);
|
|
1462
|
-
const frames = await extractFrames({
|
|
1463
|
-
inputPath: p, maxFrames: args.max_frames, targetWidth: args.target_width,
|
|
1464
|
-
format: args.format || 'jpeg', quality: args.quality, signal
|
|
1473
|
+
const { media: meta, frames } = await framesForDsh({
|
|
1474
|
+
videoPath: args.video_path, maxFrames: args.max_frames, targetWidth: args.target_width,
|
|
1475
|
+
format: optionalActionText(args.format), quality: args.quality, signal
|
|
1465
1476
|
});
|
|
1466
|
-
const mime =
|
|
1477
|
+
const mime = frames[0].mediaType;
|
|
1467
1478
|
const summary = frames.map((f, i) =>
|
|
1468
|
-
`帧${i + 1} @${f.atSec.toFixed(1)}s ${f.width}x${f.height} ${(f.
|
|
1479
|
+
`帧${i + 1} @${f.atSec.toFixed(1)}s ${f.width}x${f.height} ${(f.bytes.length / 1024).toFixed(0)}KB`
|
|
1469
1480
|
).join('\n');
|
|
1470
1481
|
// 首帧预览 base64
|
|
1471
|
-
const firstFrame = `data:${mime};base64,${frames[0].
|
|
1482
|
+
const firstFrame = `data:${mime};base64,${frames[0].bytes.toString('base64')}`;
|
|
1472
1483
|
return {
|
|
1473
1484
|
ok: true,
|
|
1485
|
+
artifactIds: frames.map((frame) => frame.artifact.id),
|
|
1474
1486
|
text: `[iris] 视频抽帧完成:${frames.length} 帧(${meta.durationSec.toFixed(1)}s ${meta.width}x${meta.height},${mime === 'image/png' ? 'PNG' : 'JPEG'})\n${summary}\n\n(预览为首帧;全部帧请用对话工具 iris_video_frames 获取 attachment)`,
|
|
1475
1487
|
imageDataUrl: firstFrame
|
|
1476
1488
|
};
|