@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
package/lib/local-api.js CHANGED
@@ -5,19 +5,51 @@ import path from 'node:path';
5
5
  import { createApplicationRuntime } from './application-runtime.js';
6
6
  import { normalizeCoreOptions } from './core-contract.js';
7
7
  import { createCoreRuntime } from './core-runtime.js';
8
- import { inspectFlowRoot } from './flow-store.js';
9
- import { inspectCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
10
- import { imageCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
8
+ import { inspectFlowRoot, listFlowRuns, readFlowRun } from './flow-store.js';
9
+ import { inspectCoreArtifactMetadata, readCoreArtifactBytes, listCoreArtifacts } from './core-artifacts.js';
10
+ import { isPluginSourceImage } from './plugin-source-image.js';
11
+ import { imageCandidatesFromCatalog, videoCandidatesFromCatalog, ttsCandidatesFromCatalog, transcribeCandidatesFromCatalog, loadProviderCatalog, providerTaskBinding } from './provider-catalog.js';
12
+ import { videoModelSupportsMode } from './video-input.js';
13
+ import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, isAudioRecipe } from './audio-flow-input.js';
14
+ import { CREATION_KINDS } from './creation-request-store.js';
11
15
  import { supportsImageEdit } from './provider-protocol.js';
16
+ import { projectComfyWorkflow, compileComfyWorkflow } from './comfyui-workflow.js';
12
17
  import { projectLocalApiFlow, projectLocalApiRunSummary } from './local-api-projection.js';
13
18
  import { inspectCreationRoot } from './creation-request-store.js';
14
19
  import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
20
+ import { DEFAULT_PANEL, LOCAL_WEB_CSP } from './local-web-assets.js';
21
+ import { loadPanelExtension } from './panel-extension.js';
22
+ import { createPluginPanelAssembly } from './plugin-web-assets.js';
23
+ import { executeConfigCommand } from './provider-config-service.js';
24
+ import { projectProviderDiagnostics, providerDependencies, providerRemovalImpact } from './provider-diagnostics.js';
25
+ import { createProviderMonitor } from './provider-monitor.js';
26
+ import { configuredImagePorts } from './configured-image-runtime.js';
27
+ import { ProviderContractError } from './provider-contract.js';
28
+ import { createProviderRegistry } from './provider-extensions.js';
29
+ import { createOperationRunner } from './operation-runner.js';
30
+ import { inspectExecutionRoot } from './operation-store.js';
31
+ import { readConnectionGrants, createConnectionRegistry } from './core-connections.js';
32
+ import { createManagedConnectionStore } from './core-connection-store.js';
33
+ import { CONNECTION_TEMPLATE_EXAMPLE } from './connection-template.js';
34
+ import { checkedBridgeCapabilities, DEFAULT_BRIDGE_CAPABILITIES } from './core-bridge-selection.js';
35
+ import { createCoreCapabilityCatalog, projectCoreSessionOperations, CORE_CAPABILITY_GROUPS } from './core-capability-catalog.js';
36
+ import { MCP_CONNECTION_PRESETS, MCP_CONFIG_FORMATS } from './mcp-delivery.js';
37
+ import { VIDEO_RECIPE, VIDEO_TYPES, MAX_VIDEO_BYTES } from './video-flow-input.js';
38
+ import { checkedPluginSelection, hasPluginSelection } from './plugin-selection.js';
39
+ import { readAllOperations, projectOperation } from './operation-store.js';
40
+ import { createWorkbenchAuth, WorkbenchAuthError } from './workbench-auth.js';
41
+ import { createProviderNetworkManager } from './network-routing.js';
42
+ import { providerNetworkRequirement } from './network-policy.js';
43
+ import { networkManagementSettings, manageNetworkResources, NETWORK_MANAGEMENT_ACTIONS } from './network-management.js';
44
+ import { networkDoctor, probeNetworkRoute } from './network-doctor.js';
45
+ import { createMediaScheduler } from './media-scheduler.js';
15
46
 
16
47
  export const MAX_LOCAL_API_IMAGE_BYTES = 64 * 1024 * 1024;
17
48
  const RUN = /^run_[a-f0-9]{24}$/;
18
49
  const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
19
50
  const KEY = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
20
51
  const IMAGE_TYPES = new Map([['image/png', 'png'], ['image/jpeg', 'jpg'], ['image/webp', 'webp']]);
52
+ const AUDIO_MEDIA_TYPES = new Map(Object.entries(AUDIO_TYPES));
21
53
  const digest = value => crypto.createHash('sha256').update(value).digest();
22
54
  const CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
23
55
 
@@ -28,35 +60,78 @@ function fail(code, message, status) { throw new LocalApiError(code, message, st
28
60
 
29
61
  function httpError(error) {
30
62
  if (error instanceof LocalApiError) return error;
63
+ if (error instanceof WorkbenchAuthError) return new LocalApiError(error.code, error.message, error.status);
64
+ if (error instanceof ProviderContractError && error.category === 'invalid_request') return new LocalApiError('IRIS_CONFIG_INPUT_INVALID',
65
+ '供应商配置或参数无效,请核对所选协议要求的 API 根地址、模型和参数', 400);
66
+ if (/^IRIS_NETWORK_/.test(error?.code || '')) return new LocalApiError(error.code, error.message, 409);
67
+ if (/^IRIS_PLUGIN_/.test(error?.code || '')) return new LocalApiError(error.code,
68
+ error.code === 'IRIS_PLUGIN_INPUT_INVALID' ? '业务输入不符合当前插件参数要求' : '插件业务未授权、缺失或版本不一致;请读取原 Operation 并核对准确插件版本',
69
+ error.code === 'IRIS_PLUGIN_INPUT_INVALID' ? 400 : error.code === 'IRIS_PLUGIN_OPERATION_DENIED' ? 403 : 409);
70
+ if (['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY'].includes(error?.code)) return new LocalApiError(error.code,
71
+ '配置已变化或有其他写入,请保留草稿并重新读取后核对', 409);
72
+ if (error?.code === 'IRIS_PROVIDER_CREDENTIAL_UNAVAILABLE') return new LocalApiError(error.code,
73
+ '环境凭据未就绪,请由所有者检查 Core 进程中的指定变量;不会改绑或重新提交任务', 409);
74
+ if (error?.providerCode === 'IRIS_PROVIDER_DIRECTORY_CREDENTIALS') return new LocalApiError(error.providerCode,
75
+ '上游模型目录包含认证内容,已拒绝读取与保存;请检查该服务的目录响应', 502);
76
+ if (error?.code === 'IRIS_CONFIG_INPUT_INVALID' || error?.code === 'IRIS_FUNCTION_POLICY_INVALID') return new LocalApiError('IRIS_CONFIG_INPUT_INVALID',
77
+ '设置无效,请检查地址、协议、模型用途和密钥动作', 400);
78
+ if (error?.code === 'IRIS_OPERATION_NOT_FOUND') return new LocalApiError(error.code, 'Operation 不存在', 404);
79
+ if (error?.code === 'IRIS_OPERATION_KEY_CONFLICT') return new LocalApiError(error.code, '该 UUID 已绑定其他业务、输入或依赖,请先查询原操作', 409);
80
+ if (error?.code === 'IRIS_OPERATION_QUEUE_FULL') return new LocalApiError(error.code, '活动操作已达上限,本次未接收', 429);
81
+ if (/^IRIS_SCHEDULER_(?:QUEUE_FULL|QUEUE_TIMEOUT)$/.test(error?.code || '')) return new LocalApiError(error.code, error.message, 429);
82
+ if (['IRIS_APP_OPERATION_BUSY', 'IRIS_PROVIDER_TASK_BUSY'].includes(error?.code)) return new LocalApiError(error.code, '原 Run 或 Task 正在执行,请查询原操作', 409);
83
+ if (error?.code === 'IRIS_OPERATION_INPUT_INVALID' || error?.code === 'IRIS_OPERATION_KEY_INVALID') return new LocalApiError(error.code, 'Operation 输入无效', 400);
84
+ if (/^IRIS_CONNECTION_/.test(error?.code || '')) {
85
+ const status = error.code === 'IRIS_CONNECTION_AUTH_REQUIRED' ? 401 : error.code === 'IRIS_CONNECTION_CAPABILITY_DENIED' ? 403
86
+ : error.code === 'IRIS_CONNECTION_BUSY' ? 429 : error.code === 'IRIS_CONNECTION_INPUT_INVALID' ? 400 : 409;
87
+ const message = {
88
+ IRIS_CONNECTION_AUTH_REQUIRED: '连接凭据无效或已撤销,请由所有者核对并重新导出配置',
89
+ IRIS_CONNECTION_CAPABILITY_DENIED: '所选功能、工具或权限未开放,请核对已保存授权和服务开关',
90
+ IRIS_CONNECTION_INPUT_INVALID: '连接请求无效,请核对功能、权限与配置格式;预设或模板不能与手动格式混用',
91
+ IRIS_CONNECTION_CONFIG_CHANGED: '连接或模板配置已变化,请刷新后核对原记录',
92
+ IRIS_CONNECTION_REVOKED: '连接已撤销,无法继续导出或使用',
93
+ IRIS_CONNECTION_HANDSHAKE_REQUIRED: '连接握手已失效,请重新连接原 Core 服务'
94
+ }[error.code];
95
+ return new LocalApiError(error.code, message || '连接身份、授权或握手状态不符合当前服务要求', status);
96
+ }
97
+ if (/^IRIS_EXECUTION_/.test(error?.code || '')) return new LocalApiError(error.code, '执行服务未就绪,请保留原 Operation 并在本机检查', 503);
31
98
  if (error?.code === 'IRIS_CORE_DATA_ROOT_BUSY') return new LocalApiError('IRIS_API_ROOT_BUSY', '该数据根已有活动写者,请稍后显式重试', 409);
32
99
  if (error?.code === 'IRIS_CREATION_RESULT_UNAVAILABLE') return new LocalApiError('IRIS_API_RESULT_UNAVAILABLE', '原创建结果已不可用;同 Key 不会重新创建或复活作品', 409);
33
100
  if (error?.code === 'IRIS_CREATION_KEY_CONFLICT') return new LocalApiError('IRIS_API_KEY_CONFLICT', '该 Key 已绑定其他输入', 409);
101
+ if (error?.code === 'IRIS_CREATION_NOT_FOUND') return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此创建请求', 404);
34
102
  if (['IRIS_CREATION_KEY_INVALID', 'IRIS_CREATION_INPUT_INVALID', 'IRIS_GENERATION_INPUT_INVALID',
35
- 'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID'].includes(error?.code)) {
103
+ 'IRIS_IMAGE_IMPORT_INVALID', 'IRIS_IMAGE_EDIT_INPUT_INVALID', 'IRIS_FLOW_INPUT_INVALID', 'IRIS_VIDEO_FLOW_INPUT_INVALID', 'IRIS_COMFYUI_PARAMETERS_INVALID'].includes(error?.code)) {
36
104
  return new LocalApiError('IRIS_API_INPUT_INVALID', '创作输入或静态图片无效,请检查来源、模型和参数', 400);
37
105
  }
38
106
  if (error?.code === 'IRIS_CREATION_MEDIA_MISMATCH') return new LocalApiError('IRIS_API_MEDIA_UNSUPPORTED', '图片真实格式与 Content-Type 不一致', 415);
39
107
  if (/^IRIS_CREATION_/.test(error?.code || '')) return new LocalApiError('IRIS_API_RECORD_CONFLICT', '创建请求证据不一致,请在本机检查原记录', 409);
40
108
  if (['IRIS_FLOW_NOT_FOUND', 'IRIS_ARTIFACT_NOT_FOUND'].includes(error?.code)) {
41
- return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或图片', 404);
109
+ return new LocalApiError('IRIS_API_NOT_FOUND', '该实例中没有此流程或作品', 404);
42
110
  }
111
+ if (error?.code === 'IRIS_FLOW_REVISION_CONFLICT') return new LocalApiError('IRIS_API_RUN_CHANGED', '流程已更新,请读取当前版本后重新选择', 409);
112
+ if (error?.code === 'IRIS_FLOW_NOT_READY') return new LocalApiError('IRIS_API_RUN_NOT_READY', '请先保存任务结果;图片编辑还需完成比较', 409);
113
+ if (error?.code === 'IRIS_FLOW_GENERATION_ROOT_REQUIRED') return new LocalApiError(error.code, '文生图需要新建 image-generation 执行根;已有根不自动迁移', 409);
114
+ if (error?.code === 'IRIS_FLOW_AUDIO_ROOT_REQUIRED') return new LocalApiError(error.code, '音频需要新建 execution init --audio 执行根;已有根不自动迁移', 409);
115
+ if (error?.code === 'IRIS_AUDIO_INPUT_INVALID') return new LocalApiError(error.code, '音频或创作输入无效:请核对模型、文字及WAV/MP3/OGG/FLAC容器和类型', 400);
116
+ if (error?.code === 'IRIS_FLOW_SELECTION_INVALID') return new LocalApiError('IRIS_API_SELECTION_INVALID', '只能选择此流程的原图或生成结果', 409);
43
117
  if (/^IRIS_PROVIDER_/.test(error?.code || '')) {
44
118
  return new LocalApiError('IRIS_API_CONFIG_UNAVAILABLE', '模型配置不可用,请在本机检查显式配置', 500);
45
119
  }
46
120
  if (error?.code === 'IRIS_APP_CLOSED' || error?.name === 'AbortError' || /^IRIS_CORE_DATA_ROOT_/.test(error?.code || '')) {
47
121
  return new LocalApiError('IRIS_API_UNAVAILABLE', '服务正在关闭或数据根不可用', 503);
48
122
  }
49
- if (/^IRIS_(?:FLOW|ARTIFACT|TASK)_/.test(error?.code || '')) {
50
- return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程或作品证据不一致,请在本机核对原记录', 409);
123
+ if (/^IRIS_(?:FLOW|ARTIFACT|TASK|OPERATION)_/.test(error?.code || '')) {
124
+ return new LocalApiError('IRIS_API_RECORD_CONFLICT', '流程、作品或操作记录不一致,请在本机核对原记录', 409);
51
125
  }
52
- return new LocalApiError('IRIS_API_INTERNAL', '读取失败,请使用关联 ID 核对本机诊断', 500);
126
+ return new LocalApiError('IRIS_API_INTERNAL', '操作失败,请使用关联 ID 核对本机诊断', 500);
53
127
  }
54
128
 
55
129
  function reportUnexpected(error, requestId) {
56
130
  // Keep call sites for diagnosis, without the potentially private exception message or directory names.
57
131
  const frames = String(error?.stack || '').split('\n').slice(1, 7)
58
132
  .map(line => line.replace(/(?:file:\/\/)?(?:\/[^\s()]+)+(?=:\d+:\d+)/g, value => path.basename(value)));
59
- console.error(JSON.stringify({ event: 'iris_local_api_error', requestId, frames }));
133
+ const code = ['IRIS_SCHEDULER_QUEUE_TIMEOUT', 'IRIS_SCHEDULER_QUEUE_FULL', 'IRIS_SCHEDULER_CLOSED'].includes(error?.code) ? error.code : undefined;
134
+ console.error(JSON.stringify({ event: 'iris_local_api_error', requestId, ...(code ? { code } : {}), frames }));
60
135
  }
61
136
 
62
137
  function headerCount(request, name) {
@@ -100,45 +175,428 @@ function send(request, response, status, bytes, headers = {}) {
100
175
  const json = (request, response, value, status = 200) => send(request, response, status, Buffer.from(JSON.stringify(value)));
101
176
 
102
177
  /** Internal Host, read-only by default. Construction performs no I/O and never binds a port. */
103
- export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false } = {}) {
178
+ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWrites = false, serveWorkbench = false,
179
+ allowExecution = false, allowSelection = false, allowMcp = false, connectionsFile, executionPorts, allowConnectionManagement = false, connectionStore, providerRegistry, pluginRegistry,
180
+ allowProviderManagement = false, allowNetworkManagement = false, panelManifestPath, panelContributions, allowedOperations, workbenchAuth, networkConfigPath, networkManager,
181
+ scheduling, scheduler: suppliedScheduler } = {}) {
182
+ if (typeof allowExecution !== 'boolean') throw new TypeError('allowExecution 必须是布尔值');
183
+ if (typeof allowSelection !== 'boolean') throw new TypeError('allowSelection 必须是布尔值');
184
+ if (typeof allowMcp !== 'boolean') throw new TypeError('allowMcp 必须是布尔值');
185
+ if (typeof allowConnectionManagement !== 'boolean') throw new TypeError('allowConnectionManagement 必须是布尔值');
186
+ if (allowConnectionManagement !== Boolean(connectionStore)) fail('IRIS_API_CONFIG_INVALID', '连接管理须显式开启并指定专用目录');
187
+ if (allowMcp && !connectionsFile && !connectionStore) fail('IRIS_API_CONFIG_INVALID', 'MCP HTTP 需要明确的连接授权文件或管理目录');
188
+ if (allowExecution) allowLocalWrites = true;
104
189
  if (typeof allowLocalWrites !== 'boolean') throw new TypeError('allowLocalWrites 必须是布尔值');
190
+ if (typeof serveWorkbench !== 'boolean') throw new TypeError('serveWorkbench 必须是布尔值');
191
+ if (workbenchAuth !== undefined && (!serveWorkbench || !workbenchAuth || Object.keys(workbenchAuth).join(',') !== 'directory'
192
+ || typeof workbenchAuth.directory !== 'string' || !path.isAbsolute(workbenchAuth.directory))) {
193
+ fail('IRIS_API_CONFIG_INVALID', '工作台口令认证只接受明确的私有 directory,且仅用于 web serve');
194
+ }
195
+ if (typeof allowProviderManagement !== 'boolean') throw new TypeError('allowProviderManagement 必须是布尔值');
196
+ if (typeof allowNetworkManagement !== 'boolean') throw new TypeError('allowNetworkManagement 必须是布尔值');
197
+ if (allowNetworkManagement && !networkConfigPath) fail('IRIS_API_CONFIG_INVALID', '网络管理需要显式network-config路径');
198
+ if (pluginRegistry && providerRegistry && providerRegistry !== pluginRegistry.providers) fail('IRIS_API_CONFIG_INVALID', '插件与 Provider 注册器须来自同一装配实例');
199
+ const suppliedRegistry = pluginRegistry?.providers ?? providerRegistry;
200
+ if (networkConfigPath !== undefined && (typeof networkConfigPath !== 'string' || !path.isAbsolute(networkConfigPath))) fail('IRIS_API_CONFIG_INVALID', '网络文件必须是显式绝对路径');
201
+ if (networkManager && (typeof networkManager.resolveProviderNetwork !== 'function' || typeof networkManager.dispose !== 'function')) throw new TypeError('networkManager 需要实例解析与释放方法');
202
+ if ((networkConfigPath || scheduling || suppliedScheduler) && suppliedRegistry && !networkManager) fail('IRIS_API_CONFIG_INVALID', '借用 Provider 注册器并使用网络或调度配置时,请同时传入由调用者持有的网络管理实例,按顺序统一释放');
203
+ const capabilityCatalog = createCoreCapabilityCatalog({ registry: pluginRegistry });
204
+ const pluginExecution = allowedOperations !== undefined;
205
+ const selectedOperations = pluginExecution ? capabilityCatalog.selectOperations(allowedOperations) : [];
206
+ const allowedPluginIds = Object.freeze(selectedOperations.map(item => item.id));
207
+ if (allowProviderManagement && !providerConfigPath) fail('IRIS_API_CONFIG_INVALID', '供应商管理需要显式 Provider 配置');
208
+ if (panelManifestPath !== undefined && (!serveWorkbench || typeof panelManifestPath !== 'string' || !path.isAbsolute(panelManifestPath))) fail('IRIS_API_CONFIG_INVALID', '替代 Panel 只用于工作台,须指定绝对清单路径');
209
+ if (panelContributions !== undefined && (!serveWorkbench || panelManifestPath !== undefined)) fail('IRIS_API_CONFIG_INVALID', '组合贡献用于默认工作台;请在组合挂载与整套 Panel 替换之间明确选择');
105
210
  const suppliedRoot = normalizeCoreOptions({ dataRoot, mode: 'reader' }).dataRoot;
106
211
  if (providerConfigPath !== undefined && (typeof providerConfigPath !== 'string' || !path.isAbsolute(providerConfigPath))) {
107
212
  throw new LocalApiError('IRIS_API_CONFIG_INVALID', 'Provider 配置必须是显式绝对路径');
108
213
  }
214
+ if (allowExecution && !providerConfigPath && !executionPorts) fail('IRIS_API_CONFIG_INVALID', '执行模式需要显式 Provider 配置');
215
+ if (connectionsFile !== undefined && (typeof connectionsFile !== 'string' || !path.isAbsolute(connectionsFile))) fail('IRIS_API_CONFIG_INVALID', '连接授权须为显式根外文件');
109
216
  let state = 'new', origin = null, tokenDigest = null, application, server, physicalRoot;
110
- let startPromise, disposal, closeRequested = false, activeReads = 0, activeUploads = 0, localWrites = 0;
217
+ const scheduler = suppliedScheduler || networkManager?.scheduler || (scheduling ? createMediaScheduler(scheduling) : undefined);
218
+ if (scheduler && networkManager && networkManager.scheduler !== scheduler) fail('IRIS_API_CONFIG_INVALID', '应用与网络管理必须共用同一调度实例');
219
+ const ownedScheduler = Boolean(scheduler && !suppliedScheduler && !networkManager?.scheduler);
220
+ const ownedNetwork = !networkManager && (networkConfigPath !== undefined || Boolean(scheduler));
221
+ const networks = networkManager || (ownedNetwork ? createProviderNetworkManager({ networkConfigPath, scheduler }) : undefined);
222
+ const resolveProviderNetwork = ({ provider, ...context }) => networks?.resolveProviderNetwork({ ...context,
223
+ networkRequirement: context.networkRequirement || providerNetworkRequirement(provider) });
224
+ let browserAuth;
225
+ let providerMonitor;
226
+ let runner, runnerStarted = false, connections, mcp, managed, imageGenerationEnabled = false, audioEnabled = false, videoEnabled = false;
227
+ let registry = suppliedRegistry, panel = DEFAULT_PANEL, pluginPanel, providerManagementBusy = false;
228
+ const capabilityFacts = () => capabilityCatalog.snapshot({ audioEnabled, videoEnabled, panelContributions: pluginPanel?.selection, allowedOperations: allowedPluginIds });
229
+ const changedAuthorizations = new Set();
230
+ let loginTicket = null;
231
+ const browserSessions = new Set();
232
+ let startPromise, disposal, closeRequested = false, cleanupFailed = false, activeReads = 0, activeUploads = 0, localWrites = 0;
111
233
  const jobs = new Set(), instanceId = crypto.randomUUID();
112
234
  let resolveClosed;
113
235
  const closed = new Promise(resolve => { resolveClosed = resolve; });
114
- const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites, activeReads });
236
+ const snapshot = () => Object.freeze({ contractVersion: 0, state, origin, readOnly: !allowLocalWrites && !allowSelection, activeReads,
237
+ ...(cleanupFailed ? { cleanupFailed: true } : {}),
238
+ ...(allowMcp ? { mcpEndpoint: origin && origin + '/mcp' } : {}),
239
+ ...(allowExecution || pluginExecution ? { executionEnabled: runner?.snapshot().executionEnabled === true } : {}) });
240
+ const availableScopes = ['read', ...(allowLocalWrites ? ['create'] : []), ...(allowExecution || pluginExecution ? ['execute', 'stop'] : [])];
241
+ // Sharing the execute scope with plugin businesses must not enable the read-only media runner.
242
+ const availableTools = () => {
243
+ const facts = capabilityFacts();
244
+ return [...facts.tools, ...facts.pluginTools].filter(tool => tool.scope !== 'execute'
245
+ || (tool.name === 'iris_core_plugin_call' ? pluginExecution : !pluginExecution || allowExecution)).map(tool => tool.name);
246
+ };
247
+ const availableCapabilities = () => capabilityCatalog.snapshot({ audioEnabled, videoEnabled }).groups;
115
248
 
116
- function authorize(request) {
249
+ function issueLoginLink() {
250
+ const ticket = crypto.randomBytes(32).toString('base64url');
251
+ const expiresAt = Date.now() + 10 * 60 * 1000;
252
+ loginTicket = { digest: digest(ticket), expiresAt };
253
+ return { loginUrl: origin + '/#login=' + ticket, loginExpiresAt: new Date(expiresAt).toISOString() };
254
+ }
255
+
256
+ function redeemLoginLink(ticket) {
257
+ if (!loginTicket || Date.now() >= loginTicket.expiresAt || !crypto.timingSafeEqual(digest(ticket), loginTicket.digest)) {
258
+ fail('IRIS_WEB_LOGIN_EXPIRED', '登录链接已失效或已使用,请从启动终端获取新链接,或展开备用登录', 401);
259
+ }
260
+ if (browserSessions.size >= 32) fail('IRIS_WEB_LOGIN_LIMIT', '本次服务的浏览器登录已达上限,请使用备用 Token 登录', 429);
261
+ // Consume synchronously, before any response I/O; concurrent redemption can succeed only once.
262
+ loginTicket = null;
263
+ const accessToken = crypto.randomBytes(32).toString('base64url');
264
+ browserSessions.add(digest(accessToken).toString('hex'));
265
+ return { accessToken };
266
+ }
267
+
268
+ function authorizeOrigin(request, documentNavigation = false) {
117
269
  const headers = request.headers;
118
270
  if (headerCount(request, 'host') !== 1 || headers.host !== new URL(origin).host) {
119
271
  fail('IRIS_API_HOST_DENIED', '请求地址不可信,请使用启动时给出的本机地址', 403);
120
272
  }
121
273
  if (headerCount(request, 'origin') > 1 || (headers.origin !== undefined && headers.origin !== origin)
122
- || (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site']))) {
274
+ || (headers['sec-fetch-site'] !== undefined && !['same-origin', 'none'].includes(headers['sec-fetch-site'])
275
+ && !(documentNavigation && headers['sec-fetch-mode'] === 'navigate' && headers['sec-fetch-dest'] === 'document'))) {
123
276
  fail('IRIS_API_ORIGIN_DENIED', '请求来源不可信', 403);
124
277
  }
278
+ }
279
+
280
+ function authorize(request) {
281
+ authorizeOrigin(request);
282
+ const headers = request.headers;
125
283
  const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(headers.authorization || '');
126
- if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)) {
127
- fail('IRIS_API_AUTH_REQUIRED', '需要有效的本机会话 Token', 401);
284
+ if (match && headerCount(request, 'iris-connection-id') === 1 && connections) {
285
+ return { connectionId: connections.identify(headers['iris-connection-id'], match[1]), runtimeInstanceId: headers['iris-runtime-id'] };
128
286
  }
287
+ if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest) && !browserSessions.has(digest(match[1]).toString('hex')) && !browserAuth?.authenticate(match[1])) {
288
+ fail('IRIS_API_AUTH_REQUIRED', browserAuth ? '请用访问口令重新登录工作台' : '需要有效的本机会话 Token', 401);
289
+ }
290
+ if (headers['iris-connection-id'] !== undefined || headers['iris-runtime-id'] !== undefined) fail('IRIS_API_AUTH_REQUIRED', '连接头字段无效', 401);
291
+ return { owner: true };
292
+ }
293
+ function scope(caller, required, tool) {
294
+ if (!availableScopes.includes(required)) fail('IRIS_API_READ_ONLY', '此服务没有开启该操作', 405);
295
+ return caller.owner ? availableScopes : connections.authorize(caller.connectionId, caller.runtimeInstanceId, required, tool);
296
+ }
297
+ async function requireFlowCapability(caller, runId) {
298
+ if (caller.owner) return;
299
+ const run = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => readFlowRun(dataRoot, runId)));
300
+ if (!allowsRun(caller, run)) {
301
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接未选择原流程所需的媒体能力', 403);
302
+ }
303
+ }
304
+ function requirePluginChoice(caller, dependency) {
305
+ if (!caller.owner && !hasPluginSelection(connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [], dependency)) {
306
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接没有选择该业务及准确版本', 403);
307
+ }
308
+ }
309
+ async function requireOperationCapability(caller, operation) {
310
+ if (operation.action === 'plugin.execute') return requirePluginChoice(caller, operation.dependency);
311
+ return requireFlowCapability(caller, operation.runId);
312
+ }
313
+ function allowsRun(caller, run) {
314
+ return caller.owner || CORE_CAPABILITY_GROUPS.some(group => group.recipe === run.recipe
315
+ && connections.selection(caller.connectionId, caller.runtimeInstanceId).capabilities.includes(group.id));
316
+ }
317
+ async function requireArtifactCapability(caller, artifactId) {
318
+ if (caller.owner) return;
319
+ const metadata = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => inspectCoreArtifactMetadata(dataRoot, artifactId)));
320
+ scope(caller, 'read'); // A revocation during the metadata read invalidates the original handshake too.
321
+ const selection = connections.selection(caller.connectionId, caller.runtimeInstanceId);
322
+ if (isPluginSourceImage(metadata) && (selection.pluginOperations || []).some(pin => selectedOperations.some(item => hasPluginSelection([pin], item.dependency) && item.artifactInputs?.length))) return;
323
+ if (metadata.metadata?.pluginOperationId) {
324
+ const record = await application.execute('operation.inspect', { id: metadata.metadata.pluginOperationId });
325
+ requirePluginChoice(caller, record.operation.dependency); return;
326
+ }
327
+ const references = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => listFlowRuns(dataRoot)))
328
+ .then(runs => runs.filter(run => [run.source, run.output, run.comparison, run.selection].some(ref => ref?.artifactId === artifactId)));
329
+ if (references.length) {
330
+ if (references.some(run => allowsRun(caller, run))) return;
331
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此作品所属流程的媒体能力未被选择', 403);
332
+ }
333
+ // Delivery can commit a Task artifact before the Run's output checkpoint is saved.
334
+ if (metadata.metadata?.taskId) {
335
+ const task = await application.execute('task.inspect', { task_id: metadata.metadata.taskId });
336
+ if (task.task.flowBinding) { await requireFlowCapability(caller, task.task.flowBinding.runId); return; }
337
+ }
338
+ // Unbound imports use the existing media/tool grant. Plugin control tools
339
+ // alone only grant the declared eligible source images handled above.
340
+ if (CORE_CAPABILITY_GROUPS.some(group => selection.capabilities.includes(group.id)
341
+ && (metadata.mediaType.startsWith('image/') ? group.media === 'image'
342
+ : metadata.mediaType.startsWith('video/') ? group.media === 'video' : metadata.mediaType.startsWith('audio/') ? group.requiresAudio
343
+ : metadata.mediaType === 'text/plain' && group.media === 'transcribe'))) return;
344
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此连接没有选择该作品的媒体功能或图片来源用途', 403);
129
345
  }
130
346
 
131
- async function image(artifactId) {
347
+ async function smallJson(request, limit = 8192) {
348
+ if (headerCount(request, 'content-type') !== 1 || !/^application\/json(?:;\s*charset=utf-8)?$/i.test(request.headers['content-type'])
349
+ || request.headers['content-encoding'] !== undefined) fail('IRIS_API_INPUT_INVALID', '需要 UTF-8 JSON 正文');
350
+ if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文过大', 413);
351
+ const chunks = []; let size = 0;
352
+ for await (const chunk of request.iterator({ destroyOnReturn: false })) {
353
+ size += chunk.length; if (size > limit) fail('IRIS_API_INPUT_TOO_LARGE', '正文过大', 413); chunks.push(chunk);
354
+ }
355
+ if (Object.keys(request.trailers).length || state !== 'open') fail('IRIS_API_INPUT_INVALID', '请求已失效');
356
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size))); }
357
+ catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效 JSON'); }
358
+ }
359
+
360
+ async function specialPost(request, response, url, caller) {
361
+ query(url);
362
+ if (url.pathname === '/api/v0/plugin-operations' && pluginExecution) {
363
+ scope(caller, 'execute', 'iris_core_plugin_call');
364
+ if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) fail('IRIS_API_INPUT_INVALID', '需要稳定 UUIDv4 Key');
365
+ const value = await smallJson(request, 65536);
366
+ if (!value || Object.keys(value).sort().join(',') !== 'dependency,input,operationId') fail('IRIS_API_INPUT_INVALID', '需要准确业务、依赖和输入');
367
+ const pin = checkedPluginSelection([value.dependency])[0];
368
+ requirePluginChoice(caller, pin);
369
+ if (pin.contributionId !== value.operationId) fail('IRIS_API_INPUT_INVALID', '业务与依赖不一致');
370
+ const authorize = () => {
371
+ if (!allowedPluginIds.includes(value.operationId)) fail('IRIS_PLUGIN_OPERATION_DENIED', '此业务未被服务明确启用', 403);
372
+ if (!caller.owner) connections.authorizePlugin(caller.connectionId, pin);
373
+ };
374
+ return json(request, response, await runner.submit({ ...value, key: request.headers['idempotency-key'] }, { authorize }), 202);
375
+ }
376
+ if (url.pathname.startsWith('/api/v0/connection-management')) return manage(request, response, url, caller);
377
+ if (url.pathname.startsWith('/api/v0/provider-management')) return manageProviders(request, response, url, caller);
378
+ if (url.pathname.startsWith('/api/v0/network-management')) return manageNetworks(request, response, url, caller);
379
+ const selecting = /^\/api\/v0\/runs\/([^/]+)\/selection$/.exec(url.pathname);
380
+ if (selecting) return selectRun(request, response, selecting[1], caller);
381
+ const connection = /^\/api\/v0\/connections\/([a-f0-9-]{36})\/(handshake|disconnect|client-info)$/.exec(url.pathname);
382
+ if (connection && connections) {
383
+ const value = await smallJson(request);
384
+ if (caller.owner && connection[2] === 'disconnect') {
385
+ if (!value || Object.keys(value).length !== 1 || typeof value.runtimeInstanceId !== 'string') fail('IRIS_API_INPUT_INVALID', '需要明确断开的 runtimeInstanceId');
386
+ return json(request, response, connections.disconnect(connection[1], value.runtimeInstanceId));
387
+ }
388
+ if (caller.owner || caller.connectionId !== connection[1] || headerCount(request, 'iris-runtime-id') !== 1) fail('IRIS_API_AUTH_REQUIRED', '连接身份不一致', 401);
389
+ if (connection[2] === 'handshake') {
390
+ if (value?.runtimeInstanceId !== caller.runtimeInstanceId) fail('IRIS_API_INPUT_INVALID', '实例头字段与握手不一致');
391
+ return json(request, response, connections.handshake(caller.connectionId, value));
392
+ }
393
+ if (connection[2] === 'client-info') return json(request, response, connections.clientInfo(caller.connectionId, caller.runtimeInstanceId, value));
394
+ if (!value || Array.isArray(value) || Object.keys(value).length) fail('IRIS_API_INPUT_INVALID', '断开操作只接受空对象');
395
+ return json(request, response, connections.disconnect(caller.connectionId, caller.runtimeInstanceId));
396
+ }
397
+ const continuing = /^\/api\/v0\/runs\/(run_[a-f0-9]{24})\/continue$/.exec(url.pathname);
398
+ const stopping = /^\/api\/v0\/operations\/(op_[a-f0-9]{64})\/cancel$/.exec(url.pathname);
399
+ if ((allowExecution && continuing) || (allowExecution || pluginExecution) && stopping) {
400
+ scope(caller, continuing ? 'execute' : 'stop', continuing ? 'iris_core_continue' : 'iris_core_stop');
401
+ const value = await smallJson(request);
402
+ if (!value || typeof value !== 'object' || Array.isArray(value)
403
+ || (continuing ? Object.keys(value).length !== 1 || !Object.hasOwn(value, 'expectedRevision')
404
+ : Object.keys(value).length)) fail('IRIS_API_INPUT_INVALID', '操作正文包含不支持的字段');
405
+ if (stopping) {
406
+ const original = await application.execute('operation.inspect', { id: stopping[1] });
407
+ await requireOperationCapability(caller, original.operation);
408
+ return json(request, response, await runner.stop(stopping[1]));
409
+ }
410
+ if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) fail('IRIS_API_INPUT_INVALID', '需要单个小写 UUIDv4 Key');
411
+ await requireFlowCapability(caller, continuing[1]);
412
+ const result = await runner.submit({ key: request.headers['idempotency-key'], runId: continuing[1], expectedRunRevision: value.expectedRevision });
413
+ return json(request, response, result, result.idempotentReplay ? 200 : 202);
414
+ }
415
+ const creationTool = ({ '/api/v0/images': 'iris_core_import_image', '/api/v0/runs': 'iris_core_create_run',
416
+ '/api/v0/generations': 'iris_core_create_generation', '/api/v0/audio': 'iris_core_import_audio',
417
+ '/api/v0/speeches': 'iris_core_create_speech', '/api/v0/transcriptions': 'iris_core_create_transcription', '/api/v0/videos': 'iris_core_create_video' })[url.pathname];
418
+ scope(caller, 'create', creationTool);
419
+ return create(request, response, url, caller, creationTool);
420
+ }
421
+
422
+ async function selectRun(request, response, runId, caller) {
423
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以选择作品', 403);
424
+ if (!allowSelection) fail('IRIS_API_READ_ONLY', '此服务没有开启作品选择', 405);
425
+ if (!RUN.test(runId)) fail('IRIS_API_INPUT_INVALID', '流程 ID 无效');
426
+ const controller = new AbortController();
427
+ const disconnect = () => { if (!response.writableFinished) controller.abort(); };
428
+ response.once('close', disconnect);
429
+ try {
430
+ const value = await smallJson(request);
431
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 2
432
+ || typeof value.artifactId !== 'string' || !ARTIFACT.test(value.artifactId)
433
+ || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 1) {
434
+ fail('IRIS_API_INPUT_INVALID', '选择需要作品 ID 与正整数 expectedRevision');
435
+ }
436
+ if (response.destroyed) controller.abort();
437
+ controller.signal.throwIfAborted();
438
+ const writes = application.snapshot(), execution = runner?.snapshot();
439
+ if (writes.activeWrites || writes.queuedWrites || execution?.active || execution?.queued) {
440
+ fail('IRIS_API_ROOT_BUSY', '当前有活动写入,请读取状态后明确重试选择', 409);
441
+ }
442
+ const facts = await application.execute('flow.select', { run_id: runId, artifact_id: value.artifactId,
443
+ expected_revision: value.expectedRevision }, { signal: controller.signal });
444
+ return await json(request, response, projectLocalApiFlow(facts));
445
+ } catch (error) {
446
+ if (error?.code === 'ECONNRESET' && request.aborted) { controller.abort(); controller.signal.throwIfAborted(); }
447
+ throw error;
448
+ } finally { response.off('close', disconnect); }
449
+ }
450
+
451
+ function managementFacts() {
452
+ const runtime = connections.list().connections, stored = managed.list(), ids = new Set(stored.map(item => item.connectionId));
453
+ return { contractVersion: 0, connections: [
454
+ ...stored.map(item => ({ ...item, instances: runtime.find(value => value.connectionId === item.connectionId)?.instances || [] })),
455
+ ...runtime.filter(item => !ids.has(item.connectionId)).map(item => ({ ...item, managed: false, state: 'configured' }))
456
+ ] };
457
+ }
458
+ async function manageProviders(request, response, url, caller) {
459
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以管理供应商', 403);
460
+ if (!allowProviderManagement) fail('IRIS_API_READ_ONLY', '此服务未开启供应商管理', 405);
461
+ query(url);
462
+ if (['GET', 'HEAD'].includes(request.method) && url.pathname === '/api/v0/provider-management/settings') {
463
+ return json(request, response, await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry }));
464
+ }
465
+ const monitor = () => providerMonitor ||= createProviderMonitor({ configPath: providerConfigPath, registry, resolveProviderNetwork });
466
+ if (['GET', 'HEAD'].includes(request.method) && url.pathname === '/api/v0/provider-management/diagnostics') {
467
+ const settings = await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry });
468
+ return json(request, response, { ...projectProviderDiagnostics(loadProviderCatalog(providerConfigPath), registry), revision: settings.revision,
469
+ monitors: monitor().snapshot(), execution: allowExecution, imageGeneration: imageGenerationEnabled, audio: audioEnabled, video: videoEnabled });
470
+ }
471
+ const command = { '/api/v0/provider-management/save': 'settings.save', '/api/v0/provider-management/discover': 'settings.discover',
472
+ '/api/v0/provider-management/policies': 'settings.policies', '/api/v0/provider-management/enabled': 'settings.enabled',
473
+ '/api/v0/provider-management/remove': 'settings.remove', '/api/v0/provider-management/impact': 'impact',
474
+ '/api/v0/provider-management/check': 'check', '/api/v0/provider-management/monitor': 'monitor',
475
+ '/api/v0/provider-management/workflow-check': 'settings.workflow-check' }[url.pathname];
476
+ if (request.method !== 'POST' || !command) fail('IRIS_API_NOT_FOUND', '没有此供应商管理操作', 404);
477
+ if (providerManagementBusy) fail('IRIS_API_BUSY', '供应商设置正在处理,请等待完成后再操作', 429);
478
+ providerManagementBusy = true;
479
+ const controller = new AbortController(), aborted = () => { if (!response.writableFinished) controller.abort(); };
480
+ response.once('close', aborted);
481
+ try {
482
+ const value = await smallJson(request, 64 * 1024);
483
+ controller.signal.throwIfAborted();
484
+ if (['impact', 'check', 'monitor'].includes(command)) {
485
+ const fields = command === 'monitor' ? ['expectedRevision', 'providerId', 'enabled', 'intervalSeconds'] : ['expectedRevision', 'providerId'];
486
+ if (!value || Array.isArray(value) || Object.keys(value).length !== fields.length || fields.some(key => !Object.hasOwn(value, key))) fail('IRIS_API_INPUT_INVALID', '供应商操作字段无效');
487
+ const settings = await executeConfigCommand(providerConfigPath, 'settings.show', {}, { registry });
488
+ if (value.expectedRevision !== settings.revision) fail('IRIS_CONFIG_CHANGED', '配置已变化,请重新读取后操作', 409);
489
+ if (!settings.providers.some(provider => provider.id === value.providerId)) fail('IRIS_API_INPUT_INVALID', '供应商不存在');
490
+ if (command === 'check') return json(request, response, await monitor().check(value.providerId, value.expectedRevision, { signal: controller.signal }));
491
+ if (command === 'monitor') return json(request, response, monitor().configure(value));
492
+ const dependencies = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => providerDependencies(dataRoot, value.providerId)), { signal: controller.signal });
493
+ return json(request, response, { contractVersion: 0, revision: settings.revision, providerId: value.providerId,
494
+ ...providerRemovalImpact(loadProviderCatalog(providerConfigPath), value.providerId), ...dependencies });
495
+ }
496
+ if (command === 'settings.remove') {
497
+ if (!value || Array.isArray(value) || Object.keys(value).sort().join(',') !== 'confirm,expectedRevision,providerId'
498
+ || typeof value.providerId !== 'string' || !value.providerId || value.confirm !== true) fail('IRIS_API_INPUT_INVALID', '删除需要明确供应商、修订和确认');
499
+ const result = await application.run('writer', runtime => runtime.run('execute', async ({ dataRoot }) => {
500
+ const dependencies = providerDependencies(dataRoot, value.providerId);
501
+ if (dependencies.blockingTasks.length || dependencies.blockingRuns.length) fail('IRIS_PROVIDER_REFERENCED', '当前工作区仍有未完成或可恢复流程依赖此供应商,请先处理原任务', 409);
502
+ return executeConfigCommand(providerConfigPath, command, value, { registry, signal: controller.signal, resolveProviderNetwork });
503
+ }), { signal: controller.signal });
504
+ providerMonitor?.snapshot(); return json(request, response, result);
505
+ }
506
+ const result = await executeConfigCommand(providerConfigPath, command, value, { registry, signal: controller.signal, resolveProviderNetwork });
507
+ if (command === 'settings.enabled' || command === 'settings.save') providerMonitor?.snapshot();
508
+ return await json(request, response, result);
509
+ } catch (error) {
510
+ if (command === 'settings.workflow-check') {
511
+ controller.signal.throwIfAborted();
512
+ if (error instanceof ProviderContractError) fail('IRIS_COMFYUI_CHECK_UNAVAILABLE', '只读检查未完成,请核对 ComfyUI 服务、地址和认证;未提交生成', error.category === 'timeout' ? 504 : 502);
513
+ }
514
+ throw error;
515
+ } finally { response.off('close', aborted); providerManagementBusy = false; }
516
+ }
517
+ async function manageNetworks(request, response, url, caller) {
518
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以管理和检测网络出口', 403);
519
+ if (!allowNetworkManagement) fail('IRIS_API_READ_ONLY', '此服务未开启网络出口管理', 405);
520
+ query(url);
521
+ const options = { networkConfigPath, providerConfigPath, registry, resolveProviderNetwork };
522
+ const action = url.pathname.slice('/api/v0/network-management/'.length);
523
+ if (['GET', 'HEAD'].includes(request.method) && ['settings', 'doctor'].includes(action)) {
524
+ return json(request, response, action === 'settings' ? networkManagementSettings(options) : { ...networkDoctor(options), instanceId });
525
+ }
526
+ if (request.method !== 'POST' || ![...NETWORK_MANAGEMENT_ACTIONS, 'probe'].includes(action)) fail('IRIS_API_NOT_FOUND', '没有此网络管理操作', 404);
527
+ if (providerManagementBusy) fail('IRIS_API_BUSY', '供应商或出口设置正在处理,请等待完成', 429);
528
+ providerManagementBusy = true;
529
+ const controller = new AbortController(), aborted = () => { if (!response.writableFinished) controller.abort(); };
530
+ response.once('close', aborted);
531
+ try {
532
+ const input = await smallJson(request, 64 * 1024);
533
+ controller.signal.throwIfAborted();
534
+ const result = action === 'probe' ? await probeNetworkRoute(options, input, { signal: controller.signal })
535
+ : manageNetworkResources(options, action, input, { signal: controller.signal });
536
+ return await json(request, response, { ...result, instanceId });
537
+ } finally { response.off('close', aborted); providerManagementBusy = false; }
538
+ }
539
+ async function manage(request, response, url, caller) {
540
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以管理连接', 403);
541
+ if (!allowConnectionManagement) fail('IRIS_API_READ_ONLY', '此服务未开启连接管理', 405);
542
+ query(url);
543
+ if (['GET', 'HEAD'].includes(request.method)) {
544
+ if (url.pathname === '/api/v0/connection-management/connections') return json(request, response, managementFacts());
545
+ if (url.pathname === '/api/v0/connection-management/templates') return json(request, response, { contractVersion: 0, templates: managed.listTemplates(), presets: MCP_CONNECTION_PRESETS, formats: MCP_CONFIG_FORMATS });
546
+ if (url.pathname === '/api/v0/connection-management/catalog') return json(request, response, { contractVersion: 0, availableScopes, availableTools: availableTools(),
547
+ httpEnabled: allowMcp, templateExample: CONNECTION_TEMPLATE_EXAMPLE,
548
+ ...capabilityFacts(), defaults: { capabilities: DEFAULT_BRIDGE_CAPABILITIES } });
549
+ fail('IRIS_API_NOT_FOUND', '没有此连接管理接口', 404);
550
+ }
551
+ const value = await smallJson(request);
552
+ if (value?.capabilities !== undefined && checkedBridgeCapabilities(value.capabilities).some(id => !availableCapabilities().some(group => group.id === id))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此工作区尚未开启所选媒体功能', 403);
553
+ if (value?.pluginOperations !== undefined) {
554
+ const pins = checkedPluginSelection(value.pluginOperations);
555
+ const updating = /^\/api\/v0\/connection-management\/connections\/([a-f0-9-]{36})\/update$/.exec(url.pathname);
556
+ const retained = updating ? managed.list().find(item => item.connectionId === updating[1])?.pluginOperations || [] : [];
557
+ if (pins.some(pin => !hasPluginSelection(selectedOperations.map(item => item.dependency), pin) && !hasPluginSelection(retained, pin))) {
558
+ fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只能选择此服务已启用的准确业务版本;旧连接可保留原依赖以读取历史记录', 403);
559
+ }
560
+ }
561
+ if (url.pathname === '/api/v0/connection-management/templates') {
562
+ const result = managed.importTemplate(value);
563
+ return json(request, response, { contractVersion: 0, ...result }, result.idempotentReplay ? 200 : 201);
564
+ }
565
+ if (url.pathname === '/api/v0/connection-management/connections') {
566
+ if (!Array.isArray(value?.scopes) || value.scopes.some(scope => !availableScopes.includes(scope))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '选择了当前服务未开启的操作权限', 403);
567
+ const result = managed.create(value);
568
+ // A brand-new grant has no cached bridge to invalidate on its first later rename.
569
+ changedAuthorizations.delete(result.connectionId);
570
+ return json(request, response, { contractVersion: 0, ...result }, result.idempotentReplay ? 200 : 201);
571
+ }
572
+ const action = /^\/api\/v0\/connection-management\/connections\/([a-f0-9-]{36})\/(update|revoke|delete|export|skill)$/.exec(url.pathname);
573
+ if (!action) fail('IRIS_API_NOT_FOUND', '没有此连接管理操作', 404);
574
+ const [, id, verb] = action;
575
+ if (verb === 'export') {
576
+ if (managed.resolveExport(value).transport === 'http' && !allowMcp) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '此服务未开启 HTTP MCP', 403);
577
+ return json(request, response, { contractVersion: 0, ...managed.exportConfig(id, value) });
578
+ }
579
+ if (verb === 'skill') return json(request, response, { contractVersion: 0, ...managed.exportSkill(id, value) });
580
+ if (verb === 'update' && (!Array.isArray(value?.scopes) || value.scopes.some(scope => !availableScopes.includes(scope)))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '选择了当前服务未开启的操作权限', 403);
581
+ let result;
582
+ try { result = verb === 'update' ? managed.update(id, value) : verb === 'delete' ? managed.remove(id, value) : managed.revoke(id, value); }
583
+ finally { if (changedAuthorizations.delete(id)) await mcp?.invalidateConnection(id); }
584
+ return json(request, response, { contractVersion: 0, ...result });
585
+ }
586
+
587
+ async function image(artifactId, kind = 'image') {
132
588
  // Use an operation-owned reader, with no writer lease or local export path.
133
589
  const reader = createCoreRuntime({ dataRoot: physicalRoot, mode: 'reader' });
134
590
  try {
135
591
  reader.start();
136
592
  return await reader.run('inspect', ({ dataRoot: physicalRoot }) => {
137
593
  const metadata = inspectCoreArtifactMetadata(physicalRoot, artifactId);
138
- if (!IMAGE_TYPES.has(metadata.mediaType)) fail('IRIS_API_MEDIA_UNSUPPORTED', '此接口只提供 PNG、JPEG 或 WebP 图片', 415);
139
- if (metadata.size > MAX_LOCAL_API_IMAGE_BYTES) fail('IRIS_API_MEDIA_TOO_LARGE', '图片超过只读接口的 64 MiB 上限', 413);
594
+ if (!(kind === 'image' ? IMAGE_TYPES.has(metadata.mediaType) : kind === 'video' ? Object.hasOwn(VIDEO_TYPES, metadata.mediaType) : kind === 'audio' ? AUDIO_MEDIA_TYPES.has(metadata.mediaType) : metadata.mediaType === 'text/plain')) {
595
+ fail('IRIS_API_MEDIA_UNSUPPORTED', '此入口不支持该作品类型', 415);
596
+ }
597
+ if (metadata.size > (kind === 'text' ? 1024 * 1024 : kind === 'video' ? MAX_VIDEO_BYTES : MAX_LOCAL_API_IMAGE_BYTES)) fail('IRIS_API_MEDIA_TOO_LARGE', '作品超过此读取入口的大小上限', 413);
140
598
  const result = readCoreArtifactBytes(physicalRoot, artifactId);
141
- if (result.bytes.length > MAX_LOCAL_API_IMAGE_BYTES || result.bytes.length !== result.artifact.size
599
+ if (result.bytes.length > (kind === 'video' ? MAX_VIDEO_BYTES : MAX_LOCAL_API_IMAGE_BYTES) || result.bytes.length !== result.artifact.size
142
600
  || digest(result.bytes).toString('hex') !== result.artifact.digest.value) {
143
601
  fail('IRIS_API_RECORD_CONFLICT', '图片字节与已保存记录不一致', 409);
144
602
  }
@@ -151,17 +609,50 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
151
609
  if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
152
610
  const catalog = loadProviderCatalog(providerConfigPath);
153
611
  let candidates;
154
- try { candidates = imageCandidatesFromCatalog(catalog); }
155
- catch (error) { if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error; candidates = []; }
612
+ try { candidates = imageCandidatesFromCatalog(catalog, '', registry); }
613
+ catch (error) {
614
+ if (error.code === 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID') {
615
+ return { contractVersion: 0, configured: true, models: [], total: 0, gap: 'protocol_unavailable' };
616
+ }
617
+ if (error.code === 'IRIS_PROVIDER_CREDENTIAL_UNAVAILABLE') return { contractVersion: 0, configured: true, models: [], total: 0, gap: 'credential_unavailable' };
618
+ if (error.code !== 'IRIS_PROVIDER_MODEL_UNAVAILABLE') throw error;
619
+ candidates = [];
620
+ }
156
621
  const entries = candidates.map(route => ({ modelRef: route.modelRef, displayName: route.model,
157
- supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model) }));
622
+ ...(route.provider.mediaProtocol === 'comfyui' ? { comfyWorkflow: projectComfyWorkflow(route.provider.comfyWorkflow) } : {}),
623
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, registry) }));
624
+ return { contractVersion: 0, configured: true, models: entries, total: entries.length };
625
+ }
626
+
627
+ function audioModels(caller) {
628
+ if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
629
+ const catalog = loadProviderCatalog(providerConfigPath), entries = [];
630
+ const capabilities = caller.owner ? ['tts', 'transcribe'] : connections.selection(caller.connectionId, caller.runtimeInstanceId).capabilities;
631
+ for (const [capability, select] of [['tts', ttsCandidatesFromCatalog], ['transcribe', transcribeCandidatesFromCatalog]]) {
632
+ if (!capabilities.includes(capability)) continue;
633
+ let candidates;
634
+ try { candidates = select(catalog, '', registry); }
635
+ catch (error) { if (!['IRIS_PROVIDER_MODEL_UNAVAILABLE', 'IRIS_PROVIDER_CREDENTIAL_UNAVAILABLE'].includes(error.code)) throw error; candidates = []; }
636
+ entries.push(...candidates.map(route => ({ capability, modelRef: route.modelRef, displayName: route.model })));
637
+ }
158
638
  return { contractVersion: 0, configured: true, models: entries, total: entries.length };
159
639
  }
160
640
 
161
- async function create(request, response, url) {
641
+ function videoModels() {
642
+ if (!providerConfigPath) return { contractVersion: 0, configured: false, models: [], total: 0 };
643
+ let candidates;
644
+ try { candidates = videoCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), '', registry); }
645
+ catch (error) { if (!['IRIS_PROVIDER_MODEL_UNAVAILABLE', 'IRIS_PROVIDER_CREDENTIAL_UNAVAILABLE'].includes(error.code)) throw error; candidates = []; }
646
+ const entries = candidates.filter(route => videoModelSupportsMode(route.model, 't2v')).map(route => ({ modelRef: route.modelRef, displayName: route.model }));
647
+ return { contractVersion: 0, configured: true, models: entries, total: entries.length };
648
+ }
649
+
650
+ async function create(request, response, url, caller, creationTool) {
162
651
  query(url);
163
- if (!['/api/v0/images', '/api/v0/runs'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
164
- const isImage = url.pathname === '/api/v0/images';
652
+ if (!['/api/v0/images', '/api/v0/runs', '/api/v0/generations', '/api/v0/audio', '/api/v0/speeches', '/api/v0/transcriptions', '/api/v0/videos'].includes(url.pathname)) fail('IRIS_API_NOT_FOUND', '没有此本地创建接口', 404);
653
+ if (url.pathname === '/api/v0/videos' && !videoEnabled) fail('IRIS_FLOW_VIDEO_ROOT_REQUIRED', '需要新的 execution init --video 根;原根不迁移', 409);
654
+ const isImage = url.pathname === '/api/v0/images', isAudio = url.pathname === '/api/v0/audio', upload = isImage || isAudio;
655
+ if (!audioEnabled && ['/api/v0/audio', '/api/v0/speeches', '/api/v0/transcriptions'].includes(url.pathname)) fail('IRIS_FLOW_AUDIO_ROOT_REQUIRED', '请使用新的音频执行根;原根不迁移', 409);
165
656
  if (headerCount(request, 'idempotency-key') !== 1 || !KEY.test(request.headers['idempotency-key'] || '')) {
166
657
  fail('IRIS_API_INPUT_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
167
658
  }
@@ -169,15 +660,15 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
169
660
  fail('IRIS_API_INPUT_INVALID', '正文需要单个 Content-Type,且不接受 Content-Encoding');
170
661
  }
171
662
  const mediaType = request.headers['content-type'];
172
- if (isImage ? !IMAGE_TYPES.has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
173
- fail('IRIS_API_MEDIA_UNSUPPORTED', '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
663
+ if (upload ? !(isImage ? IMAGE_TYPES : AUDIO_MEDIA_TYPES).has(mediaType) : !/^application\/json(?:;\s*charset=utf-8)?$/i.test(mediaType)) {
664
+ fail('IRIS_API_MEDIA_UNSUPPORTED', isAudio ? '请提供WAV/MP3/OGG/FLAC原字节与匹配的Content-Type' : '请提供静态 PNG/JPEG/WebP 原字节或 UTF-8 JSON', 415);
174
665
  }
175
- const limit = isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
666
+ const limit = isAudio ? MAX_AUDIO_INPUT_BYTES : isImage ? MAX_IMPORT_IMAGE_BYTES : 64 * 1024;
176
667
  if (request.headers['content-length'] !== undefined && Number(request.headers['content-length']) > limit) {
177
668
  fail('IRIS_API_INPUT_TOO_LARGE', '正文超过允许的大小', 413);
178
669
  }
179
- if (isImage && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
180
- if (isImage) activeUploads++;
670
+ if (upload && activeUploads >= 1 || localWrites >= 9) fail('IRIS_API_BUSY', '本地创建繁忙,请使用原 Key 稍后重试', 429);
671
+ if (upload) activeUploads++;
181
672
  localWrites++;
182
673
  const controller = new AbortController();
183
674
  let prepared = false;
@@ -195,16 +686,28 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
195
686
  if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
196
687
  const bytes = Buffer.concat(chunks, size);
197
688
  let value;
198
- if (!isImage) {
689
+ if (!upload) {
199
690
  try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
200
691
  catch (_) { fail('IRIS_API_INPUT_INVALID', '需要有效的 UTF-8 JSON'); }
201
692
  }
202
- const result = await application.execute(isImage ? 'creation.image' : 'creation.flow', {
203
- key: request.headers['idempotency-key'], ...(isImage ? { bytes, mediaType } : { value })
693
+ if (url.pathname === '/api/v0/runs' || url.pathname === '/api/v0/transcriptions') {
694
+ if (!value || typeof value !== 'object' || Array.isArray(value)
695
+ || typeof value.sourceArtifactId !== 'string' || !ARTIFACT.test(value.sourceArtifactId)) {
696
+ fail('IRIS_API_INPUT_INVALID', '需要当前数据根中的有效来源 Artifact ID');
697
+ }
698
+ // Input reuse follows the existing media/shared-source grant. Knowing an ID
699
+ // does not authorize a new Run to read or upload another capability's media.
700
+ await requireArtifactCapability(caller, value.sourceArtifactId);
701
+ scope(caller, 'create', creationTool);
702
+ }
703
+ const command = { '/api/v0/images': 'creation.image', '/api/v0/audio': 'creation.audio', '/api/v0/runs': 'creation.flow',
704
+ '/api/v0/generations': 'creation.generate', '/api/v0/speeches': 'creation.speech', '/api/v0/transcriptions': 'creation.transcribe', '/api/v0/videos': 'creation.video' }[url.pathname];
705
+ const result = await application.execute(command, {
706
+ key: request.headers['idempotency-key'], ...(upload ? { bytes, mediaType } : { value })
204
707
  }, { signal: controller.signal, onPrepared: () => { prepared = true; } });
205
708
  const body = { contractVersion: 0, idempotentReplay: result.idempotentReplay,
206
- ...(isImage ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
207
- size: result.artifact.size, width: result.artifact.metadata.width, height: result.artifact.metadata.height } }
709
+ ...(upload ? { artifact: { artifactId: result.artifact.id, mediaType: result.artifact.mediaType,
710
+ size: result.artifact.size, ...(isImage ? { width: result.artifact.metadata.width, height: result.artifact.metadata.height } : {}) } }
208
711
  : { flow: projectLocalApiFlow(result.flow) }) };
209
712
  return await json(request, response, body, result.idempotentReplay ? 200 : 201);
210
713
  } catch (error) {
@@ -212,15 +715,91 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
212
715
  throw error;
213
716
  } finally {
214
717
  response.off('close', disconnect); localWrites--;
215
- if (isImage) activeUploads--;
718
+ if (upload) activeUploads--;
216
719
  }
217
720
  }
218
721
 
219
722
  async function route(request, response) {
220
723
  if (state !== 'open') fail('IRIS_API_UNAVAILABLE', '服务正在关闭', 503);
221
- authorize(request);
222
- if (!['GET', 'HEAD'].includes(request.method) && !(allowLocalWrites && request.method === 'POST')) {
223
- fail('IRIS_API_READ_ONLY', allowLocalWrites ? '仅支持 GET/HEAD 与明确的本地创建 POST' : '当前接口仅支持只读 GET/HEAD', 405);
724
+ const passwordPath = browserAuth && /^\/api\/v0\/workbench\/(auth|setup|password-login|logout|setup-link|password-reset)$/.exec(request.url.split('?')[0]);
725
+ if (passwordPath) {
726
+ authorizeOrigin(request); query(new URL(request.url, origin));
727
+ const action = passwordPath[1];
728
+ if (action === 'auth') {
729
+ if (!['GET', 'HEAD'].includes(request.method)) throw Object.assign(new LocalApiError('IRIS_API_METHOD_DENIED', '登录状态仅支持 GET/HEAD', 405), { allow: 'GET, HEAD' });
730
+ if (request.headers['transfer-encoding'] !== undefined || request.headers['content-length'] && request.headers['content-length'] !== '0') fail('IRIS_API_INPUT_INVALID', '登录状态请求不接受正文');
731
+ return json(request, response, { contractVersion: 0, ...browserAuth.info() });
732
+ }
733
+ if (request.method !== 'POST') fail('IRIS_API_METHOD_DENIED', '登录操作仅支持 POST', 405);
734
+ const control = ['setup-link', 'password-reset'].includes(action);
735
+ if (control) {
736
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
737
+ if (!match || !crypto.timingSafeEqual(digest(match[1]), tokenDigest)
738
+ || request.headers['iris-connection-id'] !== undefined || request.headers['iris-runtime-id'] !== undefined) {
739
+ fail('IRIS_API_AUTH_REQUIRED', '此操作需要本机工作台控制权限,请通过 CLI 执行', 401);
740
+ }
741
+ } else if (request.headers.origin !== origin) fail('IRIS_API_ORIGIN_DENIED', '请在当前工作台页面登录或退出', 403);
742
+ const value = await smallJson(request, 1024);
743
+ const keys = action === 'setup' ? ['ticket', 'password', 'remember'] : action === 'password-login' ? ['password', 'remember'] : [];
744
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) {
745
+ fail('IRIS_API_INPUT_INVALID', '登录请求字段无效');
746
+ }
747
+ if (action === 'setup') return json(request, response, { contractVersion: 0, ...await browserAuth.setup(value) });
748
+ if (action === 'password-login') return json(request, response, { contractVersion: 0, ...await browserAuth.login(value) });
749
+ if (action === 'logout') {
750
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
751
+ if (!match) fail('IRIS_API_AUTH_REQUIRED', '需要当前浏览器会话', 401);
752
+ return json(request, response, { contractVersion: 0, ...browserAuth.logout(match[1]) });
753
+ }
754
+ if (action === 'password-reset') await browserAuth.reset();
755
+ return json(request, response, { contractVersion: 0, setupUrl: browserAuth.setupLink(origin) });
756
+ }
757
+ const loginPath = serveWorkbench && /^\/api\/v0\/workbench\/(login|login-link)$/.exec(request.url.split('?')[0]);
758
+ if (loginPath) {
759
+ authorizeOrigin(request); query(new URL(request.url, origin));
760
+ if (browserAuth) fail('IRIS_WEB_PASSWORD_REQUIRED', '请在工作台普通地址用访问口令登录;首次设置可运行 web login --data-root', 409);
761
+ if (request.method !== 'POST') fail('IRIS_API_METHOD_DENIED', '登录入口仅接受 POST', 405);
762
+ if (loginPath[1] === 'login') {
763
+ if (request.headers.origin !== origin) fail('IRIS_API_ORIGIN_DENIED', '请在本机工作台打开登录链接', 403);
764
+ } else if (!authorize(request).owner) fail('IRIS_API_AUTH_REQUIRED', '只有工作台所有者可以获取登录链接', 403);
765
+ const value = await smallJson(request, 256);
766
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== (loginPath[1] === 'login' ? 1 : 0)
767
+ || loginPath[1] === 'login' && (typeof value.ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.ticket))) {
768
+ fail('IRIS_API_INPUT_INVALID', '登录请求无效');
769
+ }
770
+ return json(request, response, { contractVersion: 0, ...(loginPath[1] === 'login' ? redeemLoginLink(value.ticket) : issueLoginLink()) });
771
+ }
772
+ if (allowMcp && request.url.split('?')[0] === '/mcp') {
773
+ authorizeOrigin(request); query(new URL(request.url, origin));
774
+ const match = headerCount(request, 'authorization') === 1 && /^Bearer +([A-Za-z0-9_-]{43})$/i.exec(request.headers.authorization || '');
775
+ if (!match) fail('IRIS_CONNECTION_AUTH_REQUIRED', '需要有效的 MCP 连接凭据', 401);
776
+ if (request.headers['iris-connection-id'] !== undefined || request.headers['iris-runtime-id'] !== undefined) fail('IRIS_CONNECTION_INPUT_INVALID', 'MCP 仅使用标准连接头');
777
+ return mcp.handle(request, response, match[1]);
778
+ }
779
+ if (serveWorkbench && (panel.has(request.url.split('?')[0]) || pluginPanel?.has(request.url.split('?')[0]))) {
780
+ // The shell contains no private data. API and image requests still require Bearer auth.
781
+ authorizeOrigin(request, request.url.split('?')[0] === '/' && request.method === 'GET');
782
+ if (!['GET', 'HEAD'].includes(request.method)) fail('IRIS_API_READ_ONLY', '页面资源仅支持 GET/HEAD', 405);
783
+ if (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0')) {
784
+ fail('IRIS_API_INPUT_INVALID', '页面请求不接受正文');
785
+ }
786
+ const url = new URL(request.url, origin);
787
+ query(url);
788
+ const asset = panel.has(url.pathname) ? await panel.read(url.pathname) : await pluginPanel.read(url.pathname);
789
+ if (!asset) fail('IRIS_API_UNAVAILABLE', '插件资源已关闭', 503);
790
+ const bytes = browserAuth && url.pathname === '/' && panel.identity.id === 'iris.default-panel'
791
+ ? Buffer.from(asset.bytes.toString('utf8').replace('data-auth="token"', 'data-auth="password"')) : asset.bytes;
792
+ return send(request, response, 200, bytes, { 'Content-Type': asset.mediaType,
793
+ 'Content-Security-Policy': LOCAL_WEB_CSP, 'Cross-Origin-Resource-Policy': 'same-origin' });
794
+ }
795
+ const caller = authorize(request);
796
+ const connectionPost = connections && /^\/api\/v0\/connections\/[a-f0-9-]{36}\/(?:handshake|disconnect|client-info)$/.test(request.url.split('?')[0]);
797
+ const managementPost = allowConnectionManagement && request.url.split('?')[0].startsWith('/api/v0/connection-management/');
798
+ const providerPost = allowProviderManagement && request.url.split('?')[0].startsWith('/api/v0/provider-management/');
799
+ const networkPost = allowNetworkManagement && request.url.split('?')[0].startsWith('/api/v0/network-management/');
800
+ const pluginPost = pluginExecution && (request.url.split('?')[0] === '/api/v0/plugin-operations' || /^\/api\/v0\/operations\/op_[a-f0-9]{64}\/cancel$/.test(request.url.split('?')[0]));
801
+ if (!['GET', 'HEAD'].includes(request.method) && !((allowLocalWrites || allowSelection || connectionPost || managementPost || providerPost || networkPost || pluginPost) && request.method === 'POST')) {
802
+ fail('IRIS_API_READ_ONLY', allowLocalWrites || allowSelection ? '仅支持 GET/HEAD 与明确开启的 POST 操作' : '当前接口仅支持只读 GET/HEAD', 405);
224
803
  }
225
804
  if (request.method !== 'POST' && (request.headers['transfer-encoding'] !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0'))) {
226
805
  fail('IRIS_API_INPUT_INVALID', '只读请求不接受正文');
@@ -228,48 +807,169 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
228
807
  if (!request.url.startsWith('/') || request.url.startsWith('//')) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
229
808
  const url = new URL(request.url, origin);
230
809
  if (url.pathname !== request.url.split('?')[0]) fail('IRIS_API_INPUT_INVALID', '请求路径无效');
231
- if (request.method === 'POST') return create(request, response, url);
810
+ if (request.method === 'POST') return specialPost(request, response, url, caller);
811
+ if (url.pathname.startsWith('/api/v0/connection-management')) return manage(request, response, url, caller);
812
+ if (url.pathname.startsWith('/api/v0/provider-management')) return manageProviders(request, response, url, caller);
813
+ if (url.pathname.startsWith('/api/v0/network-management')) return manageNetworks(request, response, url, caller);
814
+ const callerScopes = scope(caller, 'read');
232
815
  const matchRun = /^\/api\/v0\/runs\/([^/]+)(\/download)?$/.exec(url.pathname);
233
816
  const matchImage = /^\/api\/v0\/artifacts\/([^/]+)\/content$/.exec(url.pathname);
234
- query(url, url.pathname === '/api/v0/runs' ? ['limit', 'cursor'] : []);
235
- if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1])) {
817
+ const matchMedia = audioEnabled && /^\/api\/v0\/artifacts\/([^/]+)\/(audio|text)$/.exec(url.pathname)
818
+ || videoEnabled && /^\/api\/v0\/artifacts\/([^/]+)\/(video)$/.exec(url.pathname);
819
+ const operation = (allowExecution || pluginExecution) && /^\/api\/v0\/operations\/(op_[a-f0-9]{64})$/.exec(url.pathname);
820
+ const runOperations = allowExecution && /^\/api\/v0\/runs\/(run_[a-f0-9]{24})\/operations$/.exec(url.pathname);
821
+ const operationLookup = (allowExecution || pluginExecution) && url.pathname === '/api/v0/operations';
822
+ const pluginCatalog = pluginExecution && url.pathname === '/api/v0/plugin-operations/catalog';
823
+ const pluginHistory = pluginExecution && url.pathname === '/api/v0/plugin-operations';
824
+ const creationLookup = url.pathname === '/api/v0/creation-requests';
825
+ if (creationLookup && !caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以定位原创建请求', 403);
826
+ query(url, pluginCatalog ? ['limit', 'offset'] : pluginHistory ? ['limit', 'cursor'] : url.pathname === '/api/v0/runs' ? ['limit', 'cursor', 'selection', 'kind'] : runOperations ? ['limit', 'cursor']
827
+ : creationLookup ? ['kind', 'key'] : operationLookup ? ['key'] : []);
828
+ if (matchRun && !RUN.test(matchRun[1]) || matchImage && !ARTIFACT.test(matchImage[1]) || matchMedia && !ARTIFACT.test(matchMedia[1])) {
236
829
  fail('IRIS_API_INPUT_INVALID', '流程或图片 ID 格式无效');
237
830
  }
831
+ const readTool = pluginCatalog ? 'iris_core_plugin_catalog' : pluginHistory ? 'iris_core_operation' : operation ? 'iris_core_operation' : runOperations ? 'iris_core_operation' : operationLookup ? 'iris_core_lookup'
832
+ : matchMedia ? matchMedia[2] === 'video' ? 'iris_core_video' : matchMedia[2] === 'audio' ? 'iris_core_audio' : 'iris_core_transcript'
833
+ : matchImage ? 'iris_core_image' : matchRun ? matchRun[2] ? null : 'iris_core_run'
834
+ : ({ '/api/v0/models': 'iris_core_models', '/api/v0/video-models': 'iris_core_video_models', '/api/v0/audio-models': 'iris_core_audio_models', '/api/v0/runs': 'iris_core_runs', '/api/v0/connections': 'iris_core_connections', '/api/v0/adapters': 'iris_core_connection' })[url.pathname];
835
+ if (readTool) scope(caller, 'read', readTool);
836
+ if (matchRun?.[2] && !caller.owner && !connections.selection(caller.connectionId, caller.runtimeInstanceId).tools
837
+ .some(name => ['iris_core_image', 'iris_core_audio', 'iris_core_transcript', 'iris_core_video'].includes(name))) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '未选择作品交付工具', 403);
238
838
  if (activeReads >= 2) fail('IRIS_API_BUSY', '读取繁忙,请稍后再试', 429);
239
839
  activeReads++;
240
840
  try {
841
+ if (pluginCatalog) {
842
+ const pins = caller.owner ? selectedOperations.map(item => item.dependency) : connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [];
843
+ const limit = Number(url.searchParams.get('limit') ?? 20), offset = Number(url.searchParams.get('offset') ?? 0);
844
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30 || !Number.isSafeInteger(offset) || offset < 0) fail('IRIS_API_INPUT_INVALID', '作品分页无效');
845
+ const catalog = capabilityFacts();
846
+ const entries = pins.map(pin => { const found = catalog.contributions.operations.find(item => hasPluginSelection([pin], item.dependency));
847
+ return catalog.state === 'active' && found && allowedPluginIds.includes(found.id) ? { ...found, available: true } : { id: pin.contributionId, dependency: pin, available: false }; });
848
+ const artifacts = entries.some(item => item.available && item.artifactInputs?.length) ? await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => {
849
+ const value = listCoreArtifacts(dataRoot, { offset, limit });
850
+ return { ...value, nextOffset: offset + value.artifacts.length < value.total ? offset + value.artifacts.length : null,
851
+ artifacts: value.artifacts.filter(isPluginSourceImage)
852
+ .map(item => ({ id: item.id, mediaType: item.mediaType, size: item.size, createdAt: item.createdAt })) };
853
+ })) : { artifacts: [], total: 0 };
854
+ return json(request, response, { contractVersion: 0, operations: entries, ...artifacts });
855
+ }
856
+ if (pluginHistory) {
857
+ const pins = caller.owner ? null : connections.selection(caller.connectionId, caller.runtimeInstanceId).pluginOperations || [];
858
+ const limit = Number(url.searchParams.get('limit') ?? 20), cursor = url.searchParams.get('cursor');
859
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 30 || cursor !== null && !/^op_[a-f0-9]{64}$/.test(cursor)) fail('IRIS_API_INPUT_INVALID', '业务记录分页无效');
860
+ const facts = await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => {
861
+ const records = readAllOperations(dataRoot).filter(item => item.action === 'plugin.execute' && (!pins || hasPluginSelection(pins, item.dependency)))
862
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
863
+ const offset = cursor === null ? 0 : records.findIndex(item => item.id === cursor) + 1;
864
+ if (cursor !== null && offset === 0) fail('IRIS_API_INPUT_INVALID', '游标不属于此连接的业务记录');
865
+ const page = records.slice(offset, offset + limit);
866
+ return { operations: page.map(projectOperation), total: records.length, nextCursor: offset + page.length < records.length ? page.at(-1).id : null };
867
+ }));
868
+ return json(request, response, { contractVersion: 0, ...facts });
869
+ }
870
+ if (url.pathname === '/api/v0/capabilities') {
871
+ if (!caller.owner) fail('IRIS_CONNECTION_CAPABILITY_DENIED', '只有工作台所有者可以查看完整贡献目录', 403);
872
+ return await json(request, response, capabilityFacts());
873
+ }
241
874
  if (url.pathname === '/api/v0/session') {
242
- return await json(request, response, { contractVersion: 0, instanceId, readOnly: !allowLocalWrites,
243
- operations: ['models.list', 'runs.list', 'runs.inspect', 'artifacts.content', 'runs.download',
244
- ...(allowLocalWrites ? ['images.create', 'runs.create'] : [])],
875
+ const selection = caller.owner ? null : connections.selection(caller.connectionId, caller.runtimeInstanceId);
876
+ const identity = caller.owner ? await application.execute('creation.root', {}) : null;
877
+ const canSelect = Boolean(caller.owner && allowSelection);
878
+ const mediaReadOnly = !callerScopes.includes('create') && !(allowExecution && callerScopes.includes('execute')) && !canSelect;
879
+ return await json(request, response, { contractVersion: 0, instanceId, readOnly: mediaReadOnly,
880
+ ...(identity ? { rootId: identity.rootId } : {}),
881
+ connectionManagement: Boolean(caller.owner && allowConnectionManagement), workbenchMediaReadOnly: serveWorkbench && mediaReadOnly,
882
+ providerManagement: Boolean(caller.owner && allowProviderManagement), networkManagement: Boolean(caller.owner && allowNetworkManagement),
883
+ ...(serveWorkbench && caller.owner ? { panel: panel.identity } : {}),
884
+ ...(pluginPanel && caller.owner ? { panelContributions: pluginPanel.manifest } : {}),
885
+ ...(pluginExecution ? { pluginExecution: true, pluginOperations: caller.owner ? selectedOperations.map(item => item.dependency) : selection?.pluginOperations || [] } : {}),
886
+ ...(audioEnabled ? { audio: true } : {}), ...(videoEnabled ? { video: true } : {}),
887
+ operations: projectCoreSessionOperations({ scopes: callerScopes, ...(selection ? { tools: selection.tools } : {}),
888
+ imageGeneration: imageGenerationEnabled, audio: audioEnabled, video: videoEnabled, execution: allowExecution, pluginExecution,
889
+ creationLookup: Boolean(identity?.rootId), selection: canSelect }),
890
+ ...(selection || {}),
891
+ ...(allowExecution || pluginExecution ? { executionEnabled: runner.snapshot().executionEnabled } : {}),
892
+ ...(caller.owner && scheduler ? { scheduling: scheduler.snapshot(physicalRoot) } : {}),
245
893
  limits: { runPageSize: 30, imageBytes: MAX_LOCAL_API_IMAGE_BYTES, activeReads: 2,
246
894
  ...(allowLocalWrites ? { uploadBytes: MAX_IMPORT_IMAGE_BYTES, jsonBytes: 64 * 1024, activeUploads: 1, localWrites: 9 } : {}) } });
247
895
  }
896
+ if (connections && ['/api/v0/connections', '/api/v0/adapters'].includes(url.pathname)) {
897
+ const facts = connections.list(caller.owner ? undefined : caller.connectionId);
898
+ return await json(request, response, url.pathname.endsWith('/adapters') ? { contractVersion: 0, adapters: facts.adapters } : facts);
899
+ }
900
+ if (operation) {
901
+ const result = await application.execute('operation.inspect', { id: operation[1] });
902
+ await requireOperationCapability(caller, result.operation);
903
+ if (runner?.scheduling(operation[1])) result.operation.scheduling = runner.scheduling(operation[1]);
904
+ return await json(request, response, result);
905
+ }
906
+ if (creationLookup) {
907
+ const kind = url.searchParams.get('kind'), key = url.searchParams.get('key');
908
+ if (!CREATION_KINDS.includes(kind) || !KEY.test(key || '')) fail('IRIS_API_INPUT_INVALID', '需要明确的创建类型与原 UUIDv4 Key');
909
+ return await json(request, response, await application.execute('creation.lookup', { kind, key }));
910
+ }
911
+ if (operationLookup) {
912
+ if (!KEY.test(url.searchParams.get('key') || '')) fail('IRIS_API_INPUT_INVALID', '需要原 Operation Key');
913
+ const result = await application.execute('operation.lookup', { key: url.searchParams.get('key') });
914
+ await requireOperationCapability(caller, result.operation);
915
+ if (runner?.scheduling(result.operation.id)) result.operation.scheduling = runner.scheduling(result.operation.id);
916
+ return await json(request, response, result);
917
+ }
918
+ if (runOperations) {
919
+ await requireFlowCapability(caller, runOperations[1]);
920
+ const limit = url.searchParams.get('limit'), cursor = url.searchParams.get('cursor');
921
+ if (limit !== null && !/^(?:[1-9]|[12][0-9]|30)$/.test(limit)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30');
922
+ return await json(request, response, await application.execute('operation.list', { runId: runOperations[1],
923
+ ...(limit !== null ? { limit: Number(limit) } : {}), ...(cursor !== null ? { cursor } : {}) }));
924
+ }
248
925
  if (url.pathname === '/api/v0/models') return await json(request, response, models());
926
+ if (url.pathname === '/api/v0/video-models' && videoEnabled) return await json(request, response, videoModels());
927
+ if (url.pathname === '/api/v0/audio-models' && audioEnabled) return await json(request, response, audioModels(caller));
249
928
  if (url.pathname === '/api/v0/runs') {
250
929
  const limitText = url.searchParams.get('limit') ?? '20';
251
930
  if (!/^(?:[1-9]|[12][0-9]|30)$/.test(limitText)) fail('IRIS_API_INPUT_INVALID', 'limit 必须是 1–30 的整数');
252
931
  const cursor = url.searchParams.get('cursor');
253
932
  if (cursor !== null && !RUN.test(cursor)) fail('IRIS_API_INPUT_INVALID', '游标无效');
254
- const result = await application.execute('flow.list', {});
255
- const ordered = [...result.runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
933
+ const selection = url.searchParams.get('selection') ?? 'all';
934
+ if (!['all', 'selected', 'unselected'].includes(selection)) fail('IRIS_API_INPUT_INVALID', '选择筛选无效');
935
+ const kind = url.searchParams.get('kind') ?? 'all';
936
+ if (!['all', 'image', 'speech', 'transcribe', 'video'].includes(kind)) fail('IRIS_API_INPUT_INVALID', '作品类别无效');
937
+ const records = (await application.run('reader', runtime => runtime.run('inspect', ({ dataRoot }) => listFlowRuns(dataRoot))))
938
+ .filter(run => allowsRun(caller, run) && (kind === 'all' || kind === 'image' && !isAudioRecipe(run.recipe) && run.recipe !== VIDEO_RECIPE
939
+ || kind === 'video' && run.recipe === VIDEO_RECIPE || kind === 'speech' && run.recipe === 'speech/v0' || kind === 'transcribe' && run.recipe === 'transcribe/v0'));
940
+ const selected = records.filter(run => Boolean(run.selection)).length;
941
+ const counts = { all: records.length, selected, unselected: records.length - selected };
942
+ const ordered = records.filter(run => selection === 'all' || Boolean(run.selection) === (selection === 'selected'))
943
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
256
944
  const index = cursor === null ? 0 : ordered.findIndex(run => run.id === cursor) + 1;
257
945
  if (cursor !== null && index === 0) fail('IRIS_API_INPUT_INVALID', '游标已失效,请重新读取列表');
258
946
  const page = ordered.slice(index, index + Number(limitText));
259
- return await json(request, response, { contractVersion: 0, total: ordered.length,
947
+ return await json(request, response, { contractVersion: 0, total: ordered.length, counts,
260
948
  runs: page.map(projectLocalApiRunSummary), nextCursor: index + page.length < ordered.length ? page.at(-1).id : null });
261
949
  }
262
- let artifactId;
950
+ let artifactId, mediaKind = matchMedia ? matchMedia[2] : 'image';
263
951
  if (matchRun) {
952
+ await requireFlowCapability(caller, matchRun[1]);
264
953
  const view = projectLocalApiFlow(await application.execute('flow.inspect', { run_id: matchRun[1] }));
265
954
  if (!matchRun[2]) return await json(request, response, view);
266
- if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
267
- artifactId = view.selection.artifactId;
955
+ const audio = isAudioRecipe(view.recipe), video = view.recipe === VIDEO_RECIPE;
956
+ if (audio || video) {
957
+ mediaKind = video ? 'video' : view.recipe === 'speech/v0' ? 'audio' : 'text';
958
+ scope(caller, 'read', mediaKind === 'video' ? 'iris_core_video' : mediaKind === 'audio' ? 'iris_core_audio' : 'iris_core_transcript');
959
+ if (!view.output) fail('IRIS_API_SELECTION_REQUIRED', '请先接回原任务结果', 409);
960
+ artifactId = view.output.artifactId;
961
+ } else {
962
+ scope(caller, 'read', 'iris_core_image');
963
+ if (!view.selection) fail('IRIS_API_SELECTION_REQUIRED', '请先明确选择要下载的作品', 409);
964
+ artifactId = view.selection.artifactId;
965
+ }
268
966
  } else if (matchImage) artifactId = matchImage[1];
967
+ else if (matchMedia) artifactId = matchMedia[1];
269
968
  else fail('IRIS_API_NOT_FOUND', '没有此只读接口', 404);
270
- const result = await image(artifactId);
969
+ await requireArtifactCapability(caller, artifactId);
970
+ const result = await image(artifactId, mediaKind);
271
971
  return await send(request, response, 200, result.bytes, { 'Content-Type': result.artifact.mediaType,
272
- ...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType)}"` } : {}) });
972
+ ...(matchRun ? { 'Content-Disposition': `attachment; filename="iris-${artifactId}.${IMAGE_TYPES.get(result.artifact.mediaType) || AUDIO_TYPES[result.artifact.mediaType] || VIDEO_TYPES[result.artifact.mediaType] || 'txt'}"` } : {}) });
273
973
  } finally { activeReads--; }
274
974
  }
275
975
 
@@ -282,7 +982,8 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
282
982
  await send(request, response, safe.status, Buffer.from(JSON.stringify({ contractVersion: 0,
283
983
  error: { code: safe.code, message: safe.message, requestId } })), {
284
984
  Connection: 'close', ...(safe.status === 401 ? { 'WWW-Authenticate': 'Bearer realm="iris-local"' } : {}),
285
- ...(safe.status === 405 ? { Allow: allowLocalWrites ? 'GET, HEAD, POST' : 'GET, HEAD' } : {}) });
985
+ ...(safe.status === 405 ? { Allow: safe.allow || (safe.code === 'IRIS_API_METHOD_DENIED' ? 'POST'
986
+ : allowLocalWrites || allowSelection || allowConnectionManagement || allowProviderManagement || allowNetworkManagement ? 'GET, HEAD, POST' : 'GET, HEAD') } : {}) });
286
987
  }
287
988
  }
288
989
 
@@ -293,28 +994,113 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
293
994
  await completion;
294
995
  }
295
996
 
997
+ async function cleanupResources() {
998
+ const failures = [];
999
+ for (const [stage, close] of [
1000
+ ['provider-monitor', () => providerMonitor?.dispose()],
1001
+ ['http', closeServer], ['mcp', () => mcp?.dispose()], ['runner', () => runnerStarted ? runner.dispose() : undefined],
1002
+ ['application', () => application?.dispose()], ['requests', () => Promise.all([...jobs])],
1003
+ ['connections-store', () => managed?.dispose()], ['connections', () => connections?.dispose()],
1004
+ ['workbench-auth', () => browserAuth?.dispose()],
1005
+ ['providers', () => suppliedRegistry ? undefined : registry?.dispose()],
1006
+ ['network', () => ownedNetwork ? networks.dispose() : undefined],
1007
+ ['scheduler', () => ownedScheduler ? scheduler.dispose() : undefined]
1008
+ ]) {
1009
+ try { await close(); } catch (error) { failures.push({ stage, error }); }
1010
+ }
1011
+ if (failures.length) {
1012
+ cleanupFailed = true;
1013
+ throw Object.assign(new AggregateError(failures.map(item => item.error), '本地服务释放失败:' + failures.map(item => item.stage).join(', ')),
1014
+ { code: 'IRIS_API_CLEANUP_FAILED', stages: failures.map(item => item.stage) });
1015
+ }
1016
+ }
1017
+
296
1018
  function start({ port = 0 } = {}) {
297
1019
  if (state !== 'new') return Promise.reject(new LocalApiError('IRIS_API_LIFECYCLE', '服务只能显式启动一次', 409));
298
1020
  if (!Number.isInteger(port) || port < 0 || port > 65535) return Promise.reject(new LocalApiError('IRIS_API_INPUT_INVALID', '端口必须是 0–65535 的整数'));
299
1021
  state = 'starting';
300
1022
  const result = (async () => {
301
1023
  try {
1024
+ if (pluginRegistry && pluginRegistry.snapshot().state !== 'active') fail('IRIS_API_CONFIG_INVALID', '插件注册实例须处于活动状态');
1025
+ registry ||= await createProviderRegistry();
1026
+ if (panelManifestPath) panel = loadPanelExtension(panelManifestPath);
1027
+ if (panelContributions !== undefined) pluginPanel = createPluginPanelAssembly(pluginRegistry, panelContributions);
1028
+ if (allowProviderManagement) loadProviderCatalog(providerConfigPath);
302
1029
  const reader = createCoreRuntime({ dataRoot: suppliedRoot, mode: 'reader' });
303
1030
  try {
304
1031
  reader.start();
305
1032
  physicalRoot = await reader.run('inspect', ({ dataRoot: root }) => {
306
1033
  inspectFlowRoot(root);
307
- if (allowLocalWrites) inspectCreationRoot(root);
1034
+ const version = inspectCreationRoot(root, { allowAbsent: !allowLocalWrites })?.schemaVersion;
1035
+ imageGenerationEnabled = [2, 3, 4].includes(version); audioEnabled = [3, 4].includes(version); videoEnabled = version === 4;
308
1036
  return root;
309
1037
  });
310
1038
  } finally { await reader.dispose(); }
311
1039
  if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
312
- application = createApplicationRuntime({ dataRoot: physicalRoot,
313
- ...(allowLocalWrites && providerConfigPath ? { resolveImageCandidates: ({ modelRef }) =>
314
- imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef).map(route => ({
315
- adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol }, model: route.modelRef,
316
- providerBinding: providerTaskBinding(route.provider), selectionReason: 'explicit'
317
- })) } : {}) });
1040
+ if (workbenchAuth) { browserAuth = createWorkbenchAuth({ ...workbenchAuth, dataRoot: physicalRoot }); browserAuth.start(); }
1041
+ let ports = {};
1042
+ if (allowExecution) {
1043
+ if (!executionPorts) loadProviderCatalog(providerConfigPath); // Static validation only; no discovery or model call.
1044
+ ports = executionPorts || configuredImagePorts(providerConfigPath, registry, { resolveProviderNetwork, deferCooling: Boolean(scheduler) });
1045
+ }
1046
+ let grants = [];
1047
+ if (connectionsFile) {
1048
+ const file = fs.realpathSync.native(connectionsFile), relative = path.relative(physicalRoot, file);
1049
+ if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) fail('IRIS_API_CONFIG_INVALID', '授权文件须放在数据根之外');
1050
+ grants = readConnectionGrants(connectionsFile); const marker = inspectExecutionRoot(physicalRoot);
1051
+ if (grants.some(grant => grant.rootId !== marker.rootId)) fail('IRIS_API_CONFIG_INVALID', '连接授权属于另一数据空间');
1052
+ }
1053
+ if (connectionsFile || allowConnectionManagement) connections = createConnectionRegistry(grants, availableScopes,
1054
+ { availableCapabilities: availableCapabilities().map(group => group.id), availableTools: availableTools(), onClientInfo: (id, contact) => managed?.observe(id, contact) });
1055
+ if (allowConnectionManagement) {
1056
+ const marker = inspectExecutionRoot(physicalRoot);
1057
+ managed = createManagedConnectionStore({ directory: connectionStore, dataRoot: physicalRoot, rootId: marker.rootId,
1058
+ getOrigin: () => origin, availableScopes, availableTools: availableTools(), beforeCreate: id => {
1059
+ const active = connections.list().connections;
1060
+ if (active.some(item => item.connectionId === id)) fail('IRIS_CONNECTION_CONFIG_CHANGED', '连接身份与现有授权冲突', 409);
1061
+ if (active.length >= 32) fail('IRIS_CONNECTION_BUSY', '活动连接已达 32 项,请先撤销不用的连接', 429);
1062
+ }, onChange: (grant, id) => {
1063
+ if (!grant) { connections.revoke(id); changedAuthorizations.add(id); }
1064
+ else if (connections.setGrant(grant).authorizationChanged) changedAuthorizations.add(id);
1065
+ } });
1066
+ const dynamic = await managed.start();
1067
+ if (grants.length + dynamic.length > 32 || dynamic.some(value => grants.some(item => item.connectionId === value.connectionId || item.accessToken === value.accessToken))) {
1068
+ fail('IRIS_CONNECTION_CONFIG_CHANGED', '静态与面板授权冲突或超过容量', 409);
1069
+ }
1070
+ for (const grant of dynamic) connections.setGrant(grant);
1071
+ }
1072
+ application = createApplicationRuntime({ dataRoot: physicalRoot, ...ports, scheduler,
1073
+ ...(!allowExecution && allowLocalWrites && providerConfigPath ? { resolveVideoCandidates: ({ modelRef }) => videoCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef, registry).map(route => ({
1074
+ adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol, networkRequirement: providerNetworkRequirement(route.provider) }, model: route.modelRef,
1075
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit' })), resolveAudioCandidates: ({ capability, modelRef }) => {
1076
+ const select = capability === 'tts' ? ttsCandidatesFromCatalog : transcribeCandidatesFromCatalog;
1077
+ return select(loadProviderCatalog(providerConfigPath), modelRef, registry).map(route => ({
1078
+ adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol, networkRequirement: providerNetworkRequirement(route.provider) }, model: route.modelRef,
1079
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
1080
+ }));
1081
+ }, resolveImageCandidates: ({ modelRef, input }) =>
1082
+ imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef, registry).map(route => {
1083
+ if (input?.workflow_parameters !== undefined) {
1084
+ if (route.provider.mediaProtocol !== 'comfyui') throw Object.assign(new TypeError('工作流参数仅用于 ComfyUI 模板'), { code: 'IRIS_COMFYUI_PARAMETERS_INVALID' });
1085
+ compileComfyWorkflow(route.provider.comfyWorkflow, input);
1086
+ }
1087
+ return {
1088
+ adapter: { id: route.provider.id, protocol: route.provider.mediaProtocol, networkRequirement: providerNetworkRequirement(route.provider) }, model: route.modelRef,
1089
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, registry),
1090
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
1091
+ };
1092
+ }) } : {}) });
1093
+ if (allowExecution || pluginExecution) {
1094
+ runner = createOperationRunner({ dataRoot: physicalRoot, application, ...ports, onExecutionError: reportUnexpected, pluginRegistry, allowedOperations: allowedPluginIds });
1095
+ await runner.start(); runnerStarted = true;
1096
+ }
1097
+ if (allowMcp) {
1098
+ const { createMcpHttpGateway } = await import('./mcp-http.js');
1099
+ const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1100
+ mcp = createMcpHttpGateway({ getOrigin: () => origin, authenticate: token => connections.identifyMcp(token),
1101
+ availableScopes, version: manifest.version });
1102
+ await mcp.start();
1103
+ }
318
1104
  server = http.createServer({ maxHeaderSize: 8192, headersTimeout: 10000, requestTimeout: 15000 }, (request, response) => {
319
1105
  const job = handle(request, response).catch(error => {
320
1106
  reportUnexpected(error, crypto.randomUUID()); response.destroy();
@@ -334,12 +1120,15 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
334
1120
  const accessToken = crypto.randomBytes(32).toString('base64url');
335
1121
  tokenDigest = digest(accessToken);
336
1122
  if (closeRequested) fail('IRIS_API_UNAVAILABLE', '启动已取消', 503);
1123
+ if (browserAuth) browserAuth.bindControl({ origin, accessToken });
337
1124
  state = 'open';
338
- return Object.freeze({ origin, accessToken });
1125
+ return Object.freeze({ origin, accessToken,
1126
+ ...(browserAuth ? { authentication: browserAuth.info(), loginUrl: browserAuth.info().configured ? origin : browserAuth.setupLink(origin) }
1127
+ : serveWorkbench ? issueLoginLink() : {}), ...(allowMcp ? { mcpEndpoint: origin + '/mcp' } : {}) });
339
1128
  } catch (error) {
340
- await closeServer(); await application?.dispose();
341
- state = closeRequested ? 'closing' : 'closed';
342
- if (!closeRequested) resolveClosed(snapshot());
1129
+ try { await cleanupResources(); }
1130
+ catch (cleanup) { throw Object.assign(new AggregateError([error, cleanup], '本地服务启动与释放均失败'), { code: 'IRIS_API_STARTUP_CLEANUP_FAILED' }); }
1131
+ finally { state = closeRequested ? 'closing' : 'closed'; if (!closeRequested) resolveClosed(snapshot()); }
343
1132
  throw error;
344
1133
  }
345
1134
  })();
@@ -350,11 +1139,11 @@ export function createLocalReadApi({ dataRoot, providerConfigPath, allowLocalWri
350
1139
 
351
1140
  function dispose() {
352
1141
  if (disposal) return disposal;
353
- closeRequested = true; state = 'closing'; tokenDigest?.fill(0);
1142
+ closeRequested = true; state = 'closing'; tokenDigest?.fill(0); loginTicket = null; browserSessions.clear();
354
1143
  disposal = (async () => {
355
1144
  if (startPromise) await startPromise.catch(() => {}); // start's caller retains the original failure.
356
- await closeServer(); await application?.dispose(); await Promise.all([...jobs]);
357
- state = 'closed'; resolveClosed(snapshot());
1145
+ try { await cleanupResources(); }
1146
+ finally { state = 'closed'; resolveClosed(snapshot()); }
358
1147
  return snapshot();
359
1148
  })();
360
1149
  return disposal;
@@ -367,8 +1156,9 @@ export function writeLocalApiTokenFile(file, token, dataRoot) {
367
1156
  if (typeof file !== 'string' || !path.isAbsolute(file)) fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须是新的绝对路径');
368
1157
  let fd;
369
1158
  try {
370
- const root = fs.realpathSync(dataRoot), parent = fs.realpathSync(path.dirname(file));
371
- const destination = path.join(parent, path.basename(file)), relative = path.relative(root, destination);
1159
+ const root = fs.realpathSync.native(dataRoot), parent = fs.realpathSync(path.dirname(file));
1160
+ const destination = path.join(parent, path.basename(file));
1161
+ const relative = path.relative(root, path.join(fs.realpathSync.native(parent), path.basename(file)));
372
1162
  if (!relative || !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) {
373
1163
  fail('IRIS_API_TOKEN_FILE_INVALID', '会话 Token 文件必须放在 Core 数据根之外');
374
1164
  }