@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -1
- package/README.en.md +109 -311
- package/README.md +106 -309
- package/bin/dsh-iris.js +452 -92
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/ARTIFACT_MANIFEST.md +4 -0
- package/docs/AUDIO_FLOWS.md +87 -0
- package/docs/BETA_SCOPE.md +42 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +101 -0
- package/docs/CORE_QUICKSTART.md +110 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +101 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +21 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +30 -5
- package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
- 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 +42 -15
- 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 +207 -0
- package/docs/MCP.md +212 -0
- package/docs/MEDIA_SCHEDULING.md +72 -0
- package/docs/MUSIC.md +2 -0
- package/docs/NETWORK_RESOURCES.md +106 -0
- package/docs/NETWORK_ROUTING.md +103 -0
- package/docs/NETWORK_WORKBENCH.md +65 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +193 -0
- package/docs/PLUGIN_DEVELOPMENT.md +39 -0
- package/docs/PLUGIN_STARTUP.md +113 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
- package/docs/PROVIDER_CREDENTIALS.md +67 -0
- package/docs/PROVIDER_EXTENSIONS.md +82 -0
- package/docs/PROVIDER_HEALTH.md +5 -1
- package/docs/PROVIDER_NETWORK.md +45 -0
- package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
- package/docs/PROVIDER_SETTINGS.md +91 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +1 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
- package/docs/VIDEO_FLOWS.md +89 -0
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +2 -0
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_ARTIFACTS.md +6 -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/releases/0.3.0-beta.1.md +34 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +56 -4
- package/lib/adapters.js +75 -49
- package/lib/api.js +23 -13
- package/lib/application-runtime.js +73 -17
- package/lib/application-writer.js +193 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.d.ts +3 -3
- package/lib/client.js +402 -72
- package/lib/comfyui-adapter.js +95 -0
- package/lib/comfyui-client.js +60 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/command-service.js +18 -5
- package/lib/config.js +49 -10
- package/lib/configured-image-runtime.js +69 -0
- package/lib/connection-skill.js +98 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +84 -28
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +85 -0
- package/lib/core-capability-catalog.js +192 -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-delivery-cache.js +8 -3
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +174 -0
- package/lib/core-tasks.js +113 -21
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +41 -64
- package/lib/doctor.js +2 -2
- package/lib/dsh-core-adapter.js +61 -9
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-provider-config.js +1 -1
- package/lib/dsh-service-bridge.js +67 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +156 -14
- 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 +97 -31
- package/lib/index.js +47 -22
- package/lib/local-api-projection.js +15 -6
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +861 -71
- package/lib/local-creation.js +96 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +39 -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 +174 -0
- package/lib/mcp-server.js +69 -0
- package/lib/media-scheduler.js +194 -0
- package/lib/model-directory-service.js +37 -10
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +6 -3
- package/lib/network-config-service.js +123 -0
- package/lib/network-config.js +127 -0
- package/lib/network-doctor.js +94 -0
- package/lib/network-management.js +51 -0
- package/lib/network-policy.js +19 -0
- package/lib/network-route-evidence.js +38 -0
- package/lib/network-routing.js +283 -0
- package/lib/operation-runner.js +281 -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 +307 -0
- package/lib/plugins.js +59 -0
- package/lib/provider-adapter.js +7 -3
- package/lib/provider-adapters.js +92 -22
- package/lib/provider-catalog.js +64 -36
- package/lib/provider-config-format.js +51 -0
- package/lib/provider-config-service.js +143 -31
- package/lib/provider-context.d.ts +90 -0
- package/lib/provider-context.js +42 -0
- package/lib/provider-credentials.js +97 -0
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +18 -6
- package/lib/provider-monitor.js +81 -0
- package/lib/provider-network.js +52 -0
- package/lib/provider-protocol.js +73 -22
- package/lib/provider-registry.js +128 -0
- package/lib/provider-response.js +38 -0
- package/lib/provider-settings.js +136 -0
- package/lib/provider-task-runner.js +132 -43
- package/lib/provider-transport.js +16 -0
- package/lib/render.js +11 -1
- package/lib/scheduling-policy.js +66 -0
- package/lib/skill-installation.js +23 -11
- package/lib/tasks.js +3 -2
- package/lib/video-flow-input.js +16 -0
- package/lib/video-input.js +12 -5
- package/lib/vision-model-routing.js +5 -0
- package/lib/web/app.js +703 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +516 -0
- package/lib/web/creation.js +359 -0
- package/lib/web/flow-view.js +34 -0
- package/lib/web/gallery.js +133 -0
- package/lib/web/index.html +344 -0
- package/lib/web/media-client.js +218 -0
- package/lib/web/network.js +232 -0
- package/lib/web/plugin-panel.js +223 -0
- package/lib/web/plugin-status.js +140 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +513 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +571 -0
- package/lib/web/transport.js +101 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +3 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +24 -6
- 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/templates/plugin-starter/LICENSE +21 -0
- package/templates/plugin-starter/README.md +74 -0
- package/templates/plugin-starter/assets/peach.png +0 -0
- package/templates/plugin-starter/assets/violet.png +0 -0
- package/templates/plugin-starter/index.mjs +27 -0
- package/templates/plugin-starter/package.json +18 -0
- package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
- package/templates/plugin-starter/scripts/environment.mjs +65 -0
- package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
- package/templates/plugin-starter/scripts/setup.mjs +42 -0
- package/templates/plugin-starter/scripts/smoke.mjs +76 -0
- package/templates/plugin-starter/web/board.css +27 -0
- package/templates/plugin-starter/web/board.mjs +160 -0
- package/templates/plugin-starter/web/png.mjs +23 -0
- package/templates/provider-starter/LICENSE +21 -0
- package/templates/provider-starter/README.md +50 -0
- package/templates/provider-starter/index.d.mts +5 -0
- package/templates/provider-starter/index.mjs +165 -0
- package/templates/provider-starter/package.json +13 -0
- package/templates/provider-starter/scripts/core-cli.mjs +22 -0
- package/templates/provider-starter/scripts/environment.mjs +122 -0
- package/templates/provider-starter/scripts/fixture.mjs +54 -0
- package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
- package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
- package/templates/provider-starter/scripts/setup.mjs +44 -0
- package/templates/provider-starter/scripts/smoke.mjs +145 -0
- package/user_guide.md +5 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Owner-only editing boundary. No file 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
|
+
import { validateProviderCredential, providerCredentialStatus, scrubProviderSecrets } from './provider-credentials.js';
|
|
8
|
+
|
|
9
|
+
const CAPS = Object.values(CAPABILITIES);
|
|
10
|
+
const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
|
|
11
|
+
function object(value, fields) {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !fields.includes(key))) fail('设置包含不支持的字段');
|
|
13
|
+
}
|
|
14
|
+
function string(value, max, empty = false) {
|
|
15
|
+
if (typeof value !== 'string' || value.length > max || /[\u0000-\u001f\u007f]/.test(value) || !empty && !value.trim()) fail('设置文本为空、过长或含控制字符');
|
|
16
|
+
return value.trim();
|
|
17
|
+
}
|
|
18
|
+
function capabilities(value) {
|
|
19
|
+
if (!Array.isArray(value) || value.some(cap => !CAPS.includes(cap)) || new Set(value).size !== value.length) fail('模型用途必须是已知能力的非重复数组');
|
|
20
|
+
return [...value];
|
|
21
|
+
}
|
|
22
|
+
const protocols = registry => registry ? registry.snapshot().protocols : BUILTIN_PROVIDER_PROTOCOLS;
|
|
23
|
+
|
|
24
|
+
export function projectProviderSettings(catalog, revision, registry) {
|
|
25
|
+
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
26
|
+
const result = { contractVersion: 0, source: 'explicit-provider-config', revision,
|
|
27
|
+
protocols: protocols(registry).map(({ id, label, capabilities, configuration }) => ({ id, label, capabilities, configuration })),
|
|
28
|
+
capabilities: CAPS,
|
|
29
|
+
providers: catalog.providers.map(provider => ({ id: provider.id, name: provider.name || provider.id,
|
|
30
|
+
baseUrl: provider.baseUrl || '', mediaBaseUrl: provider.mediaBaseUrl || '',
|
|
31
|
+
enabled: provider.enabled !== false, auth: provider.auth === 'none' ? 'none' : 'bearer',
|
|
32
|
+
hasCredential: providerCredentialStatus(provider) === 'ready', configured: isConfiguredProvider(provider),
|
|
33
|
+
...(provider.apiKeyEnv !== undefined ? { apiKeyEnv: provider.apiKeyEnv, credentialStatus: providerCredentialStatus(provider) } : {}),
|
|
34
|
+
mediaProtocol: selectMediaProtocol(provider).mediaProtocol,
|
|
35
|
+
models: providerModels(provider).map(model => ({ id: model.id, ref: model.ref, capabilities: model.capabilities,
|
|
36
|
+
imageProtocol: model.imageProtocol || 'auto', musicProtocol: model.musicProtocol || '',
|
|
37
|
+
...(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow ? {
|
|
38
|
+
comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
|
|
39
|
+
catalogStatus: model.catalogStatus || 'unknown', unavailableCapabilities: model.unavailableCapabilities || [],
|
|
40
|
+
rateLimited: model.rateLimited === true,
|
|
41
|
+
verified: Object.fromEntries(Object.entries(model.verified || {}).map(([cap, value]) => [cap, {
|
|
42
|
+
ok: value.ok === true, at: value.at, category: value.category
|
|
43
|
+
}])) })) })),
|
|
44
|
+
assignments: Object.fromEntries(CAPS.map(cap => {
|
|
45
|
+
const policy = functionPolicy(catalog.assignments?.[cap], cap);
|
|
46
|
+
return [cap, { ...policy, models: savedAssignmentRefs(pool, cap, policy.models) }];
|
|
47
|
+
})) };
|
|
48
|
+
// A mistakenly pasted key in a display field must not become a key read-back route.
|
|
49
|
+
return scrubProviderSecrets(result, catalog.providers);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function applyProviderSettings(catalog, input, registry) {
|
|
53
|
+
object(input, ['expectedRevision', 'provider', 'credential', 'assignments']);
|
|
54
|
+
object(input.provider, ['id', 'name', 'baseUrl', 'mediaBaseUrl', 'mediaProtocol', 'enabled', 'auth', 'models']);
|
|
55
|
+
object(input.credential, ['action', 'value', 'name']);
|
|
56
|
+
const value = input.provider, id = string(value.id, 160);
|
|
57
|
+
const matches = catalog.providers.filter(item => item.id === id);
|
|
58
|
+
if (matches.length > 1) fail('供应商 ID 重复,请在本机核对');
|
|
59
|
+
const previous = matches[0];
|
|
60
|
+
const next = { ...previous, id, name: string(value.name, 80), baseUrl: string(value.baseUrl, 2048),
|
|
61
|
+
mediaBaseUrl: string(value.mediaBaseUrl ?? '', 2048, true), mediaProtocol: string(value.mediaProtocol, 80),
|
|
62
|
+
enabled: value.enabled, auth: value.auth };
|
|
63
|
+
next.protocolInferred = false;
|
|
64
|
+
if (typeof next.enabled !== 'boolean' || !['bearer', 'none'].includes(next.auth)) fail('供应商启用或认证方式无效');
|
|
65
|
+
try { normalizeMediaEndpoint(next.baseUrl); if (next.mediaBaseUrl) normalizeMediaEndpoint(next.mediaBaseUrl); }
|
|
66
|
+
catch (_) { fail('地址必须是无凭据、查询或片段的 HTTP(S) URL'); }
|
|
67
|
+
if (!protocols(registry).some(item => item.id === next.mediaProtocol) && previous?.mediaProtocol !== next.mediaProtocol) fail('协议未注册,请先显式加载对应扩展');
|
|
68
|
+
const credential = input.credential;
|
|
69
|
+
if (!['keep', 'replace', 'clear', 'env'].includes(credential.action)
|
|
70
|
+
|| credential.action !== 'replace' && Object.hasOwn(credential, 'value')
|
|
71
|
+
|| credential.action !== 'env' && Object.hasOwn(credential, 'name')) fail('密钥操作必须是 keep、replace、clear 或 env,并只提供对应字段');
|
|
72
|
+
if (credential.action === 'replace') { next.apiKey = string(credential.value, 8192); delete next.apiKeyEnv; }
|
|
73
|
+
if (credential.action === 'env') { next.apiKeyEnv = string(credential.name, 128); delete next.apiKey; }
|
|
74
|
+
if (credential.action === 'clear') { delete next.apiKey; delete next.apiKeyEnv; }
|
|
75
|
+
validateProviderCredential(next);
|
|
76
|
+
if (!Array.isArray(value.models) || value.models.length > 256) fail('单次最多保存 256 个模型');
|
|
77
|
+
const existing = providerModels(previous || {});
|
|
78
|
+
const entries = Array.isArray(previous?.models) ? previous.models.map(model => typeof model === 'string' ? { id: model } : { ...model })
|
|
79
|
+
: existing.map(({ id, capabilities }) => ({ id, capabilities }));
|
|
80
|
+
const ids = new Set();
|
|
81
|
+
for (const model of value.models) {
|
|
82
|
+
object(model, ['id', 'capabilities', 'imageProtocol', 'musicProtocol', 'comfyWorkflow']);
|
|
83
|
+
const modelId = string(model.id, 512);
|
|
84
|
+
if (ids.has(modelId)) fail('模型 ID 重复');
|
|
85
|
+
ids.add(modelId);
|
|
86
|
+
const old = entries.find(entry => entry.id === modelId), oldView = existing.find(entry => entry.id === modelId);
|
|
87
|
+
const updated = { ...old, id: modelId, capabilities: capabilities(model.capabilities) };
|
|
88
|
+
if (model.comfyWorkflow !== undefined) updated.comfyWorkflow = checkedComfyWorkflow(model.comfyWorkflow);
|
|
89
|
+
if (!oldView || JSON.stringify(oldView.capabilities) !== JSON.stringify(updated.capabilities)) updated.source = 'manual';
|
|
90
|
+
if (model.imageProtocol !== undefined) {
|
|
91
|
+
const protocol = model.imageProtocol === old?.imageProtocol ? model.imageProtocol : normalizeImageProtocol(model.imageProtocol, registry);
|
|
92
|
+
if (protocol === 'auto') delete updated.imageProtocol; else updated.imageProtocol = protocol;
|
|
93
|
+
}
|
|
94
|
+
if (model.musicProtocol !== undefined) {
|
|
95
|
+
if (model.musicProtocol === '') delete updated.musicProtocol;
|
|
96
|
+
else updated.musicProtocol = model.musicProtocol === old?.musicProtocol ? model.musicProtocol : normalizeMusicProtocol(model.musicProtocol, registry);
|
|
97
|
+
}
|
|
98
|
+
if (old) entries[entries.indexOf(old)] = updated; else entries.push(updated);
|
|
99
|
+
}
|
|
100
|
+
next.models = entries; // Omitted existing records survive. The Web has no hard-delete operation.
|
|
101
|
+
if (previous) {
|
|
102
|
+
const previousConnection = { ...previous, auth: previous.auth === 'none' ? 'none' : 'bearer',
|
|
103
|
+
mediaBaseUrl: previous.mediaBaseUrl || '', mediaProtocol: selectMediaProtocol(previous).mediaProtocol };
|
|
104
|
+
resetHealthOnCriticalChange(previousConnection, next, next);
|
|
105
|
+
for (const model of existing) {
|
|
106
|
+
const updated = next.models.find(entry => entry.id === model.id);
|
|
107
|
+
const connectionChanged = ['apiKey', 'apiKeyEnv', 'baseUrl', 'mediaBaseUrl', 'auth', 'mediaProtocol'].some(key => previousConnection[key] !== next[key]);
|
|
108
|
+
for (const cap of model.capabilities) if (connectionChanged || !updated.capabilities?.includes(cap)
|
|
109
|
+
|| cap === 'image-gen' && JSON.stringify(previous.models?.find(entry => entry?.id === model.id)?.comfyWorkflow) !== JSON.stringify(updated.comfyWorkflow)
|
|
110
|
+
|| cap === 'image-gen' && providerForModelBinding(previous, model.id, cap).mediaProtocol !== providerForModelBinding(next, model.id, cap).mediaProtocol
|
|
111
|
+
|| cap === 'music-gen' && previous.models?.find(entry => entry.id === model.id)?.musicProtocol !== updated.musicProtocol) {
|
|
112
|
+
clearModelCapabilityHealth(next, model.id, cap);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catalog.providers[catalog.providers.indexOf(previous)] = next;
|
|
116
|
+
} else catalog.providers.push(next);
|
|
117
|
+
if (input.assignments !== undefined) applyFunctionPolicies(catalog, input.assignments);
|
|
118
|
+
return catalog;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applyFunctionPolicies(catalog, assignments) {
|
|
122
|
+
object(assignments, CAPS);
|
|
123
|
+
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
124
|
+
catalog.assignments ||= {};
|
|
125
|
+
for (const [cap, assignment] of Object.entries(assignments)) {
|
|
126
|
+
object(assignment, ['mode', 'models', 'hostVision']);
|
|
127
|
+
let policy;
|
|
128
|
+
try { policy = functionPolicy(assignment, cap); } catch (_) { fail('功能策略无效'); }
|
|
129
|
+
if (!Object.hasOwn(assignment, 'mode') || new Set(policy.models).size !== policy.models.length || policy.models.length > 256) fail('功能策略或模型顺序无效');
|
|
130
|
+
// Existing invalid refs may be retained for diagnosis, but never newly authorized.
|
|
131
|
+
const oldRefs = savedAssignmentRefs(pool, cap, functionPolicy(catalog.assignments[cap], cap).models);
|
|
132
|
+
for (const ref of policy.models) if (!resolvePoolModel(pool, ref, cap) && !oldRefs.includes(ref)) fail('用途引用了不存在或未标注该能力的模型');
|
|
133
|
+
catalog.assignments[cap] = { mode: policy.mode, models: policy.models, ...(cap === 'vision' ? { hostVision: policy.hostVision } : {}) };
|
|
134
|
+
}
|
|
135
|
+
return catalog;
|
|
136
|
+
}
|
|
@@ -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,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
recordCoreAttemptResult,
|
|
20
21
|
recordCoreCancelResult,
|
|
21
22
|
recordCoreDownloadCompleted,
|
|
23
|
+
recordCoreNetworkRoute,
|
|
22
24
|
recordCorePollResult,
|
|
23
25
|
recordCoreWatchError,
|
|
24
26
|
recoverCoreTask,
|
|
@@ -34,8 +36,11 @@ import { ProviderContractError, providerErrorRecord, submitWithAcceptanceBoundar
|
|
|
34
36
|
import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.js';
|
|
35
37
|
import { createDeliveryCache, newDeliveryPayloadPath, readDeliveryCache,
|
|
36
38
|
readDeliveryPayload, recordDeliveryPayload } from './core-delivery-cache.js';
|
|
37
|
-
import { readFlowRun, flowFail } from './flow-store.js';
|
|
39
|
+
import { readFlowRun, flowFail, flowStep } from './flow-store.js';
|
|
38
40
|
import { normalizeGenerationInput } from './generation-input.js';
|
|
41
|
+
import { assertPluginDependencyMatches } from './plugin-contract.js';
|
|
42
|
+
import { checkedNetworkRequirement, sameNetworkRequirement, createNetworkAttemptBudget } from './network-policy.js';
|
|
43
|
+
import { assertProviderMediaSize } from './provider-response.js';
|
|
39
44
|
|
|
40
45
|
export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
41
46
|
|
|
@@ -46,7 +51,7 @@ export const PROVIDER_TASK_RUNNER_VERSION = 0;
|
|
|
46
51
|
*/
|
|
47
52
|
export const DELIVERY_PROFILES = Object.freeze({
|
|
48
53
|
image: Object.freeze({ kind: 'generated-image', mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp']), defaultMediaType: 'image/png' }),
|
|
49
|
-
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4']), defaultMediaType: 'video/mp4' }),
|
|
54
|
+
video: Object.freeze({ kind: 'generated-video', mediaTypes: Object.freeze(['video/mp4', 'video/webm']), defaultMediaType: 'video/mp4' }),
|
|
50
55
|
// TTS 同步完成型:submit 内 completed → 同一次调用直接交付(无 remote 长轮询)。
|
|
51
56
|
tts: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
52
57
|
music: Object.freeze({ kind: 'generated-audio', mediaTypes: Object.freeze(['audio/mpeg', 'audio/wav']), defaultMediaType: 'audio/mpeg' }),
|
|
@@ -55,6 +60,20 @@ export const DELIVERY_PROFILES = Object.freeze({
|
|
|
55
60
|
});
|
|
56
61
|
const runtimeTaskGates = new WeakMap();
|
|
57
62
|
|
|
63
|
+
// The Core operation owns the root and route persistence, including when the
|
|
64
|
+
// network manager is supplied by a caller rather than created by CLI/Web.
|
|
65
|
+
function networkOperationContext(runtime, dataRoot, taskId, context) {
|
|
66
|
+
const task = inspectCoreTask(dataRoot, taskId);
|
|
67
|
+
return { ...context, taskId, modelRef: task.modelRef, providerId: task.providerId,
|
|
68
|
+
...(runtime.reportScheduling ? { onSchedulingState: runtime.reportScheduling } : {}),
|
|
69
|
+
recordNetworkRoute: fact => runtime.checkpoint ? runtime.checkpoint(() => recordCoreNetworkRoute(dataRoot, taskId, fact)) : recordCoreNetworkRoute(dataRoot, taskId, fact) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function invokeNetwork(runtime, adapter, name, request, context) {
|
|
73
|
+
const invoke = () => invokeProviderOperation(adapter, name, request, context);
|
|
74
|
+
return runtime.networkWait ? runtime.networkWait(invoke, name) : invoke();
|
|
75
|
+
}
|
|
76
|
+
|
|
58
77
|
export class ProviderTaskRunnerError extends Error {
|
|
59
78
|
constructor(code, message) {
|
|
60
79
|
super(message);
|
|
@@ -73,12 +92,17 @@ function checkAdapterForTask(task, adapter) {
|
|
|
73
92
|
if (!adapter || adapter.id !== task.providerId) {
|
|
74
93
|
throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Provider Adapter 与 Task 已持久化身份不一致');
|
|
75
94
|
}
|
|
95
|
+
assertPluginDependencyMatches(task.pluginDependency, adapter.pluginDependency);
|
|
96
|
+
const requirement = adapter.networkRequirement === undefined ? undefined : checkedNetworkRequirement(adapter.networkRequirement);
|
|
97
|
+
if (!sameNetworkRequirement(task.networkRequirement, requirement)) {
|
|
98
|
+
throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', 'Provider 网络要求与原 Task 不一致;不会隐式升级、解除或借用其他网络配置');
|
|
99
|
+
}
|
|
76
100
|
return adapter;
|
|
77
101
|
}
|
|
78
102
|
|
|
79
103
|
function normalizedCandidates(capability, candidates) {
|
|
80
104
|
if (!Array.isArray(candidates) || !candidates.length) throw new TypeError('至少需要一个 Provider 候选');
|
|
81
|
-
|
|
105
|
+
const normalized = candidates.map((candidate, index) => {
|
|
82
106
|
const adapter = candidate?.adapter;
|
|
83
107
|
const model = String(candidate?.model || '').trim();
|
|
84
108
|
const parsed = parseModelRef(model);
|
|
@@ -86,12 +110,24 @@ function normalizedCandidates(capability, candidates) {
|
|
|
86
110
|
if (!adapter || !adapter.capabilities?.includes(capability) || !parsed
|
|
87
111
|
|| parsed.providerId !== adapter.id || !parsed.modelId
|
|
88
112
|
|| (candidate.providerBinding !== undefined && !/^sha256:[a-f0-9]{64}$/.test(candidate.providerBinding))
|
|
89
|
-
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))
|
|
113
|
+
|| (selectionReason !== undefined && !['explicit', 'assignment', 'pool'].includes(selectionReason))
|
|
114
|
+
|| (adapter.pluginDependency && !candidate.providerBinding)) {
|
|
90
115
|
throw new TypeError('Provider 候选 ' + (index + 1) + ' 不支持 Task capability 或模型复合身份无效');
|
|
91
116
|
}
|
|
92
117
|
if (candidate.prepareInput !== undefined && typeof candidate.prepareInput !== 'function') throw new TypeError('候选 prepareInput 必须是函数');
|
|
93
|
-
|
|
118
|
+
if (candidate.supportsImageEdit !== undefined && typeof candidate.supportsImageEdit !== 'boolean') throw new TypeError('候选 supportsImageEdit 必须是布尔值');
|
|
119
|
+
const networkRequirement = adapter.networkRequirement === undefined ? undefined : checkedNetworkRequirement(adapter.networkRequirement);
|
|
120
|
+
if (candidate.networkRequirement !== undefined && !sameNetworkRequirement(checkedNetworkRequirement(candidate.networkRequirement), networkRequirement)) {
|
|
121
|
+
throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', '候选与 Adapter 的网络要求不一致');
|
|
122
|
+
}
|
|
123
|
+
return { adapter, model, providerModel: parsed.modelId, providerBinding: candidate.providerBinding, pluginDependency: adapter.pluginDependency, selectionReason, prepareInput: candidate.prepareInput,
|
|
124
|
+
...(networkRequirement ? { networkRequirement } : {}),
|
|
125
|
+
...(candidate.supportsImageEdit !== undefined ? { supportsImageEdit: candidate.supportsImageEdit } : {}) };
|
|
94
126
|
});
|
|
127
|
+
if (normalized.some(candidate => !sameNetworkRequirement(candidate.networkRequirement, normalized[0].networkRequirement))) {
|
|
128
|
+
throw new ProviderTaskRunnerError('IRIS_NETWORK_REQUIREMENT_MISMATCH', '同一 Task 的所有模型候选必须属于同一网络要求');
|
|
129
|
+
}
|
|
130
|
+
return normalized;
|
|
95
131
|
}
|
|
96
132
|
|
|
97
133
|
function stagingFile(dataRoot) {
|
|
@@ -115,7 +151,20 @@ async function generatedImageMediaType(bytes) {
|
|
|
115
151
|
return mediaType;
|
|
116
152
|
}
|
|
117
153
|
|
|
118
|
-
|
|
154
|
+
/** Confirm the saved container, independently of an adapter's declared MIME; never buffer long audio. */
|
|
155
|
+
function generatedAudioMediaType(file) {
|
|
156
|
+
const header = Buffer.alloc(12), fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
157
|
+
let size;
|
|
158
|
+
try { size = fs.readSync(fd, header, 0, header.length, 0); }
|
|
159
|
+
finally { fs.closeSync(fd); }
|
|
160
|
+
if (size >= 12 && header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WAVE') return 'audio/wav';
|
|
161
|
+
if (size >= 10 && (header.toString('ascii', 0, 3) === 'ID3' || header[0] === 0xff && (header[1] & 0xe0) === 0xe0)) return 'audio/mpeg';
|
|
162
|
+
throw new ProviderContractError('供应商音频不是可识别的 WAV 或 MP3 容器', {
|
|
163
|
+
stage: 'download', category: 'protocol', acceptance: 'accepted'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function deliverArtifacts(runtime, dataRoot, taskId, adapter, remoteArtifacts, signal) {
|
|
119
168
|
beginCoreDelivery(dataRoot, taskId);
|
|
120
169
|
const artifactIds = [];
|
|
121
170
|
const stagedFiles = [];
|
|
@@ -141,22 +190,25 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
141
190
|
}
|
|
142
191
|
const target = stagingFile(dataRoot);
|
|
143
192
|
stagedFiles.push({ target, mediaType });
|
|
144
|
-
const downloaded = await
|
|
193
|
+
const downloaded = await invokeNetwork(runtime, adapter, 'download', {
|
|
145
194
|
capability: task.capability,
|
|
146
195
|
artifact: remote,
|
|
147
196
|
targetPath: target,
|
|
148
197
|
signal
|
|
149
|
-
}, { signal,
|
|
198
|
+
}, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
|
|
150
199
|
const stat = fs.lstatSync(target);
|
|
151
200
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== downloaded.bytes) {
|
|
152
201
|
throw Object.assign(new Error('Provider 物化结果与文件证据不一致'), {
|
|
153
202
|
stage: 'download', category: 'local_io', acceptance: 'accepted'
|
|
154
203
|
});
|
|
155
204
|
}
|
|
205
|
+
assertProviderMediaSize(stat.size, task.capability);
|
|
156
206
|
const staged = stagedFiles.at(-1);
|
|
157
207
|
if (task.capability === 'image') {
|
|
158
208
|
staged.bytes = fs.readFileSync(target);
|
|
159
209
|
staged.mediaType = await generatedImageMediaType(staged.bytes);
|
|
210
|
+
} else if (['tts', 'music'].includes(task.capability)) {
|
|
211
|
+
staged.mediaType = generatedAudioMediaType(target);
|
|
160
212
|
}
|
|
161
213
|
}
|
|
162
214
|
recordCoreDownloadCompleted(dataRoot, taskId);
|
|
@@ -182,30 +234,34 @@ async function deliverArtifacts(dataRoot, taskId, adapter, remoteArtifacts, sign
|
|
|
182
234
|
}
|
|
183
235
|
}
|
|
184
236
|
|
|
185
|
-
async function observeWithinRoot(dataRoot, taskId, adapter, signal) {
|
|
237
|
+
async function observeWithinRoot(runtime, dataRoot, taskId, adapter, signal) {
|
|
186
238
|
const before = inspectCoreTask(dataRoot, taskId);
|
|
187
239
|
checkAdapterForTask(before, adapter);
|
|
188
240
|
activateCoreWatch(dataRoot, taskId);
|
|
189
241
|
let result;
|
|
190
242
|
try {
|
|
191
|
-
result = await
|
|
243
|
+
result = await invokeNetwork(runtime, adapter, 'poll', {
|
|
192
244
|
capability: before.capability,
|
|
193
245
|
remoteTaskId: before.remoteTaskId,
|
|
194
246
|
signal
|
|
195
|
-
}, { signal,
|
|
247
|
+
}, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: before.activeAttemptId }));
|
|
196
248
|
} catch (error) {
|
|
197
249
|
return recordCoreWatchError(dataRoot, taskId, error);
|
|
198
250
|
}
|
|
199
|
-
|
|
200
|
-
if (
|
|
251
|
+
let cachedFlowResult = false, cacheError;
|
|
252
|
+
if (before.flowBinding && result.kind === 'succeeded') {
|
|
253
|
+
try { createDeliveryCache(dataRoot, before, result.artifacts); cachedFlowResult = true; }
|
|
254
|
+
catch (error) { cacheError = error; }
|
|
255
|
+
}
|
|
201
256
|
const recorded = recordCorePollResult(dataRoot, taskId, result, { deliveryCache: cachedFlowResult });
|
|
202
257
|
if (result.kind !== 'succeeded') return recorded;
|
|
203
|
-
if (
|
|
204
|
-
return
|
|
258
|
+
if (cacheError) return failCoreDelivery(dataRoot, taskId, cacheError);
|
|
259
|
+
if (cachedFlowResult) return deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal);
|
|
260
|
+
return deliverArtifacts(runtime, dataRoot, taskId, adapter, result.artifacts, signal);
|
|
205
261
|
}
|
|
206
262
|
|
|
207
263
|
/** 同步结果按已提交缓存补交付;已下载项不再访问 Provider,稳定 Artifact ID 防重复。 */
|
|
208
|
-
async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
|
|
264
|
+
async function deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal) {
|
|
209
265
|
beginCoreDelivery(dataRoot, taskId);
|
|
210
266
|
try {
|
|
211
267
|
const task = inspectCoreTask(dataRoot, taskId);
|
|
@@ -222,16 +278,17 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
|
|
|
222
278
|
}
|
|
223
279
|
checkAdapterForTask(task, adapter);
|
|
224
280
|
const target = newDeliveryPayloadPath(dataRoot, cache);
|
|
225
|
-
const downloaded = await
|
|
281
|
+
const downloaded = await invokeNetwork(runtime, adapter, 'download', {
|
|
226
282
|
capability: task.capability, artifact: item.source, targetPath: target, signal
|
|
227
|
-
}, { signal,
|
|
283
|
+
}, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
|
|
228
284
|
const stat = fs.lstatSync(target);
|
|
229
285
|
if (!stat.isFile() || stat.isSymbolicLink() || !stat.size || stat.size !== downloaded.bytes) {
|
|
230
286
|
throw new Error('Provider 物化结果与文件证据不一致');
|
|
231
287
|
}
|
|
288
|
+
assertProviderMediaSize(stat.size, task.capability);
|
|
232
289
|
const mediaType = task.capability === 'image'
|
|
233
290
|
? await generatedImageMediaType(fs.readFileSync(target))
|
|
234
|
-
: declaredMediaType;
|
|
291
|
+
: ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(target) : declaredMediaType;
|
|
235
292
|
if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
|
|
236
293
|
recordDeliveryPayload(dataRoot, cache, index, target, mediaType);
|
|
237
294
|
}
|
|
@@ -244,7 +301,7 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
|
|
|
244
301
|
const sourcePath = readDeliveryPayload(dataRoot, cache, index);
|
|
245
302
|
const mediaType = task.capability === 'image'
|
|
246
303
|
? await generatedImageMediaType(fs.readFileSync(sourcePath))
|
|
247
|
-
: (item.payload.mediaType || profile.defaultMediaType);
|
|
304
|
+
: ['tts', 'music'].includes(task.capability) ? generatedAudioMediaType(sourcePath) : (item.payload.mediaType || profile.defaultMediaType);
|
|
248
305
|
if (!profile.mediaTypes.includes(mediaType)) throw new Error('交付媒体类型不在 Profile 白名单');
|
|
249
306
|
const artifact = adoptCoreArtifactFile(dataRoot, {
|
|
250
307
|
artifactId: item.artifactId, sourcePath, createdAt: cache.createdAt,
|
|
@@ -266,10 +323,11 @@ async function deliverCachedArtifacts(dataRoot, taskId, adapter, signal) {
|
|
|
266
323
|
*/
|
|
267
324
|
export function createProviderTaskRunner(runtime) {
|
|
268
325
|
checkRuntime(runtime);
|
|
269
|
-
|
|
326
|
+
const gate = runtime.taskGate || runtime;
|
|
327
|
+
let activeTasks = runtimeTaskGates.get(gate);
|
|
270
328
|
if (!activeTasks) {
|
|
271
329
|
activeTasks = new Set();
|
|
272
|
-
runtimeTaskGates.set(
|
|
330
|
+
runtimeTaskGates.set(gate, activeTasks);
|
|
273
331
|
}
|
|
274
332
|
|
|
275
333
|
async function runExclusive(taskId, operation, callback) {
|
|
@@ -293,28 +351,43 @@ export function createProviderTaskRunner(runtime) {
|
|
|
293
351
|
if (signal.aborted) throw new ProviderTaskRunnerError('IRIS_PROVIDER_ABORTED', '提交已取消');
|
|
294
352
|
const capability = String(input.capability || '').trim();
|
|
295
353
|
let candidates = normalizedCandidates(capability, input.candidates);
|
|
354
|
+
const networkRequirement = candidates[0].networkRequirement;
|
|
355
|
+
const networkBudget = createNetworkAttemptBudget();
|
|
296
356
|
let providerInput = input.providerInput ?? {};
|
|
357
|
+
if (providerInput.workflow_parameters !== undefined && candidates.some(candidate => candidate.adapter.protocol !== 'comfyui')) {
|
|
358
|
+
throw new ProviderTaskRunnerError('IRIS_COMFYUI_PARAMETERS_INVALID', '工作流参数仅用于明确的 ComfyUI 模板');
|
|
359
|
+
}
|
|
297
360
|
let flow;
|
|
298
361
|
if (input.flowRunId !== undefined) {
|
|
299
362
|
flow = readFlowRun(dataRoot, input.flowRunId);
|
|
300
|
-
const
|
|
301
|
-
|
|
363
|
+
const audio = isAudioRecipe(flow.recipe), video = flow.recipe === 'video-generate/v0';
|
|
364
|
+
const expected = audio ? Object.fromEntries(Object.entries(normalizeAudioFlowInput(flow.recipe, flow.input)).filter(([key]) => key !== 'model_ref'))
|
|
365
|
+
: normalizeGenerationInput(video ? 'video' : 'image', flow.input).providerInput;
|
|
366
|
+
const expectedCapability = video ? 'video' : audio ? flow.recipe === 'speech/v0' ? 'tts' : 'transcribe' : 'image';
|
|
367
|
+
if (capability !== expectedCapability || !flowStep(flow).claimed || input.sourceArtifactId !== flow.source?.artifactId
|
|
302
368
|
|| Object.keys(providerInput).length !== Object.keys(expected).length
|
|
303
|
-
|| Object.keys(expected).some(key =>
|
|
369
|
+
|| Object.keys(expected).some(key => key === 'workflow_parameters'
|
|
370
|
+
? JSON.stringify(providerInput[key]) !== JSON.stringify(expected[key]) : providerInput[key] !== expected[key])
|
|
304
371
|
|| candidates.length !== 1 || candidates[0].model !== flow.input.model_ref
|
|
305
|
-
|| candidates[0].providerBinding !== flow.providerBinding) {
|
|
372
|
+
|| candidates[0].providerBinding !== flow.providerBinding || !sameNetworkRequirement(networkRequirement, flow.networkRequirement)) {
|
|
306
373
|
flowFail('IRIS_FLOW_INPUT_CONFLICT', '提交与流程已保存的输入或模型绑定冲突');
|
|
307
374
|
}
|
|
308
375
|
}
|
|
309
|
-
if (input.sourceArtifactId !== undefined) {
|
|
310
|
-
if (capability !== 'image') throw new TypeError('sourceArtifactId 仅用于图片编辑');
|
|
376
|
+
if (input.sourceArtifactId !== undefined && capability === 'image') {
|
|
311
377
|
candidates = imageEditCandidates(candidates);
|
|
312
378
|
const image = await readImageEditArtifact(dataRoot, input.sourceArtifactId, signal);
|
|
313
379
|
providerInput = { ...providerInput, image };
|
|
314
380
|
}
|
|
381
|
+
if (input.sourceArtifactId !== undefined && !['image', 'transcribe'].includes(capability)) throw new TypeError('来源作品仅用于图片编辑或转写');
|
|
382
|
+
if (runtime.scheduler) await runtime.scheduler.beforeSubmit(runtime, dataRoot, { modelRef: candidates[0].model },
|
|
383
|
+
() => candidates[0].adapter.schedulingReadyAt?.(candidates[0].providerModel));
|
|
315
384
|
const task = flow ? claimFlowTask(dataRoot, flow.id)
|
|
316
|
-
: createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId
|
|
385
|
+
: createCoreTask(dataRoot, { capability, retriedFrom: input.retriedFrom, sourceArtifactId: input.sourceArtifactId,
|
|
386
|
+
...(networkRequirement ? { networkRequirement } : {}) });
|
|
317
387
|
if (flow && task.attempts.length) flowFail('IRIS_FLOW_TASK_ALREADY_STARTED', '该流程 Task 已有提交意图,禁止重新 submit');
|
|
388
|
+
if (activeTasks.has(task.id)) throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_BUSY', '该 Task 已有生命周期操作正在执行');
|
|
389
|
+
activeTasks.add(task.id);
|
|
390
|
+
try {
|
|
318
391
|
const completedResults = new Map();
|
|
319
392
|
const wrapped = candidates.map(({ adapter, model, providerModel, prepareInput }) => ({
|
|
320
393
|
id: adapter.id,
|
|
@@ -322,17 +395,22 @@ export function createProviderTaskRunner(runtime) {
|
|
|
322
395
|
adapter,
|
|
323
396
|
submit: async (providerInput, context) => {
|
|
324
397
|
if (prepareInput) {
|
|
325
|
-
try {
|
|
326
|
-
|
|
398
|
+
try {
|
|
399
|
+
const prepareContext = networkOperationContext(runtime, dataRoot, task.id, { providerInput, signal, attempt: context.attempt, attemptId: context.attempt.id, networkBudget });
|
|
400
|
+
const prepare = () => prepareInput(prepareContext);
|
|
401
|
+
providerInput = await (runtime.networkWait ? runtime.networkWait(prepare, 'prepareInput') : prepare());
|
|
402
|
+
}
|
|
403
|
+
catch (error) { throw new ProviderContractError((capability === 'video' ? '视频' : '音频') + '输入准备失败:' + (error.safeMessage || error.message), {
|
|
327
404
|
...providerErrorRecord(error), stage: error.stage || 'prepare', category: error.category || 'local_io', acceptance: 'not_accepted'
|
|
328
405
|
}); }
|
|
329
406
|
}
|
|
330
407
|
if (signal.aborted) throw new ProviderContractError('提交已取消', { stage: 'prepare', category: 'aborted', acceptance: 'not_accepted' });
|
|
331
|
-
const result = await
|
|
408
|
+
const result = await invokeNetwork(
|
|
409
|
+
runtime,
|
|
332
410
|
adapter,
|
|
333
411
|
'submit',
|
|
334
412
|
{ capability, model: providerModel, input: providerInput, signal },
|
|
335
|
-
{ ...context, signal,
|
|
413
|
+
networkOperationContext(runtime, dataRoot, task.id, { ...context, signal, attemptId: context.attempt.id, networkBudget })
|
|
336
414
|
);
|
|
337
415
|
if (result.kind === 'completed') completedResults.set(context.attempt.id, result.artifacts);
|
|
338
416
|
return result;
|
|
@@ -342,9 +420,13 @@ export function createProviderTaskRunner(runtime) {
|
|
|
342
420
|
signal,
|
|
343
421
|
beforeAttempt: async (attempt) => {
|
|
344
422
|
const candidate = candidates[attempt.ordinal - 1];
|
|
423
|
+
if (attempt.ordinal > 1 && runtime.scheduler) await runtime.scheduler.beforeSubmit(runtime, dataRoot, { modelRef: candidate.model },
|
|
424
|
+
() => candidate.adapter.schedulingReadyAt?.(candidate.providerModel));
|
|
345
425
|
return beginCoreAttempt(dataRoot, task.id, {
|
|
346
426
|
...attempt,
|
|
347
427
|
...(candidate.providerBinding !== undefined ? { providerBinding: candidate.providerBinding } : {}),
|
|
428
|
+
...(candidate.networkRequirement ? { networkRequirement: candidate.networkRequirement } : {}),
|
|
429
|
+
...(candidate.pluginDependency ? { pluginDependency: candidate.pluginDependency } : {}),
|
|
348
430
|
...(candidate.selectionReason !== undefined ? { selectionReason: candidate.selectionReason } : {})
|
|
349
431
|
});
|
|
350
432
|
},
|
|
@@ -372,7 +454,7 @@ export function createProviderTaskRunner(runtime) {
|
|
|
372
454
|
} else if (submitted.result.kind === 'completed') {
|
|
373
455
|
return Object.freeze({
|
|
374
456
|
taskId: task.id,
|
|
375
|
-
task: await deliverCachedArtifacts(dataRoot, task.id, submitted.candidate.adapter, signal),
|
|
457
|
+
task: await deliverCachedArtifacts(runtime, dataRoot, task.id, submitted.candidate.adapter, signal),
|
|
376
458
|
attempts: submitted.attempts
|
|
377
459
|
});
|
|
378
460
|
}
|
|
@@ -381,6 +463,7 @@ export function createProviderTaskRunner(runtime) {
|
|
|
381
463
|
task: inspectCoreTask(dataRoot, task.id),
|
|
382
464
|
attempts: submitted.attempts
|
|
383
465
|
});
|
|
466
|
+
} finally { activeTasks.delete(task.id); }
|
|
384
467
|
});
|
|
385
468
|
}
|
|
386
469
|
|
|
@@ -395,7 +478,7 @@ export function createProviderTaskRunner(runtime) {
|
|
|
395
478
|
checkAdapterForTask(current, adapter);
|
|
396
479
|
if (current.watchState === 'active') recoverCoreTask(dataRoot, taskId);
|
|
397
480
|
}
|
|
398
|
-
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
481
|
+
return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
|
|
399
482
|
});
|
|
400
483
|
}
|
|
401
484
|
|
|
@@ -404,14 +487,14 @@ export function createProviderTaskRunner(runtime) {
|
|
|
404
487
|
const task = inspectCoreTask(dataRoot, taskId);
|
|
405
488
|
if (adapter || !task.deliveryCache) checkAdapterForTask(task, adapter);
|
|
406
489
|
prepareCoreRedelivery(dataRoot, taskId);
|
|
407
|
-
if (task.deliveryCache) return deliverCachedArtifacts(dataRoot, taskId, adapter, signal);
|
|
490
|
+
if (task.deliveryCache) return deliverCachedArtifacts(runtime, dataRoot, taskId, adapter, signal);
|
|
408
491
|
let result;
|
|
409
492
|
try {
|
|
410
|
-
result = await
|
|
493
|
+
result = await invokeNetwork(runtime, adapter, 'poll', {
|
|
411
494
|
capability: task.capability,
|
|
412
495
|
remoteTaskId: task.remoteTaskId,
|
|
413
496
|
signal
|
|
414
|
-
}, { signal,
|
|
497
|
+
}, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId, redelivery: true }));
|
|
415
498
|
} catch (error) {
|
|
416
499
|
return failCoreDelivery(dataRoot, taskId, error);
|
|
417
500
|
}
|
|
@@ -421,7 +504,7 @@ export function createProviderTaskRunner(runtime) {
|
|
|
421
504
|
{ stage: 'download', category: 'protocol', acceptance: 'accepted' }
|
|
422
505
|
));
|
|
423
506
|
}
|
|
424
|
-
return deliverArtifacts(dataRoot, taskId, adapter, result.artifacts, signal);
|
|
507
|
+
return deliverArtifacts(runtime, dataRoot, taskId, adapter, result.artifacts, signal);
|
|
425
508
|
});
|
|
426
509
|
}
|
|
427
510
|
|
|
@@ -433,23 +516,26 @@ export function createProviderTaskRunner(runtime) {
|
|
|
433
516
|
if (!hasProviderOperation(adapter, 'cancel')) {
|
|
434
517
|
return recordCoreCancelResult(dataRoot, taskId, { kind: 'not_supported' });
|
|
435
518
|
}
|
|
436
|
-
const result = await
|
|
519
|
+
const result = await invokeNetwork(runtime, adapter, 'cancel', {
|
|
437
520
|
capability: task.capability,
|
|
438
521
|
remoteTaskId: task.remoteTaskId,
|
|
439
522
|
signal
|
|
440
|
-
}, { signal,
|
|
523
|
+
}, networkOperationContext(runtime, dataRoot, taskId, { signal, attemptId: task.activeAttemptId }));
|
|
441
524
|
return recordCoreCancelResult(dataRoot, taskId, result);
|
|
442
525
|
});
|
|
443
526
|
}
|
|
444
527
|
|
|
445
528
|
function recover(taskId, adapter) {
|
|
446
529
|
return runExclusive(taskId, 'recover', async ({ dataRoot, signal }) => {
|
|
530
|
+
const before = inspectCoreTask(dataRoot, taskId);
|
|
531
|
+
if (before.acceptance === 'accepted' && before.remoteTaskId && before.outcome === 'none'
|
|
532
|
+
&& ['accepted', 'running'].includes(before.phase)) checkAdapterForTask(before, adapter);
|
|
447
533
|
const recovered = recoverCoreTask(dataRoot, taskId);
|
|
448
534
|
if (recovered.acceptance === 'accepted' && recovered.remoteTaskId
|
|
449
535
|
&& recovered.outcome === 'none'
|
|
450
536
|
&& ['accepted', 'running'].includes(recovered.phase)) {
|
|
451
537
|
checkAdapterForTask(recovered, adapter);
|
|
452
|
-
return observeWithinRoot(dataRoot, taskId, adapter, signal);
|
|
538
|
+
return observeWithinRoot(runtime, dataRoot, taskId, adapter, signal);
|
|
453
539
|
}
|
|
454
540
|
return recovered;
|
|
455
541
|
});
|
|
@@ -462,6 +548,9 @@ export function createProviderTaskRunner(runtime) {
|
|
|
462
548
|
observe,
|
|
463
549
|
redeliver,
|
|
464
550
|
cancel,
|
|
465
|
-
recover
|
|
551
|
+
recover,
|
|
552
|
+
assertIdle(taskId) {
|
|
553
|
+
if (activeTasks.has(taskId)) throw new ProviderTaskRunnerError('IRIS_PROVIDER_TASK_BUSY', '该 Task 已有生命周期操作正在执行');
|
|
554
|
+
}
|
|
466
555
|
});
|
|
467
556
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Bind all hops of an existing media transport to an instance-owned request function.
|
|
2
|
+
import * as transport from './adapters.js';
|
|
3
|
+
|
|
4
|
+
const REQUEST_METHODS = Object.freeze([
|
|
5
|
+
'generateImageMultimodal', 'startImageGeneration', 'submitImage', 'submitVideo', 'uploadTempFile',
|
|
6
|
+
'pollTask', 'synthesizeTts', 'openAiGenerateImage', 'openAiChatGenerateImage', 'openAiResponsesGenerateImage',
|
|
7
|
+
'listModels', 'visionStream', 'submitTranscription', 'pollTranscriptionTask', 'generateMusic'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function createProviderTransport(fetchPort) {
|
|
11
|
+
if (typeof fetchPort !== 'function') throw new TypeError('Provider transport 需要明确的 fetchPort');
|
|
12
|
+
const bound = { ...transport };
|
|
13
|
+
for (const name of REQUEST_METHODS) bound[name] = input => transport[name]({ ...input, fetchPort });
|
|
14
|
+
bound.downloadTo = (url, filePath, options) => transport.downloadTo(url, filePath, { ...options, fetchPort });
|
|
15
|
+
return Object.freeze(bound);
|
|
16
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -22,6 +22,7 @@ import { imageDimensions } from './pixels.js';
|
|
|
22
22
|
|
|
23
23
|
const PNG_DATA_PREFIX = 'data:image/png;base64,';
|
|
24
24
|
const MAX_SCREENSHOT_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
const pendingRenders = new Map();
|
|
25
26
|
|
|
26
27
|
async function screenshotPng(result) {
|
|
27
28
|
const dataUrl = result?.dataUrl;
|
|
@@ -127,6 +128,8 @@ export class BrowserHtmlRenderer {
|
|
|
127
128
|
const htmlPath = path.join(dir, 'index.html');
|
|
128
129
|
writePrivateFile(htmlPath, content);
|
|
129
130
|
const url = `${address.baseUrl}/iris/render/${path.basename(dir)}/index.html`;
|
|
131
|
+
const delivery = { served: false };
|
|
132
|
+
pendingRenders.set(path.basename(dir), delivery);
|
|
130
133
|
try {
|
|
131
134
|
// open 只接受可选的会话名称;取消发生在打开期间时,取得会话后仍须关闭。
|
|
132
135
|
const session = await b.open();
|
|
@@ -136,6 +139,9 @@ export class BrowserHtmlRenderer {
|
|
|
136
139
|
throwIfAborted(signal);
|
|
137
140
|
await waitForRender(this.waitMs, signal); // 等页面加载(无加载完成事件可用的折中)
|
|
138
141
|
throwIfAborted(signal);
|
|
142
|
+
if (!delivery.served) throw Object.assign(new RenderError('未确认 Iris 已返回本次 HTML 页面;请检查渲染路由和 DSH_WEB_BASE。'), {
|
|
143
|
+
code: 'IRIS_RENDER_ROUTE_UNCONFIRMED'
|
|
144
|
+
});
|
|
139
145
|
// 不要求浏览器写入 Iris 私有目录,兼容 downloadDir 内的保存路径限制。
|
|
140
146
|
const result = await b.screenshot(session, { fullPage: fullPage !== false, format: 'png' }, signal);
|
|
141
147
|
throwIfAborted(signal);
|
|
@@ -146,6 +152,7 @@ export class BrowserHtmlRenderer {
|
|
|
146
152
|
try { await b.close(session); } catch (_) { /* 幂等 */ }
|
|
147
153
|
}
|
|
148
154
|
} finally {
|
|
155
|
+
pendingRenders.delete(path.basename(dir));
|
|
149
156
|
fs.rmSync(dir, { recursive: true, force: true }); // 用完即清
|
|
150
157
|
}
|
|
151
158
|
}
|
|
@@ -196,7 +203,10 @@ export function serveRender(req, res) {
|
|
|
196
203
|
'Referrer-Policy': 'no-referrer',
|
|
197
204
|
'X-Content-Type-Options': 'nosniff'
|
|
198
205
|
});
|
|
199
|
-
|
|
206
|
+
const body = method === 'HEAD' ? undefined : fs.readFileSync(abs);
|
|
207
|
+
const delivery = file === 'index.html' && method === 'GET' ? pendingRenders.get(subdir) : undefined;
|
|
208
|
+
if (delivery) res.once('finish', () => { delivery.served = true; });
|
|
209
|
+
res.end(body);
|
|
200
210
|
} catch (err) {
|
|
201
211
|
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
202
212
|
res.end('render error');
|