@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.
Files changed (51) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +24 -2
  8. package/README.en.md +29 -7
  9. package/README.md +29 -7
  10. package/docs/ARCHITECTURE.md +27 -13
  11. package/docs/ARTIFACT_LIBRARY.md +24 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +86 -0
  13. package/docs/HOST_DOCTOR.md +47 -0
  14. package/docs/PROVIDER_ADAPTER_CONTRACT.md +97 -0
  15. package/docs/PROVIDER_HEALTH.md +39 -0
  16. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  17. package/docs/ROADMAP.md +18 -8
  18. package/docs/SECURITY.md +6 -2
  19. package/docs/TASK_SEMANTICS.md +3 -2
  20. package/docs/assets/diagrams/iris-architecture.png +0 -0
  21. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  22. package/docs/releases/0.1.4.md +59 -0
  23. package/lib/actions.js +357 -221
  24. package/lib/adapters.js +7 -7
  25. package/lib/api.js +30 -11
  26. package/lib/artifacts.js +239 -0
  27. package/lib/bundled-skills.js +5 -5
  28. package/lib/client.js +370 -55
  29. package/lib/config.js +192 -9
  30. package/lib/doctor.js +175 -4
  31. package/lib/dsh-host-adapter.js +189 -0
  32. package/lib/host-contract.js +131 -0
  33. package/lib/host-runtime.js +156 -0
  34. package/lib/index.js +97 -113
  35. package/lib/locate.js +1 -1
  36. package/lib/media.js +16 -6
  37. package/lib/prompt-optimizer.js +10 -20
  38. package/lib/provider-adapter.js +371 -0
  39. package/lib/provider-adapters.js +279 -0
  40. package/lib/provider-health.js +59 -0
  41. package/lib/render.js +43 -12
  42. package/lib/tasks.js +29 -5
  43. package/lib/vision.js +23 -49
  44. package/package.json +14 -8
  45. package/user_guide.md +42 -2
  46. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  47. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  48. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  49. package/docs/releases/0.1.1.md +0 -51
  50. package/docs/releases/0.1.2.md +0 -26
  51. 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 是纯函数 `(ctx, args, { signal }) => result`,
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 { buildVisionBackends, askWithBackends, testVisionCapability, RED_TEST_IMAGE } from './vision.js';
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(ctx, ref, absPath, originalPrompt) {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
44
- const { answer } = await askWithBackends(backends, {
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 { provider, protocol, model, modelRef: models.modelRef(provider.id, model) };
90
- }
91
-
92
- function imageSubmissionError(error) {
93
- const status = Number(error && error.status) || undefined;
94
- // 408/409/425/499 可能来自中间层或冲突恢复,不能证明请求未被受理。
95
- const explicitRejection = Boolean(status && status >= 400 && status < 500 && ![408, 409, 425, 499].includes(status));
96
- let category = 'provider';
97
- if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) category = 'aborted';
98
- else if (error && (error.category === 'network' || error instanceof TypeError)) category = 'network';
99
- else if (status === 401 || status === 403) category = 'authentication';
100
- else if (status === 429 && error && error.category === 'quota') category = 'quota';
101
- else if (status === 429) category = 'rate_limit';
102
- else if (status && status >= 400 && status < 500) category = 'invalid_request';
103
- else if (!status) category = 'protocol';
104
- return new ProviderContractError(String((error && error.message) || error || '提交失败'), {
105
- stage: 'submit',
106
- category,
107
- acceptance: explicitRejection ? 'not_accepted' : 'unknown',
108
- retryable: status === 429 || Boolean(status && status >= 500) || category === 'network',
109
- ...(status ? { httpStatus: status } : {})
110
- });
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
- async function deliverCompletedImage(taskId, value, signal) {
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 adapters.downloadTo(url, path.join(outputDir(), name), { signal });
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 adapters.downloadTo(item.url, target, { signal });
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
- try {
180
- if (route.protocol === 'dashscope') {
181
- if (adapters.dashscopeImageMode(route.model) === 'legacy-async') {
182
- const remoteTaskId = await adapters.submitImage({
183
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
184
- model: route.model, prompt, size: args.size, n: args.n, signal
185
- });
186
- return { kind: 'accepted', remoteTaskId };
187
- }
188
- const urls = await adapters.generateImageMultimodal({
189
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
190
- model: route.model, prompt, size: args.size, n: args.n, signal
191
- });
192
- return { kind: 'completed', value: { kind: 'urls', items: urls } };
193
- }
194
- const items = await adapters.openAiGenerateImage({
195
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
196
- model: route.model, prompt, size: args.size, n: args.n, signal
197
- });
198
- return { kind: 'completed', value: { kind: 'openai', items } };
199
- } catch (error) {
200
- throw imageSubmissionError(error);
201
- }
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
- key: () => route.provider.apiKey,
248
- poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId }),
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 adapters.downloadTo(url, path.join(outputDir(), name));
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 { provider, model, modelRef: models.modelRef(provider.id, model), isS2V, mode, protocol: 'dashscope' };
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
- try {
372
- const remoteTaskId = await adapters.submitVideo({
373
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
374
- model: route.model, ...submitArgs, signal
375
- });
376
- return { kind: 'accepted', remoteTaskId };
377
- } catch (error) {
378
- throw imageSubmissionError(error);
379
- }
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
- key: () => route.provider.apiKey,
418
- poll: ({ key, remoteTaskId: id }) => adapters.pollTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId: 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
+ ),
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 adapters.downloadTo(url, path.join(outputDir(), name));
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 { provider, model, modelRef: models.modelRef(provider.id, model), protocol };
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
- try {
462
- const remoteTaskId = await adapters.submitTranscription({
463
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
464
- model: route.model, audioUrl, signal
465
- });
466
- return { kind: 'accepted', remoteTaskId };
467
- } catch (error) {
468
- throw imageSubmissionError(error);
469
- }
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
- key: () => route.provider.apiKey,
495
- poll: async ({ key, remoteTaskId: id }) => {
496
- const result = await adapters.pollTranscriptionTask({ key, baseUrl: route.provider.baseUrl, remoteTaskId: id });
497
- return { done: result.done, ok: result.ok, urls: result.text ? [result.text] : [], message: result.message };
498
- },
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
- try {
521
- const audio = await adapters.synthesizeTts({
522
- key: route.provider.apiKey, baseUrl: route.provider.baseUrl,
523
- model: route.model, text, voice: route.voice, signal
524
- });
525
- return { kind: 'completed', value: audio };
526
- } catch (error) {
527
- throw imageSubmissionError(error);
528
- }
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 adapters.downloadTo(audio.audioUrl, target, { signal });
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
- key: () => provider.apiKey,
578
- poll: async ({ key, remoteTaskId }) => {
579
- const r = await adapters.pollTranscriptionTask({ key, baseUrl: provider.baseUrl, remoteTaskId });
580
- return { done: r.done, ok: r.ok, urls: r.text ? [r.text] : [], message: r.message };
581
- },
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
- key: () => provider.apiKey,
593
- poll: ({ key, remoteTaskId }) => adapters.pollTask({ key, baseUrl: provider.baseUrl, remoteTaskId }),
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 adapters.downloadTo(url, path.join(outputDir(), name));
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 (ctx, args, { signal }) => { ok, text?, imageDataUrl?, taskId?, blocks? }
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(ctx, name, args, { signal } = {}) {
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(ctx, args, { signal });
721
+ return fn(actionHost(host), args || {}, { signal });
632
722
  }
633
723
 
634
724
  /* ============ 动作实现 ============ */
635
725
 
636
726
  /* ---------- 🖼️ 画图 ---------- */
637
- register('image', async (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
693
- const { answer, via, model } = await askWithBackends(backends, { question: (args.question || '详细描述这张图片。'), imageDataUrl: dataUrl, signal });
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
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 (ctx, args, { signal }) => {
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 = ctx.get('browser');
742
- if (!browser) throw new Error('iris: 浏览器服务不可用——需要启用 dsh-builtin-browser 插件');
743
- const renderer = new BrowserHtmlRenderer({ browser });
744
- const { png } = await renderer.render({ html, width: args.width, height: args.height, fullPage: args.fullPage !== false });
745
- return { ok: true, text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:image/png;base64,${png.toString('base64')}` };
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 (ctx, args, { signal }) => {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION) });
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 (ctx, args, { signal }) => {
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 (ctx, args) => {
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 (ctx, args, { signal }) => {
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({ key: deps.key(), remoteTaskId: task.remoteTaskId, signal });
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 (_ctx, args) => {
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 (_ctx, args) => {
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 (ctx, args, { signal }) => {
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(ctx, action, retryArgs, { signal });
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args, { signal }) => {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
970
- const { answer, via, model } = await askWithBackends(backends, { question, imageDataUrl: dataUrl, signal });
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 (ctx, args) => {
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) => ({ id: m.id, capabilities: m.capabilities, verified: m.verified || null, source: m.source || 'auto' })),
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args) => {
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
- ids = await adapters.listModels({ key: p.apiKey, baseUrl: p.baseUrl });
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 (ctx, args) => {
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 = buildVisionBackends(ctx, { providers: visionProviders });
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: args._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(ctx, p, modelId, capability) {
1200
+ async function probeModel(host, p, modelId, capability, signal) {
1073
1201
  if (capability === cap.CAPABILITIES.VISION) {
1074
- const backend = buildVisionBackends(ctx, { providers: [p], model: modelId })[0];
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: null });
1077
- return { ok: r.ok, note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色') };
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 r = await adapters.synthesizeTts({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, text: '你好', voice: 'Cherry' });
1084
- const ok = !!(r.audioUrl || r.audioB64);
1085
- return { ok, note: ok ? '合成返回音频' : '无音频返回' };
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
- if ((p.mediaProtocol || inferMediaProtocol(p.baseUrl)) === 'openai-images') {
1089
- const outs = await adapters.openAiGenerateImage({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: 'red circle', size: '256x256', n: 1 });
1090
- return { ok: outs.length > 0, note: outs.length ? '生成返回图片' : '无图片返回' };
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
- const started = await adapters.startImageGeneration({ key: p.apiKey, baseUrl: p.baseUrl, model: modelId, prompt: '红色圆形', size: '512*512', n: 1 });
1093
- if (!started.remoteTaskId) {
1094
- return { ok: (started.urls || []).length > 0, note: (started.urls || []).length ? '生成成功' : '无图片返回' };
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
- await new Promise((r) => setTimeout(r, 3000));
1099
- const st = await adapters.pollTask({ key: p.apiKey, baseUrl: p.baseUrl, remoteTaskId: started.remoteTaskId });
1100
- if (st.done) return { ok: st.ok && (st.urls || []).length > 0, note: st.ok ? '生成成功' : (st.message || '生成失败') };
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args) => {
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(ctx, p, modelId, capability);
1312
+ result = await probeModel(host, p, modelId, capability, signal);
1151
1313
  } catch (err) {
1152
- result = { ok: false, note: String(err.message || err) };
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 (ctx, args) => {
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 (ctx, args) => {
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 (ctx, args) => {
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 sq = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : undefined;
1241
- if (sessionId && sq && typeof sq.readSession === 'function') {
1382
+ const sessions = hasHostPort(host, 'sessions') ? host.ports.sessions : undefined;
1383
+ if (sessionId && sessions) {
1242
1384
  try {
1243
- const events = (await sq.readSession(sessionId)).events;
1244
- for (const a of collectImageAttachments(events)) {
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 (ctx, args) => {
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
- const sq = ctx && typeof ctx.get === 'function' ? ctx.get('sessionQuery') : undefined;
1284
- const att = ctx && typeof ctx.get === 'function' ? ctx.get('attachments') : undefined;
1285
- if (!sessionId || !sq || !att || typeof att.readImage !== 'function') {
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 att.readImage({ attachmentId: ref.attachmentId, mediaType: ref.mediaType }, null);
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 (ctx, args, { signal }) => {
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 = buildVisionBackends(ctx, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model });
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 同步版:不自动转写,可粘贴已有转写文本