@mokuyoaxis/dsh-iris 0.1.3 → 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/.dsh/skills/iris-compose-media/SKILL.md +23 -49
- package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
- package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
- package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
- package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
- package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
- package/CHANGELOG.md +77 -2
- package/README.en.md +98 -19
- package/README.md +98 -19
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +82 -52
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- 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 +105 -0
- package/docs/HOST_DOCTOR.md +49 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +42 -76
- package/docs/SECURITY.md +10 -5
- package/docs/TASK_SEMANTICS.md +3 -2
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- 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.1.4.md +59 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +673 -525
- package/lib/adapters.js +58 -21
- package/lib/api.js +182 -16
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +1116 -86
- package/lib/command-service.js +420 -0
- package/lib/config.js +245 -49
- 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 +260 -30
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +240 -0
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +255 -166
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +397 -0
- package/lib/provider-adapters.js +342 -0
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-health.js +59 -0
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +45 -9
- package/lib/vision.js +51 -56
- package/package.json +20 -14
- package/user_guide.md +67 -12
- package/docs/assets/diagrams/iris-architecture.drawio +0 -1
- package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
- package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
- package/docs/releases/0.1.1.md +0 -51
- package/docs/releases/0.1.2.md +0 -26
- package/docs/releases/0.1.3.md +0 -30
package/lib/actions.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* ① `POST /iris/api/actions/:name` 路由(GUI 直连,submit-only 不等待)
|
|
7
7
|
* ② 工具 execute 体(Agent 调用,可等待)
|
|
8
8
|
*
|
|
9
|
-
* 每个 action 是纯函数 `(
|
|
9
|
+
* 每个 action 是纯函数 `(host, args, { signal }) => result`,
|
|
10
10
|
* 结果只有可序列化 JSON(无 live 对象)。
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'node:fs';
|
|
@@ -16,32 +16,57 @@ import * as store from './config.js';
|
|
|
16
16
|
import * as cap from './capability.js';
|
|
17
17
|
import * as models from './models.js';
|
|
18
18
|
import * as tasks from './tasks.js';
|
|
19
|
+
import * as artifacts from './artifacts.js';
|
|
19
20
|
import { registerMedia, mediaLinksOf, webBase } from './media.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
21
|
+
import { buildVisionBackendsFromHost, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
|
|
22
|
+
import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
|
|
23
|
+
import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
24
|
+
import { imageDimensions } from './pixels.js';
|
|
22
25
|
import { locateObject } from './locate.js';
|
|
23
|
-
import { BrowserHtmlRenderer } from './render.js';
|
|
24
26
|
import { longOcr } from './ocr.js';
|
|
25
27
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
26
28
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
27
29
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
28
|
-
import {
|
|
30
|
+
import { selectMediaProtocol } from './provider-protocol.js';
|
|
31
|
+
import { providerTaskBinding } from './provider-catalog.js';
|
|
29
32
|
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
33
|
+
import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
|
|
34
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
30
35
|
import { deriveUserState } from './task-semantics.js';
|
|
36
|
+
import { normalizeGenerationInput } from './generation-input.js';
|
|
31
37
|
|
|
32
38
|
const MEDIA_TYPES = {
|
|
33
39
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
34
40
|
'.webp': 'image/webp', '.gif': 'image/gif'
|
|
35
41
|
};
|
|
36
42
|
|
|
43
|
+
const EMPTY_ACTION_HOST = defineHostAdapter({
|
|
44
|
+
id: 'iris-hostless',
|
|
45
|
+
ports: {},
|
|
46
|
+
unavailable: Object.fromEntries(HOST_PORTS.map((name) => [name, {
|
|
47
|
+
kind: 'unavailable', reason: '当前动作没有宿主能力'
|
|
48
|
+
}]))
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function actionHost(value) {
|
|
52
|
+
if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) return EMPTY_ACTION_HOST;
|
|
53
|
+
if (value.contractVersion === HOST_CONTRACT_VERSION && value.ports && value.unavailable) {
|
|
54
|
+
try { return defineHostAdapter(value); } catch (_) { /* 统一为 Command 边界错误 */ }
|
|
55
|
+
}
|
|
56
|
+
throw new TypeError('Iris Action 只接受有效的 Host Adapter,不接受原始 ctx');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 工作台清空可选文本框会发送空字符串;在 Host 边界解释为未提供,保持默认值。
|
|
60
|
+
function optionalActionText(value) { return value === '' ? undefined : value; }
|
|
61
|
+
|
|
37
62
|
/* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
|
|
38
|
-
async function describeFirstImage(
|
|
63
|
+
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
39
64
|
try {
|
|
40
65
|
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
41
66
|
const buf = fs.readFileSync(absPath);
|
|
42
67
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
43
|
-
const backends =
|
|
44
|
-
const { answer } = await
|
|
68
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
69
|
+
const { answer } = await askWithHealth(backends, {
|
|
45
70
|
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
46
71
|
imageDataUrl: dataUrl,
|
|
47
72
|
signal: undefined
|
|
@@ -70,44 +95,45 @@ function throwIfAborted(signal) {
|
|
|
70
95
|
*/
|
|
71
96
|
function selectProviders(capability, label, requestedModelRef) {
|
|
72
97
|
let providers = store.pickAllFor(capability);
|
|
73
|
-
const
|
|
74
|
-
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);
|
|
75
102
|
const field = models.CAP_FIELD[capability];
|
|
76
|
-
providers = providers.filter((p) => p.id ===
|
|
103
|
+
providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
|
|
77
104
|
if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
|
|
78
105
|
}
|
|
79
106
|
if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
|
|
80
107
|
return providers;
|
|
81
108
|
}
|
|
82
109
|
|
|
83
|
-
function
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
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) };
|
|
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;
|
|
90
114
|
}
|
|
91
115
|
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
|
|
129
|
+
function imageRoute(provider, args) {
|
|
130
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
131
|
+
const model = configuredModel(provider, cap.CAPABILITIES.IMAGE, args.model);
|
|
132
|
+
return {
|
|
133
|
+
provider, protocol, model, modelRef: models.modelRef(provider.id, model),
|
|
134
|
+
selectionReason: routeSelectionReason(provider, cap.CAPABILITIES.IMAGE, args.model),
|
|
135
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
136
|
+
};
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
function taskActionError(taskId, message, details) {
|
|
@@ -117,153 +143,163 @@ function taskActionError(taskId, message, details) {
|
|
|
117
143
|
return error;
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
|
|
121
|
-
const names = [];
|
|
146
|
+
function recordProviderHealthSafe(providerId, modelId, capability, result) {
|
|
122
147
|
try {
|
|
123
|
-
|
|
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);
|
|
148
|
+
store.recordProviderHealth(providerId, modelId, capability, { source: 'task', ...result });
|
|
146
149
|
} catch (error) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
console.warn('[iris] Provider 健康事实记录失败:', String(error && error.message || error));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function observeProviderCapability(route, capability, operation, { recordSuccess = true } = {}) {
|
|
155
|
+
try {
|
|
156
|
+
const result = await operation();
|
|
157
|
+
if (recordSuccess && result && ['accepted', 'completed', 'pending', 'succeeded'].includes(result.kind)) {
|
|
158
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
159
|
+
ok: true,
|
|
160
|
+
category: 'success',
|
|
161
|
+
note: result.kind === 'accepted' ? '任务已受理' : '供应商请求成功'
|
|
162
|
+
});
|
|
163
|
+
} else if (result && result.error) {
|
|
164
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
165
|
+
ok: false,
|
|
166
|
+
category: result.error.category,
|
|
167
|
+
httpStatus: result.error.httpStatus,
|
|
168
|
+
note: result.error.safeMessage
|
|
153
169
|
});
|
|
154
170
|
}
|
|
155
|
-
|
|
171
|
+
return result;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
174
|
+
ok: false,
|
|
175
|
+
category: error && error.category,
|
|
176
|
+
httpStatus: error && (error.httpStatus ?? error.status),
|
|
177
|
+
note: String(error && (error.safeMessage || error.message) || error)
|
|
178
|
+
});
|
|
179
|
+
throw error;
|
|
156
180
|
}
|
|
157
181
|
}
|
|
158
182
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
}
|
|
183
|
+
function recordVisionHealth(errors) {
|
|
184
|
+
for (const error of errors || []) {
|
|
185
|
+
if (error && error.kind === 'selfstack') {
|
|
186
|
+
recordProviderHealthSafe(error.backendId, error.model, cap.CAPABILITIES.VISION, {
|
|
187
|
+
ok: false,
|
|
188
|
+
category: error.category,
|
|
189
|
+
httpStatus: error.status,
|
|
190
|
+
note: error.message
|
|
191
|
+
});
|
|
202
192
|
}
|
|
203
|
-
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
204
195
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
model,
|
|
213
|
-
protocol: route.protocol
|
|
196
|
+
async function askWithHealth(backends, request) {
|
|
197
|
+
try {
|
|
198
|
+
const result = await askWithBackends(backends, request);
|
|
199
|
+
recordVisionHealth(result.errors);
|
|
200
|
+
if (result.via === 'selfstack') {
|
|
201
|
+
recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
|
|
202
|
+
ok: true, category: 'success', note: '视觉请求成功'
|
|
214
203
|
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
|
|
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);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
recordVisionHealth(error && error.errors);
|
|
208
|
+
throw error;
|
|
226
209
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
210
|
+
}
|
|
211
|
+
|
|
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
|
|
226
|
+
});
|
|
227
|
+
}
|
|
230
228
|
}
|
|
229
|
+
}
|
|
231
230
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
243
|
+
});
|
|
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);
|
|
236
251
|
}
|
|
237
|
-
|
|
238
|
-
|
|
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);
|
|
239
259
|
}
|
|
260
|
+
return { task, provider: selected.provider, model: selected.model };
|
|
261
|
+
}
|
|
240
262
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
}
|
|
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
|
|
260
274
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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);
|
|
293
|
+
}
|
|
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);
|
|
301
|
+
}
|
|
302
|
+
return { task, provider: selected.provider, model: selected.model, mode: selected.mode };
|
|
267
303
|
}
|
|
268
304
|
|
|
269
305
|
function findGeneratedAttachment(attachmentId) {
|
|
@@ -300,11 +336,20 @@ function fileDataUrl(file) {
|
|
|
300
336
|
}
|
|
301
337
|
|
|
302
338
|
function videoRoute(provider, args, frame) {
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
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;
|
|
306
347
|
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
307
|
-
return {
|
|
348
|
+
return {
|
|
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)
|
|
352
|
+
};
|
|
308
353
|
}
|
|
309
354
|
|
|
310
355
|
function beforeGenerationError(error, stage) {
|
|
@@ -340,19 +385,13 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
340
385
|
}
|
|
341
386
|
if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
|
|
342
387
|
tasks.markAttemptStage(task.id, attemptId, 'upload');
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
}
|
|
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' };
|
|
356
395
|
} else {
|
|
357
396
|
if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
|
|
358
397
|
tasks.markAttemptStage(task.id, attemptId, 'prepare');
|
|
@@ -368,15 +407,10 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
368
407
|
}
|
|
369
408
|
}
|
|
370
409
|
tasks.markAttemptStage(task.id, attemptId, 'submit');
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
});
|
|
376
|
-
return { kind: 'accepted', remoteTaskId };
|
|
377
|
-
} catch (error) {
|
|
378
|
-
throw imageSubmissionError(error);
|
|
379
|
-
}
|
|
410
|
+
return observeProviderCapability(route, cap.CAPABILITIES.VIDEO, () =>
|
|
411
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
412
|
+
capability: 'video', model: route.model, input: submitArgs, signal
|
|
413
|
+
}));
|
|
380
414
|
}
|
|
381
415
|
}));
|
|
382
416
|
|
|
@@ -414,14 +448,21 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
414
448
|
const route = submitted.candidate.route;
|
|
415
449
|
tasks.activateWatch(task.id);
|
|
416
450
|
tasks.watch(tasks.get(task.id), {
|
|
417
|
-
|
|
418
|
-
|
|
451
|
+
poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
|
|
452
|
+
route, cap.CAPABILITIES.VIDEO,
|
|
453
|
+
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
454
|
+
capability: 'video', remoteTaskId: id, signal: pollSignal
|
|
455
|
+
}),
|
|
456
|
+
{ recordSuccess: false }
|
|
457
|
+
),
|
|
419
458
|
intervalMs: 6000,
|
|
420
459
|
onSuccess: async (record, result) => {
|
|
421
460
|
const names = [];
|
|
422
461
|
for (const url of result.urls || []) {
|
|
423
462
|
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
|
|
424
|
-
await
|
|
463
|
+
await invokeProviderOperation(route.adapter, 'download', {
|
|
464
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
465
|
+
});
|
|
425
466
|
names.push(name);
|
|
426
467
|
}
|
|
427
468
|
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
@@ -431,154 +472,48 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
431
472
|
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
432
473
|
}
|
|
433
474
|
|
|
434
|
-
function simpleModelRoute(provider, args,
|
|
435
|
-
const
|
|
436
|
-
const model =
|
|
437
|
-
return {
|
|
475
|
+
function simpleModelRoute(provider, args, capability) {
|
|
476
|
+
const protocol = selectMediaProtocol(provider).mediaProtocol;
|
|
477
|
+
const model = configuredModel(provider, capability, args.model);
|
|
478
|
+
return {
|
|
479
|
+
provider, model, modelRef: models.modelRef(provider.id, model), protocol,
|
|
480
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol }),
|
|
481
|
+
selectionReason: routeSelectionReason(provider, capability, args.model)
|
|
482
|
+
};
|
|
438
483
|
}
|
|
439
484
|
|
|
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
485
|
|
|
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
486
|
|
|
572
487
|
/** 重新观察/交付与启动恢复共享的远端查询和本地落盘依赖。 */
|
|
573
488
|
export function taskPollDeps(provider, task) {
|
|
574
489
|
if (!provider || !task) return null;
|
|
490
|
+
if (!['image', 'video', 'transcribe'].includes(task.cap)) return null;
|
|
491
|
+
const protocol = task.protocol || (task.cap === 'image'
|
|
492
|
+
? selectMediaProtocol(provider).mediaProtocol
|
|
493
|
+
: 'dashscope');
|
|
494
|
+
let providerAdapter;
|
|
495
|
+
try {
|
|
496
|
+
providerAdapter = createConfiguredProviderAdapter(provider, { protocol });
|
|
497
|
+
} catch (_) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
const healthCapability = task.cap === 'image'
|
|
501
|
+
? cap.CAPABILITIES.IMAGE
|
|
502
|
+
: (task.cap === 'video' ? cap.CAPABILITIES.VIDEO : cap.CAPABILITIES.TRANSCRIBE);
|
|
503
|
+
const parsedTaskModel = models.parseModelRef(String(task.model || ''));
|
|
504
|
+
const healthRoute = {
|
|
505
|
+
provider,
|
|
506
|
+
model: parsedTaskModel?.modelId || String(task.model || provider[models.CAP_FIELD[healthCapability]] || '')
|
|
507
|
+
};
|
|
575
508
|
if (task.cap === 'transcribe') {
|
|
576
509
|
return {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
510
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
511
|
+
healthRoute, healthCapability,
|
|
512
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
513
|
+
capability: 'transcribe', remoteTaskId, signal
|
|
514
|
+
}),
|
|
515
|
+
{ recordSuccess: false }
|
|
516
|
+
),
|
|
582
517
|
intervalMs: 2500,
|
|
583
518
|
allowEmptyResult: true,
|
|
584
519
|
onSuccess: async (record, result) => {
|
|
@@ -587,17 +522,23 @@ export function taskPollDeps(provider, task) {
|
|
|
587
522
|
}
|
|
588
523
|
};
|
|
589
524
|
}
|
|
590
|
-
if (task.cap !== 'image' && task.cap !== 'video') return null;
|
|
591
525
|
return {
|
|
592
|
-
|
|
593
|
-
|
|
526
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
527
|
+
healthRoute, healthCapability,
|
|
528
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
529
|
+
capability: task.cap, remoteTaskId, signal
|
|
530
|
+
}),
|
|
531
|
+
{ recordSuccess: false }
|
|
532
|
+
),
|
|
594
533
|
intervalMs: task.cap === 'video' ? 6000 : 2500,
|
|
595
534
|
onSuccess: async (record, result) => {
|
|
596
535
|
const names = [];
|
|
597
536
|
for (const url of result.urls || []) {
|
|
598
537
|
const fallback = task.cap === 'video' ? 'mp4' : 'png';
|
|
599
538
|
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, fallback);
|
|
600
|
-
await
|
|
539
|
+
await invokeProviderOperation(providerAdapter, 'download', {
|
|
540
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
541
|
+
});
|
|
601
542
|
names.push(name);
|
|
602
543
|
}
|
|
603
544
|
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
@@ -613,7 +554,7 @@ const actions = {};
|
|
|
613
554
|
/**
|
|
614
555
|
* 注册一个 action。
|
|
615
556
|
* @param {string} name 动作名(用于路由)
|
|
616
|
-
* @param {Function} fn (
|
|
557
|
+
* @param {Function} fn (host, args, { signal }) => { ok, text?, imageDataUrl?, taskId?, blocks? }
|
|
617
558
|
*/
|
|
618
559
|
function register(name, fn) {
|
|
619
560
|
actions[name] = fn;
|
|
@@ -625,103 +566,229 @@ export function listActions() {
|
|
|
625
566
|
}
|
|
626
567
|
|
|
627
568
|
/** 执行一个动作 */
|
|
628
|
-
export async function runAction(
|
|
569
|
+
export async function runAction(host, name, args, { signal } = {}) {
|
|
629
570
|
const fn = actions[name];
|
|
630
571
|
if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
|
|
631
|
-
return fn(
|
|
572
|
+
return fn(actionHost(host), args || {}, { signal });
|
|
632
573
|
}
|
|
633
574
|
|
|
634
575
|
/* ============ 动作实现 ============ */
|
|
635
576
|
|
|
636
577
|
/* ---------- 🖼️ 画图 ---------- */
|
|
637
|
-
register('image', async (
|
|
638
|
-
const
|
|
639
|
-
|
|
578
|
+
register('image', async (host, args, { signal }) => {
|
|
579
|
+
const { providerInput } = normalizeGenerationInput('image', {
|
|
580
|
+
prompt: args.prompt, size: optionalActionText(args.size), n: args.n
|
|
581
|
+
});
|
|
640
582
|
|
|
641
583
|
const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
|
|
642
584
|
const routes = imageProviders.map((provider) => imageRoute(provider, args));
|
|
643
|
-
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);
|
|
644
589
|
return {
|
|
645
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
646
|
-
remoteTaskId: submitted.remoteTaskId,
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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 + ')'
|
|
650
597
|
};
|
|
651
598
|
});
|
|
652
599
|
|
|
653
600
|
/* ---------- 🎬 视频生成 ---------- */
|
|
654
|
-
register('video', async (
|
|
601
|
+
register('video', async (host, args, { signal }) => {
|
|
655
602
|
const prompt = String(args.prompt || '').trim();
|
|
656
|
-
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
657
603
|
const frame = resolveFirstFrameFile(args);
|
|
658
604
|
const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
|
|
659
605
|
const routes = providers.map((provider) => videoRoute(provider, args, frame));
|
|
660
|
-
|
|
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);
|
|
661
636
|
return {
|
|
662
|
-
ok: true, taskId: submitted.task.id, model: submitted.model, mode: submitted.
|
|
663
|
-
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
664
|
-
|
|
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 + ')'
|
|
665
644
|
};
|
|
666
645
|
});
|
|
667
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
|
+
|
|
668
684
|
/* ---------- 🔊 语音合成 ---------- */
|
|
669
|
-
register('tts', async (
|
|
670
|
-
const
|
|
671
|
-
|
|
685
|
+
register('tts', async (host, args, { signal }) => {
|
|
686
|
+
const { providerInput } = normalizeGenerationInput('tts', {
|
|
687
|
+
text: args.text, voice: optionalActionText(args.voice)
|
|
688
|
+
});
|
|
672
689
|
const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
|
|
673
|
-
const routes = providers.map((provider) => (
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
}));
|
|
677
|
-
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];
|
|
678
693
|
return {
|
|
679
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
680
|
-
|
|
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 + ')'
|
|
681
699
|
};
|
|
682
700
|
});
|
|
683
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
|
+
|
|
684
746
|
/* ---------- 👁 看图 ---------- */
|
|
685
|
-
register('look', async (
|
|
747
|
+
register('look', async (host, args, { signal }) => {
|
|
686
748
|
const p = String(args.image_path || '').trim();
|
|
687
749
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
688
750
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
|
|
689
751
|
if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
|
|
690
752
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
691
753
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(p).toString('base64')}`;
|
|
692
|
-
const backends =
|
|
693
|
-
const { answer, via, model } = await
|
|
754
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
755
|
+
const { answer, via, model } = await askWithHealth(backends, { question: (args.question || '详细描述这张图片。'), imageDataUrl: dataUrl, signal });
|
|
694
756
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
695
757
|
return { ok: true, text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
|
|
696
758
|
});
|
|
697
759
|
|
|
698
760
|
/* ---------- ✂️ 裁剪 ---------- */
|
|
699
|
-
register('crop', async (
|
|
761
|
+
register('crop', async (host, args, { signal }) => {
|
|
700
762
|
const p = String(args.image_path || '').trim();
|
|
701
763
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
702
764
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
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}` };
|
|
706
770
|
});
|
|
707
771
|
|
|
708
772
|
/* ---------- 📷 像素 diff ---------- */
|
|
709
|
-
register('diff', async (
|
|
773
|
+
register('diff', async (host, args, { signal }) => {
|
|
710
774
|
if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
|
|
711
|
-
const
|
|
712
|
-
|
|
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
|
|
713
778
|
});
|
|
779
|
+
const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
|
|
714
780
|
const pct = (ratio * 100).toFixed(2);
|
|
715
781
|
const regions = worstRegions.map((r) => `(col ${r.col},row ${r.row}) ${(r.score * 100).toFixed(1)}%`).join(' ');
|
|
716
782
|
return {
|
|
717
783
|
ok: true,
|
|
784
|
+
artifactIds: [result.artifact.id],
|
|
718
785
|
text: `[iris] 像素差异 ${pct}%(${diffPixels}/${totalPixels}px,归一化 ${width}x${height})\n最差区域(${worstRegions.length} 格): ${regions}`,
|
|
719
|
-
imageDataUrl: `data:${
|
|
786
|
+
imageDataUrl: `data:${result.mediaType};base64,${result.bytes.toString('base64')}`
|
|
720
787
|
};
|
|
721
788
|
});
|
|
722
789
|
|
|
723
790
|
/* ---------- 📍 定位 ---------- */
|
|
724
|
-
register('locate', async (
|
|
791
|
+
register('locate', async (host, args, { signal }) => {
|
|
725
792
|
const p = String(args.image_path || '').trim();
|
|
726
793
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
727
794
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
@@ -729,28 +796,28 @@ register('locate', async (ctx, args, { signal }) => {
|
|
|
729
796
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
|
|
730
797
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
731
798
|
const { width, height } = await imageDimensions(buf);
|
|
732
|
-
const backends =
|
|
799
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
733
800
|
const r = await locateObject(backends, { target: args.target, imageDataUrl: dataUrl, width, height, signal });
|
|
734
801
|
if (!r.found) return { ok: true, text: `[iris] 在图片中未找到「${args.target}」` };
|
|
735
802
|
return { ok: true, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
|
|
736
803
|
});
|
|
737
804
|
/* ---------- 🖼️ HTML 截图 ---------- */
|
|
738
|
-
register('html', async (
|
|
805
|
+
register('html', async (host, args, { signal }) => {
|
|
739
806
|
const html = String(args.html || '').trim();
|
|
740
807
|
if (!html) throw new Error('iris: html 不能为空');
|
|
741
|
-
const browser =
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const
|
|
745
|
-
return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data
|
|
808
|
+
const browser = requireHostPort(host, 'browser', 'HTML 截图');
|
|
809
|
+
const rendered = await browser.renderHtml({ html, width: args.width, height: args.height,
|
|
810
|
+
fullPage: args.fullPage !== false, signal });
|
|
811
|
+
const png = Buffer.from(rendered.bytes);
|
|
812
|
+
return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:${rendered.mediaType || 'image/png'};base64,${png.toString('base64')}` };
|
|
746
813
|
});
|
|
747
814
|
|
|
748
815
|
/* ---------- 📄 长截图 OCR ---------- */
|
|
749
|
-
register('ocr', async (
|
|
816
|
+
register('ocr', async (host, args, { signal }) => {
|
|
750
817
|
const p = String(args.image_path || '').trim();
|
|
751
818
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
752
819
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
753
|
-
const backends =
|
|
820
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
754
821
|
const result = await longOcr({ input: p, backends, chunkHeight: args.chunk_height || 1200, overlap: args.overlap || 120, signal });
|
|
755
822
|
const errCount = result.chunks.filter((c) => c.error).length;
|
|
756
823
|
const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
|
|
@@ -758,25 +825,54 @@ register('ocr', async (ctx, args, { signal }) => {
|
|
|
758
825
|
});
|
|
759
826
|
|
|
760
827
|
/* ---------- 📋 任务查询 ---------- */
|
|
761
|
-
register('status', async (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
828
|
+
register('status', async (host, args, { signal }) => {
|
|
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);
|
|
772
854
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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] 还没有生成任务记录。' };
|
|
776
872
|
});
|
|
777
873
|
|
|
778
874
|
/* ---------- Task v2 人工接管 ---------- */
|
|
779
|
-
register('task_reobserve', async (
|
|
875
|
+
register('task_reobserve', async (host, args) => {
|
|
780
876
|
const id = String(args.task_id || '').trim();
|
|
781
877
|
const task = tasks.get(id);
|
|
782
878
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可接管的 Task v2');
|
|
@@ -790,7 +886,7 @@ register('task_reobserve', async (ctx, args) => {
|
|
|
790
886
|
return { ok: true, taskId: id, text: '[iris] 已重新观察远端任务;不会重新提交生成请求。' };
|
|
791
887
|
});
|
|
792
888
|
|
|
793
|
-
register('task_redeliver', async (
|
|
889
|
+
register('task_redeliver', async (host, args, { signal }) => {
|
|
794
890
|
const id = String(args.task_id || '').trim();
|
|
795
891
|
const task = tasks.get(id);
|
|
796
892
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重新交付的 Task v2');
|
|
@@ -802,7 +898,7 @@ register('task_redeliver', async (ctx, args, { signal }) => {
|
|
|
802
898
|
tasks.prepareRedelivery(id);
|
|
803
899
|
try {
|
|
804
900
|
tasks.beginDelivery(id);
|
|
805
|
-
const result = await deps.poll({
|
|
901
|
+
const result = providerPollTaskView(await deps.poll({ remoteTaskId: task.remoteTaskId, signal }));
|
|
806
902
|
if (!result.done) throw new Error('远端结果暂未就绪,请先重新观察');
|
|
807
903
|
if (!result.ok) throw new Error(result.message || '供应商当前无法返回成功产物');
|
|
808
904
|
if (!(result.urls || []).length) throw new Error('供应商未返回可交付的结果 URL');
|
|
@@ -816,7 +912,7 @@ register('task_redeliver', async (ctx, args, { signal }) => {
|
|
|
816
912
|
}
|
|
817
913
|
});
|
|
818
914
|
|
|
819
|
-
register('task_ack_attention', async (
|
|
915
|
+
register('task_ack_attention', async (_host, args) => {
|
|
820
916
|
const id = String(args.task_id || '').trim();
|
|
821
917
|
const task = tasks.get(id);
|
|
822
918
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可标为已读的 Task v2');
|
|
@@ -824,7 +920,7 @@ register('task_ack_attention', async (_ctx, args) => {
|
|
|
824
920
|
return { ok: true, taskId: id, text: '[iris] 提醒已标为已读;任务事实未改变,可在历史详情中恢复提醒。' };
|
|
825
921
|
});
|
|
826
922
|
|
|
827
|
-
register('task_restore_attention', async (
|
|
923
|
+
register('task_restore_attention', async (_host, args) => {
|
|
828
924
|
const id = String(args.task_id || '').trim();
|
|
829
925
|
const task = tasks.get(id);
|
|
830
926
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可恢复提醒的 Task v2');
|
|
@@ -832,7 +928,7 @@ register('task_restore_attention', async (_ctx, args) => {
|
|
|
832
928
|
return { ok: true, taskId: id, text: '[iris] 已恢复任务提醒。' };
|
|
833
929
|
});
|
|
834
930
|
|
|
835
|
-
register('task_manual_retry', async (
|
|
931
|
+
register('task_manual_retry', async (host, args, { signal }) => {
|
|
836
932
|
const id = String(args.task_id || '').trim();
|
|
837
933
|
if (args.confirm_duplicate_charge !== true) throw new Error('iris: 必须明确确认本次操作可能产生重复任务或重复费用');
|
|
838
934
|
const task = tasks.get(id);
|
|
@@ -856,7 +952,7 @@ register('task_manual_retry', async (ctx, args, { signal }) => {
|
|
|
856
952
|
if (task.modelRef) retryArgs.model = task.modelRef;
|
|
857
953
|
retryArgs._retryOf = id;
|
|
858
954
|
try {
|
|
859
|
-
const result = await runAction(
|
|
955
|
+
const result = await runAction(host, action, retryArgs, { signal });
|
|
860
956
|
if (result.taskId) tasks.linkManualRetry(id, result.taskId);
|
|
861
957
|
return { ...result, retryOf: id, text: (result.text || '[iris] 已创建新任务')
|
|
862
958
|
+ '\n[iris] 原任务提醒已归档,并保留新旧任务关联;本次新任务可能与远端未知任务重复。' };
|
|
@@ -880,6 +976,7 @@ function scanOrphanArtifacts() {
|
|
|
880
976
|
for (const m of t.media || []) referenced.add(path.basename(m.file));
|
|
881
977
|
for (const a of t.attachments || []) if (a.file) referenced.add(path.basename(a.file));
|
|
882
978
|
}
|
|
979
|
+
for (const item of artifacts.all()) referenced.add(path.basename(item.file));
|
|
883
980
|
const files = [];
|
|
884
981
|
let bytes = 0;
|
|
885
982
|
for (const name of fs.readdirSync(dir)) {
|
|
@@ -895,7 +992,7 @@ function scanOrphanArtifacts() {
|
|
|
895
992
|
}
|
|
896
993
|
|
|
897
994
|
/** 删除单条任务记录(仅终态;运行中须先取消)。只删元数据,产物文件保留。 */
|
|
898
|
-
register('tasks_delete', async (
|
|
995
|
+
register('tasks_delete', async (host, args) => {
|
|
899
996
|
const id = String(args.task_id || '').trim();
|
|
900
997
|
if (!id) throw new Error('iris: task_id 不能为空');
|
|
901
998
|
const r = tasks.remove(id);
|
|
@@ -907,7 +1004,7 @@ register('tasks_delete', async (ctx, args) => {
|
|
|
907
1004
|
* 批量清理任务记录:scope = completed(所有终态)| older_than(早于 days 天)| all(等同 completed,运行中受保护)。
|
|
908
1005
|
* 只删元数据,绝不动运行中任务,产物文件默认保留。
|
|
909
1006
|
*/
|
|
910
|
-
register('tasks_clear', async (
|
|
1007
|
+
register('tasks_clear', async (host, args) => {
|
|
911
1008
|
const scope = String(args.scope || 'completed').trim();
|
|
912
1009
|
if (!['completed', 'older_than', 'all'].includes(scope)) throw new Error('iris: scope 需为 completed/older_than/all');
|
|
913
1010
|
let removed;
|
|
@@ -920,7 +1017,28 @@ register('tasks_clear', async (ctx, args) => {
|
|
|
920
1017
|
// completed / all:删除全部终态记录(运行中永不被 prune 命中)
|
|
921
1018
|
removed = tasks.prune(() => true);
|
|
922
1019
|
}
|
|
923
|
-
return { ok: true, removed: removed.length, text: `[iris] 已清理 ${removed.length}
|
|
1020
|
+
return { ok: true, removed: removed.length, text: `[iris] 已清理 ${removed.length} 条任务记录(作品仍可在作品库查看)` };
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
/** 重新扫描 outputs/,补录旧作品并移除已经不存在的索引。 */
|
|
1024
|
+
register('artifacts_reindex', async () => {
|
|
1025
|
+
const result = artifacts.reindex();
|
|
1026
|
+
return { ok: true, ...result, text: `[iris] 作品库已刷新:找回 ${result.added} 个,移除 ${result.missing} 个失效索引,共 ${result.total} 个作品` };
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
/** 删除一件作品及其文件;任务历史不会随之删除。 */
|
|
1030
|
+
register('artifacts_delete', async (_host, args) => {
|
|
1031
|
+
const id = String(args.artifact_id || '').trim();
|
|
1032
|
+
if (!id) throw new Error('iris: artifact_id 不能为空');
|
|
1033
|
+
const result = artifacts.remove(id);
|
|
1034
|
+
if (!result.ok) throw new Error('iris: ' + result.reason);
|
|
1035
|
+
return { ok: true, removed: 1, text: `[iris] 已删除作品 ${result.file};任务历史保留` };
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
/** 清空作品库及其文件;客户端必须单独二次确认。 */
|
|
1039
|
+
register('artifacts_clear', async () => {
|
|
1040
|
+
const result = artifacts.clear();
|
|
1041
|
+
return { ok: result.failed === 0, ...result, text: `[iris] 已删除 ${result.deleted} 个作品,释放 ${(result.bytes / 1048576).toFixed(1)}MB${result.failed ? `;${result.failed} 个删除失败并保留索引` : ''}` };
|
|
924
1042
|
});
|
|
925
1043
|
|
|
926
1044
|
/** 孤儿产物报告:outputs/ 里无任务引用的文件 + 总占用(只读,不删) */
|
|
@@ -947,7 +1065,7 @@ register('tasks_purge_orphans', async () => {
|
|
|
947
1065
|
});
|
|
948
1066
|
|
|
949
1067
|
/* ---------- 👁 重看(GUI:只查 iris 自己的任务产物,无会话上下文) ---------- */
|
|
950
|
-
register('relook', async (
|
|
1068
|
+
register('relook', async (host, args, { signal }) => {
|
|
951
1069
|
const id = String(args.attachment_id || '').trim();
|
|
952
1070
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
953
1071
|
const question = String(args.question || '').trim();
|
|
@@ -966,8 +1084,8 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
966
1084
|
if (!hit) throw new Error('iris: 该 attachment 不在 iris 生成的任务产物中(GUI 重看仅支持 iris 画图产物)');
|
|
967
1085
|
const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
|
|
968
1086
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
|
|
969
|
-
const backends =
|
|
970
|
-
const { answer, via, model } = await
|
|
1087
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
1088
|
+
const { answer, via, model } = await askWithHealth(backends, { question, imageDataUrl: dataUrl, signal });
|
|
971
1089
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
972
1090
|
return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
|
|
973
1091
|
});
|
|
@@ -975,7 +1093,7 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
975
1093
|
/* ============ 供应商管理动作(阶段 6 条目 3-5) ============ */
|
|
976
1094
|
|
|
977
1095
|
/** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
|
|
978
|
-
register('providers_list', async (
|
|
1096
|
+
register('providers_list', async (host, args) => {
|
|
979
1097
|
const list = store.allProviders();
|
|
980
1098
|
return {
|
|
981
1099
|
ok: true,
|
|
@@ -984,20 +1102,30 @@ register('providers_list', async (ctx, args) => {
|
|
|
984
1102
|
name: p.name || p.id,
|
|
985
1103
|
type: p.type || 'openai',
|
|
986
1104
|
baseUrl: p.baseUrl,
|
|
1105
|
+
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
987
1106
|
enabled: p.enabled !== false,
|
|
988
|
-
|
|
1107
|
+
auth: p.auth === 'none' ? 'none' : 'bearer',
|
|
1108
|
+
...selectMediaProtocol(p),
|
|
989
1109
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
990
|
-
models: models.providerModels(p).map((m) => ({
|
|
1110
|
+
models: models.providerModels(p).map((m) => ({
|
|
1111
|
+
id: m.id,
|
|
1112
|
+
capabilities: m.capabilities,
|
|
1113
|
+
verified: m.verified || null,
|
|
1114
|
+
health: Object.fromEntries(m.capabilities.map((capability) => [
|
|
1115
|
+
capability, store.modelHealth(p.id, m.id, capability)
|
|
1116
|
+
])),
|
|
1117
|
+
source: m.source || 'auto'
|
|
1118
|
+
})),
|
|
991
1119
|
capabilities: store.capabilitiesOf(p)
|
|
992
1120
|
}))
|
|
993
1121
|
};
|
|
994
1122
|
});
|
|
995
1123
|
|
|
996
1124
|
/** 新增/更新供应商 */
|
|
997
|
-
register('providers_upsert', async (
|
|
1125
|
+
register('providers_upsert', async (host, args) => {
|
|
998
1126
|
const id = args.id || undefined;
|
|
999
1127
|
const patch = { id };
|
|
1000
|
-
const allowed = ['name', 'type', 'baseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
1128
|
+
const allowed = ['auth', 'name', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
1001
1129
|
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel', 'models'];
|
|
1002
1130
|
for (const key of allowed) {
|
|
1003
1131
|
if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
|
|
@@ -1008,54 +1136,62 @@ register('providers_upsert', async (ctx, args) => {
|
|
|
1008
1136
|
});
|
|
1009
1137
|
|
|
1010
1138
|
/** 删除供应商 */
|
|
1011
|
-
register('providers_remove', async (
|
|
1139
|
+
register('providers_remove', async (host, args) => {
|
|
1012
1140
|
const ok = store.removeProvider(String(args.id || ''));
|
|
1013
1141
|
return { ok, removed: ok };
|
|
1014
1142
|
});
|
|
1015
1143
|
|
|
1016
1144
|
/** 设置某供应商的模型池(覆盖显式 models) */
|
|
1017
|
-
register('providers_set_models', async (
|
|
1145
|
+
register('providers_set_models', async (host, args) => {
|
|
1018
1146
|
const p = store.setProviderModels(String(args.id || ''), Array.isArray(args.models) ? args.models : []);
|
|
1019
1147
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
1020
1148
|
return { ok: true };
|
|
1021
1149
|
});
|
|
1022
1150
|
|
|
1023
1151
|
/**
|
|
1024
|
-
*
|
|
1152
|
+
* 模型发现:读取供应商目录 → 官方能力元数据优先、名称规则兜底 → 写入显式池。
|
|
1025
1153
|
* 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
|
|
1026
1154
|
*/
|
|
1027
|
-
register('providers_discover', async (
|
|
1155
|
+
register('providers_discover', async (host, args, { signal }) => {
|
|
1028
1156
|
const p = store.providerById(String(args.id || ''));
|
|
1029
1157
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1030
|
-
let
|
|
1158
|
+
let discoveredModels;
|
|
1031
1159
|
try {
|
|
1032
|
-
|
|
1160
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1161
|
+
const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
|
|
1162
|
+
discoveredModels = discovered.models;
|
|
1033
1163
|
} catch (err) {
|
|
1034
1164
|
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
1035
1165
|
}
|
|
1036
1166
|
const media = [];
|
|
1037
|
-
for (const
|
|
1038
|
-
const caps = models.
|
|
1039
|
-
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 });
|
|
1040
1170
|
}
|
|
1041
1171
|
if (!media.length) {
|
|
1042
|
-
return { ok: true, total:
|
|
1172
|
+
return { ok: true, total: discoveredModels.length, media: 0, text: `[iris] ${p.name}:拉到 ${discoveredModels.length} 个模型,无媒体能力匹配——可在模型池区手动添加并标能力` };
|
|
1043
1173
|
}
|
|
1044
|
-
store.
|
|
1045
|
-
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} 个媒体能力模型已入池(官方元数据优先)` };
|
|
1046
1176
|
});
|
|
1047
1177
|
|
|
1048
1178
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
1049
|
-
register('providers_test_vision', async (
|
|
1179
|
+
register('providers_test_vision', async (host, args, { signal }) => {
|
|
1050
1180
|
const p = store.providerById(String(args.id || ''));
|
|
1051
1181
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
1052
1182
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
|
|
1053
1183
|
if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
|
|
1054
|
-
const backends =
|
|
1184
|
+
const backends = buildVisionBackendsFromHost(host, { providers: visionProviders });
|
|
1055
1185
|
const backend = backends[0];
|
|
1056
1186
|
if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
|
|
1057
1187
|
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
1058
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal
|
|
1188
|
+
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
|
|
1189
|
+
store.setModelVerified(p.id, backend.model, cap.CAPABILITIES.VISION, {
|
|
1190
|
+
ok: r.ok,
|
|
1191
|
+
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
1192
|
+
category: r.category,
|
|
1193
|
+
status: r.status
|
|
1194
|
+
});
|
|
1059
1195
|
return {
|
|
1060
1196
|
ok: true,
|
|
1061
1197
|
tested: true,
|
|
@@ -1069,35 +1205,69 @@ register('providers_test_vision', async (ctx, args) => {
|
|
|
1069
1205
|
/* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
|
|
1070
1206
|
|
|
1071
1207
|
/** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
|
|
1072
|
-
async function probeModel(
|
|
1208
|
+
async function probeModel(host, p, modelId, capability, signal) {
|
|
1073
1209
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
1074
|
-
const backend =
|
|
1210
|
+
const backend = buildVisionBackendsFromHost(host, { providers: [p], model: modelId })[0];
|
|
1075
1211
|
if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
|
|
1076
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal
|
|
1077
|
-
return {
|
|
1212
|
+
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
|
|
1213
|
+
return {
|
|
1214
|
+
ok: r.ok,
|
|
1215
|
+
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
1216
|
+
category: r.category,
|
|
1217
|
+
status: r.status
|
|
1218
|
+
};
|
|
1078
1219
|
}
|
|
1079
1220
|
if (capability === cap.CAPABILITIES.TRANSCRIBE) {
|
|
1080
1221
|
return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
|
|
1081
1222
|
}
|
|
1082
1223
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
1083
|
-
const
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1224
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1225
|
+
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1226
|
+
capability: 'tts', model: modelId, input: { text: '你好' }, signal
|
|
1227
|
+
});
|
|
1228
|
+
const value = submitted.kind === 'completed' ? submitted.value : null;
|
|
1229
|
+
const ok = Boolean(value && (value.audioUrl || value.audioB64));
|
|
1230
|
+
return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回') };
|
|
1086
1231
|
}
|
|
1087
1232
|
if (capability === cap.CAPABILITIES.IMAGE) {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1233
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1234
|
+
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1235
|
+
capability: 'image', model: modelId,
|
|
1236
|
+
input: {
|
|
1237
|
+
prompt: providerAdapter.protocol === 'openai-images' ? 'red circle' : '红色圆形',
|
|
1238
|
+
size: providerAdapter.protocol === 'openai-images' ? '256x256' : '512*512',
|
|
1239
|
+
n: 1
|
|
1240
|
+
},
|
|
1241
|
+
signal
|
|
1242
|
+
});
|
|
1243
|
+
if (submitted.kind === 'completed') {
|
|
1244
|
+
const items = Array.isArray(submitted.value?.items) ? submitted.value.items : [];
|
|
1245
|
+
return { ok: items.length > 0, note: items.length ? '生成返回图片' : '无图片返回' };
|
|
1091
1246
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1247
|
+
if (submitted.kind !== 'accepted') {
|
|
1248
|
+
return {
|
|
1249
|
+
ok: false,
|
|
1250
|
+
note: submitted.error?.safeMessage || '提交未被确认受理',
|
|
1251
|
+
category: submitted.error?.category,
|
|
1252
|
+
httpStatus: submitted.error?.httpStatus
|
|
1253
|
+
};
|
|
1095
1254
|
}
|
|
1096
|
-
// 有界轮询到终态(图像通常 5–15s)
|
|
1097
1255
|
for (let i = 0; i < 20; i++) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1256
|
+
throwIfAborted(signal);
|
|
1257
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1258
|
+
const polled = await invokeProviderOperation(providerAdapter, 'poll', {
|
|
1259
|
+
capability: 'image', remoteTaskId: submitted.remoteTaskId, signal
|
|
1260
|
+
});
|
|
1261
|
+
if (polled.kind === 'pending') continue;
|
|
1262
|
+
if (polled.kind === 'succeeded') {
|
|
1263
|
+
return { ok: polled.artifacts.length > 0, note: polled.artifacts.length ? '生成成功' : '无图片返回' };
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
ok: false,
|
|
1267
|
+
note: polled.error?.safeMessage || ('生成终态:' + polled.kind),
|
|
1268
|
+
category: polled.error?.category,
|
|
1269
|
+
httpStatus: polled.error?.httpStatus
|
|
1270
|
+
};
|
|
1101
1271
|
}
|
|
1102
1272
|
return { ok: false, note: '生成超时(>60s)' };
|
|
1103
1273
|
}
|
|
@@ -1108,7 +1278,7 @@ async function probeModel(ctx, p, modelId, capability) {
|
|
|
1108
1278
|
}
|
|
1109
1279
|
|
|
1110
1280
|
/** 手动添加模型到池(能力可选,缺省按规则推断) */
|
|
1111
|
-
register('providers_add_model', async (
|
|
1281
|
+
register('providers_add_model', async (host, args) => {
|
|
1112
1282
|
const id = String(args.id || '');
|
|
1113
1283
|
const modelId = String(args.model_id || '').trim();
|
|
1114
1284
|
if (!modelId) throw new Error('iris: model_id 不能为空');
|
|
@@ -1121,14 +1291,14 @@ register('providers_add_model', async (ctx, args) => {
|
|
|
1121
1291
|
});
|
|
1122
1292
|
|
|
1123
1293
|
/** 从池移除模型 */
|
|
1124
|
-
register('providers_remove_model', async (
|
|
1294
|
+
register('providers_remove_model', async (host, args) => {
|
|
1125
1295
|
const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
|
|
1126
1296
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
1127
1297
|
return { ok: true, text: `[iris] 已移除模型 ${args.model_id}` };
|
|
1128
1298
|
});
|
|
1129
1299
|
|
|
1130
1300
|
/** 纠正某模型的能力标签 */
|
|
1131
|
-
register('providers_set_model_caps', async (
|
|
1301
|
+
register('providers_set_model_caps', async (host, args) => {
|
|
1132
1302
|
const caps = (Array.isArray(args.capabilities) ? args.capabilities : []).filter((c) => cap.isKnownCapability(c));
|
|
1133
1303
|
const ok = store.setModelCapabilities(String(args.id || ''), String(args.model_id || ''), caps);
|
|
1134
1304
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
@@ -1136,7 +1306,7 @@ register('providers_set_model_caps', async (ctx, args) => {
|
|
|
1136
1306
|
});
|
|
1137
1307
|
|
|
1138
1308
|
/** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
|
|
1139
|
-
register('providers_test_model', async (
|
|
1309
|
+
register('providers_test_model', async (host, args, { signal }) => {
|
|
1140
1310
|
const p = store.providerById(String(args.id || ''));
|
|
1141
1311
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1142
1312
|
const modelId = String(args.model_id || '').trim();
|
|
@@ -1147,9 +1317,14 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
1147
1317
|
}
|
|
1148
1318
|
let result;
|
|
1149
1319
|
try {
|
|
1150
|
-
result = await probeModel(
|
|
1320
|
+
result = await probeModel(host, p, modelId, capability, signal);
|
|
1151
1321
|
} catch (err) {
|
|
1152
|
-
result = {
|
|
1322
|
+
result = {
|
|
1323
|
+
ok: false,
|
|
1324
|
+
note: String(err && (err.safeMessage || err.message) || err),
|
|
1325
|
+
category: err && err.category,
|
|
1326
|
+
httpStatus: err && (err.httpStatus ?? err.status)
|
|
1327
|
+
};
|
|
1153
1328
|
}
|
|
1154
1329
|
if (result.skipped) {
|
|
1155
1330
|
return { ok: true, skipped: true, passed: false, text: `[iris] ${modelId} · ${capability} 已跳过:${result.note}` };
|
|
@@ -1165,7 +1340,7 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
1165
1340
|
/* ============ 能力分配动作(阶段 6 条目 4) ============ */
|
|
1166
1341
|
|
|
1167
1342
|
/** 查询:全部分配 + 每个能力可用的模型池(client 渲染下拉/排序) */
|
|
1168
|
-
register('assignments_get', async (
|
|
1343
|
+
register('assignments_get', async (host, args) => {
|
|
1169
1344
|
const pool = models.modelPool(store.allProviders());
|
|
1170
1345
|
const byCap = {};
|
|
1171
1346
|
for (const m of pool) {
|
|
@@ -1185,7 +1360,7 @@ register('assignments_get', async (ctx, args) => {
|
|
|
1185
1360
|
* model_refs 数组 = Provider+模型复合引用的有序 failover 列表;
|
|
1186
1361
|
* model_ids/model_id 保留为旧客户端兼容入口。
|
|
1187
1362
|
*/
|
|
1188
|
-
register('assignments_set', async (
|
|
1363
|
+
register('assignments_set', async (host, args) => {
|
|
1189
1364
|
const capability = String(args.capability || '').trim();
|
|
1190
1365
|
if (!capability) throw new Error('iris: capability 不能为空');
|
|
1191
1366
|
const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
|
|
@@ -1204,45 +1379,19 @@ register('assignments_set', async (ctx, args) => {
|
|
|
1204
1379
|
|
|
1205
1380
|
/* ============ 文件选择器 L1:会话附件枚举(阶段 10) ============ */
|
|
1206
1381
|
|
|
1207
|
-
/** 深度遍历会话事件,收集所有 image 附件引用(去重 by attachmentId) */
|
|
1208
|
-
function collectImageAttachments(nodes) {
|
|
1209
|
-
const out = [];
|
|
1210
|
-
const seen = new Set();
|
|
1211
|
-
const visited = new Set();
|
|
1212
|
-
const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
|
|
1213
|
-
let scanned = 0;
|
|
1214
|
-
while (stack.length && scanned < 8000) {
|
|
1215
|
-
const node = stack.pop();
|
|
1216
|
-
scanned++;
|
|
1217
|
-
if (!node || typeof node !== 'object' || visited.has(node)) continue;
|
|
1218
|
-
visited.add(node);
|
|
1219
|
-
const ref = node.attachment && typeof node.attachment === 'object' ? node.attachment
|
|
1220
|
-
: (node.attachmentId && node.mediaType ? node : null);
|
|
1221
|
-
if (ref && typeof ref.attachmentId === 'string' && typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')) {
|
|
1222
|
-
if (!seen.has(ref.attachmentId)) {
|
|
1223
|
-
seen.add(ref.attachmentId);
|
|
1224
|
-
out.push({ attachmentId: ref.attachmentId, mediaType: ref.mediaType, name: ref.name || '', source: 'session' });
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
|
|
1228
|
-
}
|
|
1229
|
-
return out;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
1382
|
/**
|
|
1233
1383
|
* 枚举可选附件:当前会话的 image 附件(readSession)+ iris 自有产物(全局,兜底)。
|
|
1234
1384
|
* 客户端传当前会话 id;读不到会话也不报错,退化为只列 iris 产物。
|
|
1235
1385
|
*/
|
|
1236
|
-
register('attachments_list', async (
|
|
1386
|
+
register('attachments_list', async (host, args) => {
|
|
1237
1387
|
const items = [];
|
|
1238
1388
|
const seen = new Set();
|
|
1239
1389
|
const sessionId = String(args.session_id || '').trim();
|
|
1240
|
-
const
|
|
1241
|
-
if (sessionId &&
|
|
1390
|
+
const sessions = hasHostPort(host, 'sessions') ? host.ports.sessions : undefined;
|
|
1391
|
+
if (sessionId && sessions) {
|
|
1242
1392
|
try {
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
if (!seen.has(a.attachmentId)) { seen.add(a.attachmentId); items.push(a); }
|
|
1393
|
+
for (const a of await sessions.listImageAttachments(sessionId)) {
|
|
1394
|
+
if (!seen.has(a.attachmentId)) { seen.add(a.attachmentId); items.push({ ...a, source: 'session' }); }
|
|
1246
1395
|
}
|
|
1247
1396
|
} catch (_) {
|
|
1248
1397
|
/* 读不到会话 → 只列 iris 产物 */
|
|
@@ -1263,7 +1412,7 @@ register('attachments_list', async (ctx, args) => {
|
|
|
1263
1412
|
* 把一个附件(会话 image 或 iris 产物)导出为宿主可读的文件路径,供工具消费。
|
|
1264
1413
|
* iris 产物直接返回其 outputs 路径;会话附件读字节存 uploads/(按 attachmentId 哈希缓存,不重复落盘)。
|
|
1265
1414
|
*/
|
|
1266
|
-
register('attachment_export', async (
|
|
1415
|
+
register('attachment_export', async (host, args, { signal }) => {
|
|
1267
1416
|
const id = String(args.attachment_id || '').trim();
|
|
1268
1417
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
1269
1418
|
// 1) iris 自有产物:文件已在 outputs/
|
|
@@ -1280,18 +1429,13 @@ register('attachment_export', async (ctx, args) => {
|
|
|
1280
1429
|
const cached = path.join(uploads, 'att-' + id.replace(/[^a-z0-9]/gi, '').slice(-40));
|
|
1281
1430
|
if (fs.existsSync(cached)) return { ok: true, path: cached, name: path.basename(cached) };
|
|
1282
1431
|
const sessionId = String(args.session_id || '').trim();
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
throw new Error('iris: 该附件不可导出(非 iris 产物,且缺会话/附件服务上下文)');
|
|
1287
|
-
}
|
|
1432
|
+
if (!sessionId) throw new Error('iris: 非 Iris 产物必须提供 session_id');
|
|
1433
|
+
const sessions = requireHostPort(host, 'sessions', '导出会话附件');
|
|
1434
|
+
const attachments = requireHostPort(host, 'attachments', '导出会话附件');
|
|
1288
1435
|
let ref = null;
|
|
1289
|
-
try {
|
|
1290
|
-
const events = (await sq.readSession(sessionId)).events;
|
|
1291
|
-
for (const a of collectImageAttachments(events)) if (a.attachmentId === id) { ref = a; break; }
|
|
1292
|
-
} catch (_) { /* ignore */ }
|
|
1436
|
+
try { ref = await sessions.findImageAttachment(sessionId, id); } catch (_) { /* 统一为未找到 */ }
|
|
1293
1437
|
if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
|
|
1294
|
-
const stored = await
|
|
1438
|
+
const stored = await attachments.readImage(ref, signal);
|
|
1295
1439
|
ensurePrivateDir(uploads);
|
|
1296
1440
|
writePrivateFile(cached, Buffer.from(stored.data));
|
|
1297
1441
|
return { ok: true, path: cached, name: ref.name || path.basename(cached) };
|
|
@@ -1299,42 +1443,46 @@ register('attachment_export', async (ctx, args) => {
|
|
|
1299
1443
|
|
|
1300
1444
|
/* ============ 音频转写(阶段 7.2) ============ */
|
|
1301
1445
|
|
|
1302
|
-
register('transcribe', async (
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1446
|
+
register('transcribe', async (host, args, { signal }) => {
|
|
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: 音频文件不存在');
|
|
1306
1451
|
const providers = selectProviders(cap.CAPABILITIES.TRANSCRIBE, '转写', args.model);
|
|
1307
1452
|
const routes = providers.map((provider) =>
|
|
1308
|
-
simpleModelRoute(provider, args,
|
|
1453
|
+
simpleModelRoute(provider, args, cap.CAPABILITIES.TRANSCRIBE)
|
|
1309
1454
|
);
|
|
1310
|
-
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);
|
|
1311
1459
|
return {
|
|
1312
|
-
ok: true, taskId: submitted.task.id, model: submitted.model,
|
|
1313
|
-
remoteTaskId: submitted.remoteTaskId,
|
|
1314
|
-
|
|
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 + ')'
|
|
1315
1467
|
};
|
|
1316
1468
|
});
|
|
1317
1469
|
|
|
1318
1470
|
/* ============ 视频抽帧(阶段 7.1) ============ */
|
|
1319
1471
|
|
|
1320
|
-
register('video_frames', async (
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法抽帧(可选安装 ffmpeg 后启用)');
|
|
1325
|
-
const meta = probeVideo(p);
|
|
1326
|
-
const frames = await extractFrames({
|
|
1327
|
-
inputPath: p, maxFrames: args.max_frames, targetWidth: args.target_width,
|
|
1328
|
-
format: args.format || 'jpeg', quality: args.quality, signal
|
|
1472
|
+
register('video_frames', async (host, args, { 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
|
|
1329
1476
|
});
|
|
1330
|
-
const mime =
|
|
1477
|
+
const mime = frames[0].mediaType;
|
|
1331
1478
|
const summary = frames.map((f, i) =>
|
|
1332
|
-
`帧${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`
|
|
1333
1480
|
).join('\n');
|
|
1334
1481
|
// 首帧预览 base64
|
|
1335
|
-
const firstFrame = `data:${mime};base64,${frames[0].
|
|
1482
|
+
const firstFrame = `data:${mime};base64,${frames[0].bytes.toString('base64')}`;
|
|
1336
1483
|
return {
|
|
1337
1484
|
ok: true,
|
|
1485
|
+
artifactIds: frames.map((frame) => frame.artifact.id),
|
|
1338
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)`,
|
|
1339
1487
|
imageDataUrl: firstFrame
|
|
1340
1488
|
};
|
|
@@ -1342,7 +1490,7 @@ register('video_frames', async (ctx, args, { signal }) => {
|
|
|
1342
1490
|
|
|
1343
1491
|
/* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
|
|
1344
1492
|
|
|
1345
|
-
register('media_summarize', async (
|
|
1493
|
+
register('media_summarize', async (host, args, { signal }) => {
|
|
1346
1494
|
const p = String(args.video_path || '').trim();
|
|
1347
1495
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
1348
1496
|
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
@@ -1353,7 +1501,7 @@ register('media_summarize', async (ctx, args, { signal }) => {
|
|
|
1353
1501
|
format: 'jpeg', quality: 70, signal
|
|
1354
1502
|
});
|
|
1355
1503
|
const sheet = await buildContactSheet({ frames, cols: 3 });
|
|
1356
|
-
const backends =
|
|
1504
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
1357
1505
|
const { answer, via, model } = await summarizeMedia({
|
|
1358
1506
|
backends, question: args.question,
|
|
1359
1507
|
transcript: args.transcribe_text, // GUI 同步版:不自动转写,可粘贴已有转写文本
|