@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +143 -1
- package/README.en.md +109 -311
- package/README.md +106 -309
- package/bin/dsh-iris.js +452 -92
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/ARTIFACT_MANIFEST.md +4 -0
- package/docs/AUDIO_FLOWS.md +87 -0
- package/docs/BETA_SCOPE.md +42 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +101 -0
- package/docs/CORE_QUICKSTART.md +110 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +101 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +21 -1
- package/docs/DSH_CORE_MIGRATION.md +6 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +30 -5
- package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +42 -15
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +207 -0
- package/docs/MCP.md +212 -0
- package/docs/MEDIA_SCHEDULING.md +72 -0
- package/docs/MUSIC.md +2 -0
- package/docs/NETWORK_RESOURCES.md +106 -0
- package/docs/NETWORK_ROUTING.md +103 -0
- package/docs/NETWORK_WORKBENCH.md +65 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +193 -0
- package/docs/PLUGIN_DEVELOPMENT.md +39 -0
- package/docs/PLUGIN_STARTUP.md +113 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
- package/docs/PROVIDER_CREDENTIALS.md +67 -0
- package/docs/PROVIDER_EXTENSIONS.md +82 -0
- package/docs/PROVIDER_HEALTH.md +5 -1
- package/docs/PROVIDER_NETWORK.md +45 -0
- package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
- package/docs/PROVIDER_SETTINGS.md +91 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
- package/docs/ROADMAP.md +18 -8
- package/docs/SECURITY.md +1 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
- package/docs/VIDEO_FLOWS.md +89 -0
- package/docs/VISION_CLI.md +1 -1
- package/docs/VISION_MODEL.md +2 -0
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_ARTIFACTS.md +6 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/releases/0.3.0-beta.1.md +34 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +56 -4
- package/lib/adapters.js +75 -49
- package/lib/api.js +23 -13
- package/lib/application-runtime.js +73 -17
- package/lib/application-writer.js +193 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.d.ts +3 -3
- package/lib/client.js +402 -72
- package/lib/comfyui-adapter.js +95 -0
- package/lib/comfyui-client.js +60 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/command-service.js +18 -5
- package/lib/config.js +49 -10
- package/lib/configured-image-runtime.js +69 -0
- package/lib/connection-skill.js +98 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +84 -28
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +85 -0
- package/lib/core-capability-catalog.js +192 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-delivery-cache.js +8 -3
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +174 -0
- package/lib/core-tasks.js +113 -21
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +41 -64
- package/lib/doctor.js +2 -2
- package/lib/dsh-core-adapter.js +61 -9
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-provider-config.js +1 -1
- package/lib/dsh-service-bridge.js +67 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +156 -14
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +97 -31
- package/lib/index.js +47 -22
- package/lib/local-api-projection.js +15 -6
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +861 -71
- package/lib/local-creation.js +96 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +39 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +174 -0
- package/lib/mcp-server.js +69 -0
- package/lib/media-scheduler.js +194 -0
- package/lib/model-directory-service.js +37 -10
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +6 -3
- package/lib/network-config-service.js +123 -0
- package/lib/network-config.js +127 -0
- package/lib/network-doctor.js +94 -0
- package/lib/network-management.js +51 -0
- package/lib/network-policy.js +19 -0
- package/lib/network-route-evidence.js +38 -0
- package/lib/network-routing.js +283 -0
- package/lib/operation-runner.js +281 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +307 -0
- package/lib/plugins.js +59 -0
- package/lib/provider-adapter.js +7 -3
- package/lib/provider-adapters.js +92 -22
- package/lib/provider-catalog.js +64 -36
- package/lib/provider-config-format.js +51 -0
- package/lib/provider-config-service.js +143 -31
- package/lib/provider-context.d.ts +90 -0
- package/lib/provider-context.js +42 -0
- package/lib/provider-credentials.js +97 -0
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +18 -6
- package/lib/provider-monitor.js +81 -0
- package/lib/provider-network.js +52 -0
- package/lib/provider-protocol.js +73 -22
- package/lib/provider-registry.js +128 -0
- package/lib/provider-response.js +38 -0
- package/lib/provider-settings.js +136 -0
- package/lib/provider-task-runner.js +132 -43
- package/lib/provider-transport.js +16 -0
- package/lib/render.js +11 -1
- package/lib/scheduling-policy.js +66 -0
- package/lib/skill-installation.js +23 -11
- package/lib/tasks.js +3 -2
- package/lib/video-flow-input.js +16 -0
- package/lib/video-input.js +12 -5
- package/lib/vision-model-routing.js +5 -0
- package/lib/web/app.js +703 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +516 -0
- package/lib/web/creation.js +359 -0
- package/lib/web/flow-view.js +34 -0
- package/lib/web/gallery.js +133 -0
- package/lib/web/index.html +344 -0
- package/lib/web/media-client.js +218 -0
- package/lib/web/network.js +232 -0
- package/lib/web/plugin-panel.js +223 -0
- package/lib/web/plugin-status.js +140 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +513 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +571 -0
- package/lib/web/transport.js +101 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +3 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +24 -6
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/templates/plugin-starter/LICENSE +21 -0
- package/templates/plugin-starter/README.md +74 -0
- package/templates/plugin-starter/assets/peach.png +0 -0
- package/templates/plugin-starter/assets/violet.png +0 -0
- package/templates/plugin-starter/index.mjs +27 -0
- package/templates/plugin-starter/package.json +18 -0
- package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
- package/templates/plugin-starter/scripts/environment.mjs +65 -0
- package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
- package/templates/plugin-starter/scripts/setup.mjs +42 -0
- package/templates/plugin-starter/scripts/smoke.mjs +76 -0
- package/templates/plugin-starter/web/board.css +27 -0
- package/templates/plugin-starter/web/board.mjs +160 -0
- package/templates/plugin-starter/web/png.mjs +23 -0
- package/templates/provider-starter/LICENSE +21 -0
- package/templates/provider-starter/README.md +50 -0
- package/templates/provider-starter/index.d.mts +5 -0
- package/templates/provider-starter/index.mjs +165 -0
- package/templates/provider-starter/package.json +13 -0
- package/templates/provider-starter/scripts/core-cli.mjs +22 -0
- package/templates/provider-starter/scripts/environment.mjs +122 -0
- package/templates/provider-starter/scripts/fixture.mjs +54 -0
- package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
- package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
- package/templates/provider-starter/scripts/setup.mjs +44 -0
- package/templates/provider-starter/scripts/smoke.mjs +145 -0
- package/user_guide.md +5 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { createRecoveryJournal, WorkbenchMediaError } from './recovery.js';
|
|
2
|
+
import { normalizeWorkflowValues } from './comfyui.js';
|
|
3
|
+
|
|
4
|
+
const CAPABILITY = { 'image-import': 'images.create', 'flow-create': 'runs.create', 'generation-create': 'generations.create', 'video-create': 'videos.create', continue: 'runs.continue',
|
|
5
|
+
'audio-import': 'audio.create', 'speech-create': 'speeches.create', 'transcription-create': 'transcriptions.create', selection: 'runs.select', stop: 'operations.stop' };
|
|
6
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'interrupted']);
|
|
7
|
+
const RUN = /^run_[a-f0-9]{24}$/, ARTIFACT = /^artifact_[a-f0-9]{24}$/, OPERATION = /^op_[a-f0-9]{64}$/;
|
|
8
|
+
const fail = (code, message) => { throw new WorkbenchMediaError(code, message); };
|
|
9
|
+
export const CREATIONS = ['image-import', 'flow-create', 'generation-create', 'video-create', 'audio-import', 'speech-create', 'transcription-create'];
|
|
10
|
+
export const IMPORTS = ['image-import', 'audio-import'];
|
|
11
|
+
const AUDIO_TYPES = ['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'];
|
|
12
|
+
function audioInput(value, kind) {
|
|
13
|
+
const required = kind === 'speech-create' ? ['modelRef', 'text'] : ['modelRef', 'sourceArtifactId'];
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || required.some(key => typeof value[key] !== 'string' || !value[key].trim())
|
|
15
|
+
|| Object.keys(value).some(key => ![...required, ...(kind === 'speech-create' ? ['voice'] : [])].includes(key))
|
|
16
|
+
|| value.voice !== undefined && typeof value.voice !== 'string') throw new TypeError('音频流程输入无效');
|
|
17
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, item.trim()]));
|
|
18
|
+
}
|
|
19
|
+
function videoInput(value) {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
21
|
+
|| !['modelRef', 'prompt'].every(key => typeof value[key] === 'string' && value[key].trim())
|
|
22
|
+
|| Object.keys(value).some(key => !['modelRef', 'prompt', 'size', 'duration'].includes(key))
|
|
23
|
+
|| value.size !== undefined && typeof value.size !== 'string'
|
|
24
|
+
|| value.duration !== undefined && (typeof value.duration !== 'number' || !Number.isFinite(value.duration) || value.duration < 1 || value.duration > 60)) {
|
|
25
|
+
throw new TypeError('视频流程需要明确模型、描述与有效参数');
|
|
26
|
+
}
|
|
27
|
+
return { modelRef: value.modelRef.trim(), prompt: value.prompt.trim(),
|
|
28
|
+
...(value.size !== undefined ? { size: value.size.trim() } : {}), ...(value.duration !== undefined ? { duration: value.duration } : {}) };
|
|
29
|
+
}
|
|
30
|
+
function flowInput(value, generate = false) {
|
|
31
|
+
const required = generate ? ['modelRef', 'prompt'] : ['sourceArtifactId', 'modelRef', 'prompt'];
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
33
|
+
|| Object.keys(value).some(key => ![...required, 'size', ...(generate ? ['workflowParameters'] : [])].includes(key))
|
|
34
|
+
|| !required.every(key => typeof value[key] === 'string')
|
|
35
|
+
|| value.size !== undefined && typeof value.size !== 'string') throw new TypeError('流程输入无效');
|
|
36
|
+
return { ...(!generate ? { sourceArtifactId: value.sourceArtifactId } : {}), modelRef: value.modelRef, prompt: value.prompt,
|
|
37
|
+
...(value.size === undefined ? {} : { size: value.size }),
|
|
38
|
+
...(value.workflowParameters === undefined ? {} : { workflowParameters: normalizeWorkflowValues(value.workflowParameters) }) };
|
|
39
|
+
}
|
|
40
|
+
function operation(value, runId) {
|
|
41
|
+
const item = value?.operation;
|
|
42
|
+
if (value?.contractVersion !== 0 || typeof item?.id !== 'string' || !OPERATION.test(item.id)
|
|
43
|
+
|| typeof item.runId !== 'string' || !RUN.test(item.runId) || runId && item.runId !== runId
|
|
44
|
+
|| !['queued', 'running', ...TERMINAL].includes(item.state)) throw new TypeError('不支持的操作响应');
|
|
45
|
+
return item;
|
|
46
|
+
}
|
|
47
|
+
function flow(value, runId) {
|
|
48
|
+
if (value?.contractVersion !== 0 || value.id !== runId || !Number.isSafeInteger(value.revision) || value.revision < 1
|
|
49
|
+
|| value.selection !== null && (typeof value.selection?.artifactId !== 'string' || !ARTIFACT.test(value.selection.artifactId))) {
|
|
50
|
+
throw new TypeError('不支持的流程响应');
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Explicit actions only. Constructing, reconnecting and recovering never send POST. */
|
|
56
|
+
export function createMediaClient({ client, session, origin, getStorage }) {
|
|
57
|
+
if (session?.contractVersion !== 0 || !Array.isArray(session.operations) || !session.operations.every(item => typeof item === 'string')) {
|
|
58
|
+
throw new TypeError('不支持的工作台会话');
|
|
59
|
+
}
|
|
60
|
+
const journal = createRecoveryJournal({ origin, rootId: session.rootId, getStorage });
|
|
61
|
+
const capabilities = new Set(session.operations), lifetime = new AbortController(), stops = new Map();
|
|
62
|
+
let active = null, recovering = null;
|
|
63
|
+
const open = () => lifetime.signal.throwIfAborted();
|
|
64
|
+
const requireCapability = name => { if (!capabilities.has(name)) fail('IRIS_WEB_CAPABILITY_UNAVAILABLE', '当前服务未开启这项操作'); };
|
|
65
|
+
const snapshot = () => ({ ...journal.snapshot(), busy: Boolean(active || stops.size || recovering) });
|
|
66
|
+
function settle(record, response) {
|
|
67
|
+
open();
|
|
68
|
+
if (response?.contractVersion !== 0) throw new TypeError('不支持的媒体响应');
|
|
69
|
+
if (IMPORTS.includes(record.kind)) {
|
|
70
|
+
const id = response.artifact?.artifactId;
|
|
71
|
+
if (typeof id !== 'string' || !ARTIFACT.test(id)) throw new TypeError('不支持的素材响应');
|
|
72
|
+
journal.remove(record.id);
|
|
73
|
+
} else if (CREATIONS.includes(record.kind)) {
|
|
74
|
+
const result = flow(response.flow, response.flow?.id);
|
|
75
|
+
const recipes = { 'flow-create': 'image-edit/v0', 'generation-create': 'image-generate/v0', 'video-create': 'video-generate/v0', 'speech-create': 'speech/v0', 'transcription-create': 'transcribe/v0' };
|
|
76
|
+
if (typeof result.id !== 'string' || !RUN.test(result.id) || result.recipe !== recipes[record.kind]
|
|
77
|
+
|| (record.artifactId ? result.source?.artifactId !== record.artifactId : result.source !== null)) throw new TypeError('流程来源或类型与本次创建不一致');
|
|
78
|
+
journal.remove(record.id);
|
|
79
|
+
} else if (record.kind === 'selection') {
|
|
80
|
+
const result = flow(response, record.runId);
|
|
81
|
+
if (result.selection?.artifactId !== record.artifactId || result.revision < record.expectedRevision) throw new TypeError('选择结果与本次操作不一致');
|
|
82
|
+
journal.remove(record.id);
|
|
83
|
+
} else {
|
|
84
|
+
const item = operation(response, record.kind === 'continue' ? record.runId : undefined);
|
|
85
|
+
if (record.kind === 'stop' && item.id !== record.operationId) throw new TypeError('停止响应与原操作不一致');
|
|
86
|
+
if (TERMINAL.has(item.state)) {
|
|
87
|
+
journal.remove(record.id);
|
|
88
|
+
if (record.kind === 'stop') for (const other of journal.snapshot().actions) {
|
|
89
|
+
if (other.kind === 'continue' && other.operationId === item.id) journal.remove(other.id);
|
|
90
|
+
}
|
|
91
|
+
} else journal.update(record.id, { operationId: item.id });
|
|
92
|
+
}
|
|
93
|
+
return response;
|
|
94
|
+
}
|
|
95
|
+
function send(record, input) {
|
|
96
|
+
const options = { key: record.key, signal: lifetime.signal };
|
|
97
|
+
if (record.kind === 'image-import') return client.uploadImage(input, options);
|
|
98
|
+
if (record.kind === 'flow-create') return client.createRun(input, options);
|
|
99
|
+
if (record.kind === 'generation-create') return client.createGeneration(input, options);
|
|
100
|
+
if (record.kind === 'video-create') return client.createVideo(input, options);
|
|
101
|
+
if (record.kind === 'audio-import') return client.uploadAudio(input, options);
|
|
102
|
+
if (record.kind === 'speech-create') return client.createSpeech(input, options);
|
|
103
|
+
if (record.kind === 'transcription-create') return client.createTranscription(input, options);
|
|
104
|
+
if (record.kind === 'continue') return client.continueRun(record.runId, record.expectedRevision, options);
|
|
105
|
+
if (record.kind === 'selection') return client.selectRun(record.runId, record.artifactId, record.expectedRevision, options);
|
|
106
|
+
return client.stopOperation(record.operationId, options);
|
|
107
|
+
}
|
|
108
|
+
function perform(kind, input, original) {
|
|
109
|
+
open(); requireCapability(CAPABILITY[kind]);
|
|
110
|
+
if (journal.snapshot().blocked) fail('IRIS_WEB_RECOVERY_CORRUPT', '待核对记录损坏,请先明确处理原记录');
|
|
111
|
+
if (recovering) fail('IRIS_WEB_BUSY', '正在核对原操作,请等待读取完成');
|
|
112
|
+
const signature = IMPORTS.includes(kind) ? input : JSON.stringify([kind, input]);
|
|
113
|
+
const running = kind === 'stop' ? stops.get(input.operationId) : active;
|
|
114
|
+
if (running) {
|
|
115
|
+
if (running.kind === kind && running.signature === signature && running.originalId === original?.id) return running.promise;
|
|
116
|
+
fail('IRIS_WEB_BUSY', '本次操作尚未返回,请等待或读取原结果');
|
|
117
|
+
}
|
|
118
|
+
if (!original && journal.snapshot().actions.some(item => kind === 'stop'
|
|
119
|
+
? item.kind === 'stop' && item.operationId === input.operationId
|
|
120
|
+
: CREATIONS.includes(kind) ? item.kind === kind : item.runId === input.runId)) {
|
|
121
|
+
fail('IRIS_WEB_RECOVERY_REQUIRED', '原操作结果尚待核对,请先读取原结果或明确重试原动作');
|
|
122
|
+
}
|
|
123
|
+
const id = original?.id || crypto.randomUUID();
|
|
124
|
+
const record = original || { id, kind,
|
|
125
|
+
...([...CREATIONS, 'continue'].includes(kind) ? { key: id } : {}),
|
|
126
|
+
...(['flow-create', 'transcription-create'].includes(kind) ? { artifactId: input.sourceArtifactId } : {}),
|
|
127
|
+
...(['continue', 'selection'].includes(kind) ? { runId: input.runId, expectedRevision: input.expectedRevision } : {}),
|
|
128
|
+
...(kind === 'selection' ? { artifactId: input.artifactId } : {}), ...(kind === 'stop' ? { operationId: input.operationId } : {}) };
|
|
129
|
+
if (!original) journal.add(record);
|
|
130
|
+
const promise = Promise.resolve().then(() => { open(); return send(record, input); }).then(response => settle(record, response)).finally(() => {
|
|
131
|
+
if (kind === 'stop') { if (stops.get(input.operationId)?.promise === promise) stops.delete(input.operationId); }
|
|
132
|
+
else if (active?.promise === promise) active = null;
|
|
133
|
+
});
|
|
134
|
+
const job = { kind, signature, originalId: original?.id, promise };
|
|
135
|
+
if (kind === 'stop') stops.set(input.operationId, job); else active = job;
|
|
136
|
+
return promise;
|
|
137
|
+
}
|
|
138
|
+
async function readAction(action) {
|
|
139
|
+
const capability = CREATIONS.includes(action.kind) ? 'creation.lookup'
|
|
140
|
+
: action.kind === 'selection' ? 'runs.inspect' : action.operationId ? 'operations.inspect' : 'operations.lookup';
|
|
141
|
+
if (!capabilities.has(capability)) return { action, status: 'capability_unavailable' };
|
|
142
|
+
try {
|
|
143
|
+
let result;
|
|
144
|
+
if (CREATIONS.includes(action.kind)) {
|
|
145
|
+
result = await client.lookupCreation(action.kind, action.key, { signal: lifetime.signal }); open();
|
|
146
|
+
if (result.contractVersion !== 0 || result.kind !== action.kind || !['prepared', 'succeeded'].includes(result.state)
|
|
147
|
+
|| result.state === 'succeeded' && !result.result) throw new TypeError('不支持的创建查询响应');
|
|
148
|
+
if (result.result) {
|
|
149
|
+
const field = IMPORTS.includes(action.kind) ? 'artifactId' : 'runId';
|
|
150
|
+
journal.update(action.id, { [field]: result.result[field] });
|
|
151
|
+
}
|
|
152
|
+
if (result.state === 'succeeded') journal.remove(action.id);
|
|
153
|
+
return { action, status: result.state === 'succeeded' ? 'confirmed' : 'prepared', result };
|
|
154
|
+
}
|
|
155
|
+
if (action.kind === 'selection') {
|
|
156
|
+
result = flow(await client.getRun(action.runId, { signal: lifetime.signal }), action.runId); open();
|
|
157
|
+
journal.remove(action.id);
|
|
158
|
+
return { action, status: 'current', matches: result.selection?.artifactId === action.artifactId, result };
|
|
159
|
+
}
|
|
160
|
+
result = await (action.operationId ? client.getOperation(action.operationId, { signal: lifetime.signal })
|
|
161
|
+
: client.lookupOperation(action.key, { signal: lifetime.signal })); open();
|
|
162
|
+
const item = operation(result, action.runId);
|
|
163
|
+
if (action.operationId && item.id !== action.operationId) throw new TypeError('查询响应与原操作不一致');
|
|
164
|
+
if (TERMINAL.has(item.state)) journal.remove(action.id);
|
|
165
|
+
else journal.update(action.id, { operationId: item.id });
|
|
166
|
+
return { action, status: TERMINAL.has(item.state) ? 'confirmed' : 'active', result };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
open();
|
|
169
|
+
return { action, status: error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'unavailable', error };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
snapshot,
|
|
174
|
+
uploadImage(image) {
|
|
175
|
+
if (!(image instanceof Blob) || !['image/png', 'image/jpeg', 'image/webp'].includes(image.type)) throw new TypeError('需要静态图片文件');
|
|
176
|
+
return perform('image-import', image);
|
|
177
|
+
},
|
|
178
|
+
createRun(value) { return perform('flow-create', flowInput(value)); },
|
|
179
|
+
createGeneration(value) { return perform('generation-create', flowInput(value, true)); },
|
|
180
|
+
createVideo(value) { return perform('video-create', videoInput(value)); },
|
|
181
|
+
uploadAudio(audio) {
|
|
182
|
+
if (!(audio instanceof Blob) || !AUDIO_TYPES.includes(audio.type)) throw new TypeError('需要支持的音频文件');
|
|
183
|
+
return perform('audio-import', audio);
|
|
184
|
+
},
|
|
185
|
+
createSpeech(value) { return perform('speech-create', audioInput(value, 'speech-create')); },
|
|
186
|
+
createTranscription(value) { return perform('transcription-create', audioInput(value, 'transcription-create')); },
|
|
187
|
+
continueRun(runId, expectedRevision) { return perform('continue', { runId, expectedRevision }); },
|
|
188
|
+
selectRun(runId, artifactId, expectedRevision) { return perform('selection', { runId, artifactId, expectedRevision }); },
|
|
189
|
+
stopOperation(operationId) { return perform('stop', { operationId }); },
|
|
190
|
+
retry(id, input) {
|
|
191
|
+
const action = journal.snapshot().actions.find(item => item.id === id);
|
|
192
|
+
if (!action) throw new TypeError('原操作身份不存在');
|
|
193
|
+
if (IMPORTS.includes(action.kind)) {
|
|
194
|
+
if (!(input instanceof Blob) || !(action.kind === 'audio-import' ? AUDIO_TYPES : ['image/png', 'image/jpeg', 'image/webp']).includes(input.type)) throw new TypeError('请重新提供原素材');
|
|
195
|
+
} else if (['flow-create', 'generation-create'].includes(action.kind)) {
|
|
196
|
+
input = flowInput(input, action.kind === 'generation-create');
|
|
197
|
+
if (action.kind === 'flow-create' && input.sourceArtifactId !== action.artifactId) throw new TypeError('请提供原流程的来源图片与输入');
|
|
198
|
+
} else if (action.kind === 'video-create') input = videoInput(input);
|
|
199
|
+
else if (['speech-create', 'transcription-create'].includes(action.kind)) {
|
|
200
|
+
input = audioInput(input, action.kind);
|
|
201
|
+
if (action.kind === 'transcription-create' && input.sourceArtifactId !== action.artifactId) throw new TypeError('请提供原转写音频');
|
|
202
|
+
} else input = action;
|
|
203
|
+
return perform(action.kind, input, action);
|
|
204
|
+
},
|
|
205
|
+
recover() {
|
|
206
|
+
open();
|
|
207
|
+
if (active || stops.size) fail('IRIS_WEB_BUSY', '本次写入尚未返回,请等待后核对');
|
|
208
|
+
if (recovering) return recovering;
|
|
209
|
+
if (journal.snapshot().blocked) return Promise.resolve([]);
|
|
210
|
+
recovering = (async () => { const results = []; for (const item of journal.snapshot().actions) results.push(await readAction(item)); return results; })()
|
|
211
|
+
.finally(() => { recovering = null; });
|
|
212
|
+
return recovering;
|
|
213
|
+
},
|
|
214
|
+
forget(id) { open(); if (active || stops.size || recovering) fail('IRIS_WEB_BUSY', '请等待当前操作结束'); journal.remove(id); },
|
|
215
|
+
clearRecovery() { open(); if (active || stops.size || recovering) fail('IRIS_WEB_BUSY', '请等待当前操作结束'); journal.clear(); },
|
|
216
|
+
close() { lifetime.abort(); journal.close(); }
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
const el = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
|
|
3
|
+
const options = (select, rows, selected) => { select.replaceChildren(...rows.map(([value, text]) => { const item = el('option', text); item.value = value; return item; })); if (rows.some(([value]) => value === selected)) select.value = selected; };
|
|
4
|
+
const statusLabel = value => ({ configured: '已配置 · 未实测', empty: '空池', disabled: '已停用', unavailable: '无可用候选' })[value] || value;
|
|
5
|
+
export function networkRouteLabel(route) {
|
|
6
|
+
return !route ? '出口未读取' : route.state === 'inherit' ? '继承环境' : route.state === 'blocked'
|
|
7
|
+
? '已阻止 · ' + (route.poolLabel || '核对网络配置') : route.poolLabel + ' · ' + (route.mode === 'fixed' ? '固定' : '有序主备');
|
|
8
|
+
}
|
|
9
|
+
export function createNetworkPanel({ describe, onChanged = () => {} }) {
|
|
10
|
+
let client, data, draft, section = 'bindings', busy = false, unknown = false, dirty = false, generation = 0, visible = false;
|
|
11
|
+
let candidates = [], pendingLeave, probeController, readController, writeController;
|
|
12
|
+
const feedback = (text, error = false) => { $('network-feedback').textContent = text; $('network-feedback').hidden = !text; $('network-feedback').classList.toggle('error', error); };
|
|
13
|
+
const active = (ticket, owner) => ticket === generation && owner === client;
|
|
14
|
+
const nodeLabel = value => value.kind === 'direct' ? '明确直连' : data.network.nodes.find(node => node.id === value.nodeId)?.label || value.nodeId;
|
|
15
|
+
function controls() {
|
|
16
|
+
$('network-fields').disabled = busy || unknown;
|
|
17
|
+
$('network-save').disabled = busy || unknown || draft?.kind === 'pool' && $('network-pool-mode').value === 'fixed' && candidates.length > 1;
|
|
18
|
+
$('network-save').textContent = busy ? '正在保存…' : '保存设置';
|
|
19
|
+
$('network-editor-state').textContent = busy ? '处理中…' : unknown ? '结果待核对' : dirty ? '未保存' : '已读取';
|
|
20
|
+
$('network-refresh').disabled = busy && !probeController;
|
|
21
|
+
$('network-new').disabled = busy || !data?.network;
|
|
22
|
+
$('network-init').disabled = busy;
|
|
23
|
+
$('network-back').disabled = busy; $('network-cancel').disabled = busy;
|
|
24
|
+
for (const button of document.querySelectorAll('#network-list button, [data-network-section], #network-other-list button')) button.disabled = busy || button.dataset.networkBlocked === 'true';
|
|
25
|
+
$('network-probe-start').disabled = busy || !data?.network || !data.providers.length;
|
|
26
|
+
for (const id of ['network-probe-provider', 'network-probe-target', 'network-probe-url']) $(id).disabled = busy;
|
|
27
|
+
$('network-probe-cancel').hidden = !probeController;
|
|
28
|
+
}
|
|
29
|
+
function changed() { dirty = true; controls(); }
|
|
30
|
+
function leave(action) { if (busy) return; if (dirty || unknown) { pendingLeave = action; $('network-leave-confirm').hidden = false; return; } action(); }
|
|
31
|
+
function closeEditor() { draft = null; candidates = []; dirty = unknown = false; $('network-editor').hidden = true; $('network-delete-confirm').hidden = true; $('network-leave-confirm').hidden = true; render(); }
|
|
32
|
+
function stats() {
|
|
33
|
+
$('network-stats').replaceChildren(...[['节点', data.network?.nodes.length || 0], ['出口池', data.network?.pools.length || 0], ['绑定', data.network?.bindings?.length || 0], ['继承环境', data.providers.filter(provider => provider.route.state === 'inherit').length]].map(([name, value]) => {
|
|
34
|
+
const row = el('div'); row.append(el('dt', name), el('dd', String(value))); return row;
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
function render() {
|
|
38
|
+
if (!data) {
|
|
39
|
+
$('network-doctor').hidden = section !== 'doctor'; $('network-list').hidden = true; $('network-new').hidden = true; $('network-empty').hidden = true;
|
|
40
|
+
$('network-setup').hidden = true; $('network-other-bindings').hidden = true; $('network-list').replaceChildren(); $('network-stats').replaceChildren();
|
|
41
|
+
for (const button of document.querySelectorAll('[data-network-section]')) button.setAttribute('aria-pressed', String(button.dataset.networkSection === section));
|
|
42
|
+
controls(); return;
|
|
43
|
+
}
|
|
44
|
+
stats(); $('network-sections').hidden = Boolean(draft);
|
|
45
|
+
$('network-list').hidden = Boolean(draft) || section === 'doctor'; $('network-doctor').hidden = Boolean(draft) || section !== 'doctor';
|
|
46
|
+
$('network-new').hidden = Boolean(draft) || !['nodes', 'pools'].includes(section) || !data.network;
|
|
47
|
+
$('network-new').textContent = section === 'nodes' ? '+ 添加节点' : '+ 添加出口池';
|
|
48
|
+
const ready = data.catalog.state === 'ready';
|
|
49
|
+
$('network-setup').hidden = Boolean(draft) || data.storage !== 'missing' && (section !== 'bindings' || ready);
|
|
50
|
+
$('network-init').hidden = data.storage !== 'missing';
|
|
51
|
+
$('network-setup-note').textContent = data.storage === 'missing' ? '本次服务指定的网络文件尚未创建。明确初始化后才能管理节点与池;不会改动供应商目录。'
|
|
52
|
+
: data.catalog.state === 'legacy' ? '当前是旧供应商目录。节点与池可以独立配置;绑定需先另存启用新目录,旧任务不会被迁移。'
|
|
53
|
+
: data.catalog.state === 'absent' ? '此服务未指定供应商目录。可以管理资源,指定目录后再配置连接出口。' : '网络文件版本或身份与供应商目录不匹配。请核对启动时指定的文件,当前绑定不会被借用。';
|
|
54
|
+
for (const button of document.querySelectorAll('[data-network-section]')) button.setAttribute('aria-pressed', String(button.dataset.networkSection === section));
|
|
55
|
+
const records = section === 'bindings' ? data.providers : data.network?.[section] || [];
|
|
56
|
+
$('network-empty').hidden = Boolean(draft) || section === 'doctor' || records.length > 0 || data.storage === 'missing';
|
|
57
|
+
$('network-empty').textContent = section === 'nodes' ? '还没有节点。添加已有 HTTP/HTTPS 代理,不会安装代理服务。' : section === 'pools' ? '还没有出口池。节点可复用;直连需要明确添加。' : '当前没有供应商连接。';
|
|
58
|
+
$('network-list').replaceChildren(...(section === 'doctor' ? [] : records).map((record, index) => {
|
|
59
|
+
const card = el('article', '', 'card network-card'); card.dataset.networkTone = String(index % 6); card.dataset.networkId = record.id;
|
|
60
|
+
const heading = el('div', '', 'section-heading');
|
|
61
|
+
heading.append(el('span', section === 'bindings' ? '连接' : section === 'nodes' ? record.protocol.toUpperCase() : record.mode === 'fixed' ? '固定' : '有序主备', 'network-kicker'),
|
|
62
|
+
el('span', section === 'bindings' ? record.route.state === 'blocked' ? '已阻止' : '配置路线' : section === 'nodes' ? !record.enabled ? '已停用' : record.credentialStatus === 'missing' ? '认证未就绪' : record.credentialStatus === 'invalid' ? '认证值无效' : '未实测' : statusLabel(record.status), 'badge'));
|
|
63
|
+
card.append(heading, el('h3', record.name || record.label));
|
|
64
|
+
card.append(el('p', section === 'bindings' ? networkRouteLabel(record.route) : section === 'nodes' ? record.host + ':' + record.port
|
|
65
|
+
: record.candidates.length ? record.candidates.map(nodeLabel).join(' → ') : '空候选 · 绑定后阻止新请求', 'network-route'));
|
|
66
|
+
card.append(el('p', section === 'bindings' ? !record.enabled ? '供应商已停用,绑定仍保留。' : '仅影响这条供应商连接。' : section === 'nodes'
|
|
67
|
+
? (record.auth ? '环境认证 · ' + record.credentialStatus : '无需代理认证') + ' · ' + record.referencedBy.length + ' 个池引用'
|
|
68
|
+
: (record.referencedBy?.length || 0) + ' 条绑定 · 连接预检后才派发业务', 'field-note'));
|
|
69
|
+
const button = el('button', section === 'bindings' ? '设置出口 →' : '编辑 →', 'quiet network-card-action'); button.type = 'button';
|
|
70
|
+
button.dataset.networkBlocked = String(section === 'bindings' && !ready); button.addEventListener('click', () => edit(section === 'bindings' ? 'binding' : section === 'nodes' ? 'node' : 'pool', record)); card.append(button);
|
|
71
|
+
return card;
|
|
72
|
+
}));
|
|
73
|
+
// Legacy/foreign references stay explicit and removable without scanning files.
|
|
74
|
+
const others = (data.network?.bindings || []).filter(item => item.catalogId !== data.catalog.catalogId || !data.providers.some(provider => provider.id === item.providerId));
|
|
75
|
+
$('network-other-bindings').hidden = Boolean(draft) || section !== 'bindings' || !others.length;
|
|
76
|
+
$('network-other-list').replaceChildren(...others.map(item => {
|
|
77
|
+
const row = el('div', '', 'network-other-row'); row.append(el('span', item.providerId + ' → ' + item.poolId + ' · ' + item.catalogId));
|
|
78
|
+
const remove = el('button', '解除绑定', 'quiet'); remove.type = 'button'; remove.addEventListener('click', () => {
|
|
79
|
+
const confirm = el('button', '确认解除', 'secondary danger'); confirm.type = 'button'; confirm.addEventListener('click', () => save('bindings/remove', { catalogId: item.catalogId, providerId: item.providerId, confirm: true }));
|
|
80
|
+
const cancel = el('button', '取消', 'quiet'); cancel.type = 'button'; cancel.addEventListener('click', render); row.replaceChildren(el('span', '解除 ' + item.providerId + '?恢复继承环境,历史保留。'), confirm, cancel);
|
|
81
|
+
}); row.append(remove); return row;
|
|
82
|
+
}));
|
|
83
|
+
const selected = $('network-probe-provider').value;
|
|
84
|
+
options($('network-probe-provider'), data.providers.map(provider => [provider.id, provider.name]), selected || data.providers[0]?.id);
|
|
85
|
+
controls();
|
|
86
|
+
}
|
|
87
|
+
function fill(prefix, values) { for (const [name, value] of Object.entries(values)) { const field = $('network-' + prefix + '-' + name); if (field.type === 'checkbox') field.checked = value; else field.value = value ?? ''; } }
|
|
88
|
+
function edit(kind, record) {
|
|
89
|
+
if (busy) return;
|
|
90
|
+
draft = { kind, id: record?.id || null, record, revision: data.network.revision, providerRevision: data.catalog.revision }; dirty = false; unknown = false; feedback('');
|
|
91
|
+
$('network-editor').hidden = false; $('network-delete-confirm').hidden = true;
|
|
92
|
+
for (const type of ['node', 'pool', 'binding']) $('network-' + type + '-fields').hidden = type !== kind;
|
|
93
|
+
// Disabled sections must not participate in native form validation.
|
|
94
|
+
for (const type of ['node', 'pool']) for (const field of $('network-' + type + '-fields').querySelectorAll('input,select')) field.disabled = type !== kind;
|
|
95
|
+
$('network-remove').hidden = !record || kind === 'binding';
|
|
96
|
+
$('network-editor-title').textContent = kind === 'binding' ? '设置供应商出口' : (record ? '编辑' : '添加') + (kind === 'node' ? '节点' : '出口池');
|
|
97
|
+
if (kind === 'node') {
|
|
98
|
+
fill('node', { id: record?.id || '', label: record?.label || '', protocol: record?.protocol || 'http', host: record?.host || '', port: record?.port || '', enabled: record?.enabled ?? true,
|
|
99
|
+
auth: Boolean(record?.auth), username: record?.auth?.usernameEnv || '', password: record?.auth?.passwordEnv || '' });
|
|
100
|
+
$('network-node-id').readOnly = Boolean(record); $('network-auth-fields').hidden = !$('network-node-auth').checked;
|
|
101
|
+
} else if (kind === 'pool') {
|
|
102
|
+
fill('pool', { id: record?.id || '', label: record?.label || '', mode: record?.mode || 'ordered', enabled: record?.enabled ?? true });
|
|
103
|
+
$('network-pool-id').readOnly = Boolean(record); candidates = structuredClone(record?.candidates || []); renderCandidates();
|
|
104
|
+
} else {
|
|
105
|
+
$('network-binding-provider').textContent = record.name; const binding = data.network.bindings.find(item => item.catalogId === data.catalog.catalogId && item.providerId === record.id);
|
|
106
|
+
options($('network-binding-pool'), [['', '继承环境 · 解除显式绑定'], ...data.network.pools.map(pool => [pool.id, pool.label + ' · ' + statusLabel(pool.status)])], binding?.poolId || ''); bindingNote();
|
|
107
|
+
}
|
|
108
|
+
render(); $('network-editor-title').scrollIntoView({ block: 'nearest' });
|
|
109
|
+
}
|
|
110
|
+
function bindingNote() {
|
|
111
|
+
const pool = data.network.pools.find(item => item.id === $('network-binding-pool').value);
|
|
112
|
+
$('network-binding-note').textContent = !pool ? '保存后明确解除此连接的出口绑定,恢复继承当前环境;不改变已有任务身份。'
|
|
113
|
+
: pool.status !== 'configured' ? '此池为空、停用或没有可用候选。保存绑定会阻止新请求,不会偷偷直连。' : '保存后使用此池允许的候选;只在连接预检失败时按顺序选择备用,不透明重放生成。';
|
|
114
|
+
}
|
|
115
|
+
function renderCandidates() {
|
|
116
|
+
$('network-candidates').replaceChildren(...candidates.map((candidate, index) => {
|
|
117
|
+
const row = el('div', '', 'network-candidate'); row.append(el('span', (index + 1) + '. ' + nodeLabel(candidate)));
|
|
118
|
+
for (const [name, step] of [['上移', -1], ['下移', 1], ['移除', 0]]) {
|
|
119
|
+
const button = el('button', name, 'quiet'); button.type = 'button'; button.disabled = step !== 0 && (index + step < 0 || index + step >= candidates.length);
|
|
120
|
+
button.addEventListener('click', () => { if (!step) candidates.splice(index, 1); else [candidates[index], candidates[index + step]] = [candidates[index + step], candidates[index]]; changed(); renderCandidates(); }); row.append(button);
|
|
121
|
+
} return row;
|
|
122
|
+
}));
|
|
123
|
+
options($('network-candidate-choice'), [...data.network.nodes.filter(node => !candidates.some(candidate => candidate.nodeId === node.id)).map(node => [node.id, node.label]),
|
|
124
|
+
...(!candidates.some(candidate => candidate.kind === 'direct') ? [['@direct', '明确允许直连']] : [])]);
|
|
125
|
+
$('network-candidate-add').disabled = candidates.length >= 16 || !$('network-candidate-choice').options.length; controls();
|
|
126
|
+
}
|
|
127
|
+
async function refresh() {
|
|
128
|
+
if (!client || busy) return;
|
|
129
|
+
const owner = client, ticket = generation; readController?.abort(); const controller = readController = new AbortController();
|
|
130
|
+
feedback('正在读取出口配置…');
|
|
131
|
+
try {
|
|
132
|
+
const value = await owner.get('/api/v0/network-management/settings', { signal: controller.signal });
|
|
133
|
+
if (!active(ticket, owner) || controller.signal.aborted) return;
|
|
134
|
+
if (draft) return; // A background read cannot rebase an open editor's CAS.
|
|
135
|
+
if (value.contractVersion !== 0) throw new TypeError('不支持的网络设置版本');
|
|
136
|
+
if (data?.network?.revision !== value.network?.revision) $('network-probe-result').hidden = true;
|
|
137
|
+
data = value; onChanged(data); render(); feedback('');
|
|
138
|
+
if (section === 'doctor' && visible) await readDoctor();
|
|
139
|
+
} catch (error) { if (active(ticket, owner) && !controller.signal.aborted) { if (!draft) { data = null; onChanged(null); render(); } feedback(describe(error), true); } }
|
|
140
|
+
}
|
|
141
|
+
async function save(action, values = {}) {
|
|
142
|
+
if (!client || busy || unknown) return;
|
|
143
|
+
const owner = client, ticket = generation; readController?.abort(); busy = true; const controller = writeController = new AbortController(); controls(); feedback('正在保存…');
|
|
144
|
+
try {
|
|
145
|
+
const result = await owner.postNetwork(action, action === 'init' ? {} : { expectedRevision: draft?.revision || data.network.revision, ...values }, { signal: controller.signal });
|
|
146
|
+
if (!active(ticket, owner)) return;
|
|
147
|
+
data = result.settings; onChanged(data); $('network-probe-result').hidden = true; closeEditor(); feedback(result.changed ? '已保存。配置变更已生效,作品与任务历史保留。' : '设置未变化。');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!active(ticket, owner)) return;
|
|
150
|
+
unknown = error instanceof TypeError || ['AbortError', 'TimeoutError'].includes(error.name);
|
|
151
|
+
feedback(unknown ? '保存结果尚未确认,请重新读取并核对。不会自动重发。' : describe(error), true);
|
|
152
|
+
} finally { if (active(ticket, owner)) { busy = false; writeController = null; render(); } }
|
|
153
|
+
}
|
|
154
|
+
async function readDoctor() {
|
|
155
|
+
const owner = client, ticket = generation, controller = readController;
|
|
156
|
+
if (!owner || busy) return;
|
|
157
|
+
try {
|
|
158
|
+
const result = await owner.get('/api/v0/network-management/doctor', { signal: controller?.signal });
|
|
159
|
+
if (!active(ticket, owner) || !visible || section !== 'doctor') return;
|
|
160
|
+
const fields = [['检查位置', '当前 Core · ' + result.process.platform + ' / ' + result.process.arch], ['Node', result.process.node],
|
|
161
|
+
['代理变量', Object.entries(result.environment.variables).filter(([, exists]) => exists).map(([name]) => name).join('、') || '未发现'],
|
|
162
|
+
['Node 环境代理开关', result.environment.nodeFlagRequested ? '已请求 · 实际路径仍需检测' : '未发现启用请求'], ['宿主策略 / 系统 TUN', '未知 · 不根据配置猜测'], ['读取时间', new Date(result.observedAt).toLocaleTimeString('zh-CN', { hour12: false })]];
|
|
163
|
+
if (result.configuration.state === 'invalid') fields.push(['配置需核对', result.configuration.message + ' ' + result.configuration.issue]);
|
|
164
|
+
$('network-environment').replaceChildren(...fields.flatMap(([key, value]) => [el('dt', key), el('dd', value)]));
|
|
165
|
+
} catch (error) { if (active(ticket, owner) && !controller?.signal.aborted) feedback(describe(error), true); }
|
|
166
|
+
}
|
|
167
|
+
function enter(next) { section = next; closeEditor(); if (next === 'doctor') void readDoctor(); }
|
|
168
|
+
for (const button of document.querySelectorAll('[data-network-section]')) button.addEventListener('click', () => leave(() => enter(button.dataset.networkSection)));
|
|
169
|
+
$('network-new').addEventListener('click', () => edit(section === 'nodes' ? 'node' : 'pool'));
|
|
170
|
+
$('network-refresh').addEventListener('click', () => leave(() => { closeEditor(); void refresh(); }));
|
|
171
|
+
for (const id of ['network-back', 'network-cancel']) $(id).addEventListener('click', () => leave(closeEditor));
|
|
172
|
+
$('network-leave-no').addEventListener('click', () => { pendingLeave = null; $('network-leave-confirm').hidden = true; });
|
|
173
|
+
$('network-leave-yes').addEventListener('click', () => { const action = pendingLeave; pendingLeave = null; dirty = unknown = false; $('network-leave-confirm').hidden = true; action?.(); });
|
|
174
|
+
$('network-form').addEventListener('input', changed); $('network-form').addEventListener('change', changed);
|
|
175
|
+
$('network-node-auth').addEventListener('change', () => { $('network-auth-fields').hidden = !$('network-node-auth').checked; });
|
|
176
|
+
$('network-pool-mode').addEventListener('change', controls);
|
|
177
|
+
$('network-binding-pool').addEventListener('change', bindingNote);
|
|
178
|
+
$('network-candidate-add').addEventListener('click', () => { const id = $('network-candidate-choice').value; if (!id) return; candidates.push(id === '@direct' ? { kind: 'direct' } : { kind: 'node', nodeId: id }); changed(); renderCandidates(); });
|
|
179
|
+
$('network-init').addEventListener('click', () => save('init'));
|
|
180
|
+
$('network-form').addEventListener('submit', event => {
|
|
181
|
+
event.preventDefault(); if (!draft) return;
|
|
182
|
+
if (draft.kind === 'binding') {
|
|
183
|
+
const poolId = $('network-binding-pool').value;
|
|
184
|
+
if (!poolId && !data.network.bindings.some(item => item.catalogId === data.catalog.catalogId && item.providerId === draft.id)) { closeEditor(); feedback('设置未变化。'); return; }
|
|
185
|
+
return save(poolId ? 'bindings/set' : 'bindings/remove', poolId ? { expectedProviderRevision: draft.providerRevision, providerId: draft.id, poolId }
|
|
186
|
+
: { catalogId: data.catalog.catalogId, providerId: draft.id, confirm: true });
|
|
187
|
+
}
|
|
188
|
+
const kind = draft.kind, record = { id: $('network-' + kind + '-id').value, label: $('network-' + kind + '-label').value, enabled: $('network-' + kind + '-enabled').checked };
|
|
189
|
+
if (kind === 'node') Object.assign(record, { protocol: $('network-node-protocol').value, host: $('network-node-host').value, port: Number($('network-node-port').value),
|
|
190
|
+
auth: $('network-node-auth').checked ? { usernameEnv: $('network-node-username').value, passwordEnv: $('network-node-password').value } : null });
|
|
191
|
+
else Object.assign(record, { mode: $('network-pool-mode').value, candidates });
|
|
192
|
+
void save(kind + 's/' + (draft.id ? 'set' : 'add'), { [kind]: record });
|
|
193
|
+
});
|
|
194
|
+
$('network-remove').addEventListener('click', () => { $('network-delete-confirm').hidden = false; });
|
|
195
|
+
$('network-delete-no').addEventListener('click', () => { $('network-delete-confirm').hidden = true; });
|
|
196
|
+
$('network-delete-yes').addEventListener('click', () => { if (draft) void save(draft.kind + 's/remove', { id: draft.id, confirm: true }); });
|
|
197
|
+
$('network-probe-target').addEventListener('change', () => { const mode = $('network-probe-target').value; $('network-probe-url-field').hidden = mode === 'provider';
|
|
198
|
+
$('network-probe-url').required = mode !== 'provider'; if (mode === 'ip' && !$('network-probe-url').value) $('network-probe-url').value = 'https://api.ipify.org?format=json'; });
|
|
199
|
+
$('network-probe-form').addEventListener('input', () => { $('network-probe-result').hidden = true; });
|
|
200
|
+
$('network-probe-form').addEventListener('change', () => { $('network-probe-result').hidden = true; });
|
|
201
|
+
$('network-probe-form').addEventListener('submit', async event => {
|
|
202
|
+
event.preventDefault(); if (!client || busy) return;
|
|
203
|
+
const owner = client, ticket = generation, controller = probeController = new AbortController(); busy = true; controls(); feedback('正在检测这条连接…');
|
|
204
|
+
try {
|
|
205
|
+
const target = $('network-probe-target').value;
|
|
206
|
+
const result = await owner.postNetwork('probe', { expectedRevision: data.network.revision, expectedProviderRevision: data.catalog.revision,
|
|
207
|
+
providerId: $('network-probe-provider').value, target, ...(target === 'provider' ? {} : { url: $('network-probe-url').value }) }, { signal: controller.signal });
|
|
208
|
+
if (!active(ticket, owner) || controller.signal.aborted || !visible) return;
|
|
209
|
+
const node = $('network-probe-result'); node.replaceChildren(el('strong', result.ok ? '本次检测通过' : result.reachable ? '已连接 · 结果需要核对' : '本次未通过'));
|
|
210
|
+
node.append(el('p', (data.providers.find(provider => provider.id === result.providerId)?.name || result.providerId) + ' · ' + result.target.origin, 'field-note'));
|
|
211
|
+
node.append(el('p', [result.route ? result.route.route === 'proxy' ? '出口池 ' + result.route.poolId + ' / 节点 ' + result.route.nodeId : result.route.route === 'direct' ? '明确直连' : '继承环境' : '尚未派发',
|
|
212
|
+
result.httpStatus ? 'HTTP ' + result.httpStatus : null, result.elapsedMs + ' ms', result.observedIp ? '观察到 IP:' + result.observedIp : null].filter(Boolean).join(' · ')));
|
|
213
|
+
if (result.issue) node.append(el('p', ({ IRIS_NETWORK_POOL_EXHAUSTED: '候选均未通过连接预检,请检查节点和代理认证。', IRIS_NETWORK_POOL_UNAVAILABLE: '出口池为空或停用,未隐式直连。',
|
|
214
|
+
IRIS_NETWORK_PROBE_TIMEOUT: '检测超过8秒,已停止等待。', IRIS_NETWORK_PROBE_TOO_LARGE: '响应超过16 KiB,已停止读取。', IRIS_NETWORK_TARGET_HTTP_ERROR: '目标已返回响应,请核对地址及服务状态。',
|
|
215
|
+
IRIS_NETWORK_IP_NOT_OBSERVED: '已连接,但JSON中没有有效的ip字段;不推断出口IP。', IRIS_NETWORK_TRANSPORT: '选定路线的请求失败,未自动换出口重发。' })[result.issue] || '未能完成本次GET,请核对目标、证书与网络。', 'field-note'));
|
|
216
|
+
node.append(el('p', new Date(result.observedAt).toLocaleTimeString('zh-CN', { hour12: false }) + ' · 仅此目标、此时刻;不代表全部任务出口。', 'field-note'));
|
|
217
|
+
node.hidden = false; feedback('');
|
|
218
|
+
} catch (error) { if (active(ticket, owner)) feedback(controller.signal.aborted ? '检测已取消。' : describe(error), !controller.signal.aborted); }
|
|
219
|
+
finally { if (active(ticket, owner)) { busy = false; probeController = null; controls(); } }
|
|
220
|
+
});
|
|
221
|
+
$('network-probe-cancel').addEventListener('click', () => probeController?.abort());
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
connect(value) { client = value; generation++; data = null; draft = null; section = 'bindings'; dirty = unknown = busy = false; return refresh(); },
|
|
224
|
+
disconnect() { generation++; readController?.abort(); probeController?.abort(); writeController?.abort(); client = data = draft = null; busy = dirty = unknown = visible = false; pendingLeave = null;
|
|
225
|
+
$('network-list').replaceChildren(); $('network-stats').replaceChildren(); $('network-environment').replaceChildren(); $('network-other-list').replaceChildren(); $('network-editor').hidden = true;
|
|
226
|
+
$('network-probe-result').replaceChildren(); $('network-probe-result').hidden = true; $('network-form').reset(); $('network-probe-form').reset(); $('network-leave-confirm').hidden = true;
|
|
227
|
+
for (const id of ['network-candidates', 'network-probe-provider', 'network-binding-pool', 'network-binding-provider']) $(id).replaceChildren(); onChanged(null); feedback(''); },
|
|
228
|
+
show(value) { const leaving = visible && !value; visible = value; if (leaving) { readController?.abort(); probeController?.abort(); } else if (value && client && !draft && !busy) void refresh(); },
|
|
229
|
+
refresh() { if (!dirty && !draft && !busy) return refresh(); },
|
|
230
|
+
focusProvider(id) { if (!data) return; leave(() => { const provider = data.providers.find(item => item.id === id); if (provider && data.catalog.state === 'ready') edit('binding', provider); else enter('bindings'); }); }
|
|
231
|
+
});
|
|
232
|
+
}
|