@mokuyoaxis/dsh-iris 0.2.5 → 0.2.7

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 (83) hide show
  1. package/.dsh/skills/iris-use/SKILL.md +2 -2
  2. package/.dsh/skills/iris-use/references/media-contract.md +2 -2
  3. package/CHANGELOG.md +49 -1
  4. package/README.en.md +39 -22
  5. package/README.md +40 -21
  6. package/bin/dsh-iris.js +166 -35
  7. package/docs/AGENT_QUICKSTART.md +7 -7
  8. package/docs/ARCHITECTURE.md +9 -3
  9. package/docs/CLI_MANAGEMENT.md +5 -1
  10. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  11. package/docs/CORE_SKILLS.md +62 -0
  12. package/docs/DSH_COMPATIBILITY.md +8 -4
  13. package/docs/DSH_CORE_MIGRATION.md +4 -2
  14. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  15. package/docs/DSH_RUNTIME.md +36 -0
  16. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  17. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  18. package/docs/HEADLESS_CLI.md +16 -8
  19. package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
  20. package/docs/HOST_DOCTOR.md +14 -2
  21. package/docs/IMAGE_FLOW.md +71 -0
  22. package/docs/LOCAL_API.md +60 -0
  23. package/docs/LOCAL_API_CONTRACT.md +115 -0
  24. package/docs/LOCAL_CREATION.md +50 -0
  25. package/docs/MUSIC.md +2 -2
  26. package/docs/PROVIDER_HEALTH.md +26 -2
  27. package/docs/ROADMAP.md +18 -4
  28. package/docs/SECURITY.md +20 -2
  29. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  30. package/docs/VISION_CLI.md +2 -2
  31. package/docs/WEB_PROTOTYPE.md +47 -0
  32. package/docs/releases/0.2.6.md +50 -0
  33. package/docs/releases/0.2.7.md +62 -0
  34. package/lib/actions.js +208 -90
  35. package/lib/adapters.js +36 -9
  36. package/lib/api.js +40 -39
  37. package/lib/application-runtime.js +186 -0
  38. package/lib/client.js +805 -182
  39. package/lib/composite-vision-routing.js +6 -6
  40. package/lib/config.js +140 -29
  41. package/lib/core-artifact-store.js +5 -0
  42. package/lib/core-maintenance.js +25 -2
  43. package/lib/core-runtime.js +19 -0
  44. package/lib/core-tasks.js +62 -8
  45. package/lib/creation-request-store.js +159 -0
  46. package/lib/doctor.js +20 -2
  47. package/lib/dsh-core-adapter.js +669 -564
  48. package/lib/dsh-host-adapter.js +14 -1
  49. package/lib/dsh-provider-config.js +35 -0
  50. package/lib/dsh-provider-source.js +102 -0
  51. package/lib/flow-store.js +195 -0
  52. package/lib/headless-vision.js +5 -5
  53. package/lib/host-contract.js +1 -0
  54. package/lib/http-vision-model-adapter.js +5 -2
  55. package/lib/image-flow.js +188 -0
  56. package/lib/index.js +103 -78
  57. package/lib/local-api-projection.js +27 -0
  58. package/lib/local-api.js +381 -0
  59. package/lib/local-creation.js +85 -0
  60. package/lib/model-directory-service.js +53 -0
  61. package/lib/model-directory.js +103 -0
  62. package/lib/models.js +71 -6
  63. package/lib/ocr-model-routing.js +2 -2
  64. package/lib/private-storage.js +12 -0
  65. package/lib/provider-adapters.js +42 -9
  66. package/lib/provider-catalog.js +14 -9
  67. package/lib/provider-config-scope.js +16 -0
  68. package/lib/provider-config-service.js +82 -13
  69. package/lib/provider-health.js +33 -0
  70. package/lib/provider-protocol.js +30 -0
  71. package/lib/provider-task-runner.js +22 -2
  72. package/lib/render-address.js +35 -0
  73. package/lib/render.js +52 -13
  74. package/lib/skill-installation.js +199 -0
  75. package/lib/vision-model-routing.js +25 -12
  76. package/lib/workbench-artifacts.js +1 -2
  77. package/lib/workbench-management.js +8 -9
  78. package/package.json +4 -3
  79. package/skills/iris-core/SKILL.md +40 -0
  80. package/skills/iris-core/agents/openai.yaml +4 -0
  81. package/skills/iris-core/references/cli.md +95 -0
  82. package/skills/iris-core/references/tasks.md +58 -0
  83. package/user_guide.md +32 -20
package/lib/actions.js CHANGED
@@ -7,7 +7,7 @@ import { visionInputLimits } from './vision-image-input.js';
7
7
  * ① `POST /iris/api/actions/:name` 路由(GUI 直连,submit-only 不等待)
8
8
  * ② 工具 execute 体(Agent 调用,可等待)
9
9
  *
10
- * 每个 action 是纯函数 `(host, args, { signal }) => result`,
10
+ * 每个 action 接收 `(host, args, { signal, core })`,显式使用所属实例,
11
11
  * 结果只有可序列化 JSON(无 live 对象)。
12
12
  */
13
13
  import fs from 'node:fs';
@@ -23,7 +23,6 @@ import { registerMedia, mediaLinksOf, webBase } from './media.js';
23
23
  import { RED_TEST_IMAGE } from './vision.js';
24
24
  import { runVisionRequest, readVisionImageFile, probeVisionModel, visionImageFromDataUrl } from './vision-model-routing.js';
25
25
  import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
26
- import { coreSnapshotForDsh, importImageForDsh, cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, submitMusicForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
27
26
  import { runLocateRequest, runSummaryRequest } from './composite-vision-routing.js';
28
27
  import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
29
28
  import { normalizeVisionImageSource } from './vision-input.js';
@@ -31,13 +30,23 @@ import { formatOcrResult } from './ocr.js';
31
30
  import { runOcrRequest } from './ocr-model-routing.js';
32
31
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
33
32
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
34
- import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit } from './provider-protocol.js';
33
+ import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
35
34
  import { musicCandidatesFromCatalog, providerTaskBinding } from './provider-catalog.js';
36
35
  import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
37
36
  import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
38
37
  import { deriveUserState } from './task-semantics.js';
39
38
  import { normalizeGenerationInput } from './generation-input.js';
40
39
  import { videoTaskCandidates } from './video-input.js';
40
+ import { normalizeDshProvider, withDshProviderConfig } from './dsh-provider-config.js';
41
+ import { modelDirectorySummary, modelUnavailableReason } from './model-directory.js';
42
+ import { ModelPortError } from './model-port-contract.js';
43
+
44
+ const PROVIDER_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
45
+ 'locate', 'ocr', 'media_summarize', 'task_reobserve', 'task_redeliver', 'task_manual_retry',
46
+ 'providers_discover', 'providers_sync_directory', 'providers_test_vision', 'providers_test_model']);
47
+ const AUTO_DIRECTORY_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
48
+ 'locate', 'ocr', 'media_summarize']);
49
+ const VISION_ACTIONS = new Set(['look', 'relook', 'locate', 'ocr', 'media_summarize']);
41
50
 
42
51
  const MEDIA_TYPES = {
43
52
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -46,6 +55,7 @@ const MEDIA_TYPES = {
46
55
 
47
56
  function createConfiguredProviderAdapter(provider, options = {}) {
48
57
  return createProviderAdapter(provider, { ...options,
58
+ onModelAvailability: event => store.recordAvailability(event),
49
59
  onRateLimit: options.allowRateLimited ? undefined : store.recordRateLimit });
50
60
  }
51
61
 
@@ -92,11 +102,14 @@ function throwIfAborted(signal) {
92
102
  * 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
93
103
  */
94
104
  function selectProviders(capability, label, requestedModelRef) {
105
+ const policy = models.functionPolicy(store.assignments()[capability], capability);
106
+ if (policy.mode === 'disabled') throw new Error('iris: 没有可用' + label + '模型:此功能已停用,请在功能池中选择调用方式');
95
107
  let providers = store.pickAllFor(capability);
96
108
  const requested = String(requestedModelRef || '').trim();
97
109
  if (requested) {
98
- const pool = models.orderedModels(models.modelPool(store.providers()), capability, store.assignments()[capability], { includeRateLimited: true });
110
+ const pool = models.orderedModels(models.functionModelPool(store.providers(), capability, store.assignments()[capability]), capability, store.assignments()[capability], { includeRateLimited: true, includeUnavailable: true });
99
111
  const selected = models.resolvePoolModel(pool, requested, capability);
112
+ if (selected?.unavailableCapabilities?.includes(capability)) throw new Error('iris: ' + modelUnavailableReason());
100
113
  if (selected?.rateLimited) throw Object.assign(new Error(store.modelHealth(selected.providerId, selected.id, capability).retryAt
101
114
  ? 'iris: 指定模型正在限流冷却,请稍后使用或手动实测'
102
115
  : 'iris: 指定模型额度或预算耗尽,请手动实测成功后再使用'), {
@@ -106,7 +119,7 @@ function selectProviders(capability, label, requestedModelRef) {
106
119
  providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
107
120
  if (!providers.length) throw new Error('iris: 指定模型未分配给该功能、不可用或不具备' + label + '能力');
108
121
  }
109
- if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
122
+ if (!providers.length) throw new Error('iris: 没有可用' + label + '模型:请检查连接、已确认的模型用途、协议和冷却状态;手选模式还需选择模型');
110
123
  return providers;
111
124
  }
112
125
 
@@ -161,13 +174,13 @@ async function observeProviderCapability(route, capability, operation, { recordS
161
174
  const result = await operation();
162
175
  if (recordSuccess && result && ['accepted', 'completed', 'pending', 'succeeded'].includes(result.kind)) {
163
176
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
164
- ok: true,
177
+ ok: true, stage: 'poll',
165
178
  category: 'success',
166
179
  note: result.kind === 'accepted' ? '任务已受理' : '供应商请求成功'
167
180
  });
168
181
  } else if (result && result.error) {
169
182
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
170
- ok: false,
183
+ ok: false, stage: 'poll',
171
184
  category: result.error.category,
172
185
  httpStatus: result.error.httpStatus,
173
186
  note: result.error.safeMessage
@@ -176,7 +189,7 @@ async function observeProviderCapability(route, capability, operation, { recordS
176
189
  return result;
177
190
  } catch (error) {
178
191
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
179
- ok: false,
192
+ ok: false, stage: 'poll',
180
193
  category: error && error.category,
181
194
  httpStatus: error && (error.httpStatus ?? error.status),
182
195
  note: String(error && (error.safeMessage || error.message) || error)
@@ -200,7 +213,8 @@ function recordVisionHealth(errors) {
200
213
 
201
214
  async function askSingleVisionWithHealth(host, request) {
202
215
  try {
203
- const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, ...request });
216
+ const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
217
+ onModelAvailability: store.recordAvailability, ...request });
204
218
  recordVisionHealth(result.errors);
205
219
  if (result.via === 'selfstack') recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
206
220
  ok: true, category: 'success', note: '视觉请求成功'
@@ -229,8 +243,8 @@ function recordCoreTaskAttemptHealth(task, capability) {
229
243
  }
230
244
 
231
245
  /** DSH 图片入口:同步和异步候选共用一份 Core Task/Attempt/Artifact 事实。 */
232
- async function submitCoreImage(routes, providerInput, signal, sourceArtifactId) {
233
- const submitted = await submitProviderTaskForDsh({
246
+ async function submitCoreImage(core, routes, providerInput, signal, sourceArtifactId) {
247
+ const submitted = await core.submitProviderTaskForDsh({
234
248
  capability: 'image',
235
249
  candidates: routes.map((route) => ({
236
250
  adapter: route.adapter,
@@ -254,7 +268,7 @@ async function submitCoreImage(routes, providerInput, signal, sourceArtifactId)
254
268
  if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
255
269
  if (task.acceptance === 'accepted' && task.remoteTaskId
256
270
  && ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
257
- await watchProviderTaskForDsh({ taskId: task.id, signal });
271
+ await core.watchProviderTaskForDsh({ taskId: task.id, signal });
258
272
  } else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
259
273
  throw taskActionError(task.id, '图片路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
260
274
  }
@@ -266,7 +280,7 @@ async function submitCoreImage(routes, providerInput, signal, sourceArtifactId)
266
280
  * 不共用图片状态机——视频走自己的交付 Profile(video/mp4 / generated-video)。
267
281
  * s2v 在写前 Attempt 之后按账号上传首帧和音频,交付复用视频 Profile。
268
282
  */
269
- async function submitCoreVideo(routes, frame, prompt, args, signal) {
283
+ async function submitCoreVideo(core, routes, frame, prompt, args, signal) {
270
284
  const normalized = normalizeGenerationInput('video', {
271
285
  ...(prompt ? { prompt } : {}),
272
286
  ...(frame ? { first_frame_path: frame.absPath } : {}),
@@ -274,7 +288,7 @@ async function submitCoreVideo(routes, frame, prompt, args, signal) {
274
288
  size: optionalActionText(args.size),
275
289
  duration: args.duration
276
290
  }, { allowVideoPaths: true });
277
- const submitted = await submitProviderTaskForDsh({
291
+ const submitted = await core.submitProviderTaskForDsh({
278
292
  capability: 'video',
279
293
  candidates: videoTaskCandidates(routes.map((route) => ({
280
294
  adapter: route.adapter,
@@ -297,7 +311,7 @@ async function submitCoreVideo(routes, frame, prompt, args, signal) {
297
311
  if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
298
312
  if (task.acceptance === 'accepted' && task.remoteTaskId
299
313
  && ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
300
- await watchProviderTaskForDsh({ taskId: task.id, signal });
314
+ await core.watchProviderTaskForDsh({ taskId: task.id, signal });
301
315
  } else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
302
316
  throw taskActionError(task.id, '视频路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
303
317
  }
@@ -459,17 +473,34 @@ export function listActions() {
459
473
  }
460
474
 
461
475
  /** 执行一个动作 */
462
- export async function runAction(host, name, args, { signal } = {}) {
476
+ export async function runAction(host, name, args, { signal, core } = {}) {
463
477
  const fn = actions[name];
464
- if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
465
- return fn(actionHost(host), args || {}, { signal });
478
+ if (!fn) throw new Error('iris: 未知动作 "' + name + '"');
479
+ const adapter = actionHost(host);
480
+ const owned = structuredClone(args || {});
481
+ return core.runHostOperation(operationSignal => {
482
+ const execute = async () => {
483
+ if (AUTO_DIRECTORY_ACTIONS.has(name) && !owned.model) await store.refreshAutomaticDirectories({
484
+ capability: ({ image: 'image-gen', image_edit: 'image-gen', video: 'video-gen', music: 'music-gen', tts: 'tts', transcribe: 'transcribe' })[name] || 'vision', signal: operationSignal
485
+ });
486
+ operationSignal.throwIfAborted();
487
+ return fn(adapter, owned, { signal: operationSignal, core });
488
+ };
489
+ return PROVIDER_ACTIONS.has(name) ? withDshProviderConfig(adapter, execute, { signal: operationSignal }) : execute();
490
+ }, { signal }).catch(error => {
491
+ // Ownership can stop the request before it enters the Model Port's error boundary.
492
+ if (VISION_ACTIONS.has(name) && signal?.aborted && error === signal.reason) {
493
+ throw new ModelPortError('IRIS_MODEL_ABORTED');
494
+ }
495
+ throw error;
496
+ });
466
497
  }
467
498
 
468
499
  /* ============ 动作实现 ============ */
469
500
 
470
- register('image_import', async (_host, args, { signal }) => {
501
+ register('image_import', async (_host, args, { signal, core }) => {
471
502
  if (args.image_path !== undefined && args.image_bytes !== undefined) throw new Error('图片导入只能选择一个来源');
472
- const { artifact } = await importImageForDsh({ imagePath: args.image_path, imageBytes: args.image_bytes, signal });
503
+ const { artifact } = await core.importImageForDsh({ imagePath: args.image_path, imageBytes: args.image_bytes, signal });
473
504
  const imageUrl = '/iris/api/core/artifact/' + artifact.id + '/media';
474
505
  const text = `已导入图片(${artifact.metadata.width}×${artifact.metadata.height}),原文件保留。\n作品ID:${artifact.id}`;
475
506
  return { ok: true, storage: 'core', artifact, artifactId: artifact.id, artifactIds: [artifact.id], imageUrl, text,
@@ -477,7 +508,7 @@ register('image_import', async (_host, args, { signal }) => {
477
508
  });
478
509
 
479
510
  /* ---------- 🖼️ 画图 ---------- */
480
- async function imageAction(host, args, { signal }, editing = false) {
511
+ async function imageAction(host, args, { signal, core }, editing = false) {
481
512
  if (editing && !args.source_artifact_id) throw new Error('iris: 改图必须提供 source_artifact_id');
482
513
  const { providerInput, sourceArtifactId } = normalizeGenerationInput('image', {
483
514
  prompt: args.prompt, size: optionalActionText(args.size), n: args.n,
@@ -486,7 +517,7 @@ async function imageAction(host, args, { signal }, editing = false) {
486
517
 
487
518
  const imageProviders = selectProviders(cap.CAPABILITIES.IMAGE, '图像', args.model);
488
519
  const routes = imageProviders.map((provider) => imageRoute(provider, args));
489
- const submitted = await submitCoreImage(routes, providerInput, signal, sourceArtifactId);
520
+ const submitted = await submitCoreImage(core, routes, providerInput, signal, sourceArtifactId);
490
521
  const firstArtifactId = submitted.task.artifactIds[0];
491
522
  const ready = submitted.task.outcome === 'succeeded'
492
523
  && submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
@@ -505,7 +536,7 @@ register('image', imageAction);
505
536
  register('image_edit', (host, args, context) => imageAction(host, args, context, true));
506
537
 
507
538
  /* ---------- 🎬 视频生成 ---------- */
508
- register('video', async (host, args, { signal }) => {
539
+ register('video', async (host, args, { signal, core }) => {
509
540
  const prompt = String(args.prompt || '').trim();
510
541
  const frame = await resolveFirstFrameFile(host, args, signal);
511
542
  const providers = selectProviders(cap.CAPABILITIES.VIDEO, '视频', args.model);
@@ -525,7 +556,7 @@ register('video', async (host, args, { signal }) => {
525
556
  if (!coreRoutes.length) {
526
557
  throw new Error(s2v ? 'iris: 未配置可用 s2v 数字人模型' : 'iris: 当前视频候选只有 s2v 数字人模型;请使用 audio_path 的数字人入口');
527
558
  }
528
- const submitted = await submitCoreVideo(coreRoutes, frame, prompt, args, signal);
559
+ const submitted = await submitCoreVideo(core, coreRoutes, frame, prompt, args, signal);
529
560
  const firstArtifactId = submitted.task.artifactIds[0];
530
561
  const ready = submitted.task.outcome === 'succeeded'
531
562
  && submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
@@ -545,12 +576,12 @@ register('video', async (host, args, { signal }) => {
545
576
  * 直接交付 generated-audio Artifact(无 remote 长轮询)。整条候选链共用一个
546
577
  * voice(Core providerInput 静态;legacy 曾按候选各自取 provider.ttsVoice)。
547
578
  */
548
- async function submitCoreTts(routes, normalizedInput, signal) {
579
+ async function submitCoreTts(core, routes, normalizedInput, signal) {
549
580
  const voice = normalizedInput.voice || String(routes[0].provider.ttsVoice || '').trim();
550
581
  const { providerInput } = normalizeGenerationInput('tts', {
551
582
  text: normalizedInput.text, ...(voice ? { voice } : {})
552
583
  });
553
- const submitted = await submitProviderTaskForDsh({
584
+ const submitted = await core.submitProviderTaskForDsh({
554
585
  capability: 'tts',
555
586
  candidates: routes.map((route) => ({
556
587
  adapter: route.adapter,
@@ -578,15 +609,15 @@ async function submitCoreTts(routes, normalizedInput, signal) {
578
609
  }
579
610
 
580
611
  /* ---------- 🔊 语音合成 ---------- */
581
- register('music', async (host, args, { signal }) => {
612
+ register('music', async (host, args, { signal, core }) => {
582
613
  const normalized = normalizeGenerationInput('music', {
583
614
  prompt: args.prompt,
584
615
  ...(optionalActionText(args.timeout_ms) !== undefined ? { timeout_ms: args.timeout_ms } : {}),
585
616
  ...(optionalActionText(args.retry_502) !== undefined ? { retry_502: args.retry_502 } : {}),
586
617
  ...(optionalActionText(args.retry_delay_ms) !== undefined ? { retry_delay_ms: args.retry_delay_ms } : {})
587
618
  });
588
- const routes = musicCandidatesFromCatalog({ providers: store.allProviders(), assignments: store.assignments() }, args.model);
589
- const submitted = await submitMusicForDsh({
619
+ const routes = musicCandidatesFromCatalog({ providers: store.runtimeProviders(), assignments: store.assignments() }, args.model);
620
+ const submitted = await core.submitMusicForDsh({
590
621
  candidates: routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider), model: route.modelRef,
591
622
  selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) })),
592
623
  providerInput: normalized.providerInput, musicOptions: normalized.musicOptions, signal
@@ -605,13 +636,13 @@ register('music', async (host, args, { signal }) => {
605
636
  text: `[iris] 音乐已生成(${task.modelRef},重试${submitted.retries}次,Core task: ${task.id})` };
606
637
  });
607
638
 
608
- register('tts', async (host, args, { signal }) => {
639
+ register('tts', async (host, args, { signal, core }) => {
609
640
  const { providerInput } = normalizeGenerationInput('tts', {
610
641
  text: args.text, voice: optionalActionText(args.voice)
611
642
  });
612
643
  const providers = selectProviders(cap.CAPABILITIES.TTS, '语音', args.model);
613
644
  const routes = providers.map((provider) => simpleModelRoute(provider, args, cap.CAPABILITIES.TTS));
614
- const submitted = await submitCoreTts(routes, providerInput, signal);
645
+ const submitted = await submitCoreTts(core, routes, providerInput, signal);
615
646
  const firstArtifactId = submitted.task.artifactIds[0];
616
647
  return {
617
648
  ok: true, storage: 'core', taskId: submitted.task.id, model: submitted.model,
@@ -628,7 +659,7 @@ register('tts', async (host, args, { signal }) => {
628
659
  * 与人工控制面复用同一 Task/Attempt 轴;正文物化为 text/plain transcript Artifact。
629
660
  * 已知边界:临时音频 URL 属于首选候选的账号,failover 到不同账号时如实记录失败。
630
661
  */
631
- async function submitCoreTranscribe(routes, normalized, signal) {
662
+ async function submitCoreTranscribe(core, routes, normalized, signal) {
632
663
  let providerInput = normalized.providerInput;
633
664
  if (normalized.audioPath) {
634
665
  const prepared = await prepareProviderInput(routes[0].adapter, {
@@ -636,7 +667,7 @@ async function submitCoreTranscribe(routes, normalized, signal) {
636
667
  });
637
668
  providerInput = normalizeGenerationInput('transcribe', { audio_url: prepared.url }).providerInput;
638
669
  }
639
- const submitted = await submitProviderTaskForDsh({
670
+ const submitted = await core.submitProviderTaskForDsh({
640
671
  capability: 'transcribe',
641
672
  candidates: routes.map((route) => ({
642
673
  adapter: route.adapter,
@@ -659,7 +690,7 @@ async function submitCoreTranscribe(routes, normalized, signal) {
659
690
  if (!selected) throw taskActionError(task.id, 'Core Task 的供应商身份无法映射回当前路由', task);
660
691
  if (task.acceptance === 'accepted' && task.remoteTaskId
661
692
  && ['none', 'unknown'].includes(task.outcome) && task.phase !== 'terminal') {
662
- await watchProviderTaskForDsh({ taskId: task.id, signal });
693
+ await core.watchProviderTaskForDsh({ taskId: task.id, signal });
663
694
  } else if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready' || !task.artifactIds.length) {
664
695
  throw taskActionError(task.id, '转写路由没有得到可观察任务或可交付产物;事实已保留在 Core,未回退重提', task);
665
696
  }
@@ -667,7 +698,7 @@ async function submitCoreTranscribe(routes, normalized, signal) {
667
698
  }
668
699
 
669
700
  /* ---------- 👁 看图 ---------- */
670
- register('look', async (host, args, { signal }) => {
701
+ register('look', async (host, args, { signal, core }) => {
671
702
  const source = normalizeVisionImageSource(args);
672
703
  const p = String(args.image_path || '').trim();
673
704
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()];
@@ -679,18 +710,18 @@ register('look', async (host, args, { signal }) => {
679
710
  const { answer, via, model } = await askSingleVisionWithHealth(host, { model: args.model,
680
711
  question: String(args.question || '').trim() || '详细描述这张图片。',
681
712
  prepareImage: operationSignal => source === 'artifact_id'
682
- ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
713
+ ? core.visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
683
714
  const viaLabel = via === 'selfstack' ? 'iris 自持栈' : 'DSH 全局视觉模型';
684
715
  return { ok: true, ...(args.artifact_id ? { artifactId: args.artifact_id } : {}),
685
716
  text: `[iris] 看图回答(${model} · ${viaLabel}):\n${answer}` };
686
717
  });
687
718
 
688
719
  /* ---------- ✂️ 裁剪 ---------- */
689
- register('crop', async (host, args, { signal }) => {
720
+ register('crop', async (host, args, { signal, core }) => {
690
721
  const p = String(args.image_path || '').trim();
691
722
  if (!p || !path.isAbsolute(p)) throw new Error('iris: image_path 必须是绝对路径');
692
723
  if (!fs.existsSync(p)) throw new Error('iris: 图片不存在');
693
- const result = await cropForDsh({
724
+ const result = await core.cropForDsh({
694
725
  imagePath: p, left: args.left, top: args.top, width: args.width, height: args.height, signal
695
726
  });
696
727
  const b64 = result.bytes.toString('base64');
@@ -698,9 +729,9 @@ register('crop', async (host, args, { signal }) => {
698
729
  });
699
730
 
700
731
  /* ---------- 📷 像素 diff ---------- */
701
- register('diff', async (host, args, { signal }) => {
732
+ register('diff', async (host, args, { signal, core }) => {
702
733
  if (!args.image_a_path || !args.image_b_path) throw new Error('iris: 需要 image_a_path 和 image_b_path');
703
- const result = await diffForDsh({
734
+ const result = await core.diffForDsh({
704
735
  imageA: { imagePath: args.image_a_path }, imageB: { imagePath: args.image_b_path },
705
736
  grid: args.grid, topRegions: args.top_regions, signal
706
737
  });
@@ -716,32 +747,33 @@ register('diff', async (host, args, { signal }) => {
716
747
  });
717
748
 
718
749
  /* ---------- 📍 定位 ---------- */
719
- register('locate', async (host, args, { signal }) => {
750
+ register('locate', async (host, args, { signal, core }) => {
720
751
  const source = normalizeVisionImageSource(args);
721
752
  const p = String(args.image_path || '').trim();
722
753
  if (source === 'image_path' && (!p || !path.isAbsolute(p))) throw new Error('iris: image_path 必须是绝对路径');
723
754
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
724
- const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model,
755
+ const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
756
+ onModelAvailability: store.recordAvailability, model: args.model,
725
757
  target: args.target, prepareImage: operationSignal => source === 'artifact_id'
726
- ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
758
+ ? core.visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
727
759
  const { width, height } = r;
728
760
  const provenance = args.artifact_id ? { artifactId: args.artifact_id } : {};
729
761
  if (!r.found) return { ok: true, ...provenance, text: `[iris] 在图片中未找到「${args.target}」` };
730
762
  return { ok: true, ...provenance, text: `[iris] 定位「${args.target}」:bbox (${r.x1},${r.y1},${r.x2},${r.y2}) / ${width}x${height}(${r.via} · ${r.model})` };
731
763
  });
732
764
  /* ---------- 🖼️ HTML 截图 ---------- */
733
- register('html', async (host, args, { signal }) => {
765
+ register('html', async (host, args, { signal, core }) => {
734
766
  const html = String(args.html || '').trim();
735
767
  if (!html) throw new Error('iris: html 不能为空');
736
768
  const browser = requireHostPort(host, 'browser', 'HTML 截图');
737
- const rendered = await htmlForDsh({ browser, html, width: args.width, height: args.height,
769
+ const rendered = await core.htmlForDsh({ browser, html, width: args.width, height: args.height,
738
770
  fullPage: args.fullPage !== false, signal });
739
771
  const png = Buffer.from(rendered.bytes);
740
772
  return { ok: true, storage: 'core', artifactIds: [rendered.artifact.id], text: `[iris] HTML 截图完成(fullPage: ${args.fullPage !== false})`, imageDataUrl: `data:image/png;base64,${png.toString('base64')}` };
741
773
  });
742
774
 
743
775
  /* ---------- 📄 长截图 OCR ---------- */
744
- register('ocr', async (host, args, { signal }) => {
776
+ register('ocr', async (host, args, { signal, core }) => {
745
777
  const source = normalizeVisionImageSource(args);
746
778
  const p = String(args.image_path || '').trim();
747
779
  const mediaType = MEDIA_TYPES[path.extname(p).toLowerCase()];
@@ -750,9 +782,9 @@ register('ocr', async (host, args, { signal }) => {
750
782
  if (!mediaType || mediaType === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp)');
751
783
  }
752
784
  const result = await runOcrRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION),
753
- onRateLimit: store.recordRateLimit,
785
+ onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
754
786
  prepareImage: operationSignal => source === 'artifact_id'
755
- ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
787
+ ? core.visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
756
788
  chunkHeight: args.chunk_height, overlap: args.overlap, signal });
757
789
  return { ok: result.status !== 'failed', status: result.status,
758
790
  totalChunks: result.totalChunks, successfulChunks: result.successfulChunks,
@@ -761,7 +793,7 @@ register('ocr', async (host, args, { signal }) => {
761
793
  });
762
794
 
763
795
  /* ---------- 📋 任务查询 ---------- */
764
- register('status', async (host, args, { signal }) => {
796
+ register('status', async (host, args, { signal, core }) => {
765
797
  const requestedId = String(args.task_id || '').trim();
766
798
  if (requestedId) {
767
799
  const legacy = tasks.get(requestedId);
@@ -776,14 +808,14 @@ register('status', async (host, args, { signal }) => {
776
808
  }
777
809
  if (/^task_[a-f0-9]{24}$/.test(requestedId)) {
778
810
  try {
779
- const core = await inspectProviderTaskForDsh(requestedId, { signal });
780
- const lines = [`[${core.capability}] ${core.id} · Core ${core.phase} · ${core.outcome} / ${core.deliveryState} · ${core.modelRef || '未选择模型'}`];
781
- if (core.lastError) lines.push('error: ' + core.lastError.safeMessage);
782
- if (core.remoteTaskId) lines.push('remoteTask: ' + core.remoteTaskId);
783
- if (core.artifactIds.length) {
784
- lines.push('artifacts:\n' + core.artifactIds.map((id) => id + ' · /iris/api/core/artifact/' + encodeURIComponent(id) + '/media').join('\n'));
811
+ const coreTask = await core.inspectProviderTaskForDsh(requestedId, { signal });
812
+ const lines = [`[${coreTask.capability}] ${coreTask.id} · Core ${coreTask.phase} · ${coreTask.outcome} / ${coreTask.deliveryState} · ${coreTask.modelRef || '未选择模型'}`];
813
+ if (coreTask.lastError) lines.push('error: ' + coreTask.lastError.safeMessage);
814
+ if (coreTask.remoteTaskId) lines.push('remoteTask: ' + coreTask.remoteTaskId);
815
+ if (coreTask.artifactIds.length) {
816
+ lines.push('artifacts:\n' + coreTask.artifactIds.map((id) => id + ' · /iris/api/core/artifact/' + encodeURIComponent(id) + '/media').join('\n'));
785
817
  }
786
- return { ok: true, storage: 'core', taskId: core.id, artifactIds: core.artifactIds, text: lines.join('\n') };
818
+ return { ok: true, storage: 'core', taskId: coreTask.id, artifactIds: coreTask.artifactIds, text: lines.join('\n') };
787
819
  } catch (_) { /* 对外统一为不存在,不暴露 Core 数据根 */ }
788
820
  }
789
821
  throw new Error('iris: 没有这个任务:' + args.task_id);
@@ -795,7 +827,7 @@ register('status', async (host, args, { signal }) => {
795
827
  }));
796
828
  let coreRows = [];
797
829
  try {
798
- const snapshot = await coreSnapshotForDsh({ limit: 5, signal });
830
+ const snapshot = await core.coreSnapshotForDsh({ limit: 5, signal });
799
831
  coreRows = (snapshot.tasks.recent || []).map((task) => ({
800
832
  at: task.updatedAt,
801
833
  text: `[${task.capability}] ${task.id} · Core ${task.phase} · ${task.outcome} / ${task.deliveryState} · ${task.modelRef || '未选择模型'}`
@@ -822,7 +854,7 @@ register('task_reobserve', async (host, args) => {
822
854
  return { ok: true, taskId: id, text: '[iris] 已重新观察远端任务;不会重新提交生成请求。' };
823
855
  });
824
856
 
825
- register('task_redeliver', async (host, args, { signal }) => {
857
+ register('task_redeliver', async (host, args, { signal, core }) => {
826
858
  const id = String(args.task_id || '').trim();
827
859
  const task = tasks.get(id);
828
860
  if (!task || !tasks.isV2Task(task)) throw new Error('iris: 找不到可重新交付的 Task v2');
@@ -864,7 +896,7 @@ register('task_restore_attention', async (_host, args) => {
864
896
  return { ok: true, taskId: id, text: '[iris] 已恢复任务提醒。' };
865
897
  });
866
898
 
867
- register('task_manual_retry', async (host, args, { signal }) => {
899
+ register('task_manual_retry', async (host, args, { signal, core }) => {
868
900
  const id = String(args.task_id || '').trim();
869
901
  if (args.confirm_duplicate_charge !== true) throw new Error('iris: 必须明确确认本次操作可能产生重复任务或重复费用');
870
902
  const task = tasks.get(id);
@@ -888,7 +920,7 @@ register('task_manual_retry', async (host, args, { signal }) => {
888
920
  if (task.modelRef) retryArgs.model = task.modelRef;
889
921
  retryArgs._retryOf = id;
890
922
  try {
891
- const result = await runAction(host, action, retryArgs, { signal });
923
+ const result = await runAction(host, action, retryArgs, { signal, core });
892
924
  if (result.taskId) tasks.linkManualRetry(id, result.taskId);
893
925
  return { ...result, retryOf: id, text: (result.text || '[iris] 已创建新任务')
894
926
  + '\n[iris] 原任务提醒已归档,并保留新旧任务关联;本次新任务可能与远端未知任务重复。' };
@@ -1001,7 +1033,7 @@ register('tasks_purge_orphans', async () => {
1001
1033
  });
1002
1034
 
1003
1035
  /* ---------- 👁 重看(GUI:只查 iris 自己的任务产物,无会话上下文) ---------- */
1004
- register('relook', async (host, args, { signal }) => {
1036
+ register('relook', async (host, args, { signal, core }) => {
1005
1037
  const id = String(args.attachment_id || '').trim();
1006
1038
  if (!id) throw new Error('iris: attachment_id 不能为空');
1007
1039
  const question = String(args.question || '').trim();
@@ -1032,22 +1064,32 @@ register('relook', async (host, args, { signal }) => {
1032
1064
  /** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
1033
1065
  register('providers_list', async (host, args) => {
1034
1066
  const list = store.allProviders();
1067
+ const connections = list.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
1068
+ ? await host.ports.providerConnections.list() : [];
1035
1069
  return {
1036
1070
  ok: true,
1071
+ setupSuggested: Object.keys(store.assignments()).length === 0 && list.every(provider => models.providerModels(provider).length === 0),
1037
1072
  providers: list.map((p) => ({
1038
1073
  id: p.id,
1039
1074
  name: p.name || p.id,
1040
1075
  type: p.type || 'openai',
1041
- baseUrl: p.baseUrl,
1076
+ baseUrl: p.dshProvider ? (connections.find(item => item.provider === p.dshProvider.provider
1077
+ && item.settingsNs === p.dshProvider.settingsNs)?.baseUrl || '') : p.baseUrl,
1078
+ ...(p.dshProvider ? { dshProvider: p.dshProvider,
1079
+ connection: connections.find(item => item.provider === p.dshProvider.provider && item.settingsNs === p.dshProvider.settingsNs)
1080
+ || { available: false, reason: 'DSH 原连接不可用,请检查宿主供应商设置' } } : {}),
1042
1081
  mediaBaseUrl: p.mediaBaseUrl || '',
1043
1082
  enabled: p.enabled !== false,
1044
1083
  auth: p.auth === 'none' ? 'none' : 'bearer',
1045
1084
  ...selectMediaProtocol(p),
1046
1085
  visionInput: p.visionInput || null,
1047
- apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1048
- models: models.providerModels(p).map((m) => ({
1086
+ directory: modelDirectorySummary(p),
1087
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1088
+ models: models.providerModelsWithDirectory(p).map((m) => ({
1049
1089
  id: m.id,
1050
1090
  capabilities: m.capabilities,
1091
+ ...(m.directoryOnly ? { directoryOnly: true } : {}),
1092
+ ...(m.catalogStatus ? { catalogStatus: m.catalogStatus } : {}),
1051
1093
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
1052
1094
  ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
1053
1095
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
@@ -1064,6 +1106,12 @@ register('providers_list', async (host, args) => {
1064
1106
  };
1065
1107
  });
1066
1108
 
1109
+ register('providers_host_connections', async host => {
1110
+ if (!hasHostPort(host, 'providerConnections')) return { ok: true, connections: [],
1111
+ note: '当前 DSH 未提供连接复用接口,请手动添加 Iris 供应商' };
1112
+ return { ok: true, connections: await host.ports.providerConnections.list() };
1113
+ });
1114
+
1067
1115
  /** 新增/更新供应商 */
1068
1116
  register('providers_upsert', async (host, args) => {
1069
1117
  const id = args.id || undefined;
@@ -1073,6 +1121,16 @@ register('providers_upsert', async (host, args) => {
1073
1121
  for (const key of allowed) {
1074
1122
  if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
1075
1123
  }
1124
+ if (Object.hasOwn(args, 'dshProvider')) {
1125
+ patch.dshProvider = normalizeDshProvider(args.dshProvider);
1126
+ const source = requireHostPort(host, 'providerConnections', '复用 DSH 供应商连接');
1127
+ const connection = (await source.list()).find(item => item.provider === patch.dshProvider.provider
1128
+ && item.settingsNs === patch.dshProvider.settingsNs);
1129
+ if (!connection?.available) throw new Error(connection?.reason || 'DSH 连接不可用');
1130
+ if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(patch.mediaProtocol)) {
1131
+ throw new Error('复用 DSH 连接时请明确选择媒体协议');
1132
+ }
1133
+ }
1076
1134
  if (!id && !Object.prototype.hasOwnProperty.call(patch, 'enabled')) patch.enabled = true;
1077
1135
  const saved = store.upsert(patch);
1078
1136
  return { ok: true, providerId: saved.id };
@@ -1095,33 +1153,39 @@ register('providers_set_models', async (host, args) => {
1095
1153
  * 模型发现:读取供应商目录 → 明确能力元数据 → 合并模型池。
1096
1154
  * 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
1097
1155
  */
1098
- register('providers_discover', async (host, args, { signal }) => {
1156
+ register('providers_discover', async (host, args, { signal, core }) => {
1099
1157
  const p = store.providerById(String(args.id || ''));
1100
1158
  if (!p) throw new Error('iris: 供应商不存在或未启用');
1101
- let discoveredModels;
1102
- try {
1103
- const providerAdapter = createConfiguredProviderAdapter(p);
1104
- const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
1105
- discoveredModels = discovered.models;
1106
- } catch (err) {
1107
- throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
1108
- }
1109
- const entries = discoveredModels.map((model) => ({ id: model.id, capabilities: models.capabilitiesOfDiscoveredModel(model) }));
1159
+ const [updated] = await store.refreshProviderDirectories({ providerId: p.id, force: true, signal });
1160
+ if (updated.error) throw new Error('iris: 模型发现失败(保留现有池):' + updated.error);
1161
+ const entries = updated.directory.models;
1110
1162
  const mediaCount = entries.filter((model) => model.capabilities.length).length;
1111
1163
  store.mergeDiscoveredModels(p.id, entries);
1112
- return { ok: true, total: discoveredModels.length, media: mediaCount,
1164
+ return { ok: true, total: entries.length, media: mediaCount,
1113
1165
  text: `[iris] ${p.name}:发现 ${entries.length} 个模型,${mediaCount} 个有能力声明;未标注模型请在模型池中设置能力` };
1114
1166
  });
1115
1167
 
1168
+ /** 只更新目录快照;新增项在管理视图待确认,不改模型用途与功能名单。 */
1169
+ register('providers_sync_directory', async (host, args, { signal, core }) => {
1170
+ const id = String(args.id || '').trim();
1171
+ if (!store.providerById(id)) throw new Error('iris: 供应商不存在或连接未就绪');
1172
+ const [result] = await store.refreshProviderDirectories({ providerId: id, force: args.force === true, signal });
1173
+ if (result.error) return { ok: true, synced: false, error: result.error,
1174
+ text: '目录同步失败,已保留上次完整目录:' + result.error };
1175
+ return { ok: true, synced: true, refreshed: result.refreshed,
1176
+ text: result.refreshed ? '模型目录已同步;新模型仍需确认用途' : '目录仍在有效期内',
1177
+ directory: modelDirectorySummary(store.providerById(id)) };
1178
+ });
1179
+
1116
1180
  /** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
1117
- register('providers_test_vision', async (host, args, { signal }) => {
1181
+ register('providers_test_vision', async (host, args, { signal, core }) => {
1118
1182
  const p = store.providerById(String(args.id || ''));
1119
1183
  if (!p) throw new Error('iris: 供应商不存在');
1120
1184
  const vision = models.providerModels(p).find(m => m.capabilities.includes(cap.CAPABILITIES.VISION));
1121
1185
  if (!vision) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
1122
1186
  const model = vision.id;
1123
1187
  if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
1124
- const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1188
+ const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
1125
1189
  store.setModelVerified(p.id, model, cap.CAPABILITIES.VISION, {
1126
1190
  ok: r.ok,
1127
1191
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
@@ -1146,7 +1210,7 @@ async function probeModel(host, p, modelId, capability, signal) {
1146
1210
  return { skipped: true, ok: false, note: 'Music 不发起小样本探测;请通过音乐功能提交实际创作要求' };
1147
1211
  }
1148
1212
  if (capability === cap.CAPABILITIES.VISION) {
1149
- const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1213
+ const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
1150
1214
  return {
1151
1215
  ok: r.ok,
1152
1216
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
@@ -1158,6 +1222,8 @@ async function probeModel(host, p, modelId, capability, signal) {
1158
1222
  return { skipped: true, ok: false, note: '需真实音频;可用 CLI models test --capability transcribe --input 提供素材进行实测恢复' };
1159
1223
  }
1160
1224
  if (capability === cap.CAPABILITIES.TTS) {
1225
+ const issue = selectMediaProtocol(p).mediaProtocol === 'dashscope' && dashscopeTtsInputIssue(modelId);
1226
+ if (issue) return { skipped: true, ok: false, note: issue };
1161
1227
  const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true });
1162
1228
  const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
1163
1229
  capability: 'tts', model: modelId, input: { text: '你好' }, signal
@@ -1231,6 +1297,14 @@ register('providers_add_model', async (host, args) => {
1231
1297
  return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${(caps || []).join(',') || '未标注'})` };
1232
1298
  });
1233
1299
 
1300
+ register('providers_confirm_model', async (_host, args) => {
1301
+ const entry = store.confirmModelUsage(args.id, args.model_id, args.capabilities, {
1302
+ enableAutomatic: args.enableAutomatic, musicProtocol: args.musicProtocol
1303
+ });
1304
+ return { ok: true, model_id: entry.id, text: args.enableAutomatic
1305
+ ? '已确认模型用途,并启用所选功能的自动池' : '已确认模型用途;已有调用方式保持' };
1306
+ });
1307
+
1234
1308
  /** 从池移除模型 */
1235
1309
  register('providers_remove_model', async (host, args) => {
1236
1310
  const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
@@ -1266,7 +1340,7 @@ register('providers_set_model_vision_input', async (host, args) => {
1266
1340
  });
1267
1341
 
1268
1342
  /** 逐模型实测某能力 → 存 verified("test it, don't guess it";用户手动触发,发现不自动跑) */
1269
- register('providers_test_model', async (host, args, { signal }) => {
1343
+ register('providers_test_model', async (host, args, { signal, core }) => {
1270
1344
  const p = store.providerById(String(args.id || ''));
1271
1345
  if (!p) throw new Error('iris: 供应商不存在或未启用');
1272
1346
  const modelId = String(args.model_id || '').trim();
@@ -1303,22 +1377,62 @@ register('providers_test_model', async (host, args, { signal }) => {
1303
1377
  register('assignments_get', async (host, args) => {
1304
1378
  const providers = store.allProviders();
1305
1379
  const configured = new Set(store.providers().map(provider => provider.id));
1380
+ const connections = providers.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
1381
+ ? await host.ports.providerConnections.list() : [];
1382
+ for (const provider of providers) {
1383
+ if (provider.enabled !== false && provider.dshProvider && connections.some(item => item.available
1384
+ && item.provider === provider.dshProvider.provider && item.settingsNs === provider.dshProvider.settingsNs)) {
1385
+ configured.add(provider.id);
1386
+ }
1387
+ }
1388
+ const previewProviders = providers.map(provider => provider.dshProvider ? { ...provider,
1389
+ baseUrl: connections.find(item => item.provider === provider.dshProvider.provider
1390
+ && item.settingsNs === provider.dshProvider.settingsNs)?.baseUrl } : provider);
1306
1391
  const pool = models.modelPool(providers, { includeHostVision: true });
1307
1392
  const byCap = {};
1308
1393
  for (const m of pool) {
1309
1394
  for (const c of m.capabilities) {
1310
1395
  if (!byCap[c]) byCap[c] = [];
1311
- const provider = providers.find(provider => provider.id === m.providerId);
1396
+ const provider = previewProviders.find(provider => provider.id === m.providerId);
1397
+ const hostResource = m.ref === models.HOST_VISION_REF;
1398
+ const protocolSupported = hostResource || supportsModelCapability(provider, m.id, c);
1399
+ const modelUnavailable = m.unavailableCapabilities?.includes(c) === true;
1400
+ const connected = hostResource ? hasHostPort(host, 'textModel') : configured.has(m.providerId);
1312
1401
  byCap[c].push({ ref: m.ref, id: m.id, providerId: m.providerId,
1313
1402
  label: m.ref === models.HOST_VISION_REF ? 'DSH 当前默认模型' : m.id + ' · ' + m.providerId,
1314
- unavailable: m.ref !== models.HOST_VISION_REF && (!configured.has(m.providerId) || m.rateLimited === true),
1403
+ unavailable: !connected || !protocolSupported || m.rateLimited === true || modelUnavailable,
1404
+ modelUnavailable,
1405
+ pendingConfirmation: m.source === 'discovered', protocolSupported,
1406
+ reason: !connected ? '连接未就绪,请检查供应商或宿主服务' : !protocolSupported
1407
+ ? (c === cap.CAPABILITIES.TTS && selectMediaProtocol(provider).mediaProtocol === 'dashscope'
1408
+ && dashscopeTtsProtocolIssue(m.id)) || '当前接口协议不支持此功能'
1409
+ : modelUnavailable ? modelUnavailableReason() : m.rateLimited ? '限流冷却或额度不可用,请稍后或检查账号' : m.source === 'discovered' ? '已发现,待确认模型用途' : '',
1315
1410
  ...(c === cap.CAPABILITIES.IMAGE && provider ? { imageRouting: imageRoutingForModel(provider, m.id) } : {}) });
1316
1411
  }
1317
1412
  }
1318
1413
  // order:归一化的有序分配列表(GUI 直接渲染 failover 顺序,无需处理单字符串旧格式)
1319
1414
  const order = {};
1320
1415
  for (const c of Object.values(cap.CAPABILITIES)) order[c] = store.assignmentOrder(c);
1321
- return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap };
1416
+ const policies = {}, readiness = {};
1417
+ for (const capability of Object.values(cap.CAPABILITIES)) {
1418
+ const assignment = store.assignments()[capability];
1419
+ const policy = models.functionPolicy(assignment, capability);
1420
+ policies[capability] = policy;
1421
+ if (policy.mode === 'auto') order[capability] = models.orderedModels(
1422
+ models.functionModelPool(previewProviders, capability, assignment, { includeHostVision: true }),
1423
+ capability, assignment, { includeRateLimited: true, includeUnavailable: true }).map(model => model.ref);
1424
+ const options = byCap[capability] || [];
1425
+ const candidates = options.filter(model => order[capability].includes(model.ref) && !model.unavailable);
1426
+ let note = '';
1427
+ if (policy.mode === 'disabled') note = '此功能已停用,可选择自动池或手选模型';
1428
+ else if (policy.mode === 'manual' && !order[capability].length) note = '尚未选择模型,可添加所选项或改用自动池';
1429
+ else if (!options.length) note = '请先添加模型,并确认它可用于此功能';
1430
+ else if (!candidates.length) note = options.some(model => model.pendingConfirmation)
1431
+ ? '发现的模型尚待确认用途,请在供应商中确认功能'
1432
+ : options.find(model => model.reason)?.reason || '当前没有匹配的候选,请检查功能范围和模型';
1433
+ readiness[capability] = { mode: policy.mode, candidateCount: candidates.length, note };
1434
+ }
1435
+ return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap, policies, readiness };
1322
1436
  });
1323
1437
 
1324
1438
  /**
@@ -1329,6 +1443,10 @@ register('assignments_get', async (host, args) => {
1329
1443
  register('assignments_set', async (host, args) => {
1330
1444
  const capability = String(args.capability || '').trim();
1331
1445
  if (!capability) throw new Error('iris: capability 不能为空');
1446
+ if (Object.hasOwn(args, 'mode')) {
1447
+ const policy = store.setFunctionPolicy(capability, args.mode, { hostVision: args.hostVision });
1448
+ return { ok: true, capability, mode: policy.mode, model_refs: store.assignmentOrder(capability) };
1449
+ }
1332
1450
  const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
1333
1451
  if (Array.isArray(listArg)) {
1334
1452
  const refs = listArg.map((x) => String(x || '').trim()).filter(Boolean);
@@ -1378,7 +1496,7 @@ register('attachments_list', async (host, args) => {
1378
1496
  * 把一个附件(会话 image 或 iris 产物)导出为宿主可读的文件路径,供工具消费。
1379
1497
  * iris 产物直接返回其 outputs 路径;会话附件读字节存 uploads/(按 attachmentId 哈希缓存,不重复落盘)。
1380
1498
  */
1381
- register('attachment_export', async (host, args, { signal }) => {
1499
+ register('attachment_export', async (host, args, { signal, core }) => {
1382
1500
  const id = String(args.attachment_id || '').trim();
1383
1501
  if (!id) throw new Error('iris: attachment_id 不能为空');
1384
1502
  // 1) iris 自有产物:文件已在 outputs/
@@ -1409,7 +1527,7 @@ register('attachment_export', async (host, args, { signal }) => {
1409
1527
 
1410
1528
  /* ============ 音频转写(阶段 7.2) ============ */
1411
1529
 
1412
- register('transcribe', async (host, args, { signal }) => {
1530
+ register('transcribe', async (host, args, { signal, core }) => {
1413
1531
  const normalized = normalizeGenerationInput('transcribe', {
1414
1532
  audio_url: args.audio_url, audio_path: args.audio_path
1415
1533
  }, { allowAudioPath: true });
@@ -1418,7 +1536,7 @@ register('transcribe', async (host, args, { signal }) => {
1418
1536
  const routes = providers.map((provider) =>
1419
1537
  simpleModelRoute(provider, args, cap.CAPABILITIES.TRANSCRIBE)
1420
1538
  );
1421
- const submitted = await submitCoreTranscribe(routes, normalized, signal);
1539
+ const submitted = await submitCoreTranscribe(core, routes, normalized, signal);
1422
1540
  const firstArtifactId = submitted.task.artifactIds[0];
1423
1541
  const ready = submitted.task.outcome === 'succeeded'
1424
1542
  && submitted.task.deliveryState === 'ready' && Boolean(firstArtifactId);
@@ -1435,8 +1553,8 @@ register('transcribe', async (host, args, { signal }) => {
1435
1553
 
1436
1554
  /* ============ 视频抽帧(阶段 7.1) ============ */
1437
1555
 
1438
- register('video_frames', async (host, args, { signal }) => {
1439
- const { media: meta, frames } = await framesForDsh({
1556
+ register('video_frames', async (host, args, { signal, core }) => {
1557
+ const { media: meta, frames } = await core.framesForDsh({
1440
1558
  videoPath: args.video_path, maxFrames: args.max_frames, targetWidth: args.target_width,
1441
1559
  format: optionalActionText(args.format), quality: args.quality, signal
1442
1560
  });
@@ -1456,7 +1574,7 @@ register('video_frames', async (host, args, { signal }) => {
1456
1574
 
1457
1575
  /* ============ 多模态视频摘要(阶段 7.3,GUI 同步快速版) ============ */
1458
1576
 
1459
- register('media_summarize', async (host, args, { signal }) => {
1577
+ register('media_summarize', async (host, args, { signal, core }) => {
1460
1578
  const source = normalizeSummarySource(args);
1461
1579
  const p = String(args.video_path || '').trim();
1462
1580
  if (source === 'video') {
@@ -1465,11 +1583,11 @@ register('media_summarize', async (host, args, { signal }) => {
1465
1583
  if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
1466
1584
  }
1467
1585
  const { answer, via, model, sheet, meta, frames } = await runSummaryRequest(host, {
1468
- onRateLimit: store.recordRateLimit,
1586
+ onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
1469
1587
  providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model, question: args.question, signal,
1470
1588
  async prepareMedia(operationSignal) {
1471
1589
  if (source === 'core-artifacts') {
1472
- return { ...await summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal: operationSignal }), transcript: args.transcribe_text };
1590
+ return { ...await core.summaryFramesForDsh({ frameArtifactIds: args.frame_artifact_ids, signal: operationSignal }), transcript: args.transcribe_text };
1473
1591
  }
1474
1592
  const meta = probeVideo(p);
1475
1593
  const frames = await extractFrames({ inputPath: p, maxFrames: Math.min(Number(args.max_frames) || 8, 12),