@mokuyoaxis/dsh-iris 0.2.6 → 0.3.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +103 -1
- package/README.en.md +104 -304
- package/README.md +101 -302
- package/bin/dsh-iris.js +446 -64
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +7 -5
- package/docs/ARCHITECTURE.md +20 -4
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CLI_MANAGEMENT.md +1 -1
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +41 -5
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +94 -0
- package/docs/DSH_COMPATIBILITY.md +14 -4
- package/docs/DSH_CORE_MIGRATION.md +4 -2
- package/docs/DSH_PROVIDER_CONNECTIONS.md +1 -1
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +40 -0
- package/docs/FUNCTION_MODEL_POOLS.md +1 -1
- package/docs/HEADLESS_CLI.md +12 -4
- package/docs/HOST_DOCTOR.md +13 -1
- package/docs/IMAGE_FLOW.md +75 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +62 -0
- package/docs/LOCAL_API_CONTRACT.md +140 -0
- package/docs/LOCAL_CREATION.md +56 -0
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +4 -2
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +28 -6
- package/docs/SECURITY.md +9 -1
- package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
- package/docs/VISION_CLI.md +2 -2
- package/docs/WEB_PROTOTYPE.md +62 -0
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.2.7.md +62 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +132 -70
- package/lib/adapters.js +20 -11
- package/lib/api.js +38 -38
- package/lib/application-runtime.js +213 -0
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +434 -55
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +21 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +31 -2
- package/lib/core-runtime.js +20 -0
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +84 -13
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +133 -0
- package/lib/doctor.js +12 -1
- package/lib/dsh-core-adapter.js +666 -572
- package/lib/dsh-host-adapter.js +8 -2
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +290 -0
- 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 +232 -0
- package/lib/index.js +94 -68
- package/lib/local-api-projection.js +32 -0
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +1083 -0
- package/lib/local-creation.js +150 -0
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/private-storage.js +12 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +40 -7
- package/lib/render-address.js +35 -0
- package/lib/render.js +52 -13
- package/lib/skill-installation.js +211 -0
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-artifacts.js +1 -2
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/lib/workbench-management.js +8 -9
- package/package.json +22 -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 +44 -0
- package/skills/iris-core/agents/openai.yaml +4 -0
- package/skills/iris-core/references/cli.md +95 -0
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +60 -0
- package/user_guide.md +19 -9
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { checkedClientConnection, readPrivateConnectionJson, connectionFail } from './core-connections.js';
|
|
3
|
+
import { MAX_IMPORT_IMAGE_BYTES } from './image-import.js';
|
|
4
|
+
import { annotateCoreConnectionError } from './core-connection-diagnostics.js';
|
|
5
|
+
import { checkedPluginSelection, hasPluginSelection } from './plugin-selection.js';
|
|
6
|
+
|
|
7
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
8
|
+
const RUN = /^run_[a-f0-9]{24}$/, OP = /^op_[a-f0-9]{64}$/, ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
9
|
+
const input = () => connectionFail('IRIS_CONNECTION_INPUT_INVALID', 'Core 服务工具输入无效');
|
|
10
|
+
function reference(value, pattern) { if (typeof value !== 'string' || !pattern.test(value)) input(); return value; }
|
|
11
|
+
|
|
12
|
+
/** A bounded client of one explicitly authorized Core. It never owns a Core writer or falls back. */
|
|
13
|
+
export function createCoreServiceClient({ connectionFile, connection, requestedScopes = ['read'], requestedCapabilities, requestedTools, requestedPluginOperations,
|
|
14
|
+
harnessVersion = 'unknown', fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
15
|
+
if (connectionFile !== undefined && connection !== undefined) throw new TypeError('连接配置只能明确提供一种来源');
|
|
16
|
+
const runtimeInstanceId = crypto.randomUUID(), lifetime = new AbortController();
|
|
17
|
+
let state = 'new', config, negotiated, connectionPromise, metadataPromise, metadataKey, metadataAt = 0, disposal;
|
|
18
|
+
async function request(route, { method = 'GET', value, bytes, key, mediaType, binary = false, signal, handshake = false, disconnect = false } = {}) {
|
|
19
|
+
if (state !== 'open' && !(handshake && state === 'connecting') && !(disconnect && state === 'closing')) connectionFail('IRIS_CORE_SERVICE_DISCONNECTED', 'Core 服务尚未连接,请显式连接');
|
|
20
|
+
const headers = { Authorization: 'Bearer ' + config.accessToken, 'Iris-Connection-Id': config.connectionId, 'Iris-Runtime-Id': runtimeInstanceId };
|
|
21
|
+
if (key !== undefined) headers['Idempotency-Key'] = reference(key, UUID);
|
|
22
|
+
if (value !== undefined || bytes !== undefined) headers['Content-Type'] = mediaType || 'application/json';
|
|
23
|
+
const combined = AbortSignal.any([...(disconnect ? [] : [lifetime.signal]), AbortSignal.timeout(disconnect ? 2000 : 15000), ...(signal ? [signal] : [])]);
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetchImpl(config.origin + route, { method, headers, redirect: 'error', signal: combined,
|
|
27
|
+
...(value !== undefined ? { body: JSON.stringify(value) } : bytes !== undefined ? { body: bytes } : {}) });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (signal?.aborted || !disconnect && lifetime.signal.aborted) throw Object.assign(new Error('Core 请求等待已停止;已接收操作需按原 Key 查询'), { name: 'AbortError', code: 'IRIS_CORE_SERVICE_ABORTED' });
|
|
30
|
+
throw annotateCoreConnectionError(Object.assign(new Error('Core 服务无法连接,请检查服务状态'), {
|
|
31
|
+
code: combined.reason?.name === 'TimeoutError' ? 'IRIS_CORE_SERVICE_TIMEOUT' : 'IRIS_CORE_SERVICE_UNAVAILABLE' }), 'transport');
|
|
32
|
+
}
|
|
33
|
+
const limit = binary && binary !== 'text' && response.ok ? 64 * 1024 * 1024 : 1024 * 1024;
|
|
34
|
+
if (Number(response.headers.get('content-length')) > limit) { await response.body?.cancel(); connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应超过有界大小'); }
|
|
35
|
+
const chunks = []; let size = 0;
|
|
36
|
+
try {
|
|
37
|
+
if (response.body) for await (const chunk of response.body) {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > limit) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应超过有界大小');
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'IRIS_CORE_SERVICE_RESPONSE_INVALID') throw error;
|
|
44
|
+
connectionFail('IRIS_CORE_SERVICE_UNAVAILABLE', 'Core 响应中断;请读取原 Operation,不要自动再次提交');
|
|
45
|
+
}
|
|
46
|
+
const result = Buffer.concat(chunks, size);
|
|
47
|
+
if (binary && response.ok) {
|
|
48
|
+
const mediaType = response.headers.get('content-type');
|
|
49
|
+
const allowed = binary === 'audio' ? ['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'] : binary === 'text' ? ['text/plain'] : ['image/png', 'image/jpeg', 'image/webp'];
|
|
50
|
+
if (!allowed.includes(mediaType)) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core返回的作品类型与所选入口不一致');
|
|
51
|
+
return { bytes: result, mediaType };
|
|
52
|
+
}
|
|
53
|
+
let body;
|
|
54
|
+
try { body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(result)); }
|
|
55
|
+
catch (_) { connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 返回无效 JSON'); }
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const code = /^IRIS_[A-Z_]+$/.test(body?.error?.code) ? body.error.code : 'IRIS_CORE_SERVICE_REJECTED';
|
|
58
|
+
throw annotateCoreConnectionError(Object.assign(new Error('Core 服务拒绝本次请求;请检查授权、原操作及服务状态'), {
|
|
59
|
+
code, status: response.status, ...(UUID.test(body?.error?.requestId) ? { requestId: body.error.requestId } : {}) }), handshake ? 'handshake' : disconnect ? 'disconnect' : 'tool');
|
|
60
|
+
}
|
|
61
|
+
if (body?.contractVersion !== 0) connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 响应契约不受支持');
|
|
62
|
+
return body;
|
|
63
|
+
}
|
|
64
|
+
function connect() {
|
|
65
|
+
if (['closing', 'closed'].includes(state)) return Promise.reject(Object.assign(new Error('Core 连接已关闭'), { code: 'IRIS_CORE_SERVICE_DISCONNECTED' }));
|
|
66
|
+
if (connectionPromise) return connectionPromise;
|
|
67
|
+
connectionPromise = (async () => {
|
|
68
|
+
let stage = 'configuration';
|
|
69
|
+
try {
|
|
70
|
+
config = checkedClientConnection(connection ?? readPrivateConnectionJson(connectionFile));
|
|
71
|
+
state = 'connecting'; stage = 'handshake';
|
|
72
|
+
const pluginChoice = requestedPluginOperations === undefined ? config.pluginOperations : requestedPluginOperations;
|
|
73
|
+
const result = await request('/api/v0/connections/' + config.connectionId + '/handshake', { method: 'POST', handshake: true,
|
|
74
|
+
value: { contractVersion: 0, adapterId: config.adapterId, harnessKind: config.harnessKind, runtimeInstanceId,
|
|
75
|
+
harnessVersion, requestedScopes, requestedCapabilities: requestedCapabilities ?? config.capabilities,
|
|
76
|
+
requestedTools: requestedTools ?? config.tools,
|
|
77
|
+
...(pluginChoice === undefined ? {} : { requestedPluginOperations: checkedPluginSelection(pluginChoice) }) } });
|
|
78
|
+
if (state !== 'connecting') connectionFail('IRIS_CORE_SERVICE_ABORTED', '连接启动等待已停止');
|
|
79
|
+
if (result.rootId !== config.rootId || result.connectionId !== config.connectionId || result.instance?.runtimeInstanceId !== runtimeInstanceId
|
|
80
|
+
|| !Array.isArray(result.instance.scopes) || result.instance.scopes.some(scope => !requestedScopes.includes(scope))
|
|
81
|
+
|| !Array.isArray(result.instance.capabilities) || result.instance.capabilities.some(id => !(requestedCapabilities ?? config.capabilities).includes(id))
|
|
82
|
+
|| !Array.isArray(result.instance.tools) || result.instance.tools.some(name => !(requestedTools ?? config.tools).includes(name))) {
|
|
83
|
+
connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 数据空间或协商身份不一致');
|
|
84
|
+
}
|
|
85
|
+
if (checkedPluginSelection(result.instance.pluginOperations).some(pin => !hasPluginSelection(pluginChoice ?? [], pin))) {
|
|
86
|
+
connectionFail('IRIS_CORE_SERVICE_RESPONSE_INVALID', 'Core 返回了未请求的插件业务依赖');
|
|
87
|
+
}
|
|
88
|
+
negotiated = result; metadataKey = null; metadataAt = 0; state = 'open'; return structuredClone(result);
|
|
89
|
+
} catch (error) { if (!['closing', 'closed'].includes(state)) state = 'disconnected'; throw annotateCoreConnectionError(error, stage); }
|
|
90
|
+
})();
|
|
91
|
+
void connectionPromise.then(() => { connectionPromise = null; }, () => { connectionPromise = null; });
|
|
92
|
+
return connectionPromise;
|
|
93
|
+
}
|
|
94
|
+
function snapshot() {
|
|
95
|
+
return { contractVersion: 0, state, ...(config ? { connectionId: config.connectionId, adapterId: config.adapterId,
|
|
96
|
+
harnessKind: config.harnessKind, label: config.label, runtimeInstanceId, scopes: negotiated?.instance.scopes || [],
|
|
97
|
+
capabilities: negotiated?.instance.capabilities || [], tools: negotiated?.instance.tools || [],
|
|
98
|
+
...(negotiated?.instance.pluginOperations?.length ? { pluginOperations: structuredClone(negotiated.instance.pluginOperations) } : {}) } : {}) };
|
|
99
|
+
}
|
|
100
|
+
async function reportClientInfo(clientInfo, transport) {
|
|
101
|
+
if (!clientInfo || !negotiated?.connectionFeatures?.includes('client-info')) return;
|
|
102
|
+
const key = JSON.stringify({ clientInfo, transport });
|
|
103
|
+
if (key === metadataKey && Date.now() - metadataAt < 60000) return metadataPromise;
|
|
104
|
+
metadataKey = key; metadataAt = Date.now();
|
|
105
|
+
const attempt = request('/api/v0/connections/' + config.connectionId + '/client-info', { method: 'POST', value: { clientInfo, transport } });
|
|
106
|
+
metadataPromise = attempt;
|
|
107
|
+
void attempt.catch(() => { if (metadataPromise === attempt) { metadataKey = null; metadataAt = 0; } });
|
|
108
|
+
return metadataPromise;
|
|
109
|
+
}
|
|
110
|
+
function dispose() {
|
|
111
|
+
if (disposal) return disposal;
|
|
112
|
+
const wasOpen = state === 'open', starting = connectionPromise;
|
|
113
|
+
state = 'closing'; lifetime.abort();
|
|
114
|
+
disposal = (async () => {
|
|
115
|
+
let remoteDisconnect = wasOpen ? 'unconfirmed' : 'not_connected';
|
|
116
|
+
try {
|
|
117
|
+
if (wasOpen) {
|
|
118
|
+
await request('/api/v0/connections/' + config.connectionId + '/disconnect', { method: 'POST', value: {}, disconnect: true });
|
|
119
|
+
remoteDisconnect = 'confirmed';
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!['IRIS_CORE_SERVICE_UNAVAILABLE', 'IRIS_CORE_SERVICE_TIMEOUT', 'IRIS_CORE_SERVICE_ABORTED', 'IRIS_CONNECTION_HANDSHAKE_REQUIRED',
|
|
123
|
+
'IRIS_CONNECTION_AUTH_REQUIRED', 'IRIS_API_AUTH_REQUIRED', 'IRIS_API_UNAVAILABLE'].includes(error.code)) throw error;
|
|
124
|
+
} finally {
|
|
125
|
+
await Promise.allSettled([starting, metadataPromise].filter(Boolean));
|
|
126
|
+
state = 'closed'; negotiated = null; config = null;
|
|
127
|
+
}
|
|
128
|
+
return { state, remoteDisconnect };
|
|
129
|
+
})();
|
|
130
|
+
return disposal;
|
|
131
|
+
}
|
|
132
|
+
function pageQuery(value = {}) {
|
|
133
|
+
if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['limit', 'cursor'].includes(key))
|
|
134
|
+
|| value.limit !== undefined && (!Number.isInteger(value.limit) || value.limit < 1 || value.limit > 30)
|
|
135
|
+
|| value.cursor !== undefined && typeof value.cursor !== 'string') input();
|
|
136
|
+
return new URLSearchParams(Object.fromEntries(Object.entries(value).map(([key, value]) => [key, String(value)]))).toString();
|
|
137
|
+
}
|
|
138
|
+
return Object.freeze({ connect, dispose, snapshot, reportClientInfo,
|
|
139
|
+
pluginCatalog: (page, options) => request('/api/v0/plugin-operations/catalog?' + new URLSearchParams(page), options),
|
|
140
|
+
callPlugin({ key, ...value }, options) { return request('/api/v0/plugin-operations', { ...options, method: 'POST', value, key }); },
|
|
141
|
+
models: options => request('/api/v0/models', options),
|
|
142
|
+
audioModels: options => request('/api/v0/audio-models', options),
|
|
143
|
+
connections: options => request('/api/v0/connections', options),
|
|
144
|
+
runs: (page, options) => request('/api/v0/runs?' + pageQuery(page), options),
|
|
145
|
+
run: (id, options) => request('/api/v0/runs/' + reference(id, RUN), options),
|
|
146
|
+
image: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/content', { ...options, binary: true }),
|
|
147
|
+
audio: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/audio', { ...options, binary: 'audio' }),
|
|
148
|
+
transcript: (id, options) => request('/api/v0/artifacts/' + reference(id, ARTIFACT) + '/text', { ...options, binary: 'text' }),
|
|
149
|
+
createImage({ key, bytes, mediaType }, options) {
|
|
150
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > MAX_IMPORT_IMAGE_BYTES || !['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) input();
|
|
151
|
+
return request('/api/v0/images', { ...options, method: 'POST', bytes, key, mediaType });
|
|
152
|
+
},
|
|
153
|
+
createRun({ key, value }, options) { return request('/api/v0/runs', { ...options, method: 'POST', value, key }); },
|
|
154
|
+
createGeneration({ key, value }, options) { return request('/api/v0/generations', { ...options, method: 'POST', value, key }); },
|
|
155
|
+
createAudio({ key, bytes, mediaType }, options) {
|
|
156
|
+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > 20 * 1024 * 1024 || !['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac'].includes(mediaType)) input();
|
|
157
|
+
return request('/api/v0/audio', { ...options, method: 'POST', bytes, key, mediaType });
|
|
158
|
+
},
|
|
159
|
+
createSpeech({ key, value }, options) { return request('/api/v0/speeches', { ...options, method: 'POST', value, key }); },
|
|
160
|
+
createTranscription({ key, value }, options) { return request('/api/v0/transcriptions', { ...options, method: 'POST', value, key }); },
|
|
161
|
+
continueRun({ key, runId, expectedRevision }, options) {
|
|
162
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) input();
|
|
163
|
+
return request('/api/v0/runs/' + reference(runId, RUN) + '/continue', { ...options, method: 'POST', value: { expectedRevision }, key });
|
|
164
|
+
},
|
|
165
|
+
operation: (id, options) => request('/api/v0/operations/' + reference(id, OP), options),
|
|
166
|
+
lookup: (key, options) => request('/api/v0/operations?key=' + reference(key, UUID), options),
|
|
167
|
+
operations: (runId, page, options) => request('/api/v0/runs/' + reference(runId, RUN) + '/operations?' + pageQuery(page), options),
|
|
168
|
+
stop: (id, options) => request('/api/v0/operations/' + reference(id, OP) + '/cancel', { ...options, method: 'POST', value: {} })
|
|
169
|
+
});
|
|
170
|
+
}
|
package/lib/core-tasks.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
|
|
6
|
+
import { atomicCreatePrivate, atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
|
|
7
7
|
import { parseModelRef } from './models.js';
|
|
8
8
|
import { providerErrorRecord } from './provider-contract.js';
|
|
9
9
|
import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
|
|
10
10
|
import { readDeliveryCache } from './core-delivery-cache.js';
|
|
11
|
+
import { flowStep, flowStepId, flowTaskBinding, flowCapability, readFlowRun, validFlowBinding } from './flow-store.js';
|
|
12
|
+
import { checkedPluginDependency, assertPluginDependencyMatches, PluginContractError } from './plugin-contract.js';
|
|
11
13
|
|
|
12
14
|
const STORE_DIR = 'task-store';
|
|
13
15
|
const STORE_VERSION = 'v0';
|
|
@@ -110,7 +112,9 @@ function validateRecord(record, expectedId) {
|
|
|
110
112
|
|| (record.deliveryCache !== undefined && record.deliveryCache !== true)
|
|
111
113
|
|| (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
|
|
112
114
|
|| (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
|
|
113
|
-
|| (record.
|
|
115
|
+
|| (record.flowBinding !== undefined && (!validFlowBinding(record.flowBinding)
|
|
116
|
+
|| ({ edit: 'image', generate: 'image', speak: 'tts', transcribe: 'transcribe' })[record.flowBinding.stepId] !== record.capability))
|
|
117
|
+
|| (record.sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(record.capability) || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
|
|
114
118
|
fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
|
|
115
119
|
}
|
|
116
120
|
if (record.attempts.some((attempt, index) => !attempt || !ATTEMPT_ID.test(attempt.id)
|
|
@@ -121,6 +125,15 @@ function validateRecord(record, expectedId) {
|
|
|
121
125
|
|| !validAttemptStageTimestamps(attempt.stageTimestamps))) {
|
|
122
126
|
fail('IRIS_TASK_INVALID', 'Task Attempt 记录结构无效');
|
|
123
127
|
}
|
|
128
|
+
try {
|
|
129
|
+
for (const item of [record, ...record.attempts]) if (item.pluginDependency !== undefined) {
|
|
130
|
+
if (checkedPluginDependency(item.pluginDependency).kind !== 'providers' || !item.providerBinding) fail('IRIS_TASK_INVALID', 'Task 插件依赖需要 Provider 与端点绑定');
|
|
131
|
+
}
|
|
132
|
+
assertPluginDependencyMatches(record.pluginDependency, record.attempts.at(-1)?.pluginDependency);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!(error instanceof PluginContractError)) throw error;
|
|
135
|
+
fail('IRIS_TASK_INVALID', 'Task 或 Attempt 的插件依赖无效或不一致');
|
|
136
|
+
}
|
|
124
137
|
const violations = semanticViolations(record);
|
|
125
138
|
if (violations.length) fail('IRIS_TASK_INVALID', 'Task 事实语义冲突:' + violations.join(', '));
|
|
126
139
|
return record;
|
|
@@ -143,11 +156,12 @@ function readRecord(dataRoot, id) {
|
|
|
143
156
|
return validateRecord(record, id);
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
function writeRecord(dataRoot, record) {
|
|
159
|
+
function writeRecord(dataRoot, record, exclusive = false) {
|
|
147
160
|
validateRecord(record, record.id);
|
|
148
161
|
try {
|
|
149
162
|
ensurePrivateDir(taskDirectory(dataRoot));
|
|
150
|
-
|
|
163
|
+
const publish = exclusive ? atomicCreatePrivate : atomicWritePrivate;
|
|
164
|
+
publish(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
|
|
151
165
|
} catch (error) {
|
|
152
166
|
if (error instanceof CoreTaskError) throw error;
|
|
153
167
|
fail('IRIS_TASK_WRITE_FAILED', 'Task 无法安全写入');
|
|
@@ -169,18 +183,18 @@ function safeError(error, overrides) {
|
|
|
169
183
|
return providerErrorRecord(error, overrides);
|
|
170
184
|
}
|
|
171
185
|
|
|
172
|
-
|
|
186
|
+
function newTaskRecord(input, id) {
|
|
173
187
|
const capability = String(input.capability || '').trim();
|
|
174
188
|
if (!CAPABILITIES.has(capability)) fail('IRIS_TASK_INPUT_INVALID', 'Task capability 无效');
|
|
175
189
|
const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
|
|
176
190
|
if (retriedFrom !== undefined && !TASK_ID.test(retriedFrom)) fail('IRIS_TASK_INPUT_INVALID', 'retriedFrom 必须是合法 Task ID');
|
|
177
191
|
const sourceArtifactId = input.sourceArtifactId;
|
|
178
|
-
if (sourceArtifactId !== undefined && (
|
|
192
|
+
if (sourceArtifactId !== undefined && (!['image', 'transcribe'].includes(capability) || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '来源须为改图或转写的合法 Artifact ID');
|
|
179
193
|
const now = new Date().toISOString();
|
|
180
|
-
return
|
|
194
|
+
return {
|
|
181
195
|
schemaVersion: 0,
|
|
182
196
|
semanticsVersion: 2,
|
|
183
|
-
id
|
|
197
|
+
id,
|
|
184
198
|
capability,
|
|
185
199
|
status: 'running',
|
|
186
200
|
phase: 'queued',
|
|
@@ -197,7 +211,51 @@ export function createCoreTask(dataRoot, input = {}) {
|
|
|
197
211
|
// D4 retry as new task:显式单向关系(新任务指向旧任务),绝不复制 Prompt。
|
|
198
212
|
...(retriedFrom !== undefined ? { retriedFrom } : {}),
|
|
199
213
|
...(sourceArtifactId !== undefined ? { sourceArtifactId } : {})
|
|
200
|
-
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createCoreTask(dataRoot, input = {}) {
|
|
218
|
+
return writeRecord(dataRoot, newTaskRecord(input, 'task_' + crypto.randomBytes(12).toString('hex')));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
|
|
222
|
+
export function assertFlowTaskMatches(task, run) {
|
|
223
|
+
const providerId = parseModelRef(run.input.model_ref).providerId;
|
|
224
|
+
if (task.id !== flowStep(run).taskId || task.capability !== flowCapability(run) || task.sourceArtifactId !== run.source?.artifactId
|
|
225
|
+
|| !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
|
|
226
|
+
|| task.flowBinding.stepId !== flowStepId(run)
|
|
227
|
+
|| task.flowBinding.inputDigest !== run.inputDigest
|
|
228
|
+
|| task.attempts.some(attempt => attempt.providerId !== providerId
|
|
229
|
+
|| attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
|
|
230
|
+
|| (task.attempts.length && (task.providerId !== providerId || task.modelRef !== run.input.model_ref || task.providerBinding !== run.providerBinding))) {
|
|
231
|
+
fail('IRIS_FLOW_TASK_CONFLICT', '流程与原 Task 的身份、输入或模型绑定冲突');
|
|
232
|
+
}
|
|
233
|
+
return task;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function findFlowTask(dataRoot, run) {
|
|
237
|
+
for (const relative of [STORE_DIR, STORE_DIR + '/' + STORE_VERSION, STORE_DIR + '/' + STORE_VERSION + '/tasks']) {
|
|
238
|
+
try {
|
|
239
|
+
const stat = fs.lstatSync(path.join(dataRoot, relative));
|
|
240
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
|
|
241
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
242
|
+
}
|
|
243
|
+
try { fs.lstatSync(taskFile(dataRoot, flowStep(run).taskId)); }
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error.code !== 'ENOENT') throw error;
|
|
246
|
+
if (flowStep(run).claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return assertFlowTaskMatches(readRecord(dataRoot, flowStep(run).taskId), run);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
|
|
253
|
+
export function claimFlowTask(dataRoot, runId) {
|
|
254
|
+
const run = readFlowRun(dataRoot, runId);
|
|
255
|
+
const previous = findFlowTask(dataRoot, run);
|
|
256
|
+
if (previous) return previous;
|
|
257
|
+
return writeRecord(dataRoot, { ...newTaskRecord({ capability: flowCapability(run), sourceArtifactId: run.source?.artifactId }, flowStep(run).taskId),
|
|
258
|
+
flowBinding: flowTaskBinding(run) }, true);
|
|
201
259
|
}
|
|
202
260
|
|
|
203
261
|
export function inspectCoreTask(dataRoot, id) {
|
|
@@ -256,9 +314,11 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
|
|
|
256
314
|
const providerId = String(fields.providerId || '').trim();
|
|
257
315
|
const model = String(fields.model || '').trim();
|
|
258
316
|
const parsed = parseModelRef(model);
|
|
317
|
+
const pluginDependency = fields.pluginDependency === undefined ? undefined : checkedPluginDependency(fields.pluginDependency);
|
|
259
318
|
if (!providerId || !parsed || parsed.providerId !== providerId
|
|
260
319
|
|| (fields.providerBinding !== undefined && !PROVIDER_BINDING.test(fields.providerBinding))
|
|
261
|
-
|| (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))
|
|
320
|
+
|| (fields.selectionReason !== undefined && !ATTEMPT_SELECTION_REASONS.has(fields.selectionReason))
|
|
321
|
+
|| (pluginDependency && (pluginDependency.kind !== 'providers' || !fields.providerBinding))) {
|
|
262
322
|
fail('IRIS_ATTEMPT_INPUT_INVALID', 'Attempt 的 providerId 与复合 model 必须一致');
|
|
263
323
|
}
|
|
264
324
|
let created;
|
|
@@ -279,7 +339,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
|
|
|
279
339
|
startedAt: submitStartedAt,
|
|
280
340
|
stageTimestamps: { queuedAt, submitStartedAt },
|
|
281
341
|
...(fields.selectionReason !== undefined ? { selectionReason: fields.selectionReason } : {}),
|
|
282
|
-
...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {})
|
|
342
|
+
...(fields.providerBinding !== undefined ? { providerBinding: fields.providerBinding } : {}),
|
|
343
|
+
...(pluginDependency ? { pluginDependency } : {})
|
|
283
344
|
};
|
|
284
345
|
task.attempts.push(created);
|
|
285
346
|
task.activeAttemptId = created.id;
|
|
@@ -287,6 +348,8 @@ export function beginCoreAttempt(dataRoot, taskId, fields = {}) {
|
|
|
287
348
|
task.modelRef = model;
|
|
288
349
|
if (created.providerBinding) task.providerBinding = created.providerBinding;
|
|
289
350
|
else delete task.providerBinding;
|
|
351
|
+
if (created.pluginDependency) task.pluginDependency = created.pluginDependency;
|
|
352
|
+
else delete task.pluginDependency;
|
|
290
353
|
task.phase = 'submitting';
|
|
291
354
|
task.acceptance = 'none';
|
|
292
355
|
task.watchState = 'idle';
|
|
@@ -366,7 +429,7 @@ export function activateCoreWatch(dataRoot, taskId) {
|
|
|
366
429
|
});
|
|
367
430
|
}
|
|
368
431
|
|
|
369
|
-
export function recordCorePollResult(dataRoot, taskId, result) {
|
|
432
|
+
export function recordCorePollResult(dataRoot, taskId, result, { deliveryCache = false } = {}) {
|
|
370
433
|
return mutate(dataRoot, taskId, (task) => {
|
|
371
434
|
if (task.acceptance !== 'accepted' || !task.remoteTaskId) {
|
|
372
435
|
fail('IRIS_TASK_NOT_OBSERVABLE', 'Task 缺少可观察的远端受理事实');
|
|
@@ -387,6 +450,7 @@ export function recordCorePollResult(dataRoot, taskId, result) {
|
|
|
387
450
|
task.phase = 'running';
|
|
388
451
|
task.outcome = 'succeeded';
|
|
389
452
|
task.deliveryState = 'pending';
|
|
453
|
+
if (deliveryCache) task.deliveryCache = true;
|
|
390
454
|
delete task.lastError;
|
|
391
455
|
return;
|
|
392
456
|
}
|
|
@@ -472,7 +536,7 @@ export function failCoreDelivery(dataRoot, taskId, error) {
|
|
|
472
536
|
task.phase = 'terminal';
|
|
473
537
|
task.watchState = 'idle';
|
|
474
538
|
task.deliveryState = 'failed';
|
|
475
|
-
task.lastError = safeError(error, { stage: 'download', category: 'local_io', acceptance: 'accepted' });
|
|
539
|
+
task.lastError = safeError(error, { stage: 'download', category: error?.category || 'local_io', acceptance: 'accepted' });
|
|
476
540
|
task.finishedAt = new Date().toISOString();
|
|
477
541
|
});
|
|
478
542
|
}
|
|
@@ -524,6 +588,13 @@ export function recordCoreCancelResult(dataRoot, taskId, result) {
|
|
|
524
588
|
*/
|
|
525
589
|
export function recoverCoreTask(dataRoot, taskId) {
|
|
526
590
|
let current = readRecord(dataRoot, taskId);
|
|
591
|
+
// Flow 的异步完成结果同样先缓存,再记录 poll;中断时只恢复原结果,不重新查询/生成。
|
|
592
|
+
if (current.flowBinding && current.acceptance === 'accepted' && current.remoteTaskId
|
|
593
|
+
&& current.outcome === 'none' && current.cancelState === 'none'
|
|
594
|
+
&& readDeliveryCache(dataRoot, current, { allowAbsent: true })) {
|
|
595
|
+
recordCorePollResult(dataRoot, taskId, { kind: 'succeeded' }, { deliveryCache: true });
|
|
596
|
+
current = readRecord(dataRoot, taskId);
|
|
597
|
+
}
|
|
527
598
|
if (current.phase === 'submitting' && current.acceptance === 'none') {
|
|
528
599
|
const cache = readDeliveryCache(dataRoot, current, { allowAbsent: true });
|
|
529
600
|
if (cache) {
|
package/lib/core-writer-lease.js
CHANGED
|
@@ -44,6 +44,22 @@ function optionalRecord(file) {
|
|
|
44
44
|
catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Recognize only an unpublished preparation from a proven-dead owner. Keep it
|
|
48
|
+
* as evidence: initialization may ignore this metadata, but never removes it. */
|
|
49
|
+
export function isAbandonedWriterPreparation(root, name) {
|
|
50
|
+
const match = /^\.iris-runtime-writer-v0\.owner\.json\.prepare-([1-9][0-9]*)-[a-f0-9]{12}$/.exec(name);
|
|
51
|
+
if (!match) return false;
|
|
52
|
+
try {
|
|
53
|
+
const file = path.join(root, name), stat = fs.lstatSync(file);
|
|
54
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return false;
|
|
55
|
+
const owner = checkedOwner(readRecord(file).value);
|
|
56
|
+
return String(owner.pid) === match[1] && writerOwnerStatus(owner.pid) === 'missing';
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.code && !['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
47
63
|
/** 完整私有记录先在根中准备,再以 hard link 原子发布;已有目标绝不覆盖。 */
|
|
48
64
|
export function publishLeaseRecord(root, target, value) {
|
|
49
65
|
const prepared = privateSibling(path.join(root, WRITER_INTENT_FILE), 'prepare');
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Private local creation intents. No Host, HTTP, Provider or Runtime dependencies.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
|
|
6
|
+
import { validateFlowCreation } from './flow-store.js';
|
|
7
|
+
import { inspectLocalRequestRoot, initializeLocalRequestRoot, requestDirectory } from './local-request-root.js';
|
|
8
|
+
import { normalizeComfyParameters } from './comfyui-workflow.js';
|
|
9
|
+
import { AUDIO_TYPES, MAX_AUDIO_INPUT_BYTES, normalizeAudioFlowInput } from './audio-flow-input.js';
|
|
10
|
+
|
|
11
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
12
|
+
const ID = /^req_[a-f0-9]{64}$/;
|
|
13
|
+
const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
14
|
+
const SHA = /^[a-f0-9]{64}$/;
|
|
15
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
16
|
+
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
17
|
+
const MAX_IMAGE = 20 * 1024 * 1024;
|
|
18
|
+
export const CREATION_KINDS = Object.freeze(['image-import', 'flow-create', 'generation-create', 'audio-import', 'speech-create', 'transcription-create']);
|
|
19
|
+
export const isImportCreation = kind => ['image-import', 'audio-import'].includes(kind);
|
|
20
|
+
const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
21
|
+
const hash = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
22
|
+
const copy = value => structuredClone(value);
|
|
23
|
+
function keys(value, allowed, required = allowed) {
|
|
24
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
&& Object.keys(value).every(key => allowed.includes(key)) && required.every(key => Object.hasOwn(value, key));
|
|
26
|
+
}
|
|
27
|
+
export function creationFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
28
|
+
const invalid = () => creationFail('IRIS_CREATION_STORE_INVALID', '创建请求区未知、损坏或尚未完整初始化');
|
|
29
|
+
function jsonFile(file) {
|
|
30
|
+
const stat = fs.lstatSync(file);
|
|
31
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) invalid();
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { invalid(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inspectCreationRoot(root, { allowAbsent = false } = {}) {
|
|
36
|
+
return inspectLocalRequestRoot(root, { allowAbsent });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function initializeCreationRoot(root) {
|
|
40
|
+
const marker = initializeLocalRequestRoot(root, 0);
|
|
41
|
+
listCreationRequests(root);
|
|
42
|
+
return marker;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function creationRequestId(marker, kind, key) {
|
|
46
|
+
if (!UUID.test(key)) creationFail('IRIS_CREATION_KEY_INVALID', 'Idempotency-Key 须为单个小写 UUIDv4');
|
|
47
|
+
if (!CREATION_KINDS.includes(kind)) throw new TypeError('未知本地创建类型');
|
|
48
|
+
return 'req_' + hash(JSON.stringify(['creation-requests/v0', marker.rootId, kind, key]));
|
|
49
|
+
}
|
|
50
|
+
export function creationInputDigest(kind, input) {
|
|
51
|
+
if (['speech-create', 'transcription-create'].includes(kind)) return 'sha256:' + hash(JSON.stringify([kind + '/v0',
|
|
52
|
+
normalizeAudioFlowInput(kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0', input)]));
|
|
53
|
+
return 'sha256:' + hash(JSON.stringify(isImportCreation(kind)
|
|
54
|
+
? [kind + '/v0', input.mediaType, input.size, input.sha256]
|
|
55
|
+
: kind === 'generation-create' ? ['generation-create/v0', 'image-generate/v0', input.model_ref, input.prompt, input.size ?? null, 1,
|
|
56
|
+
...(input.workflow_parameters !== undefined ? [['comfyui-parameters/v0', normalizeComfyParameters(input.workflow_parameters)]] : [])]
|
|
57
|
+
: ['flow-create/v0', 'image-edit/v0', input.source_artifact_id, input.model_ref, input.prompt, input.size ?? null, 1]));
|
|
58
|
+
}
|
|
59
|
+
function validate(record, id, marker) {
|
|
60
|
+
if (!keys(record, ['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state', 'completedAt', 'result'],
|
|
61
|
+
['schemaVersion', 'id', 'rootId', 'kind', 'inputDigest', 'createdAt', 'intent', 'state'])
|
|
62
|
+
|| record.schemaVersion !== 0 || !ID.test(id) || record.id !== id || record.rootId !== marker.rootId
|
|
63
|
+
|| !DIGEST.test(record.inputDigest) || !iso(record.createdAt) || record.intent?.createdAt !== record.createdAt
|
|
64
|
+
|| !['prepared', 'succeeded'].includes(record.state)) invalid();
|
|
65
|
+
const intent = record.intent;
|
|
66
|
+
let expectedResult, expectedDigest;
|
|
67
|
+
if (isImportCreation(record.kind)) {
|
|
68
|
+
const audio = record.kind === 'audio-import';
|
|
69
|
+
if (!keys(intent, ['artifactId', 'mediaType', 'size', 'sha256', 'metadata', 'createdAt'])
|
|
70
|
+
|| audio && marker.schemaVersion !== 3
|
|
71
|
+
|| !ARTIFACT.test(intent.artifactId) || !(audio ? Object.hasOwn(AUDIO_TYPES, intent.mediaType) : TYPES.has(intent.mediaType)) || !SHA.test(intent.sha256)
|
|
72
|
+
|| !Number.isSafeInteger(intent.size) || intent.size < 1 || intent.size > (audio ? MAX_AUDIO_INPUT_BYTES : MAX_IMAGE)
|
|
73
|
+
|| (audio ? !keys(intent.metadata, []) : !keys(intent.metadata, ['width', 'height']) || !['width', 'height'].every(key => Number.isSafeInteger(intent.metadata[key]) && intent.metadata[key] > 0))) invalid();
|
|
74
|
+
expectedDigest = creationInputDigest(record.kind, intent);
|
|
75
|
+
expectedResult = { artifactId: intent.artifactId };
|
|
76
|
+
} else if (['flow-create', 'generation-create', 'speech-create', 'transcription-create'].includes(record.kind)) {
|
|
77
|
+
if (record.kind === 'generation-create' ? ![2, 3].includes(marker.schemaVersion) || intent.recipe !== 'image-generate/v0'
|
|
78
|
+
: record.kind === 'flow-create' ? intent.recipe !== undefined
|
|
79
|
+
: marker.schemaVersion !== 3 || intent.recipe !== (record.kind === 'speech-create' ? 'speech/v0' : 'transcribe/v0')) invalid();
|
|
80
|
+
try { validateFlowCreation(intent); } catch (_) { invalid(); }
|
|
81
|
+
expectedDigest = creationInputDigest(record.kind, intent.input);
|
|
82
|
+
expectedResult = { runId: intent.runId };
|
|
83
|
+
} else invalid();
|
|
84
|
+
if (record.inputDigest !== expectedDigest) invalid();
|
|
85
|
+
if (record.state === 'prepared') {
|
|
86
|
+
if (Object.hasOwn(record, 'completedAt') || Object.hasOwn(record, 'result')) invalid();
|
|
87
|
+
} else if (!iso(record.completedAt) || !keys(record.result, Object.keys(expectedResult))
|
|
88
|
+
|| JSON.stringify(record.result) !== JSON.stringify(expectedResult)) invalid();
|
|
89
|
+
return record;
|
|
90
|
+
}
|
|
91
|
+
export function readCreationRequest(root, id, marker = inspectCreationRoot(root)) {
|
|
92
|
+
if (!ID.test(id)) invalid();
|
|
93
|
+
let record;
|
|
94
|
+
try { record = jsonFile(path.join(requestDirectory(root, marker, 'requests'), id + '.json')); }
|
|
95
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
96
|
+
return copy(validate(record, id, marker));
|
|
97
|
+
}
|
|
98
|
+
export function listCreationRequests(root) {
|
|
99
|
+
const marker = inspectCreationRoot(root, { allowAbsent: true });
|
|
100
|
+
if (!marker) return [];
|
|
101
|
+
const records = [];
|
|
102
|
+
for (const name of fs.readdirSync(requestDirectory(root, marker, 'requests'))) {
|
|
103
|
+
if (/^req_[a-f0-9]{64}\.json\.tmp-[1-9][0-9]*-[a-f0-9]{12}$/.test(name)) continue;
|
|
104
|
+
if (!/^req_[a-f0-9]{64}\.json$/.test(name)) invalid();
|
|
105
|
+
const record = readCreationRequest(root, name.slice(0, -5), marker);
|
|
106
|
+
if (!record) invalid();
|
|
107
|
+
records.push(record);
|
|
108
|
+
}
|
|
109
|
+
return records;
|
|
110
|
+
}
|
|
111
|
+
export function publishCreationRequest(root, record) {
|
|
112
|
+
const marker = inspectCreationRoot(root);
|
|
113
|
+
validate(record, record.id, marker);
|
|
114
|
+
atomicCreatePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(record, null, 2) + '\n');
|
|
115
|
+
return copy(record);
|
|
116
|
+
}
|
|
117
|
+
export function completeCreationRequest(root, record) {
|
|
118
|
+
const current = readCreationRequest(root, record.id);
|
|
119
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(record) || current.state !== 'prepared') invalid();
|
|
120
|
+
const finished = { ...record, state: 'succeeded', completedAt: new Date().toISOString(),
|
|
121
|
+
result: isImportCreation(record.kind) ? { artifactId: record.intent.artifactId } : { runId: record.intent.runId } };
|
|
122
|
+
const marker = inspectCreationRoot(root);
|
|
123
|
+
validate(finished, record.id, marker);
|
|
124
|
+
atomicWritePrivate(path.join(requestDirectory(root, marker, 'requests'), record.id + '.json'), JSON.stringify(finished, null, 2) + '\n');
|
|
125
|
+
return copy(finished);
|
|
126
|
+
}
|
|
127
|
+
export function creationReferences(root) {
|
|
128
|
+
return listCreationRequests(root).filter(record => record.state === 'prepared').map(record => ({
|
|
129
|
+
requestId: record.id, taskIds: !isImportCreation(record.kind) ? [record.intent.taskId] : [],
|
|
130
|
+
artifactIds: isImportCreation(record.kind) ? [record.intent.artifactId]
|
|
131
|
+
: [record.intent.source?.artifactId, record.intent.comparisonArtifactId].filter(Boolean)
|
|
132
|
+
}));
|
|
133
|
+
}
|
package/lib/doctor.js
CHANGED
|
@@ -421,7 +421,18 @@ export function hostDoctor(host, evidence = {}, options = {}) {
|
|
|
421
421
|
+ (missingSkills.length ? ':' + missingSkills.join(', ') : ''),
|
|
422
422
|
'工具仍可直接调用;检查 DSH Skill registry 和插件启动日志。'));
|
|
423
423
|
|
|
424
|
-
capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser')
|
|
424
|
+
if (capabilityCheck(checks, snapshot, 'browser', 'browser', 'HTML Browser')) {
|
|
425
|
+
checks[checks.length - 1] = result('browser', 'ok', 'HTML 浏览器接口已提供;实际截图需单独自检',
|
|
426
|
+
'点击“截图自检”验证当前浏览器能否启动、打开页面并生成图片。');
|
|
427
|
+
const address = host.ports.browser.address;
|
|
428
|
+
if (address) {
|
|
429
|
+
checks.push(result('render-address', address.status === 'resolved' ? 'ok' : 'warn',
|
|
430
|
+
address.status === 'resolved'
|
|
431
|
+
? '截图地址:' + address.baseUrl + (address.source === 'environment' ? '(DSH_WEB_BASE)' : '(宿主监听)')
|
|
432
|
+
: address.message,
|
|
433
|
+
address.status === 'resolved' ? '' : '显式配置 DSH_WEB_BASE;不需要额外的端口转发。'));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
425
436
|
capabilityCheck(checks, snapshot, 'attachments', 'attachments', '附件读写');
|
|
426
437
|
capabilityCheck(checks, snapshot, 'sessions', 'sessions', '会话查询');
|
|
427
438
|
capabilityCheck(checks, snapshot, 'text-model', 'textModel', '宿主文本模型');
|