@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
@@ -0,0 +1,95 @@
1
+ // Native ComfyUI HTTP API. No embedded server, global interrupt, or local output-path access.
2
+ import { defineProviderAdapter } from './provider-adapter.js';
3
+ import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
4
+ import { checkedComfyWorkflow, compileComfyWorkflow } from './comfyui-workflow.js';
5
+ import { parseImageSize } from './provider-protocol.js';
6
+ import { createComfyUiClient, comfyFail as fail } from './comfyui-client.js';
7
+ import { inspectComfyWorkflow } from './comfyui-inspection.js';
8
+ import { writePrivateFile } from './private-storage.js';
9
+ import { inheritedProviderFetch } from './provider-network.js';
10
+
11
+ const TASK = /^[A-Za-z0-9_-]{1,200}$/;
12
+ function imageLocation(value) {
13
+ const safe = (text, empty = false) => typeof text === 'string' && (empty || text.length > 0) && text.length <= 1024
14
+ && !/[\x00-\x1f\x7f\\]/.test(text) && !text.startsWith('/') && !text.split('/').some(part => ['..', '.'].includes(part));
15
+ if (!value || !safe(value.filename) || value.filename.includes('/') || !safe(value.subfolder, true) || !['output', 'temp'].includes(value.type)) {
16
+ fail('ComfyUI 图片位置无效', 'poll');
17
+ }
18
+ return { filename: value.filename, subfolder: value.subfolder, type: value.type };
19
+ }
20
+ export function createComfyUiProviderAdapter(provider, { fetchPort = inheritedProviderFetch, network } = {}) {
21
+ const client = createComfyUiClient(provider, { fetchPort, network }), { route, request } = client;
22
+ function workflow(model) {
23
+ const value = model ? provider.models?.find(entry => entry.id === model)?.comfyWorkflow : provider.comfyWorkflow;
24
+ try { return checkedComfyWorkflow(value); }
25
+ catch (_) { fail('此模型尚未配置有效的 ComfyUI API 工作流', 'validate', 'invalid_request'); }
26
+ }
27
+ function historyResult(history, id, selected) {
28
+ if (!history || typeof history !== 'object' || Array.isArray(history)) fail('ComfyUI 历史响应无效', 'poll');
29
+ if (!Object.hasOwn(history, id)) return null;
30
+ const entry = history[id];
31
+ if (entry?.status?.status_str === 'error') return { kind: 'failed', error: { stage: 'poll', category: 'provider', acceptance: 'accepted', safeMessage: 'ComfyUI 工作流执行失败,请在原服务查看该任务' } };
32
+ if (entry?.status?.completed !== true) return { kind: 'pending' };
33
+ const images = entry.outputs?.[selected.outputNode]?.images;
34
+ if (!Array.isArray(images) || images.length !== 1) fail('所选 ComfyUI 输出节点需要恰好一张图片;原任务已保留', 'poll');
35
+ return { kind: 'succeeded', artifacts: [{ kind: 'remote-url', url: route('view') + '?' + new URLSearchParams(imageLocation(images[0])) }] };
36
+ }
37
+ return defineProviderAdapter({ id: provider.id, protocol: 'comfyui', capabilities: ['image'],
38
+ operations: {
39
+ async discover({ signal, timeoutMs } = {}) {
40
+ await request(route('system_stats'), { signal, timeoutMs, stage: 'validate' });
41
+ return { models: (provider.models || []).filter(model => model.comfyWorkflow).map(model => ({ id: model.id, capabilities: ['image-gen'] })) };
42
+ },
43
+ async submit({ capability, model, input = {}, signal, timeoutMs }) {
44
+ if (capability !== 'image' || input.image || input.n !== undefined && input.n !== 1) fail('ComfyUI 首版仅支持单张文生图', 'validate', 'invalid_request');
45
+ let selected;
46
+ try { selected = compileComfyWorkflow(workflow(model), input); }
47
+ catch (error) {
48
+ if (error.code !== 'IRIS_COMFYUI_PARAMETERS_INVALID') throw error;
49
+ fail(error.message, 'validate', 'invalid_request');
50
+ }
51
+ const graph = selected.graph;
52
+ if (input.size !== undefined) {
53
+ const size = parseImageSize(input.size);
54
+ if (!selected.sizeNode || !size || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height)) {
55
+ fail('此工作流需要有效的宽x高以及明确的尺寸节点;未绑定时请留空', 'validate', 'invalid_request');
56
+ }
57
+ Object.assign(graph[selected.sizeNode].inputs, { width: size.width, height: size.height });
58
+ }
59
+ if (selected.version === 1) {
60
+ const report = await inspectComfyWorkflow(client, selected, { signal, timeoutMs });
61
+ if (!report.ok) fail('ComfyUI 工作流检查未通过:' + report.issues.slice(0, 3).map(issue => issue.message).join(';'), 'validate', 'invalid_request');
62
+ }
63
+ const result = await request(route('prompt'), { method: 'POST', value: { prompt: graph }, signal, timeoutMs, stage: 'submit' });
64
+ if (typeof result?.prompt_id !== 'string' || !TASK.test(result.prompt_id)) fail('ComfyUI 未返回可恢复的任务 ID,请核对原服务,避免重复提交', 'response', 'protocol', 'unknown');
65
+ return { kind: 'accepted', remoteTaskId: result.prompt_id };
66
+ },
67
+ async poll({ remoteTaskId, model, signal, timeoutMs }) {
68
+ if (typeof remoteTaskId !== 'string' || !TASK.test(remoteTaskId)) fail('ComfyUI 原任务 ID 无效', 'poll');
69
+ const selected = workflow(model), readHistory = () => request(route('history/' + encodeURIComponent(remoteTaskId)), { signal, timeoutMs, stage: 'poll' });
70
+ const result = historyResult(await readHistory(), remoteTaskId, selected);
71
+ if (result) return result;
72
+ const queue = await request(route('queue'), { signal, timeoutMs, stage: 'poll' });
73
+ if (!Array.isArray(queue?.queue_running) || !Array.isArray(queue?.queue_pending)) fail('ComfyUI 队列响应无效', 'poll');
74
+ if ([...queue.queue_running, ...queue.queue_pending].some(entry => Array.isArray(entry) && entry[1] === remoteTaskId)) return { kind: 'pending' };
75
+ // Completion may move from queue to history between the two reads. Never resubmit.
76
+ return historyResult(await readHistory(), remoteTaskId, selected) || { kind: 'unknown', error: {
77
+ stage: 'poll', category: 'unknown', acceptance: 'accepted', safeMessage: '原任务已不在 ComfyUI 队列或历史中,请核对原服务;不会重新提交' } };
78
+ },
79
+ async download({ artifact, targetPath, signal, timeoutMs }) {
80
+ const url = new URL(artifact?.url || ''), expected = new URL(route('view'));
81
+ if (artifact.kind !== 'remote-url' || url.origin !== expected.origin || url.pathname !== expected.pathname || url.username || url.password || url.hash
82
+ || [...url.searchParams.keys()].some(name => !['filename', 'subfolder', 'type'].includes(name))
83
+ || ['filename', 'subfolder', 'type'].some(name => url.searchParams.getAll(name).length !== 1)) fail('ComfyUI 交付地址不属于原服务的图片接口', 'download');
84
+ imageLocation(Object.fromEntries(url.searchParams));
85
+ const bytes = await request(url.href, { signal, timeoutMs, stage: 'download', binary: true });
86
+ writePrivateFile(targetPath, bytes, { flag: 'wx' }); return { bytes: bytes.length };
87
+ },
88
+ mapError: (error, context) => providerErrorRecord(error, { ...context, stage: error.stage || context.stage,
89
+ acceptance: error.acceptance || context.acceptance, category: error.category || (error.name === 'AbortError' ? 'aborted' : error.name === 'TimeoutError' ? 'timeout' : 'network'),
90
+ safeMessage: error instanceof ProviderContractError ? error.message : 'ComfyUI 服务通信失败,请核对原任务与服务状态' })
91
+ },
92
+ unsupported: { cancel: 'ComfyUI /interrupt 控制当前工作流,首版不对共享服务发送全局中断' },
93
+ unsupportedPreparation: 'ComfyUI 首版不支持带图编辑或输入上传'
94
+ });
95
+ }
@@ -0,0 +1,60 @@
1
+ // Native HTTP transport shared by generation and explicit read-only workflow checks.
2
+ import { ProviderContractError } from './provider-contract.js';
3
+ import { normalizeMediaEndpoint, providerApiKey, providerMediaBaseUrl } from './provider-protocol.js';
4
+ import { resolveProviderCredential } from './provider-credentials.js';
5
+ import { inheritedProviderFetch } from './provider-network.js';
6
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
7
+
8
+ export function comfyFail(message, stage, category = 'protocol', acceptance = stage === 'validate' ? 'not_accepted' : 'accepted', details = {}) {
9
+ throw new ProviderContractError(message, { stage, category, acceptance, ...details });
10
+ }
11
+ async function bounded(response, limit, stage) {
12
+ if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); comfyFail('ComfyUI 响应超过大小上限', stage, 'protocol', stage === 'submit' ? 'unknown' : stage === 'validate' ? 'not_accepted' : 'accepted'); }
13
+ const chunks = []; let size = 0;
14
+ for await (const chunk of response.body || []) {
15
+ size += chunk.length;
16
+ if (size > limit) comfyFail('ComfyUI 响应超过大小上限', stage, 'protocol', stage === 'submit' ? 'unknown' : stage === 'validate' ? 'not_accepted' : 'accepted');
17
+ chunks.push(chunk);
18
+ }
19
+ return Buffer.concat(chunks, size);
20
+ }
21
+ export function createComfyUiClient(provider, { fetchPort = inheritedProviderFetch, network } = {}) {
22
+ const requirement = providerNetworkRequirement(provider);
23
+ if (requirement && (!network || !sameNetworkRequirement(requirement, network.networkRequirement) || network.providerId !== provider.id)) {
24
+ throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', 'ComfyUI 需要供应商匹配的网络上下文,不会静默直连');
25
+ }
26
+ provider = resolveProviderCredential(provider);
27
+ const base = normalizeMediaEndpoint(providerMediaBaseUrl(provider)), key = providerApiKey(provider);
28
+ const route = leaf => base + '/' + leaf;
29
+ async function request(url, { method = 'GET', value, signal, stage, timeoutMs = 15000, binary = false } = {}) {
30
+ const combined = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]);
31
+ let response;
32
+ try {
33
+ response = await fetchPort(url, { method, signal: combined, redirect: 'error',
34
+ headers: { ...(key ? { Authorization: 'Bearer ' + key } : {}), ...(value ? { 'Content-Type': 'application/json' } : {}) },
35
+ ...(value ? { body: JSON.stringify(value) } : {}) });
36
+ } catch (error) {
37
+ if (error instanceof ProviderContractError) throw error;
38
+ comfyFail('ComfyUI 服务通信失败,请核对原任务与服务状态', stage,
39
+ error.name === 'AbortError' ? 'aborted' : error.name === 'TimeoutError' ? 'timeout' : 'network',
40
+ stage === 'submit' ? 'unknown' : stage === 'validate' ? 'not_accepted' : 'accepted');
41
+ }
42
+ if (!response.ok) {
43
+ await response.body?.cancel();
44
+ const rejection = method === 'POST' && [400, 401, 403, 404, 422, 429].includes(response.status);
45
+ comfyFail('ComfyUI 请求未通过,请核对服务状态与 API 工作流', stage,
46
+ response.status === 401 || response.status === 403 ? 'authentication' : 'provider',
47
+ stage === 'validate' ? 'not_accepted' : method === 'POST' ? rejection ? 'not_accepted' : 'unknown' : 'accepted', { httpStatus: response.status });
48
+ }
49
+ let bytes;
50
+ try { bytes = await bounded(response, binary ? 64 * 1024 * 1024 : 2 * 1024 * 1024, stage); }
51
+ catch (error) {
52
+ if (error instanceof ProviderContractError) throw error;
53
+ comfyFail('ComfyUI 响应读取中断', stage, 'network', stage === 'submit' ? 'unknown' : stage === 'validate' ? 'not_accepted' : 'accepted');
54
+ }
55
+ if (binary) return bytes;
56
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
57
+ catch (_) { comfyFail('ComfyUI 返回了无法识别的响应', stage, 'protocol', stage === 'submit' ? 'unknown' : stage === 'validate' ? 'not_accepted' : 'accepted'); }
58
+ }
59
+ return Object.freeze({ route, request });
60
+ }
@@ -0,0 +1,73 @@
1
+ // Partial preflight against official /object_info/{node_class}. Never queues a prompt.
2
+ import { comfyWorkflowDigest, projectComfyWorkflow } from './comfyui-workflow.js';
3
+ import { comfyFail } from './comfyui-client.js';
4
+
5
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
6
+ const link = value => Array.isArray(value) && value.length === 2 && typeof value[0] === 'string' && Number.isSafeInteger(value[1]) && value[1] >= 0;
7
+ const MESSAGES = { node_unavailable: '节点尚未安装、未加载或不存在', input_missing: '缺少必需输入',
8
+ choice_unavailable: '所选值不在服务当前公布的选项中,请核对模型文件或选项', input_type: '输入类型与服务定义不符',
9
+ input_range: '数值超出服务允许范围', link_invalid: '连接引用的节点或输出不存在', link_type: '连接两端的数据类型不符',
10
+ output_node_required: '请选择可执行的图片输出节点', parameter_schema_unsupported: '此参数的节点定义暂不支持静态检查' };
11
+ function literalIssue(value, spec) {
12
+ if (!Array.isArray(spec)) return null;
13
+ const [type, limits] = spec;
14
+ if (Array.isArray(type)) return type.includes(value) ? null : 'choice_unavailable';
15
+ if (!['STRING', 'BOOLEAN', 'INT', 'FLOAT'].includes(type)) return null;
16
+ if (type === 'STRING' ? typeof value !== 'string' : type === 'BOOLEAN' ? typeof value !== 'boolean'
17
+ : type === 'INT' ? !Number.isSafeInteger(value) : typeof value !== 'number' || !Number.isFinite(value)) return 'input_type';
18
+ if (typeof value === 'number' && (typeof limits?.min === 'number' && value < limits.min || typeof limits?.max === 'number' && value > limits.max)) return 'input_range';
19
+ return null;
20
+ }
21
+ export async function inspectComfyWorkflow(client, workflow, { signal, timeoutMs = 15000 } = {}) {
22
+ const combined = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]);
23
+ const schemas = new Map();
24
+ for (const type of new Set(Object.values(workflow.graph).map(node => node.class_type))) {
25
+ const value = await client.request(client.route('object_info/' + encodeURIComponent(type)), { signal: combined, stage: 'validate', timeoutMs });
26
+ if (!object(value) || Object.hasOwn(value, type) && (!object(value[type]) || !object(value[type].input)
27
+ || value[type].input.required !== undefined && !object(value[type].input.required)
28
+ || value[type].input.optional !== undefined && !object(value[type].input.optional)
29
+ || !Array.isArray(value[type].output))) comfyFail('ComfyUI 节点定义响应无效', 'validate');
30
+ schemas.set(type, Object.hasOwn(value, type) ? value[type] : null);
31
+ }
32
+ const issues = [], fields = new Map();
33
+ const issue = (code, node, input) => {
34
+ const parameter = workflow.parameters?.find(parameter => parameter.node === node && parameter.input === input);
35
+ const label = parameter?.label || parameter?.id || '节点 ' + node + (input ? ' 的 ' + input : '');
36
+ issues.push({ code, node, ...(input ? { input } : {}), message: label + ':' + MESSAGES[code] });
37
+ };
38
+ for (const [id, node] of Object.entries(workflow.graph)) {
39
+ const schema = schemas.get(node.class_type);
40
+ if (!schema) { issue('node_unavailable', id); continue; }
41
+ const inputs = { ...schema.input.optional, ...schema.input.required };
42
+ fields.set(id, inputs);
43
+ for (const name of Object.keys(schema.input.required || {})) if (!Object.hasOwn(node.inputs, name)) issue('input_missing', id, name);
44
+ for (const [name, value] of Object.entries(node.inputs)) {
45
+ const spec = inputs[name];
46
+ if (link(value)) {
47
+ const source = workflow.graph[value[0]], sourceSchema = source && schemas.get(source.class_type);
48
+ if (!source || sourceSchema && value[1] >= sourceSchema.output.length) issue('link_invalid', id, name);
49
+ const expected = spec?.[0], actual = sourceSchema?.output[value[1]];
50
+ if (typeof expected === 'string' && typeof actual === 'string' && expected !== '*' && actual !== '*'
51
+ && !expected.includes(',') && !actual.includes(',') && expected !== actual) issue('link_type', id, name);
52
+ } else {
53
+ const code = literalIssue(value, spec);
54
+ if (code) issue(code, id, name);
55
+ }
56
+ }
57
+ }
58
+ const output = schemas.get(workflow.graph[workflow.outputNode].class_type);
59
+ if (output && output.output_node !== true) issue('output_node_required', workflow.outputNode);
60
+ const parameters = (projectComfyWorkflow(workflow).parameters || []).map(parameter => {
61
+ const { default: value, ...descriptor } = parameter;
62
+ const spec = fields.get(parameter.node)?.[parameter.input];
63
+ if (!spec || !Array.isArray(spec) || !Array.isArray(spec[0]) && !['STRING', 'BOOLEAN', 'INT', 'FLOAT'].includes(spec[0])) {
64
+ issue('parameter_schema_unsupported', parameter.node, parameter.input);
65
+ }
66
+ const choices = Array.isArray(spec?.[0]) ? spec[0] : null;
67
+ return { ...descriptor, value, ...(choices ? { availableChoices: choices.slice(0, 256), choicesTruncated: choices.length > 256 } : {}) };
68
+ });
69
+ return { contractVersion: 0, ok: issues.length === 0, workflowSha256: comfyWorkflowDigest(workflow),
70
+ nodeCount: Object.keys(workflow.graph).length, checkedClasses: schemas.size, parameters, issues,
71
+ scope: 'node-definitions-and-literal-inputs', executed: false, persisted: false,
72
+ limitations: ['不执行节点或自定义校验;不证明模型家族兼容、显存充足、外部服务可用或真实出图成功'] };
73
+ }
@@ -0,0 +1,94 @@
1
+ // API graph configuration only. Never evaluates code or reads a local workflow path.
2
+ import crypto from 'node:crypto';
3
+
4
+ export const MAX_COMFY_WORKFLOW_BYTES = 40 * 1024;
5
+ const NODE = /^[A-Za-z0-9_.:-]{1,100}$/;
6
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
7
+ const fail = () => { throw Object.assign(new TypeError('ComfyUI 需要有界的 API 工作流、提示词节点、输出节点和可选尺寸节点;请使用 Export (API)'), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
8
+ const PARAMETER = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
9
+ const scalar = value => typeof value === 'string' && value.length <= 20000 || typeof value === 'boolean'
10
+ || typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER;
11
+ const parameterFail = message => { throw Object.assign(new TypeError('ComfyUI 参数无效:' + message), { code: 'IRIS_COMFYUI_PARAMETERS_INVALID' }); };
12
+ export function normalizeComfyParameters(value) {
13
+ if (!object(value) || !Object.keys(value).length || Object.keys(value).length > 32
14
+ || Object.keys(value).some(key => !PARAMETER.test(key) || ['constructor', 'prototype'].includes(key) || !scalar(value[key]))
15
+ || Buffer.byteLength(JSON.stringify(value)) > 24 * 1024) parameterFail('需要 1–32 个具名标量,最多 24 KiB');
16
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]]));
17
+ }
18
+ function parameterValue(parameter, value) {
19
+ if (!scalar(value) || (parameter.type === 'integer' ? !Number.isSafeInteger(value)
20
+ : parameter.type === 'number' ? typeof value !== 'number' : typeof value !== parameter.type)
21
+ || parameter.min !== undefined && value < parameter.min || parameter.max !== undefined && value > parameter.max
22
+ || parameter.choices !== undefined && !parameter.choices.includes(value)) parameterFail(parameter.id + ' 的类型、范围或选项不符');
23
+ return value;
24
+ }
25
+ export function checkedComfyWorkflow(value) {
26
+ if (!object(value) || Object.keys(value).some(key => !['version', 'graph', 'promptNode', 'outputNode', 'sizeNode', 'parameters'].includes(key))
27
+ || ![0, 1].includes(value.version) || !object(value.graph) || !Object.keys(value.graph).length || Object.keys(value.graph).length > 256
28
+ || Buffer.byteLength(JSON.stringify(value)) > MAX_COMFY_WORKFLOW_BYTES) fail();
29
+ for (const [id, node] of Object.entries(value.graph)) {
30
+ if (!NODE.test(id) || ['__proto__', 'constructor', 'prototype'].includes(id) || !object(node)
31
+ || typeof node.class_type !== 'string' || !node.class_type || node.class_type.length > 200 || !object(node.inputs)) fail();
32
+ for (const [input, number] of Object.entries(node.inputs)) {
33
+ if (typeof number === 'number' && (!Number.isFinite(number) || Number.isInteger(number) && !Number.isSafeInteger(number))) {
34
+ throw Object.assign(new TypeError('ComfyUI 节点 ' + id + ' 的 ' + input + ' 超出 JavaScript 数值范围;整数须在 ±9007199254740991 内,请调整种子后重新导出'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
35
+ }
36
+ }
37
+ }
38
+ if (![value.promptNode, value.outputNode].every(id => typeof id === 'string' && Object.hasOwn(value.graph, id))
39
+ || typeof value.graph[value.promptNode].inputs.text !== 'string') fail();
40
+ if (value.sizeNode !== undefined && (typeof value.sizeNode !== 'string' || !Object.hasOwn(value.graph, value.sizeNode)
41
+ || !['width', 'height'].every(key => Number.isSafeInteger(value.graph[value.sizeNode].inputs[key]) && value.graph[value.sizeNode].inputs[key] > 0))) fail();
42
+ if (value.version === 0 && value.parameters !== undefined) fail();
43
+ if (value.version === 1) {
44
+ if (!Array.isArray(value.parameters) || !value.parameters.length || value.parameters.length > 32) fail();
45
+ const ids = new Set(), targets = new Set([value.promptNode + '\0text',
46
+ ...(value.sizeNode ? ['width', 'height'].map(key => value.sizeNode + '\0' + key) : [])]);
47
+ for (const parameter of value.parameters) {
48
+ if (!object(parameter) || Object.keys(parameter).some(key => !['id', 'label', 'node', 'input', 'type', 'min', 'max', 'choices'].includes(key))
49
+ || typeof parameter.id !== 'string' || !PARAMETER.test(parameter.id) || ['constructor', 'prototype'].includes(parameter.id) || ids.has(parameter.id)
50
+ || typeof parameter.node !== 'string' || !Object.hasOwn(value.graph, parameter.node)
51
+ || typeof parameter.input !== 'string' || !NODE.test(parameter.input) || ['__proto__', 'constructor', 'prototype'].includes(parameter.input)
52
+ || !Object.hasOwn(value.graph[parameter.node].inputs, parameter.input)
53
+ || !['string', 'integer', 'number', 'boolean'].includes(parameter.type)
54
+ || parameter.label !== undefined && (typeof parameter.label !== 'string' || !parameter.label.trim() || parameter.label.length > 80)
55
+ || targets.has(parameter.node + '\0' + parameter.input)) fail();
56
+ for (const key of ['min', 'max']) if (parameter[key] !== undefined
57
+ && (!['integer', 'number'].includes(parameter.type) || !Number.isFinite(parameter[key])
58
+ || Math.abs(parameter[key]) > Number.MAX_SAFE_INTEGER || parameter.type === 'integer' && !Number.isSafeInteger(parameter[key]))) fail();
59
+ if (parameter.min !== undefined && parameter.max !== undefined && parameter.min > parameter.max) fail();
60
+ if (parameter.choices !== undefined && (!Array.isArray(parameter.choices) || !parameter.choices.length || parameter.choices.length > 256)) fail();
61
+ for (const choice of parameter.choices || []) parameterValue({ ...parameter, choices: undefined }, choice);
62
+ parameterValue(parameter, value.graph[parameter.node].inputs[parameter.input]);
63
+ ids.add(parameter.id); targets.add(parameter.node + '\0' + parameter.input);
64
+ }
65
+ }
66
+ return structuredClone(value);
67
+ }
68
+ export function compileComfyWorkflow(value, input = {}) {
69
+ const workflow = checkedComfyWorkflow(value), parameters = input.workflow_parameters === undefined ? {}
70
+ : normalizeComfyParameters(input.workflow_parameters);
71
+ const declared = new Map((workflow.parameters || []).map(parameter => [parameter.id, parameter]));
72
+ for (const [id, item] of Object.entries(parameters)) {
73
+ const parameter = declared.get(id);
74
+ if (!parameter) parameterFail('未开放的槽位 ' + id);
75
+ workflow.graph[parameter.node].inputs[parameter.input] = parameterValue(parameter, item);
76
+ }
77
+ if (input.prompt !== undefined) workflow.graph[workflow.promptNode].inputs.text = input.prompt;
78
+ return workflow;
79
+ }
80
+ export function comfyWorkflowDigest(value) {
81
+ const workflow = checkedComfyWorkflow(value);
82
+ // Stable JSON object ordering: formatting and key order are not a workflow change.
83
+ const sorted = item => Array.isArray(item) ? item.map(sorted) : object(item)
84
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, sorted(item[key])])) : item;
85
+ return crypto.createHash('sha256').update(JSON.stringify(sorted(workflow))).digest('hex');
86
+ }
87
+ export function projectComfyWorkflow(value) {
88
+ if (value === undefined) return undefined;
89
+ const workflow = checkedComfyWorkflow(value);
90
+ return { configured: true, sha256: comfyWorkflowDigest(workflow), nodeCount: Object.keys(workflow.graph).length,
91
+ promptNode: workflow.promptNode, outputNode: workflow.outputNode, ...(workflow.sizeNode ? { sizeNode: workflow.sizeNode } : {}),
92
+ ...(workflow.version === 1 ? { version: 1, parameters: workflow.parameters.map(parameter => ({ ...parameter,
93
+ default: workflow.graph[parameter.node].inputs[parameter.input] })) } : {}) };
94
+ }
@@ -429,23 +429,34 @@ export function createCommandService(runtime, ports = {}) {
429
429
  if (input.confirm_billing !== true) {
430
430
  throw commandError('IRIS_COMMAND_BILLING_CONFIRM_REQUIRED', '重试会创建新任务并可能产生重复生成费用;必须显式确认计费');
431
431
  }
432
- return runtime.run('execute', async ({ dataRoot }) => {
432
+ return runtime.run('execute', async ({ dataRoot, signal }) => {
433
433
  const before = inspectCoreTask(dataRoot, input.task_id);
434
434
  if (before.phase !== 'terminal' || (before.outcome === 'succeeded' && before.deliveryState === 'ready')) {
435
435
  throw commandError('IRIS_TASK_NOT_RETRYABLE', '只有终态且未成功交付的 Task 可以重试为新任务');
436
436
  }
437
- let normalized;
437
+ let normalized, requestedVideoMode;
438
+ let suppliedInput = input.provider_input;
439
+ if (before.capability === 'video') {
440
+ if (!plain(suppliedInput) || !['t2v', 'i2v', 's2v'].includes(suppliedInput.video_mode)) {
441
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', '视频重试必须明确 video_mode(t2v/i2v/s2v),并重新提供该模式所需的首帧和音频;旧 Task 不保存这些输入');
442
+ }
443
+ const { video_mode, ...generation } = suppliedInput;
444
+ requestedVideoMode = video_mode; suppliedInput = generation;
445
+ }
438
446
  if (before.capability === 'music' && input.provider_input &&
439
447
  ['retry_502', 'retry_delay_ms'].some(field => Object.hasOwn(input.provider_input, field))) {
440
448
  throw commandError('IRIS_COMMAND_INPUT_INVALID', 'task.retry 只执行一次重新生成;502重试配置请使用Music生成入口');
441
449
  }
442
- try { normalized = normalizeGenerationInput(before.capability, input.provider_input, { allowVideoPaths: before.capability === 'video' }); }
450
+ try { normalized = normalizeGenerationInput(before.capability, suppliedInput, { allowVideoPaths: before.capability === 'video' }); }
443
451
  catch (error) {
444
452
  if (error instanceof GenerationInputError) throw commandError('IRIS_COMMAND_INPUT_INVALID', error.message);
445
453
  throw error;
446
454
  }
455
+ if (before.capability === 'video' && normalized.videoMode !== requestedVideoMode) {
456
+ throw commandError('IRIS_COMMAND_INPUT_INVALID', 'video_mode 与重新提供的首帧/音频不符;i2v 需首帧,s2v 需首帧和音频,可通过 CLI 重新提供本地文件');
457
+ }
447
458
  const providerInput = normalized.providerInput;
448
- if (before.sourceArtifactId && !normalized.sourceArtifactId) {
459
+ if (before.capability === 'image' && before.sourceArtifactId && !normalized.sourceArtifactId) {
449
460
  throw commandError('IRIS_COMMAND_INPUT_INVALID', '改图重试必须重新提供 source_artifact_id 和修改指令');
450
461
  }
451
462
  if (Object.hasOwn(input.provider_input, 'model_ref')) {
@@ -454,7 +465,9 @@ export function createCommandService(runtime, ports = {}) {
454
465
  const modelRef = String(input.model_ref || '').trim();
455
466
  if (input.model_ref !== undefined && !modelRef) throw commandError('IRIS_COMMAND_INPUT_INVALID', 'model_ref 必须是 providerId::modelId 复合引用');
456
467
  if (typeof ports.resolveTaskCandidates !== 'function') throw commandError('IRIS_COMMAND_PROVIDER_REQUIRED', 'task.retry 需要宿主显式提供候选链 resolver');
457
- let candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef });
468
+ signal.throwIfAborted();
469
+ let candidates = await ports.resolveTaskCandidates({ capability: before.capability, modelRef, signal });
470
+ signal.throwIfAborted();
458
471
  if (before.capability === 'video') candidates = videoTaskCandidates(candidates, normalized);
459
472
  const result = await createProviderTaskRunner(runtime).submit({
460
473
  capability: before.capability,
package/lib/config.js CHANGED
@@ -1,12 +1,13 @@
1
1
  'use strict';
2
2
  import { normalizeVisionInputLimits } from './vision-image-input.js';
3
3
  import { scopedProvider } from './provider-config-scope.js';
4
+ import { checkedComfyWorkflow, comfyWorkflowDigest } from './comfyui-workflow.js';
4
5
  import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding, modelUnavailable, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
5
6
  import { refreshModelDirectory } from './model-directory-service.js';
6
7
  /**
7
8
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
8
9
  * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
9
- * Key 明文落盘(0600);接口层永远不回明文。
10
+ * 内联 Key 明文落盘(0600);环境引用只存名称,解析值不持久化;接口层不回明文。
10
11
  */
11
12
  import fs from 'node:fs';
12
13
  import path from 'node:path';
@@ -16,6 +17,7 @@ import * as models from './models.js';
16
17
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
17
18
  import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
18
19
  import { redactProviderMessage } from './provider-contract.js';
20
+ import { validateProviderCredential, providerCredentialStatus, providerCredentialValue, applyCredentialPatch } from './provider-credentials.js';
19
21
  import {
20
22
  PROVIDER_HEALTH_FRESH_MS,
21
23
  aggregateHealthStates,
@@ -36,6 +38,7 @@ function validateStoredConfig(value) {
36
38
  for (const [capability, assignment] of Object.entries(value.assignments)) models.functionPolicy(assignment, capability);
37
39
  for (const provider of value.providers) {
38
40
  if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
41
+ validateProviderCredential(provider);
39
42
  validateModelDirectory(provider.modelDirectory);
40
43
  if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
41
44
  if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
@@ -46,7 +49,7 @@ function validateStoredConfig(value) {
46
49
  if (!isPlainObject(ref) || typeof ref.provider !== 'string' || !ref.provider.trim()
47
50
  || typeof ref.settingsNs !== 'string' || !ref.settingsNs.trim()
48
51
  || Object.keys(ref).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
49
- if (provider.apiKey !== undefined || provider.baseUrl !== undefined) throw new Error('DSH 引用不能同时保存密钥或普通 Base URL');
52
+ if (provider.apiKey !== undefined || provider.apiKeyEnv !== undefined || provider.baseUrl !== undefined) throw new Error('DSH 引用不能同时保存密钥、环境引用或普通 Base URL');
50
53
  }
51
54
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
52
55
  for (const model of provider.models || []) {
@@ -71,7 +74,7 @@ function authModeOf(provider) {
71
74
  function missingCredentials(provider) {
72
75
  if (!provider || authModeOf(provider) === 'none') return false;
73
76
  if (provider.dshProvider) return false;
74
- return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
77
+ return providerCredentialStatus(provider) !== 'ready';
75
78
  }
76
79
 
77
80
  function normalizeStoredProvider(provider) {
@@ -132,6 +135,9 @@ export function load() {
132
135
  for (const provider of cache.providers) normalized = normalizeStoredProvider(provider) || normalized;
133
136
  if (normalized) persist();
134
137
  } catch (err) {
138
+ // A rejected source/field combination is actionable configuration, not corrupt
139
+ // JSON. Keep it intact so a policy change cannot quarantine user providers.
140
+ if (err.code === 'IRIS_CONFIG_INPUT_INVALID') throw err;
135
141
  // 文件存在但损坏:隔离,绝不静默覆盖证据
136
142
  const backup = privateSibling(file, 'corrupted');
137
143
  try {
@@ -399,9 +405,8 @@ function healthObservation(provider, modelId, capability) {
399
405
  function safeHealthNote(provider, value) {
400
406
  const raw = String(value ?? '').trim();
401
407
  if (!raw) return '';
402
- let note = redactProviderMessage(raw, 200);
403
- const key = String(provider && scopedProvider(provider).apiKey || '');
404
- if (key) note = note.split(key).join('[REDACTED]');
408
+ const key = providerCredentialValue(provider && scopedProvider(provider));
409
+ let note = redactProviderMessage(key ? raw.split(key).join('[REDACTED]') : raw, 200);
405
410
  return note;
406
411
  }
407
412
 
@@ -586,13 +591,14 @@ export function upsert(provider) {
586
591
  if (!isPlainObject(reference) || typeof reference.provider !== 'string' || !reference.provider.trim()
587
592
  || typeof reference.settingsNs !== 'string' || !reference.settingsNs.trim()
588
593
  || Object.keys(reference).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
589
- if (['apiKey', 'baseUrl'].some(key => rest[key] !== undefined || existing?.[key] !== undefined)) {
594
+ if (['apiKey', 'apiKeyEnv', 'baseUrl'].some(key => rest[key] !== undefined || existing?.[key] !== undefined)) {
590
595
  throw new Error('DSH 引用不保存密钥或普通 Base URL;请新建引用供应商');
591
596
  }
592
597
  rest.dshProvider = { provider: reference.provider.trim(), settingsNs: reference.settingsNs.trim() };
593
598
  }
594
599
  if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
595
600
  if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
601
+ ...(model?.comfyWorkflow !== undefined ? { comfyWorkflow: checkedComfyWorkflow(model.comfyWorkflow) } : {}),
596
602
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
597
603
  ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
598
604
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
@@ -600,7 +606,7 @@ export function upsert(provider) {
600
606
  const i = c.providers.findIndex((p) => p.id === id);
601
607
  if (i >= 0) {
602
608
  const previous = c.providers[i];
603
- const next = { ...previous, ...rest };
609
+ const next = applyCredentialPatch(previous, rest);
604
610
  if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
605
611
  normalizeStoredProvider(next);
606
612
  resetHealthOnCriticalChange(previous, next, rest);
@@ -611,7 +617,7 @@ export function upsert(provider) {
611
617
  return next;
612
618
  }
613
619
  }
614
- const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
620
+ const fresh = applyCredentialPatch(null, { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest });
615
621
  fresh.protocolInferred = false;
616
622
  normalizeStoredProvider(fresh);
617
623
  c.providers.push(fresh);
@@ -646,6 +652,7 @@ export function setProviderModels(id, modelEntries) {
646
652
  for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
647
653
  p.models = modelEntries.map((m) => {
648
654
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
655
+ ...(m.comfyWorkflow !== undefined ? { comfyWorkflow: checkedComfyWorkflow(m.comfyWorkflow) } : {}),
649
656
  ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
650
657
  ...(m.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(m.musicProtocol) } : {}),
651
658
  ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
@@ -657,6 +664,7 @@ export function setProviderModels(id, modelEntries) {
657
664
  if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
658
665
  if (m?.musicProtocol === undefined && old.musicProtocol !== undefined) entry.musicProtocol = old.musicProtocol;
659
666
  if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
667
+ if (m?.comfyWorkflow === undefined && old.comfyWorkflow !== undefined) entry.comfyWorkflow = old.comfyWorkflow;
660
668
  }
661
669
  if (m && m.verified) entry.verified = m.verified;
662
670
  if (m && m.source) entry.source = m.source;
@@ -701,12 +709,43 @@ function resetChangedImageProtocolHealth(previous, next) {
701
709
  if (model.musicProtocol !== models.providerModels(next).find(item => item.id === model.id)?.musicProtocol) {
702
710
  clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.MUSIC);
703
711
  }
704
- if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
712
+ const beforeWorkflow = previous.models?.find(item => item?.id === model.id)?.comfyWorkflow;
713
+ const afterWorkflow = next.models?.find(item => item?.id === model.id)?.comfyWorkflow;
714
+ if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol
715
+ || (beforeWorkflow ? comfyWorkflowDigest(beforeWorkflow) : null) !== (afterWorkflow ? comfyWorkflowDigest(afterWorkflow) : null)) {
705
716
  clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
706
717
  }
707
718
  }
708
719
  }
709
720
 
721
+ /** One explicit save; stale workflow drafts cannot replace a newer template. */
722
+ export function setComfyWorkflow(id, modelId, value, { expectedSha, enableAutomatic = false } = {}) {
723
+ const workflow = checkedComfyWorkflow(value);
724
+ if (typeof modelId !== 'string' || !modelId.trim() || modelId.trim().length > 120
725
+ || !(expectedSha === null || typeof expectedSha === 'string' && /^[a-f0-9]{64}$/.test(expectedSha))
726
+ || typeof enableAutomatic !== 'boolean') throw new TypeError('请填写工作流名称并核对原工作流摘要');
727
+ const c = load(), provider = c.providers.find(item => item.id === id);
728
+ if (!provider || selectMediaProtocol(provider).mediaProtocol !== 'comfyui') throw new TypeError('请先保存 ComfyUI 连接');
729
+ const entries = (Array.isArray(provider.models) ? provider.models : models.providerModels(provider))
730
+ .map(model => typeof model === 'string' ? { id: model } : { ...model });
731
+ const name = modelId.trim(), old = entries.find(model => model.id === name);
732
+ if ((old?.comfyWorkflow ? comfyWorkflowDigest(old.comfyWorkflow) : null) !== expectedSha) {
733
+ throw Object.assign(new Error('工作流已变化,请重新读取后核对;草稿未保存'), { code: 'IRIS_CONFIG_CHANGED' });
734
+ }
735
+ const entry = { ...old, id: name, source: 'manual', imageProtocol: 'comfyui', comfyWorkflow: workflow,
736
+ capabilities: [...new Set([...(old?.capabilities || []), cap.CAPABILITIES.IMAGE])] };
737
+ if (old) entries[entries.indexOf(old)] = entry; else entries.push(entry);
738
+ const next = { ...provider, models: entries };
739
+ resetChangedImageProtocolHealth(provider, next);
740
+ c.providers[c.providers.indexOf(provider)] = next;
741
+ if (enableAutomatic) {
742
+ const previous = models.functionPolicy(c.assignments[cap.CAPABILITIES.IMAGE], cap.CAPABILITIES.IMAGE);
743
+ c.assignments[cap.CAPABILITIES.IMAGE] = { mode: 'auto', models: previous.models.slice() };
744
+ }
745
+ persist();
746
+ return entry;
747
+ }
748
+
710
749
  /** 手动选择单模型图片协议;auto 恢复账号默认,不解除模型停用。 */
711
750
  export function setModelImageProtocol(id, modelId, value) {
712
751
  const protocol = normalizeImageProtocol(value);
@@ -0,0 +1,69 @@
1
+ // Shared CLI/service assembly. Configuration is read only during explicit commands.
2
+ import path from 'node:path';
3
+ import { loadProviderCatalog, imageCandidatesFromCatalog, ttsCandidatesFromCatalog, transcribeCandidatesFromCatalog, videoCandidatesFromCatalog, providerForTaskFromCatalog, providerTaskBinding } from './provider-catalog.js';
4
+ import { createConfiguredProviderAdapter } from './provider-adapters.js';
5
+ import { recordCatalogAvailability, recordCatalogRateLimit } from './provider-config-service.js';
6
+ import { parseModelRef } from './models.js';
7
+ import { supportsImageEdit } from './provider-protocol.js';
8
+ import { compileComfyWorkflow } from './comfyui-workflow.js';
9
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
10
+
11
+ export function createTrackedProviderAdapter(provider, providerConfigPath, task, registry, { network, modelRef, deferCooling = false } = {}) {
12
+ if (task && !sameNetworkRequirement(task.networkRequirement, providerNetworkRequirement(provider))) {
13
+ throw networkPolicyError('IRIS_NETWORK_REQUIREMENT_MISMATCH', '原 Task 的网络要求与当前供应商目录不一致;不会隐式转换或解除');
14
+ }
15
+ return createConfiguredProviderAdapter(provider, {
16
+ registry,
17
+ ...(network !== undefined ? { network } : {}),
18
+ modelId: parseModelRef(task?.modelRef || modelRef)?.modelId,
19
+ deferCooling,
20
+ onModelAvailability: event => recordCatalogAvailability(providerConfigPath, event),
21
+ onRateLimit: event => recordCatalogRateLimit(providerConfigPath, event)
22
+ });
23
+ }
24
+
25
+ export function configuredImagePorts(providerConfigPath, registry, { resolveProviderNetwork, deferCooling = false } = {}) {
26
+ if (typeof providerConfigPath !== 'string' || !path.isAbsolute(providerConfigPath)) throw new TypeError('Provider 配置须为显式绝对路径');
27
+ if (resolveProviderNetwork !== undefined && typeof resolveProviderNetwork !== 'function') throw new TypeError('resolveProviderNetwork 必须是函数');
28
+ const adapterFor = (provider, task, modelRef) => createTrackedProviderAdapter(provider, providerConfigPath, task, registry, {
29
+ modelRef, deferCooling,
30
+ network: resolveProviderNetwork?.({ providerId: provider.id, modelRef: modelRef || task?.modelRef, taskId: task?.id,
31
+ ...(providerNetworkRequirement(provider) ? { networkRequirement: providerNetworkRequirement(provider) } : {}) })
32
+ });
33
+ return Object.freeze({
34
+ resolveVideoCandidates({ modelRef, signal }) {
35
+ signal?.throwIfAborted();
36
+ return videoCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef, registry, { deferCooling }).map(route => ({
37
+ adapter: adapterFor(route.provider, undefined, route.modelRef), model: route.modelRef,
38
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
39
+ }));
40
+ },
41
+ resolveAudioCandidates({ capability, modelRef, signal }) {
42
+ signal?.throwIfAborted();
43
+ if (!['tts', 'transcribe'].includes(capability)) throw new TypeError('未知音频能力');
44
+ const select = capability === 'tts' ? ttsCandidatesFromCatalog : transcribeCandidatesFromCatalog;
45
+ return select(loadProviderCatalog(providerConfigPath), modelRef, registry, { deferCooling }).map(route => ({
46
+ adapter: adapterFor(route.provider, undefined, route.modelRef), model: route.modelRef,
47
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
48
+ }));
49
+ },
50
+ resolveImageCandidates({ modelRef, signal, input }) {
51
+ signal?.throwIfAborted();
52
+ return imageCandidatesFromCatalog(loadProviderCatalog(providerConfigPath), modelRef, registry, { deferCooling }).map(route => {
53
+ if (input?.workflow_parameters !== undefined) {
54
+ if (route.provider.mediaProtocol !== 'comfyui') throw Object.assign(new TypeError('工作流参数仅用于 ComfyUI 模板'), { code: 'IRIS_COMFYUI_PARAMETERS_INVALID' });
55
+ compileComfyWorkflow(route.provider.comfyWorkflow, input);
56
+ }
57
+ return {
58
+ adapter: adapterFor(route.provider, undefined, route.modelRef), model: route.modelRef,
59
+ supportsImageEdit: supportsImageEdit(route.provider.mediaProtocol, route.model, registry),
60
+ providerBinding: providerTaskBinding(route.provider, registry), selectionReason: 'explicit'
61
+ };
62
+ });
63
+ },
64
+ resolveTaskAdapter(task, { signal } = {}) {
65
+ signal?.throwIfAborted();
66
+ return adapterFor(providerForTaskFromCatalog(loadProviderCatalog(providerConfigPath), task, registry), task);
67
+ }
68
+ });
69
+ }