@mokuyoaxis/dsh-iris 0.1.2 → 0.1.4
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 +57 -2
- package/README.en.md +43 -7
- package/README.md +43 -7
- package/bin/dsh-iris.js +23 -0
- package/docs/ARCHITECTURE.md +95 -0
- package/docs/ARTIFACT_LIBRARY.md +24 -0
- package/docs/FAULT_INJECTION.md +26 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
- package/docs/HOST_DOCTOR.md +47 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
- package/docs/PROVIDER_HEALTH.md +39 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +60 -0
- package/docs/ROADMAP.md +21 -11
- package/docs/SECURITY.md +57 -0
- package/docs/TASK_SEMANTICS.md +148 -0
- package/docs/assets/diagrams/iris-architecture.png +0 -0
- package/docs/assets/diagrams/iris-architecture.svg +5 -5
- package/docs/releases/0.1.4.md +59 -0
- package/lib/actions.js +860 -290
- package/lib/adapters.js +7 -7
- package/lib/api.js +110 -18
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +478 -68
- package/lib/config.js +192 -9
- package/lib/doctor.js +414 -0
- package/lib/dsh-host-adapter.js +189 -0
- package/lib/host-contract.js +131 -0
- package/lib/host-runtime.js +156 -0
- package/lib/index.js +142 -157
- package/lib/locate.js +1 -1
- package/lib/media.js +16 -6
- package/lib/prompt-optimizer.js +10 -20
- package/lib/provider-adapter.js +371 -0
- package/lib/provider-adapters.js +279 -0
- package/lib/provider-contract.js +234 -0
- package/lib/provider-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/task-semantics.js +209 -0
- package/lib/tasks.js +500 -32
- package/lib/vision.js +23 -49
- package/package.json +19 -8
- package/user_guide.md +62 -2
- 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/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,30 +16,51 @@ 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 { buildVisionBackendsFromHost, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
|
|
22
|
+
import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
|
|
21
23
|
import { cropImage, pixelDiff, imageDimensions } from './pixels.js';
|
|
22
24
|
import { locateObject } from './locate.js';
|
|
23
|
-
import { BrowserHtmlRenderer } from './render.js';
|
|
24
25
|
import { longOcr } from './ocr.js';
|
|
25
26
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
26
27
|
import { buildContactSheet, summarizeMedia } from './summarize.js';
|
|
27
28
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
28
29
|
import { inferMediaProtocol } from './provider-protocol.js';
|
|
30
|
+
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
31
|
+
import { invokeProviderOperation, providerPollTaskView } from './provider-adapter.js';
|
|
32
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
33
|
+
import { deriveUserState } from './task-semantics.js';
|
|
29
34
|
|
|
30
35
|
const MEDIA_TYPES = {
|
|
31
36
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
32
37
|
'.webp': 'image/webp', '.gif': 'image/gif'
|
|
33
38
|
};
|
|
34
39
|
|
|
40
|
+
const EMPTY_ACTION_HOST = defineHostAdapter({
|
|
41
|
+
id: 'iris-hostless',
|
|
42
|
+
ports: {},
|
|
43
|
+
unavailable: Object.fromEntries(HOST_PORTS.map((name) => [name, {
|
|
44
|
+
kind: 'unavailable', reason: '当前动作没有宿主能力'
|
|
45
|
+
}]))
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function actionHost(value) {
|
|
49
|
+
if (!value || (typeof value === 'object' && Object.keys(value).length === 0)) return EMPTY_ACTION_HOST;
|
|
50
|
+
if (value.contractVersion === HOST_CONTRACT_VERSION && value.ports && value.unavailable) {
|
|
51
|
+
try { return defineHostAdapter(value); } catch (_) { /* 统一为 Command 边界错误 */ }
|
|
52
|
+
}
|
|
53
|
+
throw new TypeError('Iris Action 只接受有效的 Host Adapter,不接受原始 ctx');
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
/* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
|
|
36
|
-
async function describeFirstImage(
|
|
57
|
+
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
37
58
|
try {
|
|
38
59
|
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
39
60
|
const buf = fs.readFileSync(absPath);
|
|
40
61
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
41
|
-
const backends =
|
|
42
|
-
const { answer } = await
|
|
62
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
63
|
+
const { answer } = await askWithHealth(backends, {
|
|
43
64
|
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
44
65
|
imageDataUrl: dataUrl,
|
|
45
66
|
signal: undefined
|
|
@@ -66,7 +87,7 @@ function throwIfAborted(signal) {
|
|
|
66
87
|
* 生成类 failover 只覆盖“上传/提交/同步生成”阶段。远端一旦受理即绑定该任务,
|
|
67
88
|
* 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
|
|
68
89
|
*/
|
|
69
|
-
|
|
90
|
+
function selectProviders(capability, label, requestedModelRef) {
|
|
70
91
|
let providers = store.pickAllFor(capability);
|
|
71
92
|
const parsedRef = models.parseModelRef(String(requestedModelRef || '').trim());
|
|
72
93
|
if (parsedRef) {
|
|
@@ -75,25 +96,236 @@ async function submitWithFailover(capability, label, signal, requestedModelRef,
|
|
|
75
96
|
if (!providers.length) throw new Error('iris: 指定的模型复合引用不可用或不具备' + label + '能力');
|
|
76
97
|
}
|
|
77
98
|
if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
return providers;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function imageRoute(provider, args) {
|
|
103
|
+
const protocol = provider.mediaProtocol || inferMediaProtocol(provider.baseUrl);
|
|
104
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
105
|
+
const model = args.model
|
|
106
|
+
? (parsed?.modelId || String(args.model))
|
|
107
|
+
: (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
|
|
108
|
+
return {
|
|
109
|
+
provider, protocol, model, modelRef: models.modelRef(provider.id, model),
|
|
110
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function taskActionError(taskId, message, details) {
|
|
115
|
+
const error = new Error('iris: ' + message + '(task: ' + taskId + ')');
|
|
116
|
+
error.taskId = taskId;
|
|
117
|
+
if (details) error.details = details;
|
|
118
|
+
return error;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function recordProviderHealthSafe(providerId, modelId, capability, result) {
|
|
122
|
+
try {
|
|
123
|
+
store.recordProviderHealth(providerId, modelId, capability, { source: 'task', ...result });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.warn('[iris] Provider 健康事实记录失败:', String(error && error.message || error));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function observeProviderCapability(route, capability, operation, { recordSuccess = true } = {}) {
|
|
130
|
+
try {
|
|
131
|
+
const result = await operation();
|
|
132
|
+
if (recordSuccess && result && ['accepted', 'completed', 'pending', 'succeeded'].includes(result.kind)) {
|
|
133
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
134
|
+
ok: true,
|
|
135
|
+
category: 'success',
|
|
136
|
+
note: result.kind === 'accepted' ? '任务已受理' : '供应商请求成功'
|
|
137
|
+
});
|
|
138
|
+
} else if (result && result.error) {
|
|
139
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
140
|
+
ok: false,
|
|
141
|
+
category: result.error.category,
|
|
142
|
+
httpStatus: result.error.httpStatus,
|
|
143
|
+
note: result.error.safeMessage
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
149
|
+
ok: false,
|
|
150
|
+
category: error && error.category,
|
|
151
|
+
httpStatus: error && (error.httpStatus ?? error.status),
|
|
152
|
+
note: String(error && (error.safeMessage || error.message) || error)
|
|
153
|
+
});
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function recordVisionHealth(errors) {
|
|
159
|
+
for (const error of errors || []) {
|
|
160
|
+
if (error && error.kind === 'selfstack') {
|
|
161
|
+
recordProviderHealthSafe(error.backendId, error.model, cap.CAPABILITIES.VISION, {
|
|
162
|
+
ok: false,
|
|
163
|
+
category: error.category,
|
|
164
|
+
httpStatus: error.status,
|
|
165
|
+
note: error.message
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function askWithHealth(backends, request) {
|
|
172
|
+
try {
|
|
173
|
+
const result = await askWithBackends(backends, request);
|
|
174
|
+
recordVisionHealth(result.errors);
|
|
175
|
+
if (result.via === 'selfstack') {
|
|
176
|
+
recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
|
|
177
|
+
ok: true, category: 'success', note: '视觉请求成功'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
recordVisionHealth(error && error.errors);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function deliverCompletedImage(taskId, value, signal, providerAdapter) {
|
|
188
|
+
const names = [];
|
|
189
|
+
try {
|
|
190
|
+
tasks.beginDelivery(taskId);
|
|
191
|
+
if (value && value.kind === 'urls') {
|
|
192
|
+
for (const url of value.items || []) {
|
|
193
|
+
const name = taskId + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
194
|
+
await invokeProviderOperation(providerAdapter, 'download', {
|
|
195
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name), signal
|
|
196
|
+
});
|
|
197
|
+
names.push(name);
|
|
198
|
+
}
|
|
199
|
+
} else if (value && value.kind === 'openai') {
|
|
200
|
+
for (const item of value.items || []) {
|
|
201
|
+
const name = taskId + '-' + names.length + '.png';
|
|
202
|
+
const target = path.join(outputDir(), name);
|
|
203
|
+
if (item && item.b64) writePrivateFile(target, Buffer.from(item.b64, 'base64'));
|
|
204
|
+
else if (item && item.url) await invokeProviderOperation(providerAdapter, 'download', {
|
|
205
|
+
artifact: { kind: 'remote-url', url: item.url }, targetPath: target, signal
|
|
206
|
+
});
|
|
207
|
+
else throw new Error('同步图片结果缺少 b64 或 URL');
|
|
208
|
+
names.push(name);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
throw new Error('未知同步图片结果结构');
|
|
212
|
+
}
|
|
213
|
+
if (!names.length) throw new Error('生成已成功,但没有可交付的图片');
|
|
214
|
+
for (const name of names) registerMedia(taskId, path.join(outputDir(), name));
|
|
215
|
+
tasks.completeDelivery(taskId, names);
|
|
216
|
+
return tasks.get(taskId);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
let recorded;
|
|
81
219
|
try {
|
|
82
|
-
|
|
83
|
-
} catch (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
errors.push({
|
|
87
|
-
providerId: provider.id,
|
|
88
|
-
modelRef: field && provider[field] ? models.modelRef(provider.id, provider[field]) : provider.id,
|
|
89
|
-
message: String((err && err.message) || err)
|
|
220
|
+
recorded = tasks.failDelivery(taskId, error, names);
|
|
221
|
+
} catch (persistError) {
|
|
222
|
+
throw taskActionError(taskId, '图像已生成,但交付状态无法可靠落盘;禁止重新生成', {
|
|
223
|
+
message: String((persistError && persistError.message) || persistError)
|
|
90
224
|
});
|
|
91
225
|
}
|
|
226
|
+
throw taskActionError(taskId, '图像已生成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
92
227
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** v0.1.3 图片能力统一链路:异步、同步及混合候选共用一个 Task。 */
|
|
231
|
+
async function submitImageV2(routes, prompt, args, signal) {
|
|
232
|
+
const task = tasks.createV2({
|
|
233
|
+
cap: 'image',
|
|
234
|
+
prompt: prompt.slice(0, 4000),
|
|
235
|
+
size: args.size || '',
|
|
236
|
+
count: Number(args.n || 1),
|
|
237
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {}),
|
|
238
|
+
...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
|
|
239
|
+
});
|
|
240
|
+
const candidates = routes.map((route) => ({
|
|
241
|
+
id: route.provider.id,
|
|
242
|
+
model: route.modelRef,
|
|
243
|
+
route,
|
|
244
|
+
submit: async () => {
|
|
245
|
+
if (signal && signal.aborted) {
|
|
246
|
+
throw new ProviderContractError('提交前收到取消信号', {
|
|
247
|
+
stage: 'submit', category: 'aborted', acceptance: 'unknown'
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return observeProviderCapability(route, cap.CAPABILITIES.IMAGE, () =>
|
|
251
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
252
|
+
capability: 'image',
|
|
253
|
+
model: route.model,
|
|
254
|
+
input: { prompt, size: args.size, n: args.n },
|
|
255
|
+
signal
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'image' }, {
|
|
261
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
262
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
263
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
264
|
+
return tasks.beginAttempt(task.id, {
|
|
265
|
+
providerId,
|
|
266
|
+
providerName: route.provider.name,
|
|
267
|
+
model,
|
|
268
|
+
protocol: route.protocol
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (submitted.localError) {
|
|
275
|
+
throw taskActionError(task.id, '提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
276
|
+
}
|
|
277
|
+
if (!submitted.candidate) {
|
|
278
|
+
const lastError = submitted.result && submitted.result.error;
|
|
279
|
+
tasks.finalizeNoAcceptance(task.id, lastError || '所有候选均明确未受理');
|
|
280
|
+
throw taskActionError(task.id, '所有候选均明确未受理', submitted.attempts);
|
|
281
|
+
}
|
|
282
|
+
if (submitted.result.kind === 'acceptance_unknown') {
|
|
283
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
284
|
+
throw taskActionError(task.id, '供应商受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const route = submitted.candidate.route;
|
|
288
|
+
if (submitted.result.kind === 'completed') {
|
|
289
|
+
const delivered = await deliverCompletedImage(task.id, submitted.result.value, signal, route.adapter);
|
|
290
|
+
return { task: delivered, provider: route.provider, model: route.model, remoteTaskId: null };
|
|
291
|
+
}
|
|
292
|
+
if (submitted.result.kind !== 'accepted') {
|
|
293
|
+
throw taskActionError(task.id, '图片提交结果无法识别,已停止后续候选', submitted.result);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
tasks.activateWatch(task.id);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
throw taskActionError(task.id, '远端已受理,但本地盯守状态落盘失败;禁止自动重提', { message: String(error.message || error) });
|
|
300
|
+
}
|
|
301
|
+
tasks.watch(tasks.get(task.id), {
|
|
302
|
+
poll: ({ remoteTaskId, signal: pollSignal }) => observeProviderCapability(
|
|
303
|
+
route, cap.CAPABILITIES.IMAGE,
|
|
304
|
+
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
305
|
+
capability: 'image', remoteTaskId, signal: pollSignal
|
|
306
|
+
}),
|
|
307
|
+
{ recordSuccess: false }
|
|
308
|
+
),
|
|
309
|
+
intervalMs: 2500,
|
|
310
|
+
onSuccess: async (record, result) => {
|
|
311
|
+
const names = [];
|
|
312
|
+
for (const url of result.urls || []) {
|
|
313
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
314
|
+
await invokeProviderOperation(route.adapter, 'download', {
|
|
315
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
316
|
+
});
|
|
317
|
+
names.push(name);
|
|
318
|
+
}
|
|
319
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
320
|
+
return names;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return {
|
|
324
|
+
task: tasks.get(task.id),
|
|
325
|
+
provider: route.provider,
|
|
326
|
+
model: route.model,
|
|
327
|
+
remoteTaskId: submitted.result.remoteTaskId
|
|
328
|
+
};
|
|
97
329
|
}
|
|
98
330
|
|
|
99
331
|
function findGeneratedAttachment(attachmentId) {
|
|
@@ -129,6 +361,341 @@ function fileDataUrl(file) {
|
|
|
129
361
|
return 'data:' + file.mediaType + ';base64,' + fs.readFileSync(file.absPath).toString('base64');
|
|
130
362
|
}
|
|
131
363
|
|
|
364
|
+
function videoRoute(provider, args, frame) {
|
|
365
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
366
|
+
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider.videoModel || 'wan2.2-t2v-flash');
|
|
367
|
+
const isS2V = /s2v/i.test(model);
|
|
368
|
+
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
369
|
+
return {
|
|
370
|
+
provider, model, modelRef: models.modelRef(provider.id, model), isS2V, mode, protocol: 'dashscope',
|
|
371
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol: 'dashscope' })
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function beforeGenerationError(error, stage) {
|
|
376
|
+
const category = error && (error.category === 'network' || error instanceof TypeError)
|
|
377
|
+
? 'network'
|
|
378
|
+
: (stage === 'validate' ? 'invalid_request' : 'local_io');
|
|
379
|
+
return new ProviderContractError(String((error && error.message) || error || '提交前准备失败'), {
|
|
380
|
+
stage,
|
|
381
|
+
category,
|
|
382
|
+
acceptance: 'not_accepted',
|
|
383
|
+
retryable: category === 'network'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
388
|
+
const task = tasks.createV2({
|
|
389
|
+
cap: 'video', prompt: prompt.slice(0, 4000),
|
|
390
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {}),
|
|
391
|
+
...(args._retryOf ? { retryOf: String(args._retryOf) } : {})
|
|
392
|
+
});
|
|
393
|
+
const candidates = routes.map((route) => ({
|
|
394
|
+
id: route.provider.id,
|
|
395
|
+
model: route.modelRef,
|
|
396
|
+
route,
|
|
397
|
+
submit: async (_input, context) => {
|
|
398
|
+
const attemptId = context.attempt.id;
|
|
399
|
+
let submitArgs;
|
|
400
|
+
if (route.isS2V) {
|
|
401
|
+
if (!frame) throw beforeGenerationError(new Error('iris: s2v 数字人需要首帧'), 'validate');
|
|
402
|
+
const audioPath = String(args.audio_path || '').trim();
|
|
403
|
+
if (!audioPath || !path.isAbsolute(audioPath)) {
|
|
404
|
+
throw beforeGenerationError(new Error('iris: s2v 的 audio_path 必须是绝对路径'), 'validate');
|
|
405
|
+
}
|
|
406
|
+
if (!fs.existsSync(audioPath)) throw beforeGenerationError(new Error('iris: 音频文件不存在'), 'validate');
|
|
407
|
+
tasks.markAttemptStage(task.id, attemptId, 'upload');
|
|
408
|
+
try {
|
|
409
|
+
const imageUrl = await adapters.uploadTempFile({
|
|
410
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
411
|
+
model: route.model, filePath: frame.absPath, signal
|
|
412
|
+
});
|
|
413
|
+
const audioUrl = await adapters.uploadTempFile({
|
|
414
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
415
|
+
model: route.model, filePath: audioPath, signal
|
|
416
|
+
});
|
|
417
|
+
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
418
|
+
} catch (error) {
|
|
419
|
+
throw beforeGenerationError(error, 'upload');
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
if (!prompt) throw beforeGenerationError(new Error('iris: 文生/图生视频需要 prompt'), 'validate');
|
|
423
|
+
tasks.markAttemptStage(task.id, attemptId, 'prepare');
|
|
424
|
+
try {
|
|
425
|
+
submitArgs = {
|
|
426
|
+
prompt,
|
|
427
|
+
imgDataUrl: frame ? fileDataUrl(frame) : undefined,
|
|
428
|
+
size: args.size || '1280*720',
|
|
429
|
+
duration: args.duration ? Number(args.duration) : undefined
|
|
430
|
+
};
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw beforeGenerationError(error, 'prepare');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
tasks.markAttemptStage(task.id, attemptId, 'submit');
|
|
436
|
+
return observeProviderCapability(route, cap.CAPABILITIES.VIDEO, () =>
|
|
437
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
438
|
+
capability: 'video', model: route.model, input: submitArgs, signal
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'video' }, {
|
|
444
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
445
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
446
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
447
|
+
const attempt = tasks.beginAttempt(task.id, {
|
|
448
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
449
|
+
});
|
|
450
|
+
tasks.update(task.id, {
|
|
451
|
+
mode: route.mode,
|
|
452
|
+
...(route.isS2V ? { resolution: args.resolution || '480P' } : {
|
|
453
|
+
size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
|
|
454
|
+
})
|
|
455
|
+
});
|
|
456
|
+
return attempt;
|
|
457
|
+
},
|
|
458
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
if (submitted.localError) {
|
|
462
|
+
throw taskActionError(task.id, '视频提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
463
|
+
}
|
|
464
|
+
if (!submitted.candidate) {
|
|
465
|
+
const lastError = submitted.result && submitted.result.error;
|
|
466
|
+
tasks.finalizeNoAcceptance(task.id, lastError || '所有视频候选均明确未受理');
|
|
467
|
+
throw taskActionError(task.id, '所有视频候选均明确未受理', submitted.attempts);
|
|
468
|
+
}
|
|
469
|
+
if (submitted.result.kind !== 'accepted') {
|
|
470
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
471
|
+
throw taskActionError(task.id, '视频受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const route = submitted.candidate.route;
|
|
475
|
+
tasks.activateWatch(task.id);
|
|
476
|
+
tasks.watch(tasks.get(task.id), {
|
|
477
|
+
poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
|
|
478
|
+
route, cap.CAPABILITIES.VIDEO,
|
|
479
|
+
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
480
|
+
capability: 'video', remoteTaskId: id, signal: pollSignal
|
|
481
|
+
}),
|
|
482
|
+
{ recordSuccess: false }
|
|
483
|
+
),
|
|
484
|
+
intervalMs: 6000,
|
|
485
|
+
onSuccess: async (record, result) => {
|
|
486
|
+
const names = [];
|
|
487
|
+
for (const url of result.urls || []) {
|
|
488
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
|
|
489
|
+
await invokeProviderOperation(route.adapter, 'download', {
|
|
490
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
491
|
+
});
|
|
492
|
+
names.push(name);
|
|
493
|
+
}
|
|
494
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
495
|
+
return names;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function simpleModelRoute(provider, args, field, fallback, protocol = 'dashscope') {
|
|
502
|
+
const parsed = models.parseModelRef(String(args.model || ''));
|
|
503
|
+
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider[field] || fallback);
|
|
504
|
+
return {
|
|
505
|
+
provider, model, modelRef: models.modelRef(provider.id, model), protocol,
|
|
506
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
511
|
+
const task = tasks.createV2({
|
|
512
|
+
cap: 'transcribe', prompt: 'audio transcribe: ' + path.basename(audioPath),
|
|
513
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
514
|
+
});
|
|
515
|
+
const candidates = routes.map((route) => ({
|
|
516
|
+
id: route.provider.id,
|
|
517
|
+
model: route.modelRef,
|
|
518
|
+
route,
|
|
519
|
+
submit: async (_input, context) => {
|
|
520
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'upload');
|
|
521
|
+
let audioUrl;
|
|
522
|
+
try {
|
|
523
|
+
audioUrl = await adapters.uploadTempFile({
|
|
524
|
+
key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
|
|
525
|
+
model: route.model, filePath: audioPath, signal
|
|
526
|
+
});
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw beforeGenerationError(error, 'upload');
|
|
529
|
+
}
|
|
530
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
531
|
+
return observeProviderCapability(route, cap.CAPABILITIES.TRANSCRIBE, () =>
|
|
532
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
533
|
+
capability: 'transcribe', model: route.model, input: { audioUrl }, signal
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
}));
|
|
537
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'transcribe' }, {
|
|
538
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
539
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
540
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
541
|
+
return tasks.beginAttempt(task.id, {
|
|
542
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
543
|
+
});
|
|
544
|
+
},
|
|
545
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
546
|
+
});
|
|
547
|
+
if (submitted.localError) throw taskActionError(task.id, '转写提交结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
548
|
+
if (!submitted.candidate) {
|
|
549
|
+
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有转写候选均明确未受理');
|
|
550
|
+
throw taskActionError(task.id, '所有转写候选均明确未受理', submitted.attempts);
|
|
551
|
+
}
|
|
552
|
+
if (submitted.result.kind !== 'accepted') {
|
|
553
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
554
|
+
throw taskActionError(task.id, '转写受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
555
|
+
}
|
|
556
|
+
const route = submitted.candidate.route;
|
|
557
|
+
tasks.activateWatch(task.id);
|
|
558
|
+
tasks.watch(tasks.get(task.id), {
|
|
559
|
+
poll: ({ remoteTaskId: id, signal: pollSignal }) => observeProviderCapability(
|
|
560
|
+
route, cap.CAPABILITIES.TRANSCRIBE,
|
|
561
|
+
() => invokeProviderOperation(route.adapter, 'poll', {
|
|
562
|
+
capability: 'transcribe', remoteTaskId: id, signal: pollSignal
|
|
563
|
+
}),
|
|
564
|
+
{ recordSuccess: false }
|
|
565
|
+
),
|
|
566
|
+
intervalMs: 2500,
|
|
567
|
+
allowEmptyResult: true,
|
|
568
|
+
onSuccess: async (record, result) => {
|
|
569
|
+
tasks.update(record.id, { transcribeText: (result.urls && result.urls.join('')) || '' });
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model, remoteTaskId: submitted.result.remoteTaskId };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function submitTtsV2(routes, text, args, signal) {
|
|
577
|
+
const task = tasks.createV2({
|
|
578
|
+
cap: 'tts', prompt: text.slice(0, 500),
|
|
579
|
+
...(args.model ? { requestedModelRef: String(args.model) } : {})
|
|
580
|
+
});
|
|
581
|
+
const candidates = routes.map((route) => ({
|
|
582
|
+
id: route.provider.id,
|
|
583
|
+
model: route.modelRef,
|
|
584
|
+
route,
|
|
585
|
+
submit: async (_input, context) => {
|
|
586
|
+
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
587
|
+
return observeProviderCapability(route, cap.CAPABILITIES.TTS, () =>
|
|
588
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
589
|
+
capability: 'tts', model: route.model, input: { text, voice: route.voice }, signal
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
}));
|
|
593
|
+
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'tts' }, {
|
|
594
|
+
beforeAttempt: ({ providerId, model }) => {
|
|
595
|
+
const route = routes.find((item) => item.provider.id === providerId && item.modelRef === model);
|
|
596
|
+
if (!route) throw new Error('候选路由与 Attempt 身份不一致');
|
|
597
|
+
const attempt = tasks.beginAttempt(task.id, {
|
|
598
|
+
providerId, providerName: route.provider.name, model, protocol: route.protocol
|
|
599
|
+
});
|
|
600
|
+
tasks.update(task.id, { voice: route.voice });
|
|
601
|
+
return attempt;
|
|
602
|
+
},
|
|
603
|
+
afterResult: (snapshot) => tasks.recordAttemptResult(task.id, snapshot)
|
|
604
|
+
});
|
|
605
|
+
if (submitted.localError) throw taskActionError(task.id, '语音合成结果未能可靠落盘,已停止后续候选', submitted.localError);
|
|
606
|
+
if (!submitted.candidate) {
|
|
607
|
+
tasks.finalizeNoAcceptance(task.id, submitted.result?.error || '所有语音候选均明确未受理');
|
|
608
|
+
throw taskActionError(task.id, '所有语音候选均明确未受理', submitted.attempts);
|
|
609
|
+
}
|
|
610
|
+
if (submitted.result.kind !== 'completed') {
|
|
611
|
+
const safe = submitted.result.error && submitted.result.error.safeMessage;
|
|
612
|
+
throw taskActionError(task.id, '语音合成受理状态未知,禁止自动重提' + (safe ? ':' + safe : ''), submitted.result.error);
|
|
613
|
+
}
|
|
614
|
+
const route = submitted.candidate.route;
|
|
615
|
+
const audio = submitted.result.value || {};
|
|
616
|
+
const names = [];
|
|
617
|
+
try {
|
|
618
|
+
tasks.beginDelivery(task.id);
|
|
619
|
+
const name = task.id + '.' + (audio.audioUrl ? adapters.extFromUrl(audio.audioUrl, 'wav') : 'wav');
|
|
620
|
+
const target = path.join(outputDir(), name);
|
|
621
|
+
if (audio.audioUrl) await invokeProviderOperation(route.adapter, 'download', {
|
|
622
|
+
artifact: { kind: 'remote-url', url: audio.audioUrl }, targetPath: target, signal
|
|
623
|
+
});
|
|
624
|
+
else if (audio.audioB64) writePrivateFile(target, Buffer.from(audio.audioB64, 'base64'));
|
|
625
|
+
else throw new Error('语音合成成功响应缺少音频数据');
|
|
626
|
+
names.push(name);
|
|
627
|
+
registerMedia(task.id, target);
|
|
628
|
+
tasks.completeDelivery(task.id, names);
|
|
629
|
+
} catch (error) {
|
|
630
|
+
const recorded = tasks.failDelivery(task.id, error, names);
|
|
631
|
+
throw taskActionError(task.id, '语音已合成,但产物交付失败:' + recorded.error, recorded.lastError);
|
|
632
|
+
}
|
|
633
|
+
return { task: tasks.get(task.id), provider: route.provider, model: route.model };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** 重新观察/交付与启动恢复共享的远端查询和本地落盘依赖。 */
|
|
637
|
+
export function taskPollDeps(provider, task) {
|
|
638
|
+
if (!provider || !task) return null;
|
|
639
|
+
if (!['image', 'video', 'transcribe'].includes(task.cap)) return null;
|
|
640
|
+
const protocol = task.protocol || (task.cap === 'image'
|
|
641
|
+
? (provider.mediaProtocol || inferMediaProtocol(provider.baseUrl))
|
|
642
|
+
: 'dashscope');
|
|
643
|
+
let providerAdapter;
|
|
644
|
+
try {
|
|
645
|
+
providerAdapter = createConfiguredProviderAdapter(provider, { protocol });
|
|
646
|
+
} catch (_) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const healthCapability = task.cap === 'image'
|
|
650
|
+
? cap.CAPABILITIES.IMAGE
|
|
651
|
+
: (task.cap === 'video' ? cap.CAPABILITIES.VIDEO : cap.CAPABILITIES.TRANSCRIBE);
|
|
652
|
+
const parsedTaskModel = models.parseModelRef(String(task.model || ''));
|
|
653
|
+
const healthRoute = {
|
|
654
|
+
provider,
|
|
655
|
+
model: parsedTaskModel?.modelId || String(task.model || provider[models.CAP_FIELD[healthCapability]] || '')
|
|
656
|
+
};
|
|
657
|
+
if (task.cap === 'transcribe') {
|
|
658
|
+
return {
|
|
659
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
660
|
+
healthRoute, healthCapability,
|
|
661
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
662
|
+
capability: 'transcribe', remoteTaskId, signal
|
|
663
|
+
}),
|
|
664
|
+
{ recordSuccess: false }
|
|
665
|
+
),
|
|
666
|
+
intervalMs: 2500,
|
|
667
|
+
allowEmptyResult: true,
|
|
668
|
+
onSuccess: async (record, result) => {
|
|
669
|
+
tasks.update(record.id, { transcribeText: (result.urls && result.urls.join('')) || '' });
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
676
|
+
healthRoute, healthCapability,
|
|
677
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
678
|
+
capability: task.cap, remoteTaskId, signal
|
|
679
|
+
}),
|
|
680
|
+
{ recordSuccess: false }
|
|
681
|
+
),
|
|
682
|
+
intervalMs: task.cap === 'video' ? 6000 : 2500,
|
|
683
|
+
onSuccess: async (record, result) => {
|
|
684
|
+
const names = [];
|
|
685
|
+
for (const url of result.urls || []) {
|
|
686
|
+
const fallback = task.cap === 'video' ? 'mp4' : 'png';
|
|
687
|
+
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, fallback);
|
|
688
|
+
await invokeProviderOperation(providerAdapter, 'download', {
|
|
689
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
690
|
+
});
|
|
691
|
+
names.push(name);
|
|
692
|
+
}
|
|
693
|
+
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
694
|
+
return names;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
132
699
|
/* ---------------- 动作注册表 ---------------- */
|
|
133
700
|
|
|
134
701
|
const actions = {};
|
|
@@ -136,7 +703,7 @@ const actions = {};
|
|
|
136
703
|
/**
|
|
137
704
|
* 注册一个 action。
|
|
138
705
|
* @param {string} name 动作名(用于路由)
|
|
139
|
-
* @param {Function} fn (
|
|
706
|
+
* @param {Function} fn (host, args, { signal }) => { ok, text?, imageDataUrl?, taskId?, blocks? }
|
|
140
707
|
*/
|
|
141
708
|
function register(name, fn) {
|
|
142
709
|
actions[name] = fn;
|
|
@@ -148,194 +715,78 @@ export function listActions() {
|
|
|
148
715
|
}
|
|
149
716
|
|
|
150
717
|
/** 执行一个动作 */
|
|
151
|
-
export async function runAction(
|
|
718
|
+
export async function runAction(host, name, args, { signal } = {}) {
|
|
152
719
|
const fn = actions[name];
|
|
153
720
|
if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
|
|
154
|
-
return fn(
|
|
721
|
+
return fn(actionHost(host), args || {}, { signal });
|
|
155
722
|
}
|
|
156
723
|
|
|
157
724
|
/* ============ 动作实现 ============ */
|
|
158
725
|
|
|
159
726
|
/* ---------- 🖼️ 画图 ---------- */
|
|
160
|
-
register('image', async (
|
|
727
|
+
register('image', async (host, args, { signal }) => {
|
|
161
728
|
const prompt = String(args.prompt || '').trim();
|
|
162
729
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
cap: 'image', providerId: provider.id, providerName: provider.name,
|
|
168
|
-
model, protocol, prompt: prompt.slice(0, 4000)
|
|
169
|
-
});
|
|
170
|
-
if (protocol === 'dashscope') {
|
|
171
|
-
const started = await tasks.submitGuard(task, () => adapters.startImageGeneration({
|
|
172
|
-
key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
|
|
173
|
-
}));
|
|
174
|
-
const remoteTaskId = started.remoteTaskId;
|
|
175
|
-
if (!remoteTaskId) {
|
|
176
|
-
try {
|
|
177
|
-
const names = [];
|
|
178
|
-
for (const url of started.urls || []) {
|
|
179
|
-
const name = `${task.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
|
|
180
|
-
await adapters.downloadTo(url, path.join(outputDir(), name), { signal });
|
|
181
|
-
names.push(name);
|
|
182
|
-
}
|
|
183
|
-
tasks.update(task.id, { status: 'succeeded', files: names, finishedAt: new Date().toISOString() });
|
|
184
|
-
for (const name of names) registerMedia(task.id, path.join(outputDir(), name));
|
|
185
|
-
} catch (err) {
|
|
186
|
-
// 模型已经同步生成成功,转存失败不能再向下一供应商重复计费。
|
|
187
|
-
tasks.update(task.id, { status: 'failed', error: `图片转存失败:${String(err.message || err)}`, finishedAt: new Date().toISOString() });
|
|
188
|
-
}
|
|
189
|
-
return { task: tasks.get(task.id), provider, model, remoteTaskId: null };
|
|
190
|
-
}
|
|
191
|
-
tasks.update(task.id, { remoteTaskId });
|
|
192
|
-
tasks.watch(tasks.get(task.id), {
|
|
193
|
-
key: () => provider.apiKey,
|
|
194
|
-
poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
|
|
195
|
-
intervalMs: 2500,
|
|
196
|
-
onSuccess: async (t, r) => {
|
|
197
|
-
const names = [];
|
|
198
|
-
for (const url of r.urls || []) {
|
|
199
|
-
const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'png')}`;
|
|
200
|
-
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
201
|
-
names.push(name);
|
|
202
|
-
}
|
|
203
|
-
for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
|
|
204
|
-
return names;
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
return { task, provider, model, remoteTaskId };
|
|
208
|
-
}
|
|
209
|
-
const files = await tasks.submitGuard(task, async () => {
|
|
210
|
-
const outs = await adapters.openAiGenerateImage({
|
|
211
|
-
key: provider.apiKey, baseUrl: provider.baseUrl, model, prompt, size: args.size, n: args.n, signal
|
|
212
|
-
});
|
|
213
|
-
const written = [];
|
|
214
|
-
for (let i = 0; i < outs.length; i++) {
|
|
215
|
-
const p = path.join(outputDir(), `${task.id}-${i}.png`);
|
|
216
|
-
if (outs[i].b64) writePrivateFile(p, Buffer.from(outs[i].b64, 'base64'));
|
|
217
|
-
else await adapters.downloadTo(outs[i].url, p, { signal });
|
|
218
|
-
written.push(p);
|
|
219
|
-
}
|
|
220
|
-
return written;
|
|
221
|
-
});
|
|
222
|
-
tasks.update(task.id, { status: 'succeeded', files: files.map((f) => path.basename(f)), finishedAt: new Date().toISOString() });
|
|
223
|
-
for (const f of files) registerMedia(task.id, f);
|
|
224
|
-
return { task, provider, model, remoteTaskId: null };
|
|
225
|
-
});
|
|
730
|
+
|
|
731
|
+
const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
|
|
732
|
+
const routes = imageProviders.map((provider) => imageRoute(provider, args));
|
|
733
|
+
const submitted = await submitImageV2(routes, prompt, args, signal);
|
|
226
734
|
return {
|
|
227
735
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
228
736
|
remoteTaskId: submitted.remoteTaskId,
|
|
229
737
|
text: submitted.remoteTaskId
|
|
230
|
-
?
|
|
231
|
-
:
|
|
738
|
+
? '[iris] 图像任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
739
|
+
: '[iris] 图像任务已完成(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
232
740
|
};
|
|
233
741
|
});
|
|
234
742
|
|
|
235
743
|
/* ---------- 🎬 视频生成 ---------- */
|
|
236
|
-
register('video', async (
|
|
744
|
+
register('video', async (host, args, { signal }) => {
|
|
237
745
|
const prompt = String(args.prompt || '').trim();
|
|
238
746
|
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
239
747
|
const frame = resolveFirstFrameFile(args);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (!isS2V && !prompt) throw new Error('iris: 文生/图生视频需要 prompt');
|
|
244
|
-
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
245
|
-
const task = tasks.create({
|
|
246
|
-
cap: 'video', providerId: provider.id, providerName: provider.name, model, mode,
|
|
247
|
-
...(isS2V ? { resolution: args.resolution || '480P' } : {
|
|
248
|
-
size: args.size || '1280*720', ...(args.duration ? { duration: Number(args.duration) } : {})
|
|
249
|
-
}),
|
|
250
|
-
prompt: prompt.slice(0, 4000)
|
|
251
|
-
});
|
|
252
|
-
const remoteTaskId = await tasks.submitGuard(task, async () => {
|
|
253
|
-
let submitArgs;
|
|
254
|
-
if (isS2V) {
|
|
255
|
-
if (!frame) throw new Error('iris: s2v 数字人需要首帧');
|
|
256
|
-
const audioPath = String(args.audio_path || '').trim();
|
|
257
|
-
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: s2v 的 audio_path 必须是绝对路径');
|
|
258
|
-
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在:' + audioPath);
|
|
259
|
-
const imageUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: frame.absPath, signal });
|
|
260
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
261
|
-
submitArgs = { imgDataUrl: imageUrl, audioUrl, resolution: args.resolution || '480P' };
|
|
262
|
-
} else {
|
|
263
|
-
submitArgs = {
|
|
264
|
-
prompt,
|
|
265
|
-
imgDataUrl: frame ? fileDataUrl(frame) : undefined,
|
|
266
|
-
size: args.size || '1280*720',
|
|
267
|
-
duration: args.duration ? Number(args.duration) : undefined
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
return adapters.submitVideo({ key: provider.apiKey, baseUrl: provider.baseUrl, model, ...submitArgs, signal });
|
|
271
|
-
});
|
|
272
|
-
tasks.update(task.id, { remoteTaskId });
|
|
273
|
-
tasks.watch(tasks.get(task.id), {
|
|
274
|
-
key: () => provider.apiKey,
|
|
275
|
-
poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id }),
|
|
276
|
-
intervalMs: 6000,
|
|
277
|
-
onSuccess: async (t, r) => {
|
|
278
|
-
const names = [];
|
|
279
|
-
for (const url of r.urls || []) {
|
|
280
|
-
const name = `${t.id}-${names.length}.${adapters.extFromUrl(url, 'mp4')}`;
|
|
281
|
-
await adapters.downloadTo(url, path.join(outputDir(), name));
|
|
282
|
-
names.push(name);
|
|
283
|
-
}
|
|
284
|
-
for (const n of names) registerMedia(t.id, path.join(outputDir(), n));
|
|
285
|
-
return names;
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
return { task, provider, model, remoteTaskId };
|
|
289
|
-
});
|
|
748
|
+
const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
|
|
749
|
+
const routes = providers.map((provider) => videoRoute(provider, args, frame));
|
|
750
|
+
const submitted = await submitVideoV2(routes, frame, prompt, args, signal);
|
|
290
751
|
return {
|
|
291
752
|
ok: true, taskId: submitted.task.id, model: submitted.model, mode: submitted.task.mode,
|
|
292
753
|
providerId: submitted.provider.id, remoteTaskId: submitted.remoteTaskId,
|
|
293
|
-
text:
|
|
754
|
+
text: '[iris] 视频任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
294
755
|
};
|
|
295
756
|
});
|
|
296
757
|
|
|
297
758
|
/* ---------- 🔊 语音合成 ---------- */
|
|
298
|
-
register('tts', async (
|
|
759
|
+
register('tts', async (host, args, { signal }) => {
|
|
299
760
|
const text = String(args.text || '').trim();
|
|
300
761
|
if (!text) throw new Error('iris: text 不能为空');
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const name = `iris-tts-${Date.now()}.${r.audioUrl ? adapters.extFromUrl(r.audioUrl, 'wav') : 'wav'}`;
|
|
308
|
-
const p = path.join(outputDir(), name);
|
|
309
|
-
if (r.audioUrl) await adapters.downloadTo(r.audioUrl, p, { signal });
|
|
310
|
-
else writePrivateFile(p, Buffer.from(r.audioB64, 'base64'));
|
|
311
|
-
return name;
|
|
312
|
-
});
|
|
313
|
-
tasks.update(task.id, { status: 'succeeded', files: [fileName], finishedAt: new Date().toISOString() });
|
|
314
|
-
registerMedia(task.id, path.join(outputDir(), fileName));
|
|
315
|
-
return { task, provider, model };
|
|
316
|
-
});
|
|
762
|
+
const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
|
|
763
|
+
const routes = providers.map((provider) => ({
|
|
764
|
+
...simpleModelRoute(provider, args, 'ttsModel', 'qwen-tts-latest'),
|
|
765
|
+
voice: args.voice || provider.ttsVoice || 'Cherry'
|
|
766
|
+
}));
|
|
767
|
+
const submitted = await submitTtsV2(routes, text, args, signal);
|
|
317
768
|
return {
|
|
318
769
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
319
|
-
text:
|
|
770
|
+
text: '[iris] 语音已合成(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
320
771
|
};
|
|
321
772
|
});
|
|
322
773
|
|
|
323
774
|
/* ---------- 👁 看图 ---------- */
|
|
324
|
-
register('look', async (
|
|
775
|
+
register('look', async (host, args, { signal }) => {
|
|
325
776
|
const p = String(args.image_path || '').trim();
|
|
326
777
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
327
778
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
|
|
328
779
|
if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
|
|
329
780
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
330
781
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(p).toString('base64')}`;
|
|
331
|
-
const backends =
|
|
332
|
-
const { answer, via, model } = await
|
|
782
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
783
|
+
const { answer, via, model } = await askWithHealth(backends, { question: (args.question || '详细描述这张图片。'), imageDataUrl: dataUrl, signal });
|
|
333
784
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
334
785
|
return { ok: true, text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
|
|
335
786
|
});
|
|
336
787
|
|
|
337
788
|
/* ---------- ✂️ 裁剪 ---------- */
|
|
338
|
-
register('crop', async (
|
|
789
|
+
register('crop', async (host, args, { signal }) => {
|
|
339
790
|
const p = String(args.image_path || '').trim();
|
|
340
791
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
341
792
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
@@ -345,7 +796,7 @@ register('crop', async (ctx, args, { signal }) => {
|
|
|
345
796
|
});
|
|
346
797
|
|
|
347
798
|
/* ---------- 📷 像素 diff ---------- */
|
|
348
|
-
register('diff', async (
|
|
799
|
+
register('diff', async (host, args, { signal }) => {
|
|
349
800
|
if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
|
|
350
801
|
const { ratio, diffPixels, totalPixels, width, height, worstRegions, heatmap, mime } = await pixelDiff({
|
|
351
802
|
inputA: args.image_a_path, inputB: args.image_b_path, grid: args.grid || 8, topRegions: args.top_regions || 3
|
|
@@ -360,7 +811,7 @@ register('diff', async (ctx, args, { signal }) => {
|
|
|
360
811
|
});
|
|
361
812
|
|
|
362
813
|
/* ---------- 📍 定位 ---------- */
|
|
363
|
-
register('locate', async (
|
|
814
|
+
register('locate', async (host, args, { signal }) => {
|
|
364
815
|
const p = String(args.image_path || '').trim();
|
|
365
816
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
366
817
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
@@ -368,28 +819,28 @@ register('locate', async (ctx, args, { signal }) => {
|
|
|
368
819
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
|
|
369
820
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
370
821
|
const { width, height } = await imageDimensions(buf);
|
|
371
|
-
const backends =
|
|
822
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
372
823
|
const r = await locateObject(backends, { target: args.target, imageDataUrl: dataUrl, width, height, signal });
|
|
373
824
|
if (!r.found) return { ok: true, text: `[iris] 在图片中未找到「${args.target}」` };
|
|
374
825
|
return { ok: true, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
|
|
375
826
|
});
|
|
376
827
|
/* ---------- 🖼️ HTML 截图 ---------- */
|
|
377
|
-
register('html', async (
|
|
828
|
+
register('html', async (host, args, { signal }) => {
|
|
378
829
|
const html = String(args.html || '').trim();
|
|
379
830
|
if (!html) throw new Error('iris: html 不能为空');
|
|
380
|
-
const browser =
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data
|
|
831
|
+
const browser = requireHostPort(host, 'browser', 'HTML 截图');
|
|
832
|
+
const rendered = await browser.renderHtml({ html, width: args.width, height: args.height,
|
|
833
|
+
fullPage: args.fullPage !== false, signal });
|
|
834
|
+
const png = Buffer.from(rendered.bytes);
|
|
835
|
+
return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:${rendered.mediaType || 'image/png'};base64,${png.toString('base64')}` };
|
|
385
836
|
});
|
|
386
837
|
|
|
387
838
|
/* ---------- 📄 长截图 OCR ---------- */
|
|
388
|
-
register('ocr', async (
|
|
839
|
+
register('ocr', async (host, args, { signal }) => {
|
|
389
840
|
const p = String(args.image_path || '').trim();
|
|
390
841
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
391
842
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
392
|
-
const backends =
|
|
843
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
393
844
|
const result = await longOcr({ input: p, backends, chunkHeight: args.chunk_height || 1200, overlap: args.overlap || 120, signal });
|
|
394
845
|
const errCount = result.chunks.filter((c) => c.error).length;
|
|
395
846
|
const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
|
|
@@ -397,7 +848,7 @@ register('ocr', async (ctx, args, { signal }) => {
|
|
|
397
848
|
});
|
|
398
849
|
|
|
399
850
|
/* ---------- 📋 任务查询 ---------- */
|
|
400
|
-
register('status', async (
|
|
851
|
+
register('status', async (host, args, { signal }) => {
|
|
401
852
|
if (args.task_id) {
|
|
402
853
|
const t = tasks.get(String(args.task_id).trim());
|
|
403
854
|
if (!t) throw new Error('iris: 没有这个任务:' + args.task_id);
|
|
@@ -414,6 +865,99 @@ register('status', async (ctx, args, { signal }) => {
|
|
|
414
865
|
return { ok: true, text: recent.map((t) => `[${t.cap}] ${t.id} · ${t.status} · ${t.model}`).join('\n') };
|
|
415
866
|
});
|
|
416
867
|
|
|
868
|
+
/* ---------- Task v2 人工接管 ---------- */
|
|
869
|
+
register('task_reobserve', async (host, args) => {
|
|
870
|
+
const id = String(args.task_id || '').trim();
|
|
871
|
+
const task = tasks.get(id);
|
|
872
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可接管的 Task v2');
|
|
873
|
+
if (task.acceptance !== 'accepted' || !task.remoteTaskId) throw new Error('iris: 缺少已受理证据或远端任务 ID,不能重新观察');
|
|
874
|
+
if (task.outcome === 'succeeded') throw new Error('iris: 生成已经成功,请使用重新交付');
|
|
875
|
+
if (task.outcome === 'failed' || task.outcome === 'canceled') throw new Error('iris: 任务已有明确终态,不能重新观察');
|
|
876
|
+
const provider = store.providerById(task.providerId);
|
|
877
|
+
const deps = provider && taskPollDeps(provider, task);
|
|
878
|
+
if (!deps) throw new Error('iris: 对应供应商或轮询协议不可用');
|
|
879
|
+
tasks.watch(task, deps);
|
|
880
|
+
return { ok: true, taskId: id, text: '[iris] 已重新观察远端任务;不会重新提交生成请求。' };
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
register('task_redeliver', async (host, args, { signal }) => {
|
|
884
|
+
const id = String(args.task_id || '').trim();
|
|
885
|
+
const task = tasks.get(id);
|
|
886
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重新交付的 Task v2');
|
|
887
|
+
if (task.outcome !== 'succeeded' || task.deliveryState !== 'failed') throw new Error('iris: 任务不处于生成成功但交付失败状态');
|
|
888
|
+
if (!task.remoteTaskId) throw new Error('iris: 该同步结果没有可重新获取的远端任务 ID,请重新从原始输入发起');
|
|
889
|
+
const provider = store.providerById(task.providerId);
|
|
890
|
+
const deps = provider && taskPollDeps(provider, task);
|
|
891
|
+
if (!deps || task.cap === 'transcribe') throw new Error('iris: 对应供应商或产物交付协议不可用');
|
|
892
|
+
tasks.prepareRedelivery(id);
|
|
893
|
+
try {
|
|
894
|
+
tasks.beginDelivery(id);
|
|
895
|
+
const result = providerPollTaskView(await deps.poll({ remoteTaskId: task.remoteTaskId, signal }));
|
|
896
|
+
if (!result.done) throw new Error('远端结果暂未就绪,请先重新观察');
|
|
897
|
+
if (!result.ok) throw new Error(result.message || '供应商当前无法返回成功产物');
|
|
898
|
+
if (!(result.urls || []).length) throw new Error('供应商未返回可交付的结果 URL');
|
|
899
|
+
const files = await deps.onSuccess(tasks.get(id), result);
|
|
900
|
+
tasks.completeDelivery(id, files);
|
|
901
|
+
return { ok: true, taskId: id, files, text: '[iris] 产物已重新交付;未重新生成。' };
|
|
902
|
+
} catch (error) {
|
|
903
|
+
const current = tasks.get(id);
|
|
904
|
+
if (current && current.outcome === 'succeeded' && current.deliveryState !== 'failed') tasks.failDelivery(id, error);
|
|
905
|
+
throw new Error('iris: 重新交付失败:' + String((error && error.message) || error));
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
register('task_ack_attention', async (_host, args) => {
|
|
910
|
+
const id = String(args.task_id || '').trim();
|
|
911
|
+
const task = tasks.get(id);
|
|
912
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可标为已读的 Task v2');
|
|
913
|
+
tasks.acknowledgeAttention(id, 'read');
|
|
914
|
+
return { ok: true, taskId: id, text: '[iris] 提醒已标为已读;任务事实未改变,可在历史详情中恢复提醒。' };
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
register('task_restore_attention', async (_host, args) => {
|
|
918
|
+
const id = String(args.task_id || '').trim();
|
|
919
|
+
const task = tasks.get(id);
|
|
920
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可恢复提醒的 Task v2');
|
|
921
|
+
tasks.restoreAttention(id);
|
|
922
|
+
return { ok: true, taskId: id, text: '[iris] 已恢复任务提醒。' };
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
register('task_manual_retry', async (host, args, { signal }) => {
|
|
926
|
+
const id = String(args.task_id || '').trim();
|
|
927
|
+
if (args.confirm_duplicate_charge !== true) throw new Error('iris: 必须明确确认本次操作可能产生重复任务或重复费用');
|
|
928
|
+
const task = tasks.get(id);
|
|
929
|
+
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重试的 Task v2');
|
|
930
|
+
const state = deriveUserState(task);
|
|
931
|
+
if (!['needs_attention', 'failed', 'canceled'].includes(state)) throw new Error('iris: 当前状态不需要重新生成');
|
|
932
|
+
if (tasks.attentionDisposition(task).status === 'acknowledged') {
|
|
933
|
+
throw new Error('iris: 该提醒已经处理;如需再次操作,请先恢复提醒');
|
|
934
|
+
}
|
|
935
|
+
let action;
|
|
936
|
+
let retryArgs;
|
|
937
|
+
if (task.cap === 'image' && task.prompt) {
|
|
938
|
+
action = 'image';
|
|
939
|
+
retryArgs = { prompt: task.prompt, size: task.size || undefined, n: task.count || 1 };
|
|
940
|
+
} else if (task.cap === 'video' && task.mode === 't2v' && task.prompt) {
|
|
941
|
+
action = 'video';
|
|
942
|
+
retryArgs = { prompt: task.prompt, size: task.size || undefined, duration: task.duration || undefined };
|
|
943
|
+
} else {
|
|
944
|
+
throw new Error('iris: 该任务的原始输入无法无损重建,请从原始文件或文本手动发起新任务');
|
|
945
|
+
}
|
|
946
|
+
if (task.modelRef) retryArgs.model = task.modelRef;
|
|
947
|
+
retryArgs._retryOf = id;
|
|
948
|
+
try {
|
|
949
|
+
const result = await runAction(host, action, retryArgs, { signal });
|
|
950
|
+
if (result.taskId) tasks.linkManualRetry(id, result.taskId);
|
|
951
|
+
return { ...result, retryOf: id, text: (result.text || '[iris] 已创建新任务')
|
|
952
|
+
+ '\n[iris] 原任务提醒已归档,并保留新旧任务关联;本次新任务可能与远端未知任务重复。' };
|
|
953
|
+
} catch (error) {
|
|
954
|
+
if (error && error.taskId) {
|
|
955
|
+
try { tasks.linkManualRetry(id, error.taskId); } catch (_) {}
|
|
956
|
+
}
|
|
957
|
+
throw error;
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
|
|
417
961
|
/* ---------- 🧹 任务清理(阶段 4 续:元数据删除,产物文件默认保留) ---------- */
|
|
418
962
|
|
|
419
963
|
/** 扫描 outputs/ 里不被任何任务记录引用的孤儿文件(files[] + media[].file 并集) */
|
|
@@ -426,6 +970,7 @@ function scanOrphanArtifacts() {
|
|
|
426
970
|
for (const m of t.media || []) referenced.add(path.basename(m.file));
|
|
427
971
|
for (const a of t.attachments || []) if (a.file) referenced.add(path.basename(a.file));
|
|
428
972
|
}
|
|
973
|
+
for (const item of artifacts.all()) referenced.add(path.basename(item.file));
|
|
429
974
|
const files = [];
|
|
430
975
|
let bytes = 0;
|
|
431
976
|
for (const name of fs.readdirSync(dir)) {
|
|
@@ -441,7 +986,7 @@ function scanOrphanArtifacts() {
|
|
|
441
986
|
}
|
|
442
987
|
|
|
443
988
|
/** 删除单条任务记录(仅终态;运行中须先取消)。只删元数据,产物文件保留。 */
|
|
444
|
-
register('tasks_delete', async (
|
|
989
|
+
register('tasks_delete', async (host, args) => {
|
|
445
990
|
const id = String(args.task_id || '').trim();
|
|
446
991
|
if (!id) throw new Error('iris: task_id 不能为空');
|
|
447
992
|
const r = tasks.remove(id);
|
|
@@ -453,7 +998,7 @@ register('tasks_delete', async (ctx, args) => {
|
|
|
453
998
|
* 批量清理任务记录:scope = completed(所有终态)| older_than(早于 days 天)| all(等同 completed,运行中受保护)。
|
|
454
999
|
* 只删元数据,绝不动运行中任务,产物文件默认保留。
|
|
455
1000
|
*/
|
|
456
|
-
register('tasks_clear', async (
|
|
1001
|
+
register('tasks_clear', async (host, args) => {
|
|
457
1002
|
const scope = String(args.scope || 'completed').trim();
|
|
458
1003
|
if (!['completed', 'older_than', 'all'].includes(scope)) throw new Error('iris: scope 需为 completed/older_than/all');
|
|
459
1004
|
let removed;
|
|
@@ -466,7 +1011,28 @@ register('tasks_clear', async (ctx, args) => {
|
|
|
466
1011
|
// completed / all:删除全部终态记录(运行中永不被 prune 命中)
|
|
467
1012
|
removed = tasks.prune(() => true);
|
|
468
1013
|
}
|
|
469
|
-
return { ok: true, removed: removed.length, text: `[iris] 已清理 ${removed.length}
|
|
1014
|
+
return { ok: true, removed: removed.length, text: `[iris] 已清理 ${removed.length} 条任务记录(作品仍可在作品库查看)` };
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
/** 重新扫描 outputs/,补录旧作品并移除已经不存在的索引。 */
|
|
1018
|
+
register('artifacts_reindex', async () => {
|
|
1019
|
+
const result = artifacts.reindex();
|
|
1020
|
+
return { ok: true, ...result, text: `[iris] 作品库已刷新:找回 ${result.added} 个,移除 ${result.missing} 个失效索引,共 ${result.total} 个作品` };
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
/** 删除一件作品及其文件;任务历史不会随之删除。 */
|
|
1024
|
+
register('artifacts_delete', async (_host, args) => {
|
|
1025
|
+
const id = String(args.artifact_id || '').trim();
|
|
1026
|
+
if (!id) throw new Error('iris: artifact_id 不能为空');
|
|
1027
|
+
const result = artifacts.remove(id);
|
|
1028
|
+
if (!result.ok) throw new Error('iris: ' + result.reason);
|
|
1029
|
+
return { ok: true, removed: 1, text: `[iris] 已删除作品 ${result.file};任务历史保留` };
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
/** 清空作品库及其文件;客户端必须单独二次确认。 */
|
|
1033
|
+
register('artifacts_clear', async () => {
|
|
1034
|
+
const result = artifacts.clear();
|
|
1035
|
+
return { ok: result.failed === 0, ...result, text: `[iris] 已删除 ${result.deleted} 个作品,释放 ${(result.bytes / 1048576).toFixed(1)}MB${result.failed ? `;${result.failed} 个删除失败并保留索引` : ''}` };
|
|
470
1036
|
});
|
|
471
1037
|
|
|
472
1038
|
/** 孤儿产物报告:outputs/ 里无任务引用的文件 + 总占用(只读,不删) */
|
|
@@ -493,7 +1059,7 @@ register('tasks_purge_orphans', async () => {
|
|
|
493
1059
|
});
|
|
494
1060
|
|
|
495
1061
|
/* ---------- 👁 重看(GUI:只查 iris 自己的任务产物,无会话上下文) ---------- */
|
|
496
|
-
register('relook', async (
|
|
1062
|
+
register('relook', async (host, args, { signal }) => {
|
|
497
1063
|
const id = String(args.attachment_id || '').trim();
|
|
498
1064
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
499
1065
|
const question = String(args.question || '').trim();
|
|
@@ -512,8 +1078,8 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
512
1078
|
if (!hit) throw new Error('iris: 该 attachment 不在 iris 生成的任务产物中(GUI 重看仅支持 iris 画图产物)');
|
|
513
1079
|
const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
|
|
514
1080
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
|
|
515
|
-
const backends =
|
|
516
|
-
const { answer, via, model } = await
|
|
1081
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
1082
|
+
const { answer, via, model } = await askWithHealth(backends, { question, imageDataUrl: dataUrl, signal });
|
|
517
1083
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
518
1084
|
return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
|
|
519
1085
|
});
|
|
@@ -521,7 +1087,7 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
521
1087
|
/* ============ 供应商管理动作(阶段 6 条目 3-5) ============ */
|
|
522
1088
|
|
|
523
1089
|
/** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
|
|
524
|
-
register('providers_list', async (
|
|
1090
|
+
register('providers_list', async (host, args) => {
|
|
525
1091
|
const list = store.allProviders();
|
|
526
1092
|
return {
|
|
527
1093
|
ok: true,
|
|
@@ -533,14 +1099,22 @@ register('providers_list', async (ctx, args) => {
|
|
|
533
1099
|
enabled: p.enabled !== false,
|
|
534
1100
|
mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
|
|
535
1101
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
536
|
-
models: models.providerModels(p).map((m) => ({
|
|
1102
|
+
models: models.providerModels(p).map((m) => ({
|
|
1103
|
+
id: m.id,
|
|
1104
|
+
capabilities: m.capabilities,
|
|
1105
|
+
verified: m.verified || null,
|
|
1106
|
+
health: Object.fromEntries(m.capabilities.map((capability) => [
|
|
1107
|
+
capability, store.modelHealth(p.id, m.id, capability)
|
|
1108
|
+
])),
|
|
1109
|
+
source: m.source || 'auto'
|
|
1110
|
+
})),
|
|
537
1111
|
capabilities: store.capabilitiesOf(p)
|
|
538
1112
|
}))
|
|
539
1113
|
};
|
|
540
1114
|
});
|
|
541
1115
|
|
|
542
1116
|
/** 新增/更新供应商 */
|
|
543
|
-
register('providers_upsert', async (
|
|
1117
|
+
register('providers_upsert', async (host, args) => {
|
|
544
1118
|
const id = args.id || undefined;
|
|
545
1119
|
const patch = { id };
|
|
546
1120
|
const allowed = ['name', 'type', 'baseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
@@ -554,13 +1128,13 @@ register('providers_upsert', async (ctx, args) => {
|
|
|
554
1128
|
});
|
|
555
1129
|
|
|
556
1130
|
/** 删除供应商 */
|
|
557
|
-
register('providers_remove', async (
|
|
1131
|
+
register('providers_remove', async (host, args) => {
|
|
558
1132
|
const ok = store.removeProvider(String(args.id || ''));
|
|
559
1133
|
return { ok, removed: ok };
|
|
560
1134
|
});
|
|
561
1135
|
|
|
562
1136
|
/** 设置某供应商的模型池(覆盖显式 models) */
|
|
563
|
-
register('providers_set_models', async (
|
|
1137
|
+
register('providers_set_models', async (host, args) => {
|
|
564
1138
|
const p = store.setProviderModels(String(args.id || ''), Array.isArray(args.models) ? args.models : []);
|
|
565
1139
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
566
1140
|
return { ok: true };
|
|
@@ -570,12 +1144,14 @@ register('providers_set_models', async (ctx, args) => {
|
|
|
570
1144
|
* 模型发现(P2):GET /models 拉全量 → 按规则过滤出媒体能力模型 → 写入显式池。
|
|
571
1145
|
* 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
|
|
572
1146
|
*/
|
|
573
|
-
register('providers_discover', async (
|
|
1147
|
+
register('providers_discover', async (host, args, { signal }) => {
|
|
574
1148
|
const p = store.providerById(String(args.id || ''));
|
|
575
1149
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
576
1150
|
let ids;
|
|
577
1151
|
try {
|
|
578
|
-
|
|
1152
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1153
|
+
const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
|
|
1154
|
+
ids = discovered.models.map((model) => model.id);
|
|
579
1155
|
} catch (err) {
|
|
580
1156
|
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
581
1157
|
}
|
|
@@ -592,16 +1168,22 @@ register('providers_discover', async (ctx, args) => {
|
|
|
592
1168
|
});
|
|
593
1169
|
|
|
594
1170
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
595
|
-
register('providers_test_vision', async (
|
|
1171
|
+
register('providers_test_vision', async (host, args, { signal }) => {
|
|
596
1172
|
const p = store.providerById(String(args.id || ''));
|
|
597
1173
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
598
1174
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
|
|
599
1175
|
if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
|
|
600
|
-
const backends =
|
|
1176
|
+
const backends = buildVisionBackendsFromHost(host, { providers: visionProviders });
|
|
601
1177
|
const backend = backends[0];
|
|
602
1178
|
if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
|
|
603
1179
|
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
604
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal
|
|
1180
|
+
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
|
|
1181
|
+
store.setModelVerified(p.id, backend.model, cap.CAPABILITIES.VISION, {
|
|
1182
|
+
ok: r.ok,
|
|
1183
|
+
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
1184
|
+
category: r.category,
|
|
1185
|
+
status: r.status
|
|
1186
|
+
});
|
|
605
1187
|
return {
|
|
606
1188
|
ok: true,
|
|
607
1189
|
tested: true,
|
|
@@ -615,35 +1197,69 @@ register('providers_test_vision', async (ctx, args) => {
|
|
|
615
1197
|
/* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
|
|
616
1198
|
|
|
617
1199
|
/** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
|
|
618
|
-
async function probeModel(
|
|
1200
|
+
async function probeModel(host, p, modelId, capability, signal) {
|
|
619
1201
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
620
|
-
const backend =
|
|
1202
|
+
const backend = buildVisionBackendsFromHost(host, { providers: [p], model: modelId })[0];
|
|
621
1203
|
if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
|
|
622
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal
|
|
623
|
-
return {
|
|
1204
|
+
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal });
|
|
1205
|
+
return {
|
|
1206
|
+
ok: r.ok,
|
|
1207
|
+
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
1208
|
+
category: r.category,
|
|
1209
|
+
status: r.status
|
|
1210
|
+
};
|
|
624
1211
|
}
|
|
625
1212
|
if (capability === cap.CAPABILITIES.TRANSCRIBE) {
|
|
626
1213
|
return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
|
|
627
1214
|
}
|
|
628
1215
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
629
|
-
const
|
|
630
|
-
const
|
|
631
|
-
|
|
1216
|
+
const providerAdapter = createConfiguredProviderAdapter(p, { protocol: 'dashscope' });
|
|
1217
|
+
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1218
|
+
capability: 'tts', model: modelId, input: { text: '你好', voice: 'Cherry' }, signal
|
|
1219
|
+
});
|
|
1220
|
+
const value = submitted.kind === 'completed' ? submitted.value : null;
|
|
1221
|
+
const ok = Boolean(value && (value.audioUrl || value.audioB64));
|
|
1222
|
+
return { ok, note: ok ? '合成返回音频' : (submitted.error?.safeMessage || '无音频返回') };
|
|
632
1223
|
}
|
|
633
1224
|
if (capability === cap.CAPABILITIES.IMAGE) {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
1225
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1226
|
+
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1227
|
+
capability: 'image', model: modelId,
|
|
1228
|
+
input: {
|
|
1229
|
+
prompt: providerAdapter.protocol === 'openai-images' ? 'red circle' : '红色圆形',
|
|
1230
|
+
size: providerAdapter.protocol === 'openai-images' ? '256x256' : '512*512',
|
|
1231
|
+
n: 1
|
|
1232
|
+
},
|
|
1233
|
+
signal
|
|
1234
|
+
});
|
|
1235
|
+
if (submitted.kind === 'completed') {
|
|
1236
|
+
const items = Array.isArray(submitted.value?.items) ? submitted.value.items : [];
|
|
1237
|
+
return { ok: items.length > 0, note: items.length ? '生成返回图片' : '无图片返回' };
|
|
637
1238
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1239
|
+
if (submitted.kind !== 'accepted') {
|
|
1240
|
+
return {
|
|
1241
|
+
ok: false,
|
|
1242
|
+
note: submitted.error?.safeMessage || '提交未被确认受理',
|
|
1243
|
+
category: submitted.error?.category,
|
|
1244
|
+
httpStatus: submitted.error?.httpStatus
|
|
1245
|
+
};
|
|
641
1246
|
}
|
|
642
|
-
// 有界轮询到终态(图像通常 5–15s)
|
|
643
1247
|
for (let i = 0; i < 20; i++) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1248
|
+
throwIfAborted(signal);
|
|
1249
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
1250
|
+
const polled = await invokeProviderOperation(providerAdapter, 'poll', {
|
|
1251
|
+
capability: 'image', remoteTaskId: submitted.remoteTaskId, signal
|
|
1252
|
+
});
|
|
1253
|
+
if (polled.kind === 'pending') continue;
|
|
1254
|
+
if (polled.kind === 'succeeded') {
|
|
1255
|
+
return { ok: polled.artifacts.length > 0, note: polled.artifacts.length ? '生成成功' : '无图片返回' };
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
ok: false,
|
|
1259
|
+
note: polled.error?.safeMessage || ('生成终态:' + polled.kind),
|
|
1260
|
+
category: polled.error?.category,
|
|
1261
|
+
httpStatus: polled.error?.httpStatus
|
|
1262
|
+
};
|
|
647
1263
|
}
|
|
648
1264
|
return { ok: false, note: '生成超时(>60s)' };
|
|
649
1265
|
}
|
|
@@ -654,7 +1270,7 @@ async function probeModel(ctx, p, modelId, capability) {
|
|
|
654
1270
|
}
|
|
655
1271
|
|
|
656
1272
|
/** 手动添加模型到池(能力可选,缺省按规则推断) */
|
|
657
|
-
register('providers_add_model', async (
|
|
1273
|
+
register('providers_add_model', async (host, args) => {
|
|
658
1274
|
const id = String(args.id || '');
|
|
659
1275
|
const modelId = String(args.model_id || '').trim();
|
|
660
1276
|
if (!modelId) throw new Error('iris: model_id 不能为空');
|
|
@@ -667,14 +1283,14 @@ register('providers_add_model', async (ctx, args) => {
|
|
|
667
1283
|
});
|
|
668
1284
|
|
|
669
1285
|
/** 从池移除模型 */
|
|
670
|
-
register('providers_remove_model', async (
|
|
1286
|
+
register('providers_remove_model', async (host, args) => {
|
|
671
1287
|
const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
|
|
672
1288
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
673
1289
|
return { ok: true, text: `[iris] 已移除模型 ${args.model_id}` };
|
|
674
1290
|
});
|
|
675
1291
|
|
|
676
1292
|
/** 纠正某模型的能力标签 */
|
|
677
|
-
register('providers_set_model_caps', async (
|
|
1293
|
+
register('providers_set_model_caps', async (host, args) => {
|
|
678
1294
|
const caps = (Array.isArray(args.capabilities) ? args.capabilities : []).filter((c) => cap.isKnownCapability(c));
|
|
679
1295
|
const ok = store.setModelCapabilities(String(args.id || ''), String(args.model_id || ''), caps);
|
|
680
1296
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
@@ -682,7 +1298,7 @@ register('providers_set_model_caps', async (ctx, args) => {
|
|
|
682
1298
|
});
|
|
683
1299
|
|
|
684
1300
|
/** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
|
|
685
|
-
register('providers_test_model', async (
|
|
1301
|
+
register('providers_test_model', async (host, args, { signal }) => {
|
|
686
1302
|
const p = store.providerById(String(args.id || ''));
|
|
687
1303
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
688
1304
|
const modelId = String(args.model_id || '').trim();
|
|
@@ -693,9 +1309,14 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
693
1309
|
}
|
|
694
1310
|
let result;
|
|
695
1311
|
try {
|
|
696
|
-
result = await probeModel(
|
|
1312
|
+
result = await probeModel(host, p, modelId, capability, signal);
|
|
697
1313
|
} catch (err) {
|
|
698
|
-
result = {
|
|
1314
|
+
result = {
|
|
1315
|
+
ok: false,
|
|
1316
|
+
note: String(err && (err.safeMessage || err.message) || err),
|
|
1317
|
+
category: err && err.category,
|
|
1318
|
+
httpStatus: err && (err.httpStatus ?? err.status)
|
|
1319
|
+
};
|
|
699
1320
|
}
|
|
700
1321
|
if (result.skipped) {
|
|
701
1322
|
return { ok: true, skipped: true, passed: false, text: `[iris] ${modelId} · ${capability} 已跳过:${result.note}` };
|
|
@@ -711,7 +1332,7 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
711
1332
|
/* ============ 能力分配动作(阶段 6 条目 4) ============ */
|
|
712
1333
|
|
|
713
1334
|
/** 查询:全部分配 + 每个能力可用的模型池(client 渲染下拉/排序) */
|
|
714
|
-
register('assignments_get', async (
|
|
1335
|
+
register('assignments_get', async (host, args) => {
|
|
715
1336
|
const pool = models.modelPool(store.allProviders());
|
|
716
1337
|
const byCap = {};
|
|
717
1338
|
for (const m of pool) {
|
|
@@ -731,7 +1352,7 @@ register('assignments_get', async (ctx, args) => {
|
|
|
731
1352
|
* model_refs 数组 = Provider+模型复合引用的有序 failover 列表;
|
|
732
1353
|
* model_ids/model_id 保留为旧客户端兼容入口。
|
|
733
1354
|
*/
|
|
734
|
-
register('assignments_set', async (
|
|
1355
|
+
register('assignments_set', async (host, args) => {
|
|
735
1356
|
const capability = String(args.capability || '').trim();
|
|
736
1357
|
if (!capability) throw new Error('iris: capability 不能为空');
|
|
737
1358
|
const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
|
|
@@ -750,45 +1371,19 @@ register('assignments_set', async (ctx, args) => {
|
|
|
750
1371
|
|
|
751
1372
|
/* ============ 文件选择器 L1:会话附件枚举(阶段 10) ============ */
|
|
752
1373
|
|
|
753
|
-
/** 深度遍历会话事件,收集所有 image 附件引用(去重 by attachmentId) */
|
|
754
|
-
function collectImageAttachments(nodes) {
|
|
755
|
-
const out = [];
|
|
756
|
-
const seen = new Set();
|
|
757
|
-
const visited = new Set();
|
|
758
|
-
const stack = Array.isArray(nodes) ? [...nodes] : [nodes];
|
|
759
|
-
let scanned = 0;
|
|
760
|
-
while (stack.length && scanned < 8000) {
|
|
761
|
-
const node = stack.pop();
|
|
762
|
-
scanned++;
|
|
763
|
-
if (!node || typeof node !== 'object' || visited.has(node)) continue;
|
|
764
|
-
visited.add(node);
|
|
765
|
-
const ref = node.attachment && typeof node.attachment === 'object' ? node.attachment
|
|
766
|
-
: (node.attachmentId && node.mediaType ? node : null);
|
|
767
|
-
if (ref && typeof ref.attachmentId === 'string' && typeof ref.mediaType === 'string' && ref.mediaType.startsWith('image/')) {
|
|
768
|
-
if (!seen.has(ref.attachmentId)) {
|
|
769
|
-
seen.add(ref.attachmentId);
|
|
770
|
-
out.push({ attachmentId: ref.attachmentId, mediaType: ref.mediaType, name: ref.name || '', source: 'session' });
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
for (const v of Object.values(node)) if (v && typeof v === 'object') stack.push(v);
|
|
774
|
-
}
|
|
775
|
-
return out;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
1374
|
/**
|
|
779
1375
|
* 枚举可选附件:当前会话的 image 附件(readSession)+ iris 自有产物(全局,兜底)。
|
|
780
1376
|
* 客户端传当前会话 id;读不到会话也不报错,退化为只列 iris 产物。
|
|
781
1377
|
*/
|
|
782
|
-
register('attachments_list', async (
|
|
1378
|
+
register('attachments_list', async (host, args) => {
|
|
783
1379
|
const items = [];
|
|
784
1380
|
const seen = new Set();
|
|
785
1381
|
const sessionId = String(args.session_id || '').trim();
|
|
786
|
-
const
|
|
787
|
-
if (sessionId &&
|
|
1382
|
+
const sessions = hasHostPort(host, 'sessions') ? host.ports.sessions : undefined;
|
|
1383
|
+
if (sessionId && sessions) {
|
|
788
1384
|
try {
|
|
789
|
-
const
|
|
790
|
-
|
|
791
|
-
if (!seen.has(a.attachmentId)) { seen.add(a.attachmentId); items.push(a); }
|
|
1385
|
+
for (const a of await sessions.listImageAttachments(sessionId)) {
|
|
1386
|
+
if (!seen.has(a.attachmentId)) { seen.add(a.attachmentId); items.push({ ...a, source: 'session' }); }
|
|
792
1387
|
}
|
|
793
1388
|
} catch (_) {
|
|
794
1389
|
/* 读不到会话 → 只列 iris 产物 */
|
|
@@ -809,7 +1404,7 @@ register('attachments_list', async (ctx, args) => {
|
|
|
809
1404
|
* 把一个附件(会话 image 或 iris 产物)导出为宿主可读的文件路径,供工具消费。
|
|
810
1405
|
* iris 产物直接返回其 outputs 路径;会话附件读字节存 uploads/(按 attachmentId 哈希缓存,不重复落盘)。
|
|
811
1406
|
*/
|
|
812
|
-
register('attachment_export', async (
|
|
1407
|
+
register('attachment_export', async (host, args, { signal }) => {
|
|
813
1408
|
const id = String(args.attachment_id || '').trim();
|
|
814
1409
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
815
1410
|
// 1) iris 自有产物:文件已在 outputs/
|
|
@@ -826,18 +1421,13 @@ register('attachment_export', async (ctx, args) => {
|
|
|
826
1421
|
const cached = path.join(uploads, 'att-' + id.replace(/[^a-z0-9]/gi, '').slice(-40));
|
|
827
1422
|
if (fs.existsSync(cached)) return { ok: true, path: cached, name: path.basename(cached) };
|
|
828
1423
|
const sessionId = String(args.session_id || '').trim();
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
throw new Error('iris: 该附件不可导出(非 iris 产物,且缺会话/附件服务上下文)');
|
|
833
|
-
}
|
|
1424
|
+
if (!sessionId) throw new Error('iris: 非 Iris 产物必须提供 session_id');
|
|
1425
|
+
const sessions = requireHostPort(host, 'sessions', '导出会话附件');
|
|
1426
|
+
const attachments = requireHostPort(host, 'attachments', '导出会话附件');
|
|
834
1427
|
let ref = null;
|
|
835
|
-
try {
|
|
836
|
-
const events = (await sq.readSession(sessionId)).events;
|
|
837
|
-
for (const a of collectImageAttachments(events)) if (a.attachmentId === id) { ref = a; break; }
|
|
838
|
-
} catch (_) { /* ignore */ }
|
|
1428
|
+
try { ref = await sessions.findImageAttachment(sessionId, id); } catch (_) { /* 统一为未找到 */ }
|
|
839
1429
|
if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
|
|
840
|
-
const stored = await
|
|
1430
|
+
const stored = await attachments.readImage(ref, signal);
|
|
841
1431
|
ensurePrivateDir(uploads);
|
|
842
1432
|
writePrivateFile(cached, Buffer.from(stored.data));
|
|
843
1433
|
return { ok: true, path: cached, name: ref.name || path.basename(cached) };
|
|
@@ -845,45 +1435,25 @@ register('attachment_export', async (ctx, args) => {
|
|
|
845
1435
|
|
|
846
1436
|
/* ============ 音频转写(阶段 7.2) ============ */
|
|
847
1437
|
|
|
848
|
-
register('transcribe', async (
|
|
1438
|
+
register('transcribe', async (host, args, { signal }) => {
|
|
849
1439
|
const audioPath = String(args.audio_path || '').trim();
|
|
850
1440
|
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: audio_path 必须是绝对路径');
|
|
851
1441
|
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在');
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
});
|
|
858
|
-
const remoteTaskId = await tasks.submitGuard(task, async () => {
|
|
859
|
-
const audioUrl = await adapters.uploadTempFile({ key: provider.apiKey, baseUrl: provider.baseUrl, model, filePath: audioPath, signal });
|
|
860
|
-
return adapters.submitTranscription({ key: provider.apiKey, baseUrl: provider.baseUrl, model, audioUrl, signal });
|
|
861
|
-
});
|
|
862
|
-
tasks.update(task.id, { remoteTaskId });
|
|
863
|
-
tasks.watch(tasks.get(task.id), {
|
|
864
|
-
key: () => provider.apiKey,
|
|
865
|
-
poll: async ({ key, remoteTaskId: id }) => {
|
|
866
|
-
const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId: id });
|
|
867
|
-
return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
|
|
868
|
-
},
|
|
869
|
-
intervalMs: 2500,
|
|
870
|
-
onSuccess: async (t, r) => {
|
|
871
|
-
tasks.update(t.id, { transcribeText: (r.urls && r.urls.join('')) || '' });
|
|
872
|
-
return [];
|
|
873
|
-
}
|
|
874
|
-
});
|
|
875
|
-
return { task, provider, model, remoteTaskId };
|
|
876
|
-
});
|
|
1442
|
+
const providers = selectProviders(cap.CAPABILITIES.TRANSCRIBE, '转写', args.model);
|
|
1443
|
+
const routes = providers.map((provider) =>
|
|
1444
|
+
simpleModelRoute(provider, args, 'transcribeModel', 'qwen-audio-3.0-asr-flash-filetrans')
|
|
1445
|
+
);
|
|
1446
|
+
const submitted = await submitTranscriptionV2(routes, audioPath, args, signal);
|
|
877
1447
|
return {
|
|
878
1448
|
ok: true, taskId: submitted.task.id, model: submitted.model, providerId: submitted.provider.id,
|
|
879
1449
|
remoteTaskId: submitted.remoteTaskId,
|
|
880
|
-
text:
|
|
1450
|
+
text: '[iris] 音频转写任务已提交(' + submitted.model + ',task: ' + submitted.task.id + ')'
|
|
881
1451
|
};
|
|
882
1452
|
});
|
|
883
1453
|
|
|
884
1454
|
/* ============ 视频抽帧(阶段 7.1) ============ */
|
|
885
1455
|
|
|
886
|
-
register('video_frames', async (
|
|
1456
|
+
register('video_frames', async (host, args, { signal }) => {
|
|
887
1457
|
const p = String(args.video_path || '').trim();
|
|
888
1458
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
889
1459
|
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
@@ -908,7 +1478,7 @@ register('video_frames', async (ctx, args, { signal }) => {
|
|
|
908
1478
|
|
|
909
1479
|
/* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
|
|
910
1480
|
|
|
911
|
-
register('media_summarize', async (
|
|
1481
|
+
register('media_summarize', async (host, args, { signal }) => {
|
|
912
1482
|
const p = String(args.video_path || '').trim();
|
|
913
1483
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
914
1484
|
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
@@ -919,7 +1489,7 @@ register('media_summarize', async (ctx, args, { signal }) => {
|
|
|
919
1489
|
format: 'jpeg', quality: 70, signal
|
|
920
1490
|
});
|
|
921
1491
|
const sheet = await buildContactSheet({ frames, cols: 3 });
|
|
922
|
-
const backends =
|
|
1492
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
923
1493
|
const { answer, via, model } = await summarizeMedia({
|
|
924
1494
|
backends, question: args.question,
|
|
925
1495
|
transcript: args.transcribe_text, // GUI 同步版:不自动转写,可粘贴已有转写文本
|