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