@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1

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 (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
@@ -133,12 +133,15 @@ function dshVisionPort(llm, defaults) {
133
133
  if (chunk?.type === 'finish') {
134
134
  if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
135
135
  if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
136
+ if (['length', 'max-tokens', 'tool-calls', 'content-blocked'].includes(chunk.reason?.kind)) {
137
+ throw visionRouteError('DSH 视觉结果未完整结束:' + chunk.reason.kind);
138
+ }
136
139
  }
137
140
  const text = typeof chunk?.delta === 'string'
138
141
  ? chunk.delta
139
142
  : (chunk?.type === 'text-delta' ? String(chunk.text || '') : '');
140
143
  if (text) chunks.push(text);
141
- if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) break;
144
+ if (chunks.reduce((sum, item) => sum + item.length, 0) > 6000) throw visionRouteError('DSH 视觉结果超过长度限制,未返回部分结果');
142
145
  }
143
146
  return chunks.join('').trim();
144
147
  }
@@ -24,7 +24,7 @@ export async function withDshProviderConfig(host, callback, { signal, fresh = fa
24
24
  throw new DshProviderSourceError('当前宿主无法解析 DSH 连接,请检查原连接或改用手动供应商');
25
25
  }
26
26
  const value = await host.ports.providerConnections.resolve(provider.dshProvider, { signal });
27
- return { ...provider, baseUrl: value.baseUrl, apiKey: value.apiKey };
27
+ return { ...provider, mediaBaseUrl: undefined, baseUrl: value.baseUrl, apiKey: value.apiKey };
28
28
  } catch (error) {
29
29
  if (!(error instanceof DshProviderSourceError)) throw error;
30
30
  return { ...provider, enabled: false, connectionError: error.message };
@@ -0,0 +1,67 @@
1
+ import { createCoreServiceClient } from './core-service-client.js';
2
+ import { bridgeToolsForScopes, callCoreBridgeTool } from './core-bridge-tools.js';
3
+ import { requireHostPort } from './host-contract.js';
4
+
5
+ /** Optional shared-Flow connection. Native DSH ports stay in the host; no remote ctx or local execution fallback. */
6
+ export function createDshServiceBridge({ connectionFile, requestedScopes = ['read'], getHost, harnessVersion = 'unknown', fetch } = {}) {
7
+ if (typeof getHost !== 'function') throw new TypeError('DSH 服务桥需要真实 Host 工厂');
8
+ const client = createCoreServiceClient({ connectionFile, requestedScopes, harnessVersion, fetch });
9
+ const lifetime = new AbortController(), jobs = new Set();
10
+ const toolDisposers = [];
11
+ let state = 'new', disposal, remoteDisconnect = 'not_connected';
12
+ async function start() {
13
+ if (state !== 'new') throw new Error('DSH Core 服务桥只能启动一次');
14
+ state = 'connecting';
15
+ try {
16
+ await client.connect();
17
+ if (client.snapshot().adapterId !== 'iris.dsh-service') throw Object.assign(new Error('连接文件不是 DSH 适配授权'), { code: 'IRIS_CONNECTION_INPUT_INVALID' });
18
+ const selected = client.snapshot();
19
+ if (selected.capabilities.some(id => ['tts', 'transcribe'].includes(id))) throw Object.assign(new Error('DSH 原生共享桥尚未接入音频附件交付;请为此桥选择图片功能,音频使用通用 MCP 或 Core 工作台'), { code: 'IRIS_DSH_AUDIO_NOT_SUPPORTED' });
20
+ if (selected.capabilities.includes('video-generate')) throw Object.assign(new Error('DSH 原生共享桥尚未接入视频资源交付;请为此桥选择图片功能,视频使用通用 MCP 或 Core 工作台'), { code: 'IRIS_DSH_VIDEO_NOT_SUPPORTED' });
21
+ const tools = requireHostPort(getHost(), 'tools', '注册 Core 服务工具');
22
+ for (const tool of bridgeToolsForScopes(selected.scopes, selected.capabilities, selected.tools, selected.pluginOperations)) {
23
+ const remove = tools.register({ name: tool.name, description: tool.description, parameters: tool.parameters,
24
+ isConcurrencySafe: () => tool.scope === 'read' || tool.scope === 'stop',
25
+ output: { schema: { type: 'object' }, render: (_args, result) => result.blocks },
26
+ async execute(args, exec = {}) {
27
+ const signal = AbortSignal.any([lifetime.signal, ...(exec.signal ? [exec.signal] : [])]);
28
+ const job = (async () => {
29
+ signal.throwIfAborted();
30
+ const result = await callCoreBridgeTool(client, tool.name, args, { signal });
31
+ if (!result.image) return { value: result.value, blocks: [{ type: 'text', text: JSON.stringify(result.value) }] };
32
+ const attachments = requireHostPort(getHost(), 'attachments', '显示 Core 图片');
33
+ const attachment = await attachments.saveImage({ data: new Uint8Array(result.image.bytes), mediaType: result.image.mediaType,
34
+ name: 'iris-core-' + result.image.artifactId + '.' + (result.image.mediaType === 'image/jpeg' ? 'jpg' : result.image.mediaType.slice(6)) });
35
+ signal.throwIfAborted();
36
+ if (!attachment?.attachmentId) throw Object.assign(new Error('DSH 没有返回图片附件身份'), { code: 'IRIS_DSH_PROJECTION_FAILED' });
37
+ return { artifactId: result.image.artifactId, blocks: [{ type: 'image', attachment }] };
38
+ })();
39
+ jobs.add(job); try { return await job; } finally { jobs.delete(job); }
40
+ }
41
+ });
42
+ if (typeof remove === 'function') toolDisposers.push(remove);
43
+ }
44
+ state = 'open'; return snapshot();
45
+ } catch (error) {
46
+ try { await dispose(); }
47
+ catch (cleanup) { throw new AggregateError([error, cleanup], 'DSH 服务桥启动与释放均失败'); }
48
+ throw error;
49
+ }
50
+ }
51
+ function snapshot() { return { state, connection: client.snapshot(), operations: jobs.size, remoteDisconnect }; }
52
+ function dispose() {
53
+ if (disposal) return disposal;
54
+ state = 'closing'; lifetime.abort();
55
+ disposal = (async () => {
56
+ const errors = [];
57
+ for (const remove of toolDisposers.splice(0).reverse()) { try { await remove(); } catch (error) { errors.push(error); } }
58
+ await Promise.allSettled([...jobs]);
59
+ try { remoteDisconnect = (await client.dispose()).remoteDisconnect; } catch (error) { errors.push(error); }
60
+ state = 'closed';
61
+ if (errors.length) throw new AggregateError(errors, 'DSH 服务桥释放失败');
62
+ return snapshot();
63
+ })();
64
+ return disposal;
65
+ }
66
+ return Object.freeze({ start, snapshot, dispose });
67
+ }
@@ -0,0 +1,43 @@
1
+ // Runner ownership uses the existing Core lease protocol in its own private directory.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readWriterLease, WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
5
+
6
+ const UUID = '[a-f0-9-]{36}';
7
+ const FILE = new RegExp('^(?:' + WRITER_INTENT_FILE.replaceAll('.', '\\.')
8
+ + '(?:\\.prepare-[1-9][0-9]*-[a-f0-9]{12})?|' + WRITER_LEASE_DIR.replaceAll('.', '\\.')
9
+ + '\\.recovery-' + UUID + '\\.json)$');
10
+ const RETIRED = new RegExp('^' + WRITER_LEASE_DIR.replaceAll('.', '\\.') + '\\.released-' + UUID + '$');
11
+ const invalid = () => { throw Object.assign(new Error('执行所有权目录未知或损坏;已保留现场'), { code: 'IRIS_EXECUTION_LEASE_INVALID' }); };
12
+
13
+ export function inspectRunnerArea(directory) {
14
+ function regular(file) {
15
+ const stat = fs.lstatSync(file);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024) invalid();
17
+ }
18
+ function ownerDirectory(file, audit = false) {
19
+ const stat = fs.lstatSync(file);
20
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
21
+ for (const name of fs.readdirSync(file)) {
22
+ if (name !== 'owner.json' && !/^\.owner\.stale-[a-f0-9-]{36}\.json$/.test(name)
23
+ && !(audit && new RegExp('^' + WRITER_LEASE_DIR.replaceAll('.', '\\.') + '\\.recovery-' + UUID + '\\.json$').test(name))) invalid();
24
+ regular(path.join(file, name));
25
+ }
26
+ }
27
+ for (const name of fs.readdirSync(directory)) {
28
+ const file = path.join(directory, name);
29
+ if (name === WRITER_LEASE_DIR || RETIRED.test(name)) ownerDirectory(file);
30
+ else if (FILE.test(name)) regular(file);
31
+ else if (name === '.iris-runtime-recovery-audit-v0') {
32
+ const stat = fs.lstatSync(file);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink()) invalid();
34
+ for (const entry of fs.readdirSync(file)) {
35
+ if (/^lease_recovery_[a-f0-9]{24}\.json$/.test(entry)) regular(path.join(file, entry));
36
+ else if (/^lease_recovery_[a-f0-9]{24}\.lease$/.test(entry)) ownerDirectory(path.join(file, entry), true);
37
+ else invalid();
38
+ }
39
+ } else invalid();
40
+ }
41
+ try { return readWriterLease(directory); }
42
+ catch (_) { invalid(); }
43
+ }
package/lib/flow-store.js CHANGED
@@ -4,9 +4,14 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
7
- import { WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
7
+ import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, isAbandonedWriterPreparation } from './core-writer-lease.js';
8
8
  import { normalizeGenerationInput } from './generation-input.js';
9
9
  import { parseModelRef } from './models.js';
10
+ import { inspectLocalRequestRoot } from './local-request-root.js';
11
+ import { normalizeComfyParameters } from './comfyui-workflow.js';
12
+ import { AUDIO_RECIPES, isAudioRecipe, normalizeAudioFlowInput } from './audio-flow-input.js';
13
+ import { VIDEO_RECIPE, normalizeVideoFlowInput } from './video-flow-input.js';
14
+ import { checkedNetworkRequirement } from './network-policy.js';
10
15
 
11
16
  const RUN = /^run_[a-f0-9]{24}$/;
12
17
  const TASK = /^task_[a-f0-9]{24}$/;
@@ -14,6 +19,7 @@ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
14
19
  const SHA = /^[a-f0-9]{64}$/;
15
20
  const BINDING = /^sha256:[a-f0-9]{64}$/;
16
21
  const RECIPE = 'image-edit/v0';
22
+ export const IMAGE_GENERATE_RECIPE = 'image-generate/v0';
17
23
 
18
24
  export class FlowError extends Error {
19
25
  constructor(code, message) { super(message); this.name = 'FlowError'; this.code = code; }
@@ -28,25 +34,114 @@ function iso(value) { return typeof value === 'string' && Number.isFinite(Date.p
28
34
  const copy = value => Object.freeze(structuredClone(value));
29
35
  export const flowId = prefix => prefix + '_' + crypto.randomBytes(12).toString('hex');
30
36
  export const flowSha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
37
+ export const flowStepId = run => run.recipe === VIDEO_RECIPE ? 'video' : isAudioRecipe(run.recipe) ? AUDIO_RECIPES[run.recipe].step : run.recipe === IMAGE_GENERATE_RECIPE ? 'generate' : 'edit';
38
+ export const flowCapability = run => run.recipe === VIDEO_RECIPE ? 'video' : isAudioRecipe(run.recipe) ? AUDIO_RECIPES[run.recipe].capability : 'image';
39
+ export const flowStep = run => run[flowStepId(run)];
40
+ export function requireImageGenerationRoot(root) {
41
+ if (![2, 3, 4].includes(inspectLocalRequestRoot(root, { allowAbsent: true })?.schemaVersion)) {
42
+ flowFail('IRIS_FLOW_GENERATION_ROOT_REQUIRED', '文生图需要新的专用执行根:execution init --image-generation;不迁移已有根');
43
+ }
44
+ }
45
+ export function requireAudioRoot(root) {
46
+ if (![3, 4].includes(inspectLocalRequestRoot(root, { allowAbsent: true })?.schemaVersion)) {
47
+ flowFail('IRIS_FLOW_AUDIO_ROOT_REQUIRED', '音频需要新的专用执行根:execution init --audio;不迁移已有根');
48
+ }
49
+ }
50
+
51
+ export function requireVideoRoot(root) {
52
+ if (inspectLocalRequestRoot(root, { allowAbsent: true })?.schemaVersion !== 4) {
53
+ flowFail('IRIS_FLOW_VIDEO_ROOT_REQUIRED', '文生视频需要新的专用 execution init --video 根;原根不迁移');
54
+ }
55
+ }
31
56
 
32
57
  export function validFlowBinding(value) {
33
58
  return keys(value, ['runId', 'stepId', 'inputDigest']) && RUN.test(value.runId)
34
- && value.stepId === 'edit' && BINDING.test(value.inputDigest);
59
+ && ['edit', 'generate', 'speak', 'transcribe', 'video'].includes(value.stepId) && BINDING.test(value.inputDigest);
35
60
  }
36
- export function flowTaskBinding(run) { return { runId: run.id, stepId: 'edit', inputDigest: run.inputDigest }; }
61
+ export function flowTaskBinding(run) { return { runId: run.id, stepId: flowStepId(run), inputDigest: run.inputDigest }; }
37
62
 
38
- function inputDigest(run) {
63
+ function legacyInputDigest(run) {
64
+ if (run.recipe === VIDEO_RECIPE) return 'sha256:' + flowSha256(JSON.stringify([VIDEO_RECIPE, run.providerBinding, normalizeVideoFlowInput(run.input)]));
65
+ if (isAudioRecipe(run.recipe)) return 'sha256:' + flowSha256(JSON.stringify([run.recipe, run.providerBinding,
66
+ normalizeAudioFlowInput(run.recipe, run.input), run.source?.sha256 ?? null]));
67
+ if (run.recipe === IMAGE_GENERATE_RECIPE) return 'sha256:' + flowSha256(JSON.stringify([IMAGE_GENERATE_RECIPE,
68
+ run.input.model_ref, run.providerBinding, run.input.prompt, run.input.size ?? null, 1,
69
+ ...(run.input.workflow_parameters !== undefined ? [['comfyui-parameters/v0', normalizeComfyParameters(run.input.workflow_parameters)]] : [])]));
39
70
  return 'sha256:' + flowSha256(JSON.stringify([RECIPE, run.source.artifactId, run.source.sha256,
40
71
  run.input.model_ref, run.providerBinding, run.input.prompt, run.input.size ?? null, 1]));
41
72
  }
42
73
 
74
+ function networkPart(value) {
75
+ if (value?.networkRequirement === undefined) return {};
76
+ try { return { networkRequirement: checkedNetworkRequirement(value.networkRequirement) }; }
77
+ catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '流程网络要求身份或版本无效'); }
78
+ }
79
+ function inputDigest(run) {
80
+ const base = legacyInputDigest(run);
81
+ return run.networkRequirement === undefined ? base
82
+ : 'sha256:' + flowSha256(JSON.stringify(['network-flow/v1', base, networkPart(run).networkRequirement]));
83
+ }
84
+
43
85
  function reference(value) { return keys(value, ['artifactId', 'sha256']) && ARTIFACT.test(value.artifactId) && SHA.test(value.sha256); }
86
+ function validateAudio(run, expectedId) {
87
+ let normalized;
88
+ try { normalized = normalizeAudioFlowInput(run.recipe, run.input); }
89
+ catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '音频流程输入无效'); }
90
+ const step = flowStepId(run), transcribe = run.recipe === 'transcribe/v0';
91
+ if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', step, 'source', 'output', 'networkRequirement'],
92
+ ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', step, ...(transcribe ? ['source'] : [])])
93
+ || run.schemaVersion !== 3 || !RUN.test(run.id) || run.id !== expectedId || !Number.isSafeInteger(run.revision) || run.revision < 1
94
+ || !iso(run.createdAt) || !iso(run.updatedAt) || !BINDING.test(run.providerBinding)
95
+ || JSON.stringify(run.input) !== JSON.stringify(normalized)
96
+ || transcribe && (!reference(run.source) || run.source.artifactId !== run.input.source_artifact_id)
97
+ || !transcribe && run.source !== undefined
98
+ || !keys(run[step], ['taskId', 'claimed']) || !TASK.test(run[step].taskId) || typeof run[step].claimed !== 'boolean'
99
+ || run.inputDigest !== inputDigest(run) || run.output !== undefined && (!reference(run.output) || !run[step].claimed)) {
100
+ flowFail('IRIS_FLOW_RECORD_INVALID', '音频流程记录结构或绑定摘要冲突');
101
+ }
102
+ return run;
103
+ }
104
+ function validateGeneration(run, expectedId, normalized) {
105
+ if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding',
106
+ 'inputDigest', 'generate', 'output', 'selection', 'networkRequirement'],
107
+ ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', 'generate'])
108
+ || run.schemaVersion !== (run.input?.workflow_parameters === undefined ? 1 : 2) || run.recipe !== IMAGE_GENERATE_RECIPE || !RUN.test(run.id) || run.id !== expectedId
109
+ || !Number.isSafeInteger(run.revision) || run.revision < 1 || !iso(run.createdAt) || !iso(run.updatedAt)
110
+ || !BINDING.test(run.providerBinding) || !parseModelRef(run.input.model_ref)
111
+ || !keys(run.input, ['prompt', 'model_ref', 'n', 'size', 'workflow_parameters'], ['prompt', 'model_ref', 'n'])
112
+ || normalized.modelRef !== run.input.model_ref || normalized.sourceArtifactId !== undefined
113
+ || run.input.prompt !== normalized.providerInput.prompt || run.input.n !== 1
114
+ || run.input.size !== normalized.providerInput.size
115
+ || !keys(run.generate, ['taskId', 'claimed']) || !TASK.test(run.generate.taskId) || typeof run.generate.claimed !== 'boolean'
116
+ || run.inputDigest !== inputDigest(run)
117
+ || run.output !== undefined && (!reference(run.output) || !run.generate.claimed)
118
+ || run.selection !== undefined && (!reference(run.selection) || !run.output
119
+ || run.selection.artifactId !== run.output.artifactId || run.selection.sha256 !== run.output.sha256)) {
120
+ flowFail('IRIS_FLOW_RECORD_INVALID', '文生图记录结构或绑定摘要冲突');
121
+ }
122
+ return run;
123
+ }
44
124
  function validate(run, expectedId) {
125
+ networkPart(run);
126
+ if (run?.recipe === VIDEO_RECIPE) {
127
+ let normalized;
128
+ try { normalized = normalizeVideoFlowInput(run.input); } catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '视频流程输入无效'); }
129
+ if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', 'video', 'output', 'networkRequirement'],
130
+ ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', 'video'])
131
+ || run.schemaVersion !== 4 || run.id !== expectedId || !RUN.test(run.id) || !Number.isSafeInteger(run.revision) || run.revision < 1
132
+ || !iso(run.createdAt) || !iso(run.updatedAt) || !BINDING.test(run.providerBinding)
133
+ || JSON.stringify(run.input) !== JSON.stringify(normalized) || run.inputDigest !== inputDigest(run)
134
+ || !keys(run.video, ['taskId', 'claimed']) || !TASK.test(run.video.taskId) || typeof run.video.claimed !== 'boolean'
135
+ || run.output !== undefined && (!reference(run.output) || !run.video.claimed)) flowFail('IRIS_FLOW_RECORD_INVALID', '视频流程记录或绑定摘要冲突');
136
+ return run;
137
+ }
138
+ if (isAudioRecipe(run?.recipe)) return validateAudio(run, expectedId);
45
139
  let normalized;
46
140
  try { normalized = normalizeGenerationInput('image', run?.input); }
47
141
  catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '流程创作输入无效'); }
142
+ if (run.recipe === IMAGE_GENERATE_RECIPE) return validateGeneration(run, expectedId, normalized);
48
143
  if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding',
49
- 'inputDigest', 'edit', 'compare', 'output', 'comparison', 'selection'],
144
+ 'inputDigest', 'edit', 'compare', 'output', 'comparison', 'selection', 'networkRequirement'],
50
145
  ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding', 'inputDigest', 'edit', 'compare'])
51
146
  || run.schemaVersion !== 0 || run.recipe !== RECIPE || !RUN.test(run.id) || run.id !== expectedId
52
147
  || !Number.isSafeInteger(run.revision) || run.revision < 1 || !iso(run.createdAt) || !iso(run.updatedAt)
@@ -101,7 +196,7 @@ export function inspectFlowRoot(root) {
101
196
  export function initializeFlowRoot(root) {
102
197
  try { fs.lstatSync(path.join(root, 'flow-store')); return inspectFlowRoot(root); }
103
198
  catch (error) { if (error.code !== 'ENOENT') throw error; }
104
- if (fs.readdirSync(root).some(name => ![WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name))) {
199
+ if (fs.readdirSync(root).some(name => ![WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name) && !isAbandonedWriterPreparation(root, name))) {
105
200
  flowFail('IRIS_FLOW_ROOT_NOT_EMPTY', '流程只在新的专用数据根启用;不迁移已有 Task/Artifact 数据根');
106
201
  }
107
202
  directory(root, 'runs', true);
@@ -122,20 +217,61 @@ export function readFlowRun(root, id) {
122
217
  if (error.code === 'ENOENT') flowFail('IRIS_FLOW_NOT_FOUND', '流程记录不存在');
123
218
  throw error;
124
219
  }
125
- return copy(validate(record, id));
220
+ validate(record, id);
221
+ if (record.recipe === VIDEO_RECIPE) requireVideoRoot(root);
222
+ if (isAudioRecipe(record.recipe)) requireAudioRoot(root);
223
+ if (record.recipe === IMAGE_GENERATE_RECIPE) requireImageGenerationRoot(root);
224
+ return copy(record);
126
225
  }
127
226
 
128
227
  /** Internal immutable creation intent; HTTP callers cannot choose these identities. */
129
- export function prepareFlowCreation({ source, input, providerBinding }) {
130
- return { runId: flowId('run'), taskId: flowId('task'), comparisonArtifactId: flowId('artifact'),
228
+ export function prepareFlowCreation({ source, input, providerBinding, recipe, networkRequirement }) {
229
+ const network = networkPart({ networkRequirement });
230
+ if (recipe === VIDEO_RECIPE) return { ...network, recipe, runId: flowId('run'), taskId: flowId('task'), createdAt: new Date().toISOString(), input, providerBinding };
231
+ if (isAudioRecipe(recipe)) return { ...network, recipe, runId: flowId('run'), taskId: flowId('task'), createdAt: new Date().toISOString(),
232
+ ...(source ? { source } : {}), input, providerBinding };
233
+ if (!source) return { ...network, recipe: IMAGE_GENERATE_RECIPE, runId: flowId('run'), taskId: flowId('task'),
234
+ createdAt: new Date().toISOString(), input, providerBinding };
235
+ return { ...network, runId: flowId('run'), taskId: flowId('task'), comparisonArtifactId: flowId('artifact'),
131
236
  createdAt: new Date().toISOString(), source, input, providerBinding };
132
237
  }
133
238
 
134
239
  export function validateFlowCreation(intent) {
240
+ if (!intent || typeof intent !== 'object' || Array.isArray(intent)) flowFail('IRIS_FLOW_RECORD_INVALID', '流程创建意图无效');
241
+ const network = networkPart(intent);
242
+ const { networkRequirement, ...payload } = intent;
243
+ intent = payload;
244
+ if (intent?.recipe === VIDEO_RECIPE) {
245
+ if (!keys(intent, ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding'])) flowFail('IRIS_FLOW_RECORD_INVALID', '视频创建意图无效');
246
+ const record = { ...network, schemaVersion: 4, recipe: VIDEO_RECIPE, id: intent.runId, revision: 1, createdAt: intent.createdAt, updatedAt: intent.createdAt,
247
+ input: intent.input, providerBinding: intent.providerBinding, video: { taskId: intent.taskId, claimed: false } };
248
+ record.inputDigest = inputDigest(record); return validate(record, record.id);
249
+ }
250
+ if (isAudioRecipe(intent?.recipe)) {
251
+ if (!keys(intent, ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding', 'source'],
252
+ ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding', ...(intent.recipe === 'transcribe/v0' ? ['source'] : [])])) {
253
+ flowFail('IRIS_FLOW_RECORD_INVALID', '音频创建意图无效');
254
+ }
255
+ const record = { ...network, schemaVersion: 3, recipe: intent.recipe, id: intent.runId, revision: 1,
256
+ createdAt: intent.createdAt, updatedAt: intent.createdAt, input: intent.input, providerBinding: intent.providerBinding,
257
+ ...(intent.source ? { source: intent.source } : {}), [AUDIO_RECIPES[intent.recipe].step]: { taskId: intent.taskId, claimed: false } };
258
+ record.inputDigest = inputDigest(record);
259
+ return validate(record, record.id);
260
+ }
261
+ if (intent?.recipe === IMAGE_GENERATE_RECIPE) {
262
+ if (!keys(intent, ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding'])) {
263
+ flowFail('IRIS_FLOW_RECORD_INVALID', '文生图创建意图无效');
264
+ }
265
+ const record = { ...network, schemaVersion: intent.input?.workflow_parameters === undefined ? 1 : 2, recipe: IMAGE_GENERATE_RECIPE, id: intent.runId, revision: 1,
266
+ createdAt: intent.createdAt, updatedAt: intent.createdAt, input: intent.input,
267
+ providerBinding: intent.providerBinding, generate: { taskId: intent.taskId, claimed: false } };
268
+ record.inputDigest = inputDigest(record);
269
+ return validate(record, record.id);
270
+ }
135
271
  if (!keys(intent, ['runId', 'taskId', 'comparisonArtifactId', 'createdAt', 'source', 'input', 'providerBinding'])) {
136
272
  flowFail('IRIS_FLOW_RECORD_INVALID', '流程创建意图无效');
137
273
  }
138
- const record = { schemaVersion: 0, recipe: RECIPE, id: intent.runId, revision: 1,
274
+ const record = { ...network, schemaVersion: 0, recipe: RECIPE, id: intent.runId, revision: 1,
139
275
  createdAt: intent.createdAt, updatedAt: intent.createdAt, source: intent.source, input: intent.input,
140
276
  providerBinding: intent.providerBinding, edit: { taskId: intent.taskId, claimed: false },
141
277
  compare: { artifactId: intent.comparisonArtifactId } };
@@ -146,12 +282,16 @@ export function validateFlowCreation(intent) {
146
282
  export function commitFlowCreation(root, intent, { allowCreate = true } = {}) {
147
283
  inspectFlowRoot(root);
148
284
  const record = validateFlowCreation(intent);
285
+ if (record.recipe === VIDEO_RECIPE) requireVideoRoot(root);
286
+ if (isAudioRecipe(record.recipe)) requireAudioRoot(root);
287
+ if (record.recipe === IMAGE_GENERATE_RECIPE) requireImageGenerationRoot(root);
149
288
  let current;
150
289
  try { current = readFlowRun(root, record.id); }
151
290
  catch (error) { if (error.code !== 'IRIS_FLOW_NOT_FOUND') throw error; }
152
291
  if (current) {
153
292
  if (current.createdAt !== record.createdAt || current.inputDigest !== record.inputDigest
154
- || current.edit.taskId !== record.edit.taskId || current.compare.artifactId !== record.compare.artifactId) {
293
+ || current.recipe !== record.recipe || flowStep(current).taskId !== flowStep(record).taskId
294
+ || current.compare?.artifactId !== record.compare?.artifactId) {
155
295
  flowFail('IRIS_FLOW_INPUT_CONFLICT', '原流程与预留创建身份不一致');
156
296
  }
157
297
  return current;
@@ -168,7 +308,9 @@ export function createFlowRun(root, input) {
168
308
  export function updateFlowRun(root, previous, patch) {
169
309
  const current = readFlowRun(root, previous.id);
170
310
  if (current.revision !== previous.revision) flowFail('IRIS_FLOW_REVISION_CONFLICT', '流程在本次操作期间已改变');
171
- if (!keys(patch, ['edit', 'output', 'comparison', 'selection'], [])) throw new TypeError('未知流程更新字段');
311
+ const allowed = isAudioRecipe(current.recipe) || current.recipe === VIDEO_RECIPE ? [flowStepId(current), 'output']
312
+ : current.recipe === IMAGE_GENERATE_RECIPE ? ['generate', 'output', 'selection'] : ['edit', 'output', 'comparison', 'selection'];
313
+ if (!keys(patch, allowed, [])) throw new TypeError('未知流程更新字段');
172
314
  const record = { ...current, ...patch, revision: current.revision + 1, updatedAt: new Date().toISOString() };
173
315
  validate(record, current.id);
174
316
  atomicWritePrivate(recordFile(root, record.id), JSON.stringify(record, null, 2) + '\n');
@@ -190,6 +332,6 @@ export function listFlowRuns(root) {
190
332
  export function flowReferences(root) {
191
333
  try { fs.lstatSync(path.join(root, 'flow-store')); }
192
334
  catch (error) { if (error.code === 'ENOENT') return []; throw error; }
193
- return listFlowRuns(root).map(run => ({ run, taskIds: [run.edit.taskId],
194
- artifactIds: [...new Set([run.source.artifactId, run.compare.artifactId, run.output?.artifactId, run.selection?.artifactId].filter(Boolean))] }));
335
+ return listFlowRuns(root).map(run => ({ run, taskIds: [flowStep(run).taskId],
336
+ artifactIds: [...new Set([run.source?.artifactId, run.compare?.artifactId, run.output?.artifactId, run.selection?.artifactId].filter(Boolean))] }));
195
337
  }
@@ -3,9 +3,10 @@
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import { normalizeMusicOptions } from './music-options.js';
6
+ import { normalizeComfyParameters } from './comfyui-workflow.js';
6
7
 
7
8
  const FIELDS = Object.freeze({
8
- image: ['prompt', 'size', 'n', 'source_artifact_id'],
9
+ image: ['prompt', 'size', 'n', 'source_artifact_id', 'workflow_parameters'],
9
10
  video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
10
11
  tts: ['text', 'voice'],
11
12
  music: ['prompt', 'timeout_ms', 'retry_502', 'retry_delay_ms'],
@@ -86,6 +87,14 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
86
87
  const n = input.n === undefined ? 1 : Number(input.n);
87
88
  if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
88
89
  providerInput.n = n;
90
+ if (input.workflow_parameters !== undefined) {
91
+ if (!modelRef || sourceArtifactId || n !== 1) fail('工作流参数需要明确 model_ref、n=1 且不带来源图片');
92
+ try { providerInput.workflow_parameters = Object.freeze(normalizeComfyParameters(input.workflow_parameters)); }
93
+ catch (error) { fail(error.message); }
94
+ if (Buffer.byteLength(JSON.stringify({ model_ref: modelRef, ...providerInput }, null, 2)) > 96 * 1024) {
95
+ fail('参数化流程输入最多 96 KiB;请缩短提示词或参数值');
96
+ }
97
+ }
89
98
  }
90
99
  if (capability === 'video') {
91
100
  if (input.duration !== undefined) {
@@ -70,10 +70,10 @@ function completionResult(completion, routes) {
70
70
  return { ...result, ...selectedModel(completion, routes) };
71
71
  }
72
72
 
73
- async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) {
74
- const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref);
75
- const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit, onModelAvailability }),
76
- model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
73
+ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability, registry }) {
74
+ const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref, registry);
75
+ const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit, onModelAvailability, registry }),
76
+ model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider, registry) }));
77
77
  const audio = await waitForModelWork(() => extractAudioTrack({ inputPath: input.video_path, signal: operation.signal }), operation.signal);
78
78
  const abortRuntime = () => { void runtime.dispose().catch(() => {}); };
79
79
  operation.signal.addEventListener('abort', abortRuntime, { once: true });
@@ -87,7 +87,7 @@ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit
87
87
  providerInput: { audioUrl: prepared.url } }), operation.signal, 'invoke', 'unknown');
88
88
  task = submitted.task;
89
89
  if (task.acceptance === 'accepted' && task.outcome === 'none') {
90
- const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task), { onRateLimit,
90
+ const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task, registry), { onRateLimit, registry,
91
91
  modelId: routes.find(route => route.modelRef === task.modelRef)?.model });
92
92
  while (task.outcome === 'none') {
93
93
  task = await waitForModelWork(() => runner.observe(task.id, adapter), operation.signal, 'read', 'responded');
@@ -111,7 +111,7 @@ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit
111
111
  }
112
112
  }
113
113
 
114
- export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit, onModelAvailability }) {
114
+ export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit, onModelAvailability, registry }) {
115
115
  normalizeVisionInput(command, input);
116
116
  if (modelRef && !/^[^:]+::.+$/.test(modelRef)) invalid('--model-ref 必须是 providerId::modelId');
117
117
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > VISION_BUDGET.timeoutMs) invalid('--timeout-ms 必须是 1–120000 的整数');
@@ -140,7 +140,7 @@ export async function executeVisionCommand({ command, input, catalog, modelRef =
140
140
  }
141
141
  let transcript = input.transcribe_text || '', transcription = { status: transcript.trim() ? 'provided' : 'disabled' };
142
142
  if (input.transcribe) {
143
- const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) : { report: { status: 'no_audio' }, text: '' };
143
+ const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability, registry }) : { report: { status: 'no_audio' }, text: '' };
144
144
  transcript = asr.text; transcription = asr.report;
145
145
  }
146
146
  const completion = await summarizeMedia({ ports, frames, question: input.question, transcript, operation, budget });
@@ -10,7 +10,7 @@ function invalid(message) {
10
10
  }
11
11
 
12
12
  export function imageEditCandidates(candidates) {
13
- const supported = candidate => supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
13
+ const supported = candidate => candidate.supportsImageEdit ?? supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
14
14
  if (candidates.some(candidate => candidate.selectionReason === 'explicit'
15
15
  && !supported(candidate))) {
16
16
  throw invalid('指定模型的图片协议不支持此类改图,请选择聊天生图模型或 DashScope Qwen 原生改图模型');