@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.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 (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  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/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. package/user_guide.md +5 -1
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  /** 供应商媒体协议判断与 DashScope 凭据边界。 */
3
+ import { comfyWorkflowDigest } from './comfyui-workflow.js';
3
4
 
4
5
  const DASH_SCOPE_HOST = /^dashscope(?:-[a-z0-9-]+)?\.aliyuncs\.com$/i;
5
6
  const REGIONAL_HOST = /^cn-hongkong\.dashscope\.aliyuncs\.com$/i;
@@ -46,44 +47,54 @@ export function selectMediaProtocol(provider) {
46
47
  }
47
48
 
48
49
  /** 模型图片协议只覆盖生图;auto 沿用账号级默认,不另外猜测端点。 */
49
- export function normalizeImageProtocol(value) {
50
+ export function normalizeImageProtocol(value, registry) {
50
51
  const protocol = typeof value === 'string' ? value.trim() : '';
51
- if (!['auto', 'dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) {
52
- throw Object.assign(new TypeError('imageProtocol 必须是 auto、dashscope、openai-images、openai-chat-images 或 openai-responses-images'), {
52
+ if (protocol !== 'auto' && !providerProtocolDescriptor(protocol, registry)?.capabilities.includes('image-gen')) {
53
+ throw Object.assign(new TypeError('imageProtocol 必须是 auto 或当前实例已注册的图片协议'), {
53
54
  code: 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID'
54
55
  });
55
56
  }
56
57
  return protocol;
57
58
  }
58
59
 
59
- export function selectImageProtocol(provider, modelId) {
60
+ export function selectImageProtocol(provider, modelId, registry) {
60
61
  const entry = Array.isArray(provider?.models) ? provider.models.find(model => model?.id?.trim() === modelId) : null;
61
62
  if (entry?.imageProtocol === undefined) return selectMediaProtocol(provider);
62
- const protocol = normalizeImageProtocol(entry.imageProtocol);
63
+ const protocol = normalizeImageProtocol(entry.imageProtocol, registry);
63
64
  return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
64
65
  }
65
66
 
67
+ /** Binding/health reads preserve an explicitly saved protocol even if its extension is absent. */
68
+ export function providerForModelBinding(provider, modelId, capability) {
69
+ const entry = provider.models?.find(model => model?.id?.trim() === modelId);
70
+ const value = capability === 'music-gen' ? entry?.musicProtocol : entry?.imageProtocol;
71
+ const protocol = typeof value === 'string' ? value.trim() : '';
72
+ return { ...provider, ...(protocol && protocol !== 'auto'
73
+ ? { mediaProtocol: protocol, protocolInferred: false } : selectMediaProtocol(provider)) };
74
+ }
75
+
66
76
  /** 已接入的带图编辑协议;这里只判断传输,不授予模型 image-gen 能力。 */
67
- export function supportsImageEdit(protocol, modelId) {
68
- return protocol === 'openai-chat-images' || (protocol === 'dashscope'
69
- && /^qwen-image-(?:edit(?:-(?:max|plus))?|2\.0(?:-pro)?)(?:-\d{4}-\d{2}-\d{2})?$/.test(String(modelId || '')));
77
+ export function supportsImageEdit(protocol, modelId, registry) {
78
+ return providerProtocolDescriptor(protocol, registry)?.supportsImageEdit?.(modelId) === true;
70
79
  }
71
80
 
72
81
  /** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
73
- export function providerForImageModel(provider, modelId) {
74
- return { ...provider, ...selectImageProtocol(provider, modelId) };
82
+ export function providerForImageModel(provider, modelId, registry) {
83
+ const selection = selectImageProtocol(provider, modelId, registry);
84
+ return { ...provider, ...selection, ...(selection.mediaProtocol === 'comfyui'
85
+ ? { comfyWorkflow: provider.models?.find(model => model?.id === modelId)?.comfyWorkflow } : {}) };
75
86
  }
76
87
 
77
- export function normalizeMusicProtocol(value) {
78
- if (value !== 'gemini-web-music') throw Object.assign(new TypeError('musicProtocol 必须是 gemini-web-music'), {
88
+ export function normalizeMusicProtocol(value, registry) {
89
+ if (!providerProtocolDescriptor(value, registry)?.capabilities.includes('music-gen')) throw Object.assign(new TypeError('musicProtocol 必须是已注册的音乐协议'), {
79
90
  code: 'IRIS_PROVIDER_MUSIC_PROTOCOL_INVALID'
80
91
  });
81
92
  return value;
82
93
  }
83
94
 
84
- export function providerForMusicModel(provider, modelId) {
95
+ export function providerForMusicModel(provider, modelId, registry) {
85
96
  const entry = provider.models?.find(model => model?.id?.trim() === modelId);
86
- return { ...provider, mediaProtocol: normalizeMusicProtocol(entry?.musicProtocol), protocolInferred: false };
97
+ return { ...provider, mediaProtocol: normalizeMusicProtocol(entry?.musicProtocol, registry), protocolInferred: false };
87
98
  }
88
99
 
89
100
  export function unsupportedProtocolError(protocol, kind = 'media') {
@@ -135,13 +146,52 @@ export function dashscopeTtsInputIssue(modelId, voice) {
135
146
  }
136
147
 
137
148
  /** Protocol support is independent from model capability declarations and health. */
138
- export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true } = {}) {
149
+ export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true, registry } = {}) {
139
150
  if (capability === 'vision') return true;
140
- if (capability === 'music-gen') return provider?.models?.some(model => model?.id === modelId && model.musicProtocol === 'gemini-web-music') === true;
141
- const protocol = capability === 'image-gen' ? selectImageProtocol(provider, modelId).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
142
- if (protocol === 'dashscope') return (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
143
- && (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider)));
144
- return capability === 'image-gen' && ['openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol);
151
+ const protocol = capability === 'music-gen' ? provider?.models?.find(model => model?.id === modelId)?.musicProtocol
152
+ : capability === 'image-gen' ? selectImageProtocol(provider, modelId, registry).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
153
+ const descriptor = providerProtocolDescriptor(protocol, registry);
154
+ return Boolean(descriptor?.capabilities.includes(capability)
155
+ && (!descriptor.supportsModel || descriptor.supportsModel({ provider, modelId, capability, checkEndpoint })));
156
+ }
157
+
158
+ /** Canonical binding spelling; existing Task hashes depend on this exact normalization. */
159
+ export function normalizeMediaEndpoint(baseUrl) {
160
+ const url = new URL(baseUrl);
161
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
162
+ throw new TypeError('媒体端点必须是无凭据、查询或片段的 HTTP(S) 地址');
163
+ }
164
+ return url.href.replace(/\/+$/, '');
165
+ }
166
+
167
+ const CONNECTION_FIELDS = Object.freeze([
168
+ Object.freeze({ key: 'baseUrl', label: 'API 地址', type: 'url', required: true }),
169
+ Object.freeze({ key: 'mediaBaseUrl', label: '媒体专用地址', type: 'url', advanced: true }),
170
+ Object.freeze({ key: 'apiKey', label: 'API Key', type: 'secret' })
171
+ ]);
172
+
173
+ /** Immutable defaults, not a mutable process-wide extension registry. */
174
+ export const BUILTIN_PROVIDER_PROTOCOLS = Object.freeze([
175
+ { id: 'gemini-web-music', label: 'Chat Completions(内嵌音频)', capabilities: ['music-gen'] },
176
+ { id: 'dashscope', label: 'DashScope', capabilities: ['image-gen', 'video-gen', 'tts', 'transcribe'],
177
+ normalizeEndpoint: dashscopeApiBase,
178
+ supportsModel: ({ provider, modelId, capability, checkEndpoint }) => (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
179
+ && (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider))),
180
+ supportsImageEdit: modelId => /^qwen-image-(?:edit(?:-(?:max|plus))?|2\.0(?:-pro)?)(?:-\d{4}-\d{2}-\d{2})?$/.test(String(modelId || '')) },
181
+ { id: 'openai-images', label: 'OpenAI Images', capabilities: ['image-gen'] },
182
+ { id: 'openai-chat-images', label: 'Chat Completions 图片', capabilities: ['image-gen'], supportsImageEdit: () => true },
183
+ { id: 'openai-responses-images', label: 'Responses 图片', capabilities: ['image-gen'] },
184
+ { id: 'comfyui', label: 'ComfyUI · API 工作流', capabilities: ['image-gen'],
185
+ supportsModel: ({ provider, modelId }) => {
186
+ const value = provider.models?.find(model => model?.id === modelId)?.comfyWorkflow;
187
+ if (!value) return false;
188
+ comfyWorkflowDigest(value); return true;
189
+ } }
190
+ ].map(descriptor => Object.freeze({ normalizeEndpoint: normalizeMediaEndpoint, configuration: CONNECTION_FIELDS,
191
+ ...descriptor, capabilities: Object.freeze(descriptor.capabilities) })));
192
+
193
+ export function providerProtocolDescriptor(protocol, registry) {
194
+ return registry ? registry.descriptor(protocol) : BUILTIN_PROVIDER_PROTOCOLS.find(descriptor => descriptor.id === protocol);
145
195
  }
146
196
 
147
197
  /**
@@ -0,0 +1,118 @@
1
+ import { defineProviderAdapter } from './provider-adapter.js';
2
+ import { unsupportedProtocolError, providerApiKey } from './provider-protocol.js';
3
+ import { checkedPluginDependency, assertPluginDependencyMatches } from './plugin-contract.js';
4
+
5
+ export const PROVIDER_EXTENSION_CONTRACT_VERSION = 0;
6
+ const ID = /^[a-z][a-z0-9._-]{0,79}$/;
7
+ const CAPS = Object.freeze({ 'image-gen': 'image', 'video-gen': 'video', tts: 'tts', 'music-gen': 'music', transcribe: 'transcribe' });
8
+ const invalid = message => Object.assign(new TypeError(message), { code: 'IRIS_PROVIDER_EXTENSION_INVALID' });
9
+ const closed = () => Object.assign(new Error('Provider 扩展实例已关闭或正在关闭'), {
10
+ code: 'IRIS_PROVIDER_EXTENSION_CLOSED', stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted' });
11
+
12
+ function checkedProtocol(value) {
13
+ if (!value || typeof value.id !== 'string' || !ID.test(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
14
+ || !Array.isArray(value.capabilities) || !value.capabilities.length
15
+ || value.capabilities.some(cap => !Object.hasOwn(CAPS, cap)) || new Set(value.capabilities).size !== value.capabilities.length
16
+ || typeof value.createAdapter !== 'function' || typeof value.normalizeEndpoint !== 'function'
17
+ || (value.supportsModel !== undefined && typeof value.supportsModel !== 'function')
18
+ || (value.supportsImageEdit !== undefined && typeof value.supportsImageEdit !== 'function')) throw invalid('Provider 协议描述无效');
19
+ if (value.configuration !== undefined && !Array.isArray(value.configuration)) throw invalid('配置字段描述必须是数组');
20
+ const keys = new Set();
21
+ const configuration = (value.configuration || []).map(field => {
22
+ if (!field || !['baseUrl', 'mediaBaseUrl', 'apiKey'].includes(field.key) || keys.has(field.key)
23
+ || typeof field.label !== 'string' || !field.label.trim() || field.label.length > 100
24
+ || field.type !== (field.key === 'apiKey' ? 'secret' : 'url')
25
+ || (field.required !== undefined && typeof field.required !== 'boolean')
26
+ || (field.advanced !== undefined && typeof field.advanced !== 'boolean')) throw invalid('Provider 配置字段描述无效');
27
+ keys.add(field.key);
28
+ return Object.freeze({ key: field.key, label: field.label, type: field.type,
29
+ ...(field.required !== undefined ? { required: field.required } : {}), ...(field.advanced !== undefined ? { advanced: field.advanced } : {}) });
30
+ });
31
+ const dependency = value.dependency === undefined ? undefined : checkedPluginDependency(value.dependency);
32
+ if (dependency && (dependency.kind !== 'providers' || dependency.contributionId !== value.id)) throw invalid('Provider 与插件贡献依赖不一致');
33
+ return Object.freeze({ id: value.id, label: value.label, capabilities: Object.freeze([...value.capabilities]),
34
+ configuration: Object.freeze(configuration), normalizeEndpoint: value.normalizeEndpoint,
35
+ ...(dependency ? { dependency } : {}),
36
+ ...(value.supportsModel ? { supportsModel: value.supportsModel } : {}),
37
+ ...(value.supportsImageEdit ? { supportsImageEdit: value.supportsImageEdit } : {}), createAdapter: value.createAdapter });
38
+ }
39
+
40
+ /** One registry owns its factory instances, active calls and serial asynchronous cleanup. */
41
+ export async function createExtensionRegistry(factories) {
42
+ const protocols = new Map(), extensions = [], active = new Set();
43
+ let state = 'loading', disposal, cleanupFailed = false;
44
+
45
+ function dispose() {
46
+ if (!disposal) {
47
+ state = 'closing';
48
+ disposal = (async () => {
49
+ await Promise.allSettled([...active]);
50
+ const errors = [];
51
+ for (const extension of [...extensions].reverse()) {
52
+ try { await extension.dispose?.(); } catch (error) { errors.push(error); }
53
+ }
54
+ protocols.clear(); state = 'closed'; cleanupFailed = errors.length > 0;
55
+ if (errors.length) throw new AggregateError(errors, 'Provider 扩展释放失败');
56
+ })();
57
+ }
58
+ return disposal;
59
+ }
60
+
61
+ try {
62
+ for (const factory of factories) {
63
+ if (typeof factory !== 'function') throw invalid('扩展入口必须是创建独立实例的工厂函数');
64
+ const extension = await factory();
65
+ if (extension && typeof extension.dispose === 'function') extensions.push({ dispose: () => extension.dispose() });
66
+ if (!extension || extension.contractVersion !== PROVIDER_EXTENSION_CONTRACT_VERSION || typeof extension.id !== 'string' || !ID.test(extension.id)
67
+ || typeof extension.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(extension.version)
68
+ || !Array.isArray(extension.protocols) || !extension.protocols.length
69
+ || (extension.dispose !== undefined && typeof extension.dispose !== 'function')) throw invalid('扩展身份、版本或协议集合无效');
70
+ if (extensions.some(item => item.id === extension.id)) throw invalid('扩展 ID 重复:' + extension.id);
71
+ const owned = extension.protocols.map(checkedProtocol);
72
+ for (const protocol of owned) {
73
+ if (protocol.dependency && (protocol.dependency.pluginId !== extension.id || protocol.dependency.pluginVersion !== extension.version)) throw invalid('Provider 依赖不属于当前扩展版本');
74
+ if (protocols.has(protocol.id)) throw invalid('Provider 协议重复:' + protocol.id);
75
+ protocols.set(protocol.id, protocol);
76
+ }
77
+ if (typeof extension.dispose === 'function') Object.assign(extensions[extensions.length - 1], { id: extension.id, version: extension.version });
78
+ else extensions.push({ id: extension.id, version: extension.version });
79
+ }
80
+ state = 'active';
81
+ } catch (error) {
82
+ try { await dispose(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Provider 扩展装配与释放均失败'); }
83
+ throw error;
84
+ }
85
+
86
+ const wrap = action => async (...args) => {
87
+ if (state !== 'active') throw closed();
88
+ const call = Promise.resolve().then(() => action(...args));
89
+ active.add(call);
90
+ try { return await call; } finally { active.delete(call); }
91
+ };
92
+
93
+ return Object.freeze({
94
+ descriptor: id => state === 'active' ? protocols.get(id) : undefined,
95
+ snapshot: () => ({ contractVersion: PROVIDER_EXTENSION_CONTRACT_VERSION, state, activeCalls: active.size, cleanupFailed,
96
+ extensions: extensions.filter(item => item.id).map(({ id, version }) => ({ id, version })),
97
+ protocols: [...protocols.values()].map(({ id, label, capabilities, configuration, dependency }) => ({ id, label,
98
+ capabilities: [...capabilities], configuration: configuration.map(field => ({ ...field })),
99
+ ...(dependency ? { dependency: { ...dependency } } : {}) })) }),
100
+ createAdapter(id, provider, options) {
101
+ if (state !== 'active') throw closed();
102
+ const descriptor = protocols.get(id);
103
+ if (!descriptor) throw unsupportedProtocolError(id);
104
+ const value = descriptor.createAdapter({ ...provider, apiKey: providerApiKey(provider) }, options);
105
+ if (!value || typeof value.then === 'function') throw invalid('createAdapter 必须同步返回 Adapter');
106
+ if (value.pluginDependency !== undefined) assertPluginDependencyMatches(descriptor.dependency, value.pluginDependency);
107
+ const adapter = defineProviderAdapter({ ...value,
108
+ ...(descriptor.dependency ? { pluginDependency: descriptor.dependency } : {}),
109
+ unsupported: Object.fromEntries(Object.entries(value.unsupported || {}).map(([name, reason]) => [name, reason?.reason ?? reason])) });
110
+ if (adapter.id !== provider.id || adapter.protocol !== id || adapter.capabilities.some(cap =>
111
+ !descriptor.capabilities.some(known => CAPS[known] === cap))) throw invalid('Adapter 身份或能力与注册协议不一致');
112
+ return Object.freeze({ ...adapter, operations: Object.freeze(Object.fromEntries(Object.entries(adapter.operations)
113
+ .map(([name, action]) => [name, name === 'mapError' ? action : wrap(action)]))),
114
+ ...(adapter.prepareInput ? { prepareInput: wrap(adapter.prepareInput) } : {}) });
115
+ },
116
+ dispose
117
+ });
118
+ }
@@ -0,0 +1,28 @@
1
+ import { ProviderContractError } from './provider-contract.js';
2
+
3
+ export const PROVIDER_RESPONSE_LIMITS = Object.freeze({ json: 2 * 1024 * 1024, mediaJson: 128 * 1024 * 1024,
4
+ image: 64 * 1024 * 1024, audio: 128 * 1024 * 1024, transcript: 8 * 1024 * 1024, video: 1024 * 1024 * 1024 });
5
+
6
+ /** Count decoded response bytes as well as Content-Length: chunked and compressed
7
+ * responses cannot bypass the limit. Iterator cancellation closes the body. */
8
+ export async function* boundedProviderBody(response, { maxBytes, stage = 'response', acceptance = 'unknown' }) {
9
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError('供应商响应限额须为正整数字节数');
10
+ const tooLarge = () => Object.assign(new ProviderContractError('供应商响应超过允许的大小(' + maxBytes + ' 字节)', {
11
+ stage, category: 'protocol', acceptance, retryable: false
12
+ }), { code: 'IRIS_PROVIDER_RESPONSE_TOO_LARGE' });
13
+ if (Number(response.headers.get('content-length')) > maxBytes) {
14
+ try { await response.body?.cancel(); } finally { throw tooLarge(); }
15
+ }
16
+ let size = 0;
17
+ if (response.body) for await (const chunk of response.body) {
18
+ size += chunk.length;
19
+ if (size > maxBytes) throw tooLarge();
20
+ yield chunk;
21
+ }
22
+ }
23
+
24
+ export async function readProviderText(response, options) {
25
+ const chunks = []; let size = 0;
26
+ for await (const chunk of boundedProviderBody(response, options)) { chunks.push(chunk); size += chunk.length; }
27
+ return Buffer.concat(chunks, size).toString('utf8');
28
+ }
@@ -0,0 +1,134 @@
1
+ // Owner-only editing boundary. No I/O, secrets in projections, or implicit model authorization.
2
+ import { CAPABILITIES } from './capability.js';
3
+ import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, resolvePoolModel } from './models.js';
4
+ import { BUILTIN_PROVIDER_PROTOCOLS, normalizeMediaEndpoint, normalizeImageProtocol, normalizeMusicProtocol, selectMediaProtocol, providerForModelBinding, isConfiguredProvider } from './provider-protocol.js';
5
+ import { clearModelCapabilityHealth, resetHealthOnCriticalChange } from './provider-health.js';
6
+ import { checkedComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
7
+
8
+ const CAPS = Object.values(CAPABILITIES);
9
+ const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
10
+ function object(value, fields) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !fields.includes(key))) fail('设置包含不支持的字段');
12
+ }
13
+ function string(value, max, empty = false) {
14
+ if (typeof value !== 'string' || value.length > max || /[\u0000-\u001f\u007f]/.test(value) || !empty && !value.trim()) fail('设置文本为空、过长或含控制字符');
15
+ return value.trim();
16
+ }
17
+ function capabilities(value) {
18
+ if (!Array.isArray(value) || value.some(cap => !CAPS.includes(cap)) || new Set(value).size !== value.length) fail('模型用途必须是已知能力的非重复数组');
19
+ return [...value];
20
+ }
21
+ const protocols = registry => registry ? registry.snapshot().protocols : BUILTIN_PROVIDER_PROTOCOLS;
22
+
23
+ export function projectProviderSettings(catalog, revision, registry) {
24
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
25
+ const result = { contractVersion: 0, source: 'explicit-provider-config', revision,
26
+ protocols: protocols(registry).map(({ id, label, capabilities, configuration }) => ({ id, label, capabilities, configuration })),
27
+ capabilities: CAPS,
28
+ providers: catalog.providers.map(provider => ({ id: provider.id, name: provider.name || provider.id,
29
+ baseUrl: provider.baseUrl || '', mediaBaseUrl: provider.mediaBaseUrl || '',
30
+ enabled: provider.enabled !== false, auth: provider.auth === 'none' ? 'none' : 'bearer',
31
+ hasCredential: Boolean(provider.apiKey), configured: isConfiguredProvider(provider),
32
+ mediaProtocol: selectMediaProtocol(provider).mediaProtocol,
33
+ models: providerModels(provider).map(model => ({ id: model.id, ref: model.ref, capabilities: model.capabilities,
34
+ imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
35
+ ...(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow ? {
36
+ comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
37
+ catalogStatus: model.catalogStatus || 'unknown', unavailableCapabilities: model.unavailableCapabilities || [],
38
+ rateLimited: model.rateLimited === true,
39
+ verified: Object.fromEntries(Object.entries(model.verified || {}).map(([cap, value]) => [cap, {
40
+ ok: value.ok === true, at: value.at, category: value.category
41
+ }])) })) })),
42
+ assignments: Object.fromEntries(CAPS.map(cap => {
43
+ const policy = functionPolicy(catalog.assignments?.[cap], cap);
44
+ return [cap, { ...policy, models: savedAssignmentRefs(pool, cap, policy.models) }];
45
+ })) };
46
+ // A mistakenly pasted key in a display field must not become a key read-back route.
47
+ const keys = catalog.providers.map(provider => provider.apiKey).filter(key => typeof key === 'string' && key);
48
+ const scrub = value => typeof value === 'string' ? keys.reduce((s, key) => s.split(key).join('[redacted]'), value)
49
+ : Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object'
50
+ ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)])) : value;
51
+ return scrub(result);
52
+ }
53
+
54
+ export function applyProviderSettings(catalog, input, registry) {
55
+ object(input, ['expectedRevision', 'provider', 'credential', 'assignments']);
56
+ object(input.provider, ['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth', 'models']);
57
+ object(input.credential, ['action', 'value']);
58
+ const value = input.provider, id = string(value.id, 160);
59
+ const matches = catalog.providers.filter(item => item.id === id);
60
+ if (matches.length > 1) fail('供应商 ID 重复,请在本机核对');
61
+ const previous = matches[0];
62
+ const next = { ...previous, id, name: string(value.name, 80), baseUrl: string(value.baseUrl, 2048),
63
+ mediaBaseUrl: string(value.mediaBaseUrl ?? '', 2048, true), mediaProtocol: string(value.mediaProtocol, 80),
64
+ enabled: value.enabled, auth: value.auth };
65
+ next.protocolInferred = false;
66
+ if (typeof next.enabled !== 'boolean' || !['bearer', 'none'].includes(next.auth)) fail('供应商启用或认证方式无效');
67
+ try { normalizeMediaEndpoint(next.baseUrl); if (next.mediaBaseUrl) normalizeMediaEndpoint(next.mediaBaseUrl); }
68
+ catch (_) { fail('地址必须是无凭据、查询或片段的 HTTP(S) URL'); }
69
+ if (!protocols(registry).some(item => item.id === next.mediaProtocol) && previous?.mediaProtocol !== next.mediaProtocol) fail('协议未注册,请先显式加载对应扩展');
70
+ const credential = input.credential;
71
+ if (!['keep', 'replace', 'clear'].includes(credential.action) || credential.action !== 'replace' && Object.hasOwn(credential, 'value')) fail('密钥操作必须是 keep、replace 或 clear');
72
+ if (credential.action === 'replace') next.apiKey = string(credential.value, 8192);
73
+ if (credential.action === 'clear') delete next.apiKey;
74
+ if (!Array.isArray(value.models) || value.models.length > 256) fail('单次最多保存 256 个模型');
75
+ const existing = providerModels(previous || {});
76
+ const entries = Array.isArray(previous?.models) ? previous.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
77
+ : existing.map(({ id, capabilities }) => ({ id, capabilities }));
78
+ const ids = new Set();
79
+ for (const model of value.models) {
80
+ object(model, ['id', 'capabilities', 'imageProtocol', 'musicProtocol', 'comfyWorkflow']);
81
+ const modelId = string(model.id, 512);
82
+ if (ids.has(modelId)) fail('模型 ID 重复');
83
+ ids.add(modelId);
84
+ const old = entries.find(entry => entry.id === modelId), oldView = existing.find(entry => entry.id === modelId);
85
+ const updated = { ...old, id: modelId, capabilities: capabilities(model.capabilities) };
86
+ if (model.comfyWorkflow !== undefined) updated.comfyWorkflow = checkedComfyWorkflow(model.comfyWorkflow);
87
+ if (!oldView || JSON.stringify(oldView.capabilities) !== JSON.stringify(updated.capabilities)) updated.source = 'manual';
88
+ if (model.imageProtocol !== undefined) {
89
+ const protocol = model.imageProtocol === old?.imageProtocol ? model.imageProtocol : normalizeImageProtocol(model.imageProtocol, registry);
90
+ if (protocol === 'auto') delete updated.imageProtocol; else updated.imageProtocol = protocol;
91
+ }
92
+ if (model.musicProtocol !== undefined) {
93
+ if (model.musicProtocol === '') delete updated.musicProtocol;
94
+ else updated.musicProtocol = model.musicProtocol === old?.musicProtocol ? model.musicProtocol : normalizeMusicProtocol(model.musicProtocol, registry);
95
+ }
96
+ if (old) entries[entries.indexOf(old)] = updated; else entries.push(updated);
97
+ }
98
+ next.models = entries; // Omitted existing records survive. The Web has no hard-delete operation.
99
+ if (previous) {
100
+ const previousConnection = { ...previous, auth: previous.auth === 'none' ? 'none' : 'bearer',
101
+ mediaBaseUrl: previous.mediaBaseUrl || '', mediaProtocol: selectMediaProtocol(previous).mediaProtocol };
102
+ resetHealthOnCriticalChange(previousConnection, next, next);
103
+ for (const model of existing) {
104
+ const updated = next.models.find(entry => entry.id === model.id);
105
+ const connectionChanged = ['apiKey', 'baseUrl', 'mediaBaseUrl', 'auth', 'mediaProtocol'].some(key => previousConnection[key] !== next[key]);
106
+ for (const cap of model.capabilities) if (connectionChanged || !updated.capabilities?.includes(cap)
107
+ || cap === 'image-gen' && JSON.stringify(previous.models?.find(entry => entry?.id === model.id)?.comfyWorkflow) !== JSON.stringify(updated.comfyWorkflow)
108
+ || cap === 'image-gen' && providerForModelBinding(previous, model.id, cap).mediaProtocol !== providerForModelBinding(next, model.id, cap).mediaProtocol
109
+ || cap === 'music-gen' && previous.models?.find(entry => entry.id === model.id)?.musicProtocol !== updated.musicProtocol) {
110
+ clearModelCapabilityHealth(next, model.id, cap);
111
+ }
112
+ }
113
+ catalog.providers[catalog.providers.indexOf(previous)] = next;
114
+ } else catalog.providers.push(next);
115
+ if (input.assignments !== undefined) applyFunctionPolicies(catalog, input.assignments);
116
+ return catalog;
117
+ }
118
+
119
+ export function applyFunctionPolicies(catalog, assignments) {
120
+ object(assignments, CAPS);
121
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
122
+ catalog.assignments ||= {};
123
+ for (const [cap, assignment] of Object.entries(assignments)) {
124
+ object(assignment, ['mode', 'models', 'hostVision']);
125
+ let policy;
126
+ try { policy = functionPolicy(assignment, cap); } catch (_) { fail('功能策略无效'); }
127
+ if (!Object.hasOwn(assignment, 'mode') || new Set(policy.models).size !== policy.models.length || policy.models.length > 256) fail('功能策略或模型顺序无效');
128
+ // Existing invalid refs may be retained for diagnosis, but never newly authorized.
129
+ const oldRefs = savedAssignmentRefs(pool, cap, functionPolicy(catalog.assignments[cap], cap).models);
130
+ for (const ref of policy.models) if (!resolvePoolModel(pool, ref, cap) && !oldRefs.includes(ref)) fail('用途引用了不存在或未标注该能力的模型');
131
+ catalog.assignments[cap] = { mode: policy.mode, models: policy.models, ...(cap === 'vision' ? { hostVision: policy.hostVision } : {}) };
132
+ }
133
+ return catalog;
134
+ }
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import sharp from 'sharp';
7
+ import { isAudioRecipe, normalizeAudioFlowInput } from './audio-flow-input.js';
7
8
  import { adoptCoreArtifactFile, createCoreArtifact } from './core-artifacts.js';
8
9
  import {
9
10
  activateCoreWatch,
@@ -34,8 +35,9 @@ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundar
34
35
  import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
35
36
  import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
36
37
  readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
37
- import { readFlowRun, flowFail } from './flow-store.js';
38
+ import { readFlowRun, flowFail, flowStep } from './flow-store.js';
38
39
  import { normalizeGenerationInput } from './generation-input.js';
40
+ import { assertPluginDependencyMatches } from './plugin-contract.js';
39
41
 
40
42
  export const PROVIDER_TASK_RUNNER_VERSION = 0;
41
43
 
@@ -73,6 +75,7 @@ function checkAdapterForTask(task, adapter) {
73
75
  if (!adapter || adapter.id !== task.providerId) {
74
76
  throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
75
77
  }
78
+ assertPluginDependencyMatches(task.pluginDependency, adapter.pluginDependency);
76
79
  return adapter;
77
80
  }
78
81
 
@@ -86,11 +89,14 @@ function normalizedCandidates(capability, candidates) {
86
89
  if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
87
90
  || parsed.providerId !== adapter.id || !parsed.modelId
88
91
  || (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
89
- || (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))) {
92
+ || (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))
93
+ || (adapter.pluginDependency && !candidate.providerBinding)) {
90
94
  throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
91
95
  }
92
96
  if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
93
- return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, selectionReason, prepareInput: candidate.prepareInput };
97
+ if (candidate.supportsImageEdit !== undefined && typeof candidate.supportsImageEdit !== 'boolean') throw new TypeError('候选 supportsImageEdit 必须是布尔值');
98
+ return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, pluginDependency: adapter.pluginDependency, selectionReason, prepareInput: candidate.prepareInput,
99
+ ...(candidate.supportsImageEdit !== undefined ? { supportsImageEdit: candidate.supportsImageEdit } : {}) };
94
100
  });
95
101
  }
96
102
 
@@ -294,24 +300,30 @@ export function createProviderTaskRunner(runtime) {
294
300
  const capability = String(input.capability || '').trim();
295
301
  let candidates = normalizedCandidates(capability, input.candidates);
296
302
  let providerInput = input.providerInput ?? {};
303
+ if (providerInput.workflow_parameters !== undefined && candidates.some(candidate => candidate.adapter.protocol !== 'comfyui')) {
304
+ throw new ProviderTaskRunnerError('IRIS_COMFYUI_PARAMETERS_INVALID', '工作流参数仅用于明确的 ComfyUI 模板');
305
+ }
297
306
  let flow;
298
307
  if (input.flowRunId !== undefined) {
299
308
  flow = readFlowRun(dataRoot, input.flowRunId);
300
- const expected = normalizeGenerationInput('image', flow.input).providerInput;
301
- if (capability !== 'image' || !flow.edit.claimed || input.sourceArtifactId !== flow.source.artifactId
309
+ const audio = isAudioRecipe(flow.recipe), normalized = audio ? normalizeAudioFlowInput(flow.recipe, flow.input) : null;
310
+ const expected = audio ? Object.fromEntries(Object.entries(normalized).filter(([key]) => key !== 'model_ref')) : normalizeGenerationInput('image', flow.input).providerInput;
311
+ const expectedCapability = audio ? flow.recipe === 'speech/v0' ? 'tts' : 'transcribe' : 'image';
312
+ if (capability !== expectedCapability || !flowStep(flow).claimed || input.sourceArtifactId !== flow.source?.artifactId
302
313
  || Object.keys(providerInput).length !== Object.keys(expected).length
303
- || Object.keys(expected).some(key => providerInput[key] !== expected[key])
314
+ || Object.keys(expected).some(key => key === 'workflow_parameters'
315
+ ? JSON.stringify(providerInput[key]) !== JSON.stringify(expected[key]) : providerInput[key] !== expected[key])
304
316
  || candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
305
317
  || candidates[0].providerBinding !== flow.providerBinding) {
306
318
  flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
307
319
  }
308
320
  }
309
- if (input.sourceArtifactId !== undefined) {
310
- if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
321
+ if (input.sourceArtifactId !== undefined && capability === 'image') {
311
322
  candidates = imageEditCandidates(candidates);
312
323
  const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
313
324
  providerInput = { ...providerInput, image };
314
325
  }
326
+ if (input.sourceArtifactId !== undefined && !['image', 'transcribe'].includes(capability)) throw new TypeError('来源作品仅用于图片编辑或转写');
315
327
  const task = flow ? claimFlowTask(dataRoot, flow.id)
316
328
  : createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId });
317
329
  if (flow && task.attempts.length) flowFail('IRIS_FLOW_TASK_ALREADY_STARTED', '该流程 Task 已有提交意图,禁止重新 submit');
@@ -323,7 +335,7 @@ export function createProviderTaskRunner(runtime) {
323
335
  submit: async (providerInput, context) => {
324
336
  if (prepareInput) {
325
337
  try { providerInput = await prepareInput({ providerInput, signal }); }
326
- catch (error) { throw new ProviderContractError('视频输入准备失败:' + (error.safeMessage || error.message), {
338
+ catch (error) { throw new ProviderContractError((capability === 'video' ? '视频' : '音频') + '输入准备失败:' + (error.safeMessage || error.message), {
327
339
  ...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
328
340
  }); }
329
341
  }
@@ -345,6 +357,7 @@ export function createProviderTaskRunner(runtime) {
345
357
  return beginCoreAttempt(dataRoot, task.id, {
346
358
  ...attempt,
347
359
  ...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
360
+ ...(candidate.pluginDependency ? { pluginDependency: candidate.pluginDependency } : {}),
348
361
  ...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
349
362
  });
350
363
  },
@@ -1,9 +1,10 @@
1
- /** Explicit, copy-based installation of the bundled Core skill. No host/model I/O. */
1
+ /** Explicit installation of the bundled Core skill or a rendered connection snapshot. No host/model I/O. */
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { connectionSkillResources } from './connection-skill.js';
7
8
 
8
9
  const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url));
9
10
  const SKILL = 'iris-core';
@@ -58,10 +59,10 @@ function sameTree(tree, recorded) {
58
59
  && JSON.stringify(tree.directories) === JSON.stringify([...recorded.directories].sort());
59
60
  }
60
61
 
61
- function validReceipt(value, packageName) {
62
+ function validReceipt(value, packageName, skillName) {
62
63
  const relative = name => typeof name === 'string' && name.length > 0 && !name.includes('\\')
63
64
  && !name.includes('\0') && !name.split('/').some(part => !part || part === '.' || part === '..');
64
- return value?.schemaVersion === 1 && value.packageName === packageName && value.skillName === SKILL
65
+ return value?.schemaVersion === 1 && value.packageName === packageName && value.skillName === skillName
65
66
  && typeof value.packageVersion === 'string' && value.packageVersion.length > 0
66
67
  && value.files && !Array.isArray(value.files) && typeof value.files === 'object'
67
68
  && Object.hasOwn(value.files, 'SKILL.md') && !Object.hasOwn(value.files, RECEIPT)
@@ -77,7 +78,18 @@ function packageResources(packageRoot) {
77
78
  if (!tree.files['SKILL.md'] || tree.files[RECEIPT]) {
78
79
  throw error('IRIS_SKILL_PACKAGE_INVALID', '包内缺少 Skill 入口,或含有不应发布的安装记录');
79
80
  }
80
- return { source, metadata, ...tree };
81
+ return { skillName: SKILL, source, metadata, ...tree };
82
+ }
83
+
84
+ function bundleResources(file, packageRoot) {
85
+ if (typeof file !== 'string' || !path.isAbsolute(file)) throw error('IRIS_SKILL_OPTIONS_INVALID', '--bundle 必须是绝对路径');
86
+ const entry = fs.lstatSync(file);
87
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 16384) throw error('IRIS_SKILL_BUNDLE_INVALID', 'Skill 交付文件必须是最多 16 KiB 的普通 JSON 文件');
88
+ let value;
89
+ try { value = JSON.parse(fs.readFileSync(file, 'utf8')); }
90
+ catch (cause) { if (!(cause instanceof SyntaxError)) throw cause; throw error('IRIS_SKILL_BUNDLE_INVALID', 'Skill 交付文件不是有效 JSON'); }
91
+ const metadata = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
92
+ return { ...connectionSkillResources(value, metadata), source: fs.realpathSync(file) };
81
93
  }
82
94
 
83
95
  function inspect(destination, resources) {
@@ -92,7 +104,7 @@ function inspect(destination, resources) {
92
104
  if (!(cause instanceof SyntaxError)) throw cause;
93
105
  return { status: 'unmanaged', reason: 'invalid_receipt' };
94
106
  }
95
- if (!validReceipt(receipt, resources.metadata.name)) return { status: 'unmanaged', reason: 'invalid_receipt' };
107
+ if (!validReceipt(receipt, resources.metadata.name, resources.skillName)) return { status: 'unmanaged', reason: 'invalid_receipt' };
96
108
  const base = { installedVersion: receipt.packageVersion };
97
109
  let tree;
98
110
  try { tree = readTree(destination, { installed: true, expected: receipt.files }); }
@@ -125,7 +137,7 @@ function ensureDirectory(directory) {
125
137
 
126
138
  function archive(destination, control, action) {
127
139
  const transaction = fs.mkdtempSync(path.join(control, 'backup-'));
128
- const backupPath = path.join(transaction, SKILL);
140
+ const backupPath = path.join(transaction, path.basename(destination));
129
141
  fs.writeFileSync(path.join(transaction, 'move.json'), JSON.stringify({ schemaVersion: 1, action,
130
142
  at: new Date().toISOString(), origin: destination, backupPath }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
131
143
  fs.renameSync(destination, backupPath);
@@ -137,24 +149,24 @@ function copyResources(destination, resources) {
137
149
  for (const relative of resources.directories) fs.mkdirSync(path.join(destination, relative), { recursive: true, mode: 0o700 });
138
150
  for (const [relative, bytes] of Object.entries(resources.bytes)) fs.writeFileSync(path.join(destination, relative), bytes, { flag: 'wx', mode: 0o600 });
139
151
  fs.writeFileSync(path.join(destination, RECEIPT), JSON.stringify({ schemaVersion: 1,
140
- packageName: resources.metadata.name, packageVersion: resources.metadata.version, skillName: SKILL,
152
+ packageName: resources.metadata.name, packageVersion: resources.metadata.version, skillName: resources.skillName,
141
153
  installedAt: new Date().toISOString(), files: resources.files, directories: resources.directories
142
154
  }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
143
155
  }
144
156
 
145
157
  /** Internal CLI service. Explicit mutations only; no postinstall or public SDK export. */
146
- export function manageCoreSkill({ action, target, directory, dryRun = false }, {
158
+ export function manageCoreSkill({ action, target, directory, bundle, dryRun = false }, {
147
159
  packageRoot = PACKAGE_ROOT, env = process.env, home = os.homedir()
148
160
  } = {}) {
149
161
  if (!['install', 'status', 'uninstall'].includes(action) || action === 'status' && dryRun) {
150
162
  throw error('IRIS_SKILL_OPTIONS_INVALID', '使用 skills install/status/uninstall;status 不接受 --dry-run');
151
163
  }
152
- const resources = packageResources(packageRoot);
153
- const resolved = resolveDirectory({ target, directory, env, home }), destination = path.join(resolved, SKILL);
164
+ const resources = bundle === undefined ? packageResources(packageRoot) : bundleResources(bundle, packageRoot);
165
+ const resolved = resolveDirectory({ target, directory, env, home }), destination = path.join(resolved, resources.skillName);
154
166
  if (isWithin(resources.source, destination) || isWithin(destination, resources.source)) {
155
167
  throw error('IRIS_SKILL_OPTIONS_INVALID', '安装目标不能与包内 Skill 源目录重叠');
156
168
  }
157
- const base = { skill: SKILL, target: target || 'directory', directory: resolved, destination,
169
+ const base = { skill: resources.skillName, target: target || 'directory', directory: resolved, destination,
158
170
  packageVersion: resources.metadata.version, discovery: 'not_checked' };
159
171
  const before = inspect(destination, resources);
160
172
  const result = { ...base, ...before };
@@ -61,6 +61,9 @@ export function createConfiguredVisionModelPort(provider, model, { allowRateLimi
61
61
  orientImages, prepareImageRequest,
62
62
  ...(prepareImages ? { imageInput: visionInputLimits(provider, parsed.modelId) } : {}) });
63
63
  const availability = result => {
64
+ // Ordinary vision/OCR is read-only for provider configuration. Explicit probes
65
+ // and definitive failures still update availability through the existing hook.
66
+ if (result.ok === true && !allowRateLimited) return;
64
67
  const event = { ...result, stage: 'submit', source: allowRateLimited ? 'probe' : 'task',
65
68
  binding: modelAvailabilityBinding(provider, parsed.modelId, 'vision') };
66
69
  if (!recordModelAvailability(provider, parsed.modelId, 'vision', event)) return;