@mokuyoaxis/dsh-iris 0.1.3 → 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 +24 -2
- package/README.en.md +29 -7
- package/README.md +29 -7
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/ARTIFACT_LIBRARY.md +24 -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 +2 -2
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +6 -2
- 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/releases/0.1.4.md +59 -0
- package/lib/actions.js +357 -221
- package/lib/adapters.js +7 -7
- package/lib/api.js +30 -11
- package/lib/artifacts.js +239 -0
- package/lib/bundled-skills.js +5 -5
- package/lib/client.js +370 -55
- package/lib/config.js +192 -9
- package/lib/doctor.js +175 -4
- 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 +97 -113
- 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-health.js +59 -0
- package/lib/render.js +43 -12
- package/lib/tasks.js +29 -5
- package/lib/vision.js +23 -49
- package/package.json +14 -8
- package/user_guide.md +42 -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/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,17 +16,20 @@ 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';
|
|
29
30
|
import { ProviderContractError, submitWithAcceptanceBoundary } from './provider-contract.js';
|
|
31
|
+
import { invokeProviderOperation, providerPollTaskView } from './provider-adapter.js';
|
|
32
|
+
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
30
33
|
import { deriveUserState } from './task-semantics.js';
|
|
31
34
|
|
|
32
35
|
const MEDIA_TYPES = {
|
|
@@ -34,14 +37,30 @@ const MEDIA_TYPES = {
|
|
|
34
37
|
'.webp': 'image/webp', '.gif': 'image/gif'
|
|
35
38
|
};
|
|
36
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
|
+
|
|
37
56
|
/* ---------------- 视觉效果自述(辅助 iris_draw_image) ---------------- */
|
|
38
|
-
async function describeFirstImage(
|
|
57
|
+
async function describeFirstImage(host, ref, absPath, originalPrompt) {
|
|
39
58
|
try {
|
|
40
59
|
const mt = MEDIA_TYPES[path.extname(absPath).toLowerCase()] || 'image/png';
|
|
41
60
|
const buf = fs.readFileSync(absPath);
|
|
42
61
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
43
|
-
const backends =
|
|
44
|
-
const { answer } = await
|
|
62
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
63
|
+
const { answer } = await askWithHealth(backends, {
|
|
45
64
|
question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」`,
|
|
46
65
|
imageDataUrl: dataUrl,
|
|
47
66
|
signal: undefined
|
|
@@ -86,28 +105,10 @@ function imageRoute(provider, args) {
|
|
|
86
105
|
const model = args.model
|
|
87
106
|
? (parsed?.modelId || String(args.model))
|
|
88
107
|
: (provider.imageModel || (protocol === 'openai-images' ? 'gpt-image-1' : 'wan2.2-t2i-flash'));
|
|
89
|
-
return {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const status = Number(error && error.status) || undefined;
|
|
94
|
-
// 408/409/425/499 可能来自中间层或冲突恢复,不能证明请求未被受理。
|
|
95
|
-
const explicitRejection = Boolean(status && status >= 400 && status < 500 && ![408, 409, 425, 499].includes(status));
|
|
96
|
-
let category = 'provider';
|
|
97
|
-
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) category = 'aborted';
|
|
98
|
-
else if (error && (error.category === 'network' || error instanceof TypeError)) category = 'network';
|
|
99
|
-
else if (status === 401 || status === 403) category = 'authentication';
|
|
100
|
-
else if (status === 429 && error && error.category === 'quota') category = 'quota';
|
|
101
|
-
else if (status === 429) category = 'rate_limit';
|
|
102
|
-
else if (status && status >= 400 && status < 500) category = 'invalid_request';
|
|
103
|
-
else if (!status) category = 'protocol';
|
|
104
|
-
return new ProviderContractError(String((error && error.message) || error || '提交失败'), {
|
|
105
|
-
stage: 'submit',
|
|
106
|
-
category,
|
|
107
|
-
acceptance: explicitRejection ? 'not_accepted' : 'unknown',
|
|
108
|
-
retryable: status === 429 || Boolean(status && status >= 500) || category === 'network',
|
|
109
|
-
...(status ? { httpStatus: status } : {})
|
|
110
|
-
});
|
|
108
|
+
return {
|
|
109
|
+
provider, protocol, model, modelRef: models.modelRef(provider.id, model),
|
|
110
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
111
|
+
};
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
function taskActionError(taskId, message, details) {
|
|
@@ -117,14 +118,82 @@ function taskActionError(taskId, message, details) {
|
|
|
117
118
|
return error;
|
|
118
119
|
}
|
|
119
120
|
|
|
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) {
|
|
121
188
|
const names = [];
|
|
122
189
|
try {
|
|
123
190
|
tasks.beginDelivery(taskId);
|
|
124
191
|
if (value && value.kind === 'urls') {
|
|
125
192
|
for (const url of value.items || []) {
|
|
126
193
|
const name = taskId + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
127
|
-
await
|
|
194
|
+
await invokeProviderOperation(providerAdapter, 'download', {
|
|
195
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name), signal
|
|
196
|
+
});
|
|
128
197
|
names.push(name);
|
|
129
198
|
}
|
|
130
199
|
} else if (value && value.kind === 'openai') {
|
|
@@ -132,7 +201,9 @@ async function deliverCompletedImage(taskId, value, signal) {
|
|
|
132
201
|
const name = taskId + '-' + names.length + '.png';
|
|
133
202
|
const target = path.join(outputDir(), name);
|
|
134
203
|
if (item && item.b64) writePrivateFile(target, Buffer.from(item.b64, 'base64'));
|
|
135
|
-
else if (item && item.url) await
|
|
204
|
+
else if (item && item.url) await invokeProviderOperation(providerAdapter, 'download', {
|
|
205
|
+
artifact: { kind: 'remote-url', url: item.url }, targetPath: target, signal
|
|
206
|
+
});
|
|
136
207
|
else throw new Error('同步图片结果缺少 b64 或 URL');
|
|
137
208
|
names.push(name);
|
|
138
209
|
}
|
|
@@ -176,29 +247,13 @@ async function submitImageV2(routes, prompt, args, signal) {
|
|
|
176
247
|
stage: 'submit', category: 'aborted', acceptance: 'unknown'
|
|
177
248
|
});
|
|
178
249
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
}
|
|
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
|
+
}));
|
|
202
257
|
}
|
|
203
258
|
}));
|
|
204
259
|
|
|
@@ -231,7 +286,7 @@ async function submitImageV2(routes, prompt, args, signal) {
|
|
|
231
286
|
|
|
232
287
|
const route = submitted.candidate.route;
|
|
233
288
|
if (submitted.result.kind === 'completed') {
|
|
234
|
-
const delivered = await deliverCompletedImage(task.id, submitted.result.value, signal);
|
|
289
|
+
const delivered = await deliverCompletedImage(task.id, submitted.result.value, signal, route.adapter);
|
|
235
290
|
return { task: delivered, provider: route.provider, model: route.model, remoteTaskId: null };
|
|
236
291
|
}
|
|
237
292
|
if (submitted.result.kind !== 'accepted') {
|
|
@@ -244,14 +299,21 @@ async function submitImageV2(routes, prompt, args, signal) {
|
|
|
244
299
|
throw taskActionError(task.id, '远端已受理,但本地盯守状态落盘失败;禁止自动重提', { message: String(error.message || error) });
|
|
245
300
|
}
|
|
246
301
|
tasks.watch(tasks.get(task.id), {
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
),
|
|
249
309
|
intervalMs: 2500,
|
|
250
310
|
onSuccess: async (record, result) => {
|
|
251
311
|
const names = [];
|
|
252
312
|
for (const url of result.urls || []) {
|
|
253
313
|
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'png');
|
|
254
|
-
await
|
|
314
|
+
await invokeProviderOperation(route.adapter, 'download', {
|
|
315
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
316
|
+
});
|
|
255
317
|
names.push(name);
|
|
256
318
|
}
|
|
257
319
|
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
@@ -304,7 +366,10 @@ function videoRoute(provider, args, frame) {
|
|
|
304
366
|
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider.videoModel || 'wan2.2-t2v-flash');
|
|
305
367
|
const isS2V = /s2v/i.test(model);
|
|
306
368
|
const mode = isS2V ? 's2v' : (frame ? 'i2v' : 't2v');
|
|
307
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
provider, model, modelRef: models.modelRef(provider.id, model), isS2V, mode, protocol: 'dashscope',
|
|
371
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol: 'dashscope' })
|
|
372
|
+
};
|
|
308
373
|
}
|
|
309
374
|
|
|
310
375
|
function beforeGenerationError(error, stage) {
|
|
@@ -368,15 +433,10 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
368
433
|
}
|
|
369
434
|
}
|
|
370
435
|
tasks.markAttemptStage(task.id, attemptId, 'submit');
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
});
|
|
376
|
-
return { kind: 'accepted', remoteTaskId };
|
|
377
|
-
} catch (error) {
|
|
378
|
-
throw imageSubmissionError(error);
|
|
379
|
-
}
|
|
436
|
+
return observeProviderCapability(route, cap.CAPABILITIES.VIDEO, () =>
|
|
437
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
438
|
+
capability: 'video', model: route.model, input: submitArgs, signal
|
|
439
|
+
}));
|
|
380
440
|
}
|
|
381
441
|
}));
|
|
382
442
|
|
|
@@ -414,14 +474,21 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
414
474
|
const route = submitted.candidate.route;
|
|
415
475
|
tasks.activateWatch(task.id);
|
|
416
476
|
tasks.watch(tasks.get(task.id), {
|
|
417
|
-
|
|
418
|
-
|
|
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
|
+
),
|
|
419
484
|
intervalMs: 6000,
|
|
420
485
|
onSuccess: async (record, result) => {
|
|
421
486
|
const names = [];
|
|
422
487
|
for (const url of result.urls || []) {
|
|
423
488
|
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, 'mp4');
|
|
424
|
-
await
|
|
489
|
+
await invokeProviderOperation(route.adapter, 'download', {
|
|
490
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
491
|
+
});
|
|
425
492
|
names.push(name);
|
|
426
493
|
}
|
|
427
494
|
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
@@ -434,7 +501,10 @@ async function submitVideoV2(routes, frame, prompt, args, signal) {
|
|
|
434
501
|
function simpleModelRoute(provider, args, field, fallback, protocol = 'dashscope') {
|
|
435
502
|
const parsed = models.parseModelRef(String(args.model || ''));
|
|
436
503
|
const model = args.model ? (parsed?.modelId || String(args.model)) : (provider[field] || fallback);
|
|
437
|
-
return {
|
|
504
|
+
return {
|
|
505
|
+
provider, model, modelRef: models.modelRef(provider.id, model), protocol,
|
|
506
|
+
adapter: createConfiguredProviderAdapter(provider, { protocol })
|
|
507
|
+
};
|
|
438
508
|
}
|
|
439
509
|
|
|
440
510
|
async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
@@ -458,15 +528,10 @@ async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
|
458
528
|
throw beforeGenerationError(error, 'upload');
|
|
459
529
|
}
|
|
460
530
|
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
});
|
|
466
|
-
return { kind: 'accepted', remoteTaskId };
|
|
467
|
-
} catch (error) {
|
|
468
|
-
throw imageSubmissionError(error);
|
|
469
|
-
}
|
|
531
|
+
return observeProviderCapability(route, cap.CAPABILITIES.TRANSCRIBE, () =>
|
|
532
|
+
invokeProviderOperation(route.adapter, 'submit', {
|
|
533
|
+
capability: 'transcribe', model: route.model, input: { audioUrl }, signal
|
|
534
|
+
}));
|
|
470
535
|
}
|
|
471
536
|
}));
|
|
472
537
|
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'transcribe' }, {
|
|
@@ -491,11 +556,13 @@ async function submitTranscriptionV2(routes, audioPath, args, signal) {
|
|
|
491
556
|
const route = submitted.candidate.route;
|
|
492
557
|
tasks.activateWatch(task.id);
|
|
493
558
|
tasks.watch(tasks.get(task.id), {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
),
|
|
499
566
|
intervalMs: 2500,
|
|
500
567
|
allowEmptyResult: true,
|
|
501
568
|
onSuccess: async (record, result) => {
|
|
@@ -517,15 +584,10 @@ async function submitTtsV2(routes, text, args, signal) {
|
|
|
517
584
|
route,
|
|
518
585
|
submit: async (_input, context) => {
|
|
519
586
|
tasks.markAttemptStage(task.id, context.attempt.id, 'submit');
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
});
|
|
525
|
-
return { kind: 'completed', value: audio };
|
|
526
|
-
} catch (error) {
|
|
527
|
-
throw imageSubmissionError(error);
|
|
528
|
-
}
|
|
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
|
+
}));
|
|
529
591
|
}
|
|
530
592
|
}));
|
|
531
593
|
const submitted = await submitWithAcceptanceBoundary(candidates, { capability: 'tts' }, {
|
|
@@ -556,7 +618,9 @@ async function submitTtsV2(routes, text, args, signal) {
|
|
|
556
618
|
tasks.beginDelivery(task.id);
|
|
557
619
|
const name = task.id + '.' + (audio.audioUrl ? adapters.extFromUrl(audio.audioUrl, 'wav') : 'wav');
|
|
558
620
|
const target = path.join(outputDir(), name);
|
|
559
|
-
if (audio.audioUrl) await
|
|
621
|
+
if (audio.audioUrl) await invokeProviderOperation(route.adapter, 'download', {
|
|
622
|
+
artifact: { kind: 'remote-url', url: audio.audioUrl }, targetPath: target, signal
|
|
623
|
+
});
|
|
560
624
|
else if (audio.audioB64) writePrivateFile(target, Buffer.from(audio.audioB64, 'base64'));
|
|
561
625
|
else throw new Error('语音合成成功响应缺少音频数据');
|
|
562
626
|
names.push(name);
|
|
@@ -572,13 +636,33 @@ async function submitTtsV2(routes, text, args, signal) {
|
|
|
572
636
|
/** 重新观察/交付与启动恢复共享的远端查询和本地落盘依赖。 */
|
|
573
637
|
export function taskPollDeps(provider, task) {
|
|
574
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
|
+
};
|
|
575
657
|
if (task.cap === 'transcribe') {
|
|
576
658
|
return {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
659
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
660
|
+
healthRoute, healthCapability,
|
|
661
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
662
|
+
capability: 'transcribe', remoteTaskId, signal
|
|
663
|
+
}),
|
|
664
|
+
{ recordSuccess: false }
|
|
665
|
+
),
|
|
582
666
|
intervalMs: 2500,
|
|
583
667
|
allowEmptyResult: true,
|
|
584
668
|
onSuccess: async (record, result) => {
|
|
@@ -587,17 +671,23 @@ export function taskPollDeps(provider, task) {
|
|
|
587
671
|
}
|
|
588
672
|
};
|
|
589
673
|
}
|
|
590
|
-
if (task.cap !== 'image' && task.cap !== 'video') return null;
|
|
591
674
|
return {
|
|
592
|
-
|
|
593
|
-
|
|
675
|
+
poll: ({ remoteTaskId, signal }) => observeProviderCapability(
|
|
676
|
+
healthRoute, healthCapability,
|
|
677
|
+
() => invokeProviderOperation(providerAdapter, 'poll', {
|
|
678
|
+
capability: task.cap, remoteTaskId, signal
|
|
679
|
+
}),
|
|
680
|
+
{ recordSuccess: false }
|
|
681
|
+
),
|
|
594
682
|
intervalMs: task.cap === 'video' ? 6000 : 2500,
|
|
595
683
|
onSuccess: async (record, result) => {
|
|
596
684
|
const names = [];
|
|
597
685
|
for (const url of result.urls || []) {
|
|
598
686
|
const fallback = task.cap === 'video' ? 'mp4' : 'png';
|
|
599
687
|
const name = record.id + '-' + names.length + '.' + adapters.extFromUrl(url, fallback);
|
|
600
|
-
await
|
|
688
|
+
await invokeProviderOperation(providerAdapter, 'download', {
|
|
689
|
+
artifact: { kind: 'remote-url', url }, targetPath: path.join(outputDir(), name)
|
|
690
|
+
});
|
|
601
691
|
names.push(name);
|
|
602
692
|
}
|
|
603
693
|
for (const name of names) registerMedia(record.id, path.join(outputDir(), name));
|
|
@@ -613,7 +703,7 @@ const actions = {};
|
|
|
613
703
|
/**
|
|
614
704
|
* 注册一个 action。
|
|
615
705
|
* @param {string} name 动作名(用于路由)
|
|
616
|
-
* @param {Function} fn (
|
|
706
|
+
* @param {Function} fn (host, args, { signal }) => { ok, text?, imageDataUrl?, taskId?, blocks? }
|
|
617
707
|
*/
|
|
618
708
|
function register(name, fn) {
|
|
619
709
|
actions[name] = fn;
|
|
@@ -625,16 +715,16 @@ export function listActions() {
|
|
|
625
715
|
}
|
|
626
716
|
|
|
627
717
|
/** 执行一个动作 */
|
|
628
|
-
export async function runAction(
|
|
718
|
+
export async function runAction(host, name, args, { signal } = {}) {
|
|
629
719
|
const fn = actions[name];
|
|
630
720
|
if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
|
|
631
|
-
return fn(
|
|
721
|
+
return fn(actionHost(host), args || {}, { signal });
|
|
632
722
|
}
|
|
633
723
|
|
|
634
724
|
/* ============ 动作实现 ============ */
|
|
635
725
|
|
|
636
726
|
/* ---------- 🖼️ 画图 ---------- */
|
|
637
|
-
register('image', async (
|
|
727
|
+
register('image', async (host, args, { signal }) => {
|
|
638
728
|
const prompt = String(args.prompt || '').trim();
|
|
639
729
|
if (!prompt) throw new Error('iris: prompt 不能为空');
|
|
640
730
|
|
|
@@ -651,7 +741,7 @@ register('image', async (ctx, args, { signal }) => {
|
|
|
651
741
|
});
|
|
652
742
|
|
|
653
743
|
/* ---------- 🎬 视频生成 ---------- */
|
|
654
|
-
register('video', async (
|
|
744
|
+
register('video', async (host, args, { signal }) => {
|
|
655
745
|
const prompt = String(args.prompt || '').trim();
|
|
656
746
|
if (!prompt && !args.audio_path) throw new Error('iris: prompt 不能为空');
|
|
657
747
|
const frame = resolveFirstFrameFile(args);
|
|
@@ -666,7 +756,7 @@ register('video', async (ctx, args, { signal }) => {
|
|
|
666
756
|
});
|
|
667
757
|
|
|
668
758
|
/* ---------- 🔊 语音合成 ---------- */
|
|
669
|
-
register('tts', async (
|
|
759
|
+
register('tts', async (host, args, { signal }) => {
|
|
670
760
|
const text = String(args.text || '').trim();
|
|
671
761
|
if (!text) throw new Error('iris: text 不能为空');
|
|
672
762
|
const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
|
|
@@ -682,21 +772,21 @@ register('tts', async (ctx, args, { signal }) => {
|
|
|
682
772
|
});
|
|
683
773
|
|
|
684
774
|
/* ---------- 👁 看图 ---------- */
|
|
685
|
-
register('look', async (
|
|
775
|
+
register('look', async (host, args, { signal }) => {
|
|
686
776
|
const p = String(args.image_path || '').trim();
|
|
687
777
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
688
778
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
|
|
689
779
|
if (!mt || mt === 'image/gif') throw new Error('iris: 不支持的图片格式');
|
|
690
780
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
691
781
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(p).toString('base64')}`;
|
|
692
|
-
const backends =
|
|
693
|
-
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 });
|
|
694
784
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
695
785
|
return { ok: true, text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
|
|
696
786
|
});
|
|
697
787
|
|
|
698
788
|
/* ---------- ✂️ 裁剪 ---------- */
|
|
699
|
-
register('crop', async (
|
|
789
|
+
register('crop', async (host, args, { signal }) => {
|
|
700
790
|
const p = String(args.image_path || '').trim();
|
|
701
791
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
702
792
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
@@ -706,7 +796,7 @@ register('crop', async (ctx, args, { signal }) => {
|
|
|
706
796
|
});
|
|
707
797
|
|
|
708
798
|
/* ---------- 📷 像素 diff ---------- */
|
|
709
|
-
register('diff', async (
|
|
799
|
+
register('diff', async (host, args, { signal }) => {
|
|
710
800
|
if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
|
|
711
801
|
const { ratio, diffPixels, totalPixels, width, height, worstRegions, heatmap, mime } = await pixelDiff({
|
|
712
802
|
inputA: args.image_a_path, inputB: args.image_b_path, grid: args.grid || 8, topRegions: args.top_regions || 3
|
|
@@ -721,7 +811,7 @@ register('diff', async (ctx, args, { signal }) => {
|
|
|
721
811
|
});
|
|
722
812
|
|
|
723
813
|
/* ---------- 📍 定位 ---------- */
|
|
724
|
-
register('locate', async (
|
|
814
|
+
register('locate', async (host, args, { signal }) => {
|
|
725
815
|
const p = String(args.image_path || '').trim();
|
|
726
816
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
727
817
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
@@ -729,28 +819,28 @@ register('locate', async (ctx, args, { signal }) => {
|
|
|
729
819
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
|
|
730
820
|
const dataUrl = `data:${mt};base64,${buf.toString('base64')}`;
|
|
731
821
|
const { width, height } = await imageDimensions(buf);
|
|
732
|
-
const backends =
|
|
822
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
733
823
|
const r = await locateObject(backends, { target: args.target, imageDataUrl: dataUrl, width, height, signal });
|
|
734
824
|
if (!r.found) return { ok: true, text: `[iris] 在图片中未找到「${args.target}」` };
|
|
735
825
|
return { ok: true, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
|
|
736
826
|
});
|
|
737
827
|
/* ---------- 🖼️ HTML 截图 ---------- */
|
|
738
|
-
register('html', async (
|
|
828
|
+
register('html', async (host, args, { signal }) => {
|
|
739
829
|
const html = String(args.html || '').trim();
|
|
740
830
|
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
|
|
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')}` };
|
|
746
836
|
});
|
|
747
837
|
|
|
748
838
|
/* ---------- 📄 长截图 OCR ---------- */
|
|
749
|
-
register('ocr', async (
|
|
839
|
+
register('ocr', async (host, args, { signal }) => {
|
|
750
840
|
const p = String(args.image_path || '').trim();
|
|
751
841
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
|
|
752
842
|
if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
|
|
753
|
-
const backends =
|
|
843
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
|
|
754
844
|
const result = await longOcr({ input: p, backends, chunkHeight: args.chunk_height || 1200, overlap: args.overlap || 120, signal });
|
|
755
845
|
const errCount = result.chunks.filter((c) => c.error).length;
|
|
756
846
|
const head = `[iris] 长截图 OCR 完成:${result.width}x${result.height}px,${result.totalChunks} 块${errCount ? '(' + errCount + ' 块失败)' : ''}\n`;
|
|
@@ -758,7 +848,7 @@ register('ocr', async (ctx, args, { signal }) => {
|
|
|
758
848
|
});
|
|
759
849
|
|
|
760
850
|
/* ---------- 📋 任务查询 ---------- */
|
|
761
|
-
register('status', async (
|
|
851
|
+
register('status', async (host, args, { signal }) => {
|
|
762
852
|
if (args.task_id) {
|
|
763
853
|
const t = tasks.get(String(args.task_id).trim());
|
|
764
854
|
if (!t) throw new Error('iris: 没有这个任务:' + args.task_id);
|
|
@@ -776,7 +866,7 @@ register('status', async (ctx, args, { signal }) => {
|
|
|
776
866
|
});
|
|
777
867
|
|
|
778
868
|
/* ---------- Task v2 人工接管 ---------- */
|
|
779
|
-
register('task_reobserve', async (
|
|
869
|
+
register('task_reobserve', async (host, args) => {
|
|
780
870
|
const id = String(args.task_id || '').trim();
|
|
781
871
|
const task = tasks.get(id);
|
|
782
872
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可接管的 Task v2');
|
|
@@ -790,7 +880,7 @@ register('task_reobserve', async (ctx, args) => {
|
|
|
790
880
|
return { ok: true, taskId: id, text: '[iris] 已重新观察远端任务;不会重新提交生成请求。' };
|
|
791
881
|
});
|
|
792
882
|
|
|
793
|
-
register('task_redeliver', async (
|
|
883
|
+
register('task_redeliver', async (host, args, { signal }) => {
|
|
794
884
|
const id = String(args.task_id || '').trim();
|
|
795
885
|
const task = tasks.get(id);
|
|
796
886
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重新交付的 Task v2');
|
|
@@ -802,7 +892,7 @@ register('task_redeliver', async (ctx, args, { signal }) => {
|
|
|
802
892
|
tasks.prepareRedelivery(id);
|
|
803
893
|
try {
|
|
804
894
|
tasks.beginDelivery(id);
|
|
805
|
-
const result = await deps.poll({
|
|
895
|
+
const result = providerPollTaskView(await deps.poll({ remoteTaskId: task.remoteTaskId, signal }));
|
|
806
896
|
if (!result.done) throw new Error('远端结果暂未就绪,请先重新观察');
|
|
807
897
|
if (!result.ok) throw new Error(result.message || '供应商当前无法返回成功产物');
|
|
808
898
|
if (!(result.urls || []).length) throw new Error('供应商未返回可交付的结果 URL');
|
|
@@ -816,7 +906,7 @@ register('task_redeliver', async (ctx, args, { signal }) => {
|
|
|
816
906
|
}
|
|
817
907
|
});
|
|
818
908
|
|
|
819
|
-
register('task_ack_attention', async (
|
|
909
|
+
register('task_ack_attention', async (_host, args) => {
|
|
820
910
|
const id = String(args.task_id || '').trim();
|
|
821
911
|
const task = tasks.get(id);
|
|
822
912
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可标为已读的 Task v2');
|
|
@@ -824,7 +914,7 @@ register('task_ack_attention', async (_ctx, args) => {
|
|
|
824
914
|
return { ok: true, taskId: id, text: '[iris] 提醒已标为已读;任务事实未改变,可在历史详情中恢复提醒。' };
|
|
825
915
|
});
|
|
826
916
|
|
|
827
|
-
register('task_restore_attention', async (
|
|
917
|
+
register('task_restore_attention', async (_host, args) => {
|
|
828
918
|
const id = String(args.task_id || '').trim();
|
|
829
919
|
const task = tasks.get(id);
|
|
830
920
|
if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可恢复提醒的 Task v2');
|
|
@@ -832,7 +922,7 @@ register('task_restore_attention', async (_ctx, args) => {
|
|
|
832
922
|
return { ok: true, taskId: id, text: '[iris] 已恢复任务提醒。' };
|
|
833
923
|
});
|
|
834
924
|
|
|
835
|
-
register('task_manual_retry', async (
|
|
925
|
+
register('task_manual_retry', async (host, args, { signal }) => {
|
|
836
926
|
const id = String(args.task_id || '').trim();
|
|
837
927
|
if (args.confirm_duplicate_charge !== true) throw new Error('iris: 必须明确确认本次操作可能产生重复任务或重复费用');
|
|
838
928
|
const task = tasks.get(id);
|
|
@@ -856,7 +946,7 @@ register('task_manual_retry', async (ctx, args, { signal }) => {
|
|
|
856
946
|
if (task.modelRef) retryArgs.model = task.modelRef;
|
|
857
947
|
retryArgs._retryOf = id;
|
|
858
948
|
try {
|
|
859
|
-
const result = await runAction(
|
|
949
|
+
const result = await runAction(host, action, retryArgs, { signal });
|
|
860
950
|
if (result.taskId) tasks.linkManualRetry(id, result.taskId);
|
|
861
951
|
return { ...result, retryOf: id, text: (result.text || '[iris] 已创建新任务')
|
|
862
952
|
+ '\n[iris] 原任务提醒已归档,并保留新旧任务关联;本次新任务可能与远端未知任务重复。' };
|
|
@@ -880,6 +970,7 @@ function scanOrphanArtifacts() {
|
|
|
880
970
|
for (const m of t.media || []) referenced.add(path.basename(m.file));
|
|
881
971
|
for (const a of t.attachments || []) if (a.file) referenced.add(path.basename(a.file));
|
|
882
972
|
}
|
|
973
|
+
for (const item of artifacts.all()) referenced.add(path.basename(item.file));
|
|
883
974
|
const files = [];
|
|
884
975
|
let bytes = 0;
|
|
885
976
|
for (const name of fs.readdirSync(dir)) {
|
|
@@ -895,7 +986,7 @@ function scanOrphanArtifacts() {
|
|
|
895
986
|
}
|
|
896
987
|
|
|
897
988
|
/** 删除单条任务记录(仅终态;运行中须先取消)。只删元数据,产物文件保留。 */
|
|
898
|
-
register('tasks_delete', async (
|
|
989
|
+
register('tasks_delete', async (host, args) => {
|
|
899
990
|
const id = String(args.task_id || '').trim();
|
|
900
991
|
if (!id) throw new Error('iris: task_id 不能为空');
|
|
901
992
|
const r = tasks.remove(id);
|
|
@@ -907,7 +998,7 @@ register('tasks_delete', async (ctx, args) => {
|
|
|
907
998
|
* 批量清理任务记录:scope = completed(所有终态)| older_than(早于 days 天)| all(等同 completed,运行中受保护)。
|
|
908
999
|
* 只删元数据,绝不动运行中任务,产物文件默认保留。
|
|
909
1000
|
*/
|
|
910
|
-
register('tasks_clear', async (
|
|
1001
|
+
register('tasks_clear', async (host, args) => {
|
|
911
1002
|
const scope = String(args.scope || 'completed').trim();
|
|
912
1003
|
if (!['completed', 'older_than', 'all'].includes(scope)) throw new Error('iris: scope 需为 completed/older_than/all');
|
|
913
1004
|
let removed;
|
|
@@ -920,7 +1011,28 @@ register('tasks_clear', async (ctx, args) => {
|
|
|
920
1011
|
// completed / all:删除全部终态记录(运行中永不被 prune 命中)
|
|
921
1012
|
removed = tasks.prune(() => true);
|
|
922
1013
|
}
|
|
923
|
-
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} 个删除失败并保留索引` : ''}` };
|
|
924
1036
|
});
|
|
925
1037
|
|
|
926
1038
|
/** 孤儿产物报告:outputs/ 里无任务引用的文件 + 总占用(只读,不删) */
|
|
@@ -947,7 +1059,7 @@ register('tasks_purge_orphans', async () => {
|
|
|
947
1059
|
});
|
|
948
1060
|
|
|
949
1061
|
/* ---------- 👁 重看(GUI:只查 iris 自己的任务产物,无会话上下文) ---------- */
|
|
950
|
-
register('relook', async (
|
|
1062
|
+
register('relook', async (host, args, { signal }) => {
|
|
951
1063
|
const id = String(args.attachment_id || '').trim();
|
|
952
1064
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
953
1065
|
const question = String(args.question || '').trim();
|
|
@@ -966,8 +1078,8 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
966
1078
|
if (!hit) throw new Error('iris: 该 attachment 不在 iris 生成的任务产物中(GUI 重看仅支持 iris 画图产物)');
|
|
967
1079
|
const mt = hit.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png';
|
|
968
1080
|
const dataUrl = `data:${mt};base64,${fs.readFileSync(hit.absPath).toString('base64')}`;
|
|
969
|
-
const backends =
|
|
970
|
-
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 });
|
|
971
1083
|
const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
|
|
972
1084
|
return { ok: true, text: `[iris] 重看回答(${model} · ${viaLabel}):\n${answer}` };
|
|
973
1085
|
});
|
|
@@ -975,7 +1087,7 @@ register('relook', async (ctx, args, { signal }) => {
|
|
|
975
1087
|
/* ============ 供应商管理动作(阶段 6 条目 3-5) ============ */
|
|
976
1088
|
|
|
977
1089
|
/** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
|
|
978
|
-
register('providers_list', async (
|
|
1090
|
+
register('providers_list', async (host, args) => {
|
|
979
1091
|
const list = store.allProviders();
|
|
980
1092
|
return {
|
|
981
1093
|
ok: true,
|
|
@@ -987,14 +1099,22 @@ register('providers_list', async (ctx, args) => {
|
|
|
987
1099
|
enabled: p.enabled !== false,
|
|
988
1100
|
mediaProtocol: p.mediaProtocol || inferMediaProtocol(p.baseUrl),
|
|
989
1101
|
apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
990
|
-
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
|
+
})),
|
|
991
1111
|
capabilities: store.capabilitiesOf(p)
|
|
992
1112
|
}))
|
|
993
1113
|
};
|
|
994
1114
|
});
|
|
995
1115
|
|
|
996
1116
|
/** 新增/更新供应商 */
|
|
997
|
-
register('providers_upsert', async (
|
|
1117
|
+
register('providers_upsert', async (host, args) => {
|
|
998
1118
|
const id = args.id || undefined;
|
|
999
1119
|
const patch = { id };
|
|
1000
1120
|
const allowed = ['name', 'type', 'baseUrl', 'apiKey', 'enabled', 'mediaProtocol',
|
|
@@ -1008,13 +1128,13 @@ register('providers_upsert', async (ctx, args) => {
|
|
|
1008
1128
|
});
|
|
1009
1129
|
|
|
1010
1130
|
/** 删除供应商 */
|
|
1011
|
-
register('providers_remove', async (
|
|
1131
|
+
register('providers_remove', async (host, args) => {
|
|
1012
1132
|
const ok = store.removeProvider(String(args.id || ''));
|
|
1013
1133
|
return { ok, removed: ok };
|
|
1014
1134
|
});
|
|
1015
1135
|
|
|
1016
1136
|
/** 设置某供应商的模型池(覆盖显式 models) */
|
|
1017
|
-
register('providers_set_models', async (
|
|
1137
|
+
register('providers_set_models', async (host, args) => {
|
|
1018
1138
|
const p = store.setProviderModels(String(args.id || ''), Array.isArray(args.models) ? args.models : []);
|
|
1019
1139
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
1020
1140
|
return { ok: true };
|
|
@@ -1024,12 +1144,14 @@ register('providers_set_models', async (ctx, args) => {
|
|
|
1024
1144
|
* 模型发现(P2):GET /models 拉全量 → 按规则过滤出媒体能力模型 → 写入显式池。
|
|
1025
1145
|
* 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
|
|
1026
1146
|
*/
|
|
1027
|
-
register('providers_discover', async (
|
|
1147
|
+
register('providers_discover', async (host, args, { signal }) => {
|
|
1028
1148
|
const p = store.providerById(String(args.id || ''));
|
|
1029
1149
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1030
1150
|
let ids;
|
|
1031
1151
|
try {
|
|
1032
|
-
|
|
1152
|
+
const providerAdapter = createConfiguredProviderAdapter(p);
|
|
1153
|
+
const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
|
|
1154
|
+
ids = discovered.models.map((model) => model.id);
|
|
1033
1155
|
} catch (err) {
|
|
1034
1156
|
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
1035
1157
|
}
|
|
@@ -1046,16 +1168,22 @@ register('providers_discover', async (ctx, args) => {
|
|
|
1046
1168
|
});
|
|
1047
1169
|
|
|
1048
1170
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
1049
|
-
register('providers_test_vision', async (
|
|
1171
|
+
register('providers_test_vision', async (host, args, { signal }) => {
|
|
1050
1172
|
const p = store.providerById(String(args.id || ''));
|
|
1051
1173
|
if (!p) throw new Error('iris: 供应商不存在');
|
|
1052
1174
|
const visionProviders = store.pickAllFor(cap.CAPABILITIES.VISION).filter((x) => x.id === p.id);
|
|
1053
1175
|
if (!visionProviders.length) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
|
|
1054
|
-
const backends =
|
|
1176
|
+
const backends = buildVisionBackendsFromHost(host, { providers: visionProviders });
|
|
1055
1177
|
const backend = backends[0];
|
|
1056
1178
|
if (!backend) return { ok: true, tested: false, text: '该供应商的 vision 配置不可用(类型非 openai)' };
|
|
1057
1179
|
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
1058
|
-
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
|
+
});
|
|
1059
1187
|
return {
|
|
1060
1188
|
ok: true,
|
|
1061
1189
|
tested: true,
|
|
@@ -1069,35 +1197,69 @@ register('providers_test_vision', async (ctx, args) => {
|
|
|
1069
1197
|
/* ============ 模型池逐模型管理(阶段 9 P3:手动增删改 + 逐能力实测) ============ */
|
|
1070
1198
|
|
|
1071
1199
|
/** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
|
|
1072
|
-
async function probeModel(
|
|
1200
|
+
async function probeModel(host, p, modelId, capability, signal) {
|
|
1073
1201
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
1074
|
-
const backend =
|
|
1202
|
+
const backend = buildVisionBackendsFromHost(host, { providers: [p], model: modelId })[0];
|
|
1075
1203
|
if (!backend) return { ok: false, note: '非 openai 类型,无法直测' };
|
|
1076
|
-
const r = await testVisionCapability(backend, { timeoutMs: 15000, signal
|
|
1077
|
-
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
|
+
};
|
|
1078
1211
|
}
|
|
1079
1212
|
if (capability === cap.CAPABILITIES.TRANSCRIBE) {
|
|
1080
1213
|
return { skipped: true, ok: false, note: '需真实音频,请通过“音频转写”动作验证' };
|
|
1081
1214
|
}
|
|
1082
1215
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
1083
|
-
const
|
|
1084
|
-
const
|
|
1085
|
-
|
|
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 || '无音频返回') };
|
|
1086
1223
|
}
|
|
1087
1224
|
if (capability === cap.CAPABILITIES.IMAGE) {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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 ? '生成返回图片' : '无图片返回' };
|
|
1091
1238
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
+
};
|
|
1095
1246
|
}
|
|
1096
|
-
// 有界轮询到终态(图像通常 5–15s)
|
|
1097
1247
|
for (let i = 0; i < 20; i++) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
+
};
|
|
1101
1263
|
}
|
|
1102
1264
|
return { ok: false, note: '生成超时(>60s)' };
|
|
1103
1265
|
}
|
|
@@ -1108,7 +1270,7 @@ async function probeModel(ctx, p, modelId, capability) {
|
|
|
1108
1270
|
}
|
|
1109
1271
|
|
|
1110
1272
|
/** 手动添加模型到池(能力可选,缺省按规则推断) */
|
|
1111
|
-
register('providers_add_model', async (
|
|
1273
|
+
register('providers_add_model', async (host, args) => {
|
|
1112
1274
|
const id = String(args.id || '');
|
|
1113
1275
|
const modelId = String(args.model_id || '').trim();
|
|
1114
1276
|
if (!modelId) throw new Error('iris: model_id 不能为空');
|
|
@@ -1121,14 +1283,14 @@ register('providers_add_model', async (ctx, args) => {
|
|
|
1121
1283
|
});
|
|
1122
1284
|
|
|
1123
1285
|
/** 从池移除模型 */
|
|
1124
|
-
register('providers_remove_model', async (
|
|
1286
|
+
register('providers_remove_model', async (host, args) => {
|
|
1125
1287
|
const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
|
|
1126
1288
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
1127
1289
|
return { ok: true, text: `[iris] 已移除模型 ${args.model_id}` };
|
|
1128
1290
|
});
|
|
1129
1291
|
|
|
1130
1292
|
/** 纠正某模型的能力标签 */
|
|
1131
|
-
register('providers_set_model_caps', async (
|
|
1293
|
+
register('providers_set_model_caps', async (host, args) => {
|
|
1132
1294
|
const caps = (Array.isArray(args.capabilities) ? args.capabilities : []).filter((c) => cap.isKnownCapability(c));
|
|
1133
1295
|
const ok = store.setModelCapabilities(String(args.id || ''), String(args.model_id || ''), caps);
|
|
1134
1296
|
if (!ok) throw new Error('iris: 模型不在池中');
|
|
@@ -1136,7 +1298,7 @@ register('providers_set_model_caps', async (ctx, args) => {
|
|
|
1136
1298
|
});
|
|
1137
1299
|
|
|
1138
1300
|
/** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
|
|
1139
|
-
register('providers_test_model', async (
|
|
1301
|
+
register('providers_test_model', async (host, args, { signal }) => {
|
|
1140
1302
|
const p = store.providerById(String(args.id || ''));
|
|
1141
1303
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1142
1304
|
const modelId = String(args.model_id || '').trim();
|
|
@@ -1147,9 +1309,14 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
1147
1309
|
}
|
|
1148
1310
|
let result;
|
|
1149
1311
|
try {
|
|
1150
|
-
result = await probeModel(
|
|
1312
|
+
result = await probeModel(host, p, modelId, capability, signal);
|
|
1151
1313
|
} catch (err) {
|
|
1152
|
-
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
|
+
};
|
|
1153
1320
|
}
|
|
1154
1321
|
if (result.skipped) {
|
|
1155
1322
|
return { ok: true, skipped: true, passed: false, text: `[iris] ${modelId} · ${capability} 已跳过:${result.note}` };
|
|
@@ -1165,7 +1332,7 @@ register('providers_test_model', async (ctx, args) => {
|
|
|
1165
1332
|
/* ============ 能力分配动作(阶段 6 条目 4) ============ */
|
|
1166
1333
|
|
|
1167
1334
|
/** 查询:全部分配 + 每个能力可用的模型池(client 渲染下拉/排序) */
|
|
1168
|
-
register('assignments_get', async (
|
|
1335
|
+
register('assignments_get', async (host, args) => {
|
|
1169
1336
|
const pool = models.modelPool(store.allProviders());
|
|
1170
1337
|
const byCap = {};
|
|
1171
1338
|
for (const m of pool) {
|
|
@@ -1185,7 +1352,7 @@ register('assignments_get', async (ctx, args) => {
|
|
|
1185
1352
|
* model_refs 数组 = Provider+模型复合引用的有序 failover 列表;
|
|
1186
1353
|
* model_ids/model_id 保留为旧客户端兼容入口。
|
|
1187
1354
|
*/
|
|
1188
|
-
register('assignments_set', async (
|
|
1355
|
+
register('assignments_set', async (host, args) => {
|
|
1189
1356
|
const capability = String(args.capability || '').trim();
|
|
1190
1357
|
if (!capability) throw new Error('iris: capability 不能为空');
|
|
1191
1358
|
const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
|
|
@@ -1204,45 +1371,19 @@ register('assignments_set', async (ctx, args) => {
|
|
|
1204
1371
|
|
|
1205
1372
|
/* ============ 文件选择器 L1:会话附件枚举(阶段 10) ============ */
|
|
1206
1373
|
|
|
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
1374
|
/**
|
|
1233
1375
|
* 枚举可选附件:当前会话的 image 附件(readSession)+ iris 自有产物(全局,兜底)。
|
|
1234
1376
|
* 客户端传当前会话 id;读不到会话也不报错,退化为只列 iris 产物。
|
|
1235
1377
|
*/
|
|
1236
|
-
register('attachments_list', async (
|
|
1378
|
+
register('attachments_list', async (host, args) => {
|
|
1237
1379
|
const items = [];
|
|
1238
1380
|
const seen = new Set();
|
|
1239
1381
|
const sessionId = String(args.session_id || '').trim();
|
|
1240
|
-
const
|
|
1241
|
-
if (sessionId &&
|
|
1382
|
+
const sessions = hasHostPort(host, 'sessions') ? host.ports.sessions : undefined;
|
|
1383
|
+
if (sessionId && sessions) {
|
|
1242
1384
|
try {
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
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' }); }
|
|
1246
1387
|
}
|
|
1247
1388
|
} catch (_) {
|
|
1248
1389
|
/* 读不到会话 → 只列 iris 产物 */
|
|
@@ -1263,7 +1404,7 @@ register('attachments_list', async (ctx, args) => {
|
|
|
1263
1404
|
* 把一个附件(会话 image 或 iris 产物)导出为宿主可读的文件路径,供工具消费。
|
|
1264
1405
|
* iris 产物直接返回其 outputs 路径;会话附件读字节存 uploads/(按 attachmentId 哈希缓存,不重复落盘)。
|
|
1265
1406
|
*/
|
|
1266
|
-
register('attachment_export', async (
|
|
1407
|
+
register('attachment_export', async (host, args, { signal }) => {
|
|
1267
1408
|
const id = String(args.attachment_id || '').trim();
|
|
1268
1409
|
if (!id) throw new Error('iris: attachment_id 不能为空');
|
|
1269
1410
|
// 1) iris 自有产物:文件已在 outputs/
|
|
@@ -1280,18 +1421,13 @@ register('attachment_export', async (ctx, args) => {
|
|
|
1280
1421
|
const cached = path.join(uploads, 'att-' + id.replace(/[^a-z0-9]/gi, '').slice(-40));
|
|
1281
1422
|
if (fs.existsSync(cached)) return { ok: true, path: cached, name: path.basename(cached) };
|
|
1282
1423
|
const sessionId = String(args.session_id || '').trim();
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
throw new Error('iris: 该附件不可导出(非 iris 产物,且缺会话/附件服务上下文)');
|
|
1287
|
-
}
|
|
1424
|
+
if (!sessionId) throw new Error('iris: 非 Iris 产物必须提供 session_id');
|
|
1425
|
+
const sessions = requireHostPort(host, 'sessions', '导出会话附件');
|
|
1426
|
+
const attachments = requireHostPort(host, 'attachments', '导出会话附件');
|
|
1288
1427
|
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 */ }
|
|
1428
|
+
try { ref = await sessions.findImageAttachment(sessionId, id); } catch (_) { /* 统一为未找到 */ }
|
|
1293
1429
|
if (!ref) throw new Error('iris: 该 attachment 不在本会话中、也不是 iris 产物:' + id);
|
|
1294
|
-
const stored = await
|
|
1430
|
+
const stored = await attachments.readImage(ref, signal);
|
|
1295
1431
|
ensurePrivateDir(uploads);
|
|
1296
1432
|
writePrivateFile(cached, Buffer.from(stored.data));
|
|
1297
1433
|
return { ok: true, path: cached, name: ref.name || path.basename(cached) };
|
|
@@ -1299,7 +1435,7 @@ register('attachment_export', async (ctx, args) => {
|
|
|
1299
1435
|
|
|
1300
1436
|
/* ============ 音频转写(阶段 7.2) ============ */
|
|
1301
1437
|
|
|
1302
|
-
register('transcribe', async (
|
|
1438
|
+
register('transcribe', async (host, args, { signal }) => {
|
|
1303
1439
|
const audioPath = String(args.audio_path || '').trim();
|
|
1304
1440
|
if (!audioPath || !path.isAbsolute(audioPath)) throw new Error('iris: audio_path 必须是绝对路径');
|
|
1305
1441
|
if (!fs.existsSync(audioPath)) throw new Error('iris: 音频文件不存在');
|
|
@@ -1317,7 +1453,7 @@ register('transcribe', async (ctx, args, { signal }) => {
|
|
|
1317
1453
|
|
|
1318
1454
|
/* ============ 视频抽帧(阶段 7.1) ============ */
|
|
1319
1455
|
|
|
1320
|
-
register('video_frames', async (
|
|
1456
|
+
register('video_frames', async (host, args, { signal }) => {
|
|
1321
1457
|
const p = String(args.video_path || '').trim();
|
|
1322
1458
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
1323
1459
|
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
@@ -1342,7 +1478,7 @@ register('video_frames', async (ctx, args, { signal }) => {
|
|
|
1342
1478
|
|
|
1343
1479
|
/* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
|
|
1344
1480
|
|
|
1345
|
-
register('media_summarize', async (
|
|
1481
|
+
register('media_summarize', async (host, args, { signal }) => {
|
|
1346
1482
|
const p = String(args.video_path || '').trim();
|
|
1347
1483
|
if (!p || !path.isAbsolute(p)) throw new Error('iris: video_path 必须是绝对路径');
|
|
1348
1484
|
if (!fs.existsSync(p)) throw new Error('iris: 视频文件不存在');
|
|
@@ -1353,7 +1489,7 @@ register('media_summarize', async (ctx, args, { signal }) => {
|
|
|
1353
1489
|
format: 'jpeg', quality: 70, signal
|
|
1354
1490
|
});
|
|
1355
1491
|
const sheet = await buildContactSheet({ frames, cols: 3 });
|
|
1356
|
-
const backends =
|
|
1492
|
+
const backends = buildVisionBackendsFromHost(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
|
|
1357
1493
|
const { answer, via, model } = await summarizeMedia({
|
|
1358
1494
|
backends, question: args.question,
|
|
1359
1495
|
transcript: args.transcribe_text, // GUI 同步版:不自动转写,可粘贴已有转写文本
|