@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.
- package/CHANGELOG.md +81 -1
- package/README.en.md +103 -312
- package/README.md +100 -310
- package/bin/dsh-iris.js +333 -65
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +10 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +2 -2
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +39 -14
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +3 -1
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +17 -7
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +53 -2
- package/lib/adapters.js +20 -11
- package/lib/application-runtime.js +34 -7
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +346 -53
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +16 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +29 -12
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +38 -64
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +105 -10
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +73 -29
- package/lib/index.js +37 -17
- package/lib/local-api-projection.js +10 -5
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +769 -67
- package/lib/local-creation.js +89 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +22 -9
- package/lib/skill-installation.js +23 -11
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +20 -5
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/user_guide.md +5 -1
package/lib/provider-protocol.js
CHANGED
|
@@ -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 (
|
|
52
|
-
throw Object.assign(new TypeError('imageProtocol 必须是 auto
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
301
|
-
|
|
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 =>
|
|
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('
|
|
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
|
|
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 ===
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
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;
|