@mokuyoaxis/dsh-iris 0.1.3 → 0.2.0

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