@mokuyoaxis/dsh-iris 0.2.7 → 0.3.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/README.en.md +103 -312
- package/README.md +100 -310
- package/bin/dsh-iris.js +333 -65
- package/docs/AGENT_CORE_QUICKSTART.md +64 -0
- package/docs/AGENT_QUICKSTART.md +2 -0
- package/docs/ARCHITECTURE.md +13 -3
- package/docs/AUDIO_FLOWS.md +71 -0
- package/docs/CONTACT_SHEET.md +62 -0
- package/docs/CORE_CONNECTIONS.md +99 -0
- package/docs/CORE_QUICKSTART.md +108 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
- package/docs/CORE_SERVICE.md +97 -0
- package/docs/CORE_SKILLS.md +35 -3
- package/docs/DSH_COMPATIBILITY.md +10 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/DSH_RUNTIME.md +6 -2
- package/docs/HEADLESS_CLI.md +2 -2
- package/docs/IMAGE_FLOW.md +4 -0
- package/docs/IMAGE_GENERATION.md +127 -0
- package/docs/LOCAL_API.md +4 -2
- package/docs/LOCAL_API_CONTRACT.md +39 -14
- package/docs/LOCAL_CREATION.md +7 -1
- package/docs/LOCAL_OPERATIONS.md +46 -0
- package/docs/LOCAL_SELECTION.md +57 -0
- package/docs/LOCAL_WEB.md +201 -0
- package/docs/MCP.md +191 -0
- package/docs/MUSIC.md +2 -0
- package/docs/PANEL_EXTENSIONS.md +64 -0
- package/docs/PLUGINS.md +189 -0
- package/docs/PLUGIN_STARTUP.md +100 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
- package/docs/PROVIDER_EXTENSIONS.md +78 -0
- package/docs/PROVIDER_HEALTH.md +3 -1
- package/docs/PROVIDER_SETTINGS.md +85 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
- package/docs/ROADMAP.md +17 -7
- package/docs/VISION_CLI.md +1 -1
- package/docs/WEB_PROTOTYPE.md +21 -6
- package/docs/WEB_RECOVERY.md +46 -0
- package/docs/WORKBENCH_LOGIN.md +63 -0
- package/docs/assets/screenshots/core-connections-preview.webp +0 -0
- package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
- package/docs/assets/screenshots/core-overview-preview.webp +0 -0
- package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
- package/docs/assets/screenshots/core-studio-preview.webp +0 -0
- package/docs/releases/0.3.0-alpha.1.md +51 -0
- package/docs/screenshots.md +41 -1
- package/lib/actions.js +53 -2
- package/lib/adapters.js +20 -11
- package/lib/application-runtime.js +34 -7
- package/lib/audio-flow-input.js +37 -0
- package/lib/client.js +346 -53
- package/lib/comfyui-adapter.js +94 -0
- package/lib/comfyui-client.js +51 -0
- package/lib/comfyui-inspection.js +73 -0
- package/lib/comfyui-workflow.js +94 -0
- package/lib/config.js +36 -1
- package/lib/configured-image-runtime.js +50 -0
- package/lib/connection-skill.js +89 -0
- package/lib/connection-template.js +23 -0
- package/lib/core-artifact-store.js +16 -2
- package/lib/core-bridge-selection.js +31 -0
- package/lib/core-bridge-tools.js +83 -0
- package/lib/core-capability-catalog.js +182 -0
- package/lib/core-connection-diagnostics.js +49 -0
- package/lib/core-connection-store.js +277 -0
- package/lib/core-connections.js +225 -0
- package/lib/core-maintenance.js +9 -3
- package/lib/core-runtime.js +2 -1
- package/lib/core-service-client.js +170 -0
- package/lib/core-tasks.js +29 -12
- package/lib/core-writer-lease.js +16 -0
- package/lib/creation-request-store.js +38 -64
- package/lib/dsh-host-adapter.js +4 -1
- package/lib/dsh-service-bridge.js +66 -0
- package/lib/execution-ownership.js +43 -0
- package/lib/flow-store.js +105 -10
- package/lib/generation-input.js +10 -1
- package/lib/headless-vision.js +7 -7
- package/lib/image-edit-input.js +1 -1
- package/lib/image-flow.js +73 -29
- package/lib/index.js +37 -17
- package/lib/local-api-projection.js +10 -5
- package/lib/local-api-token-directory.js +31 -0
- package/lib/local-api.js +769 -67
- package/lib/local-creation.js +89 -24
- package/lib/local-operations.js +36 -0
- package/lib/local-request-root.js +86 -0
- package/lib/local-web-assets.js +37 -0
- package/lib/mcp-bridge.js +58 -0
- package/lib/mcp-check.js +26 -0
- package/lib/mcp-config.js +42 -0
- package/lib/mcp-delivery.js +46 -0
- package/lib/mcp-http.js +101 -0
- package/lib/mcp-server.js +47 -0
- package/lib/model-directory-service.js +16 -6
- package/lib/model-directory.js +3 -3
- package/lib/models.js +2 -2
- package/lib/music-transport.js +2 -1
- package/lib/operation-runner.js +236 -0
- package/lib/operation-store.js +216 -0
- package/lib/panel-extension.js +36 -0
- package/lib/plugin-contract.js +194 -0
- package/lib/plugin-execution.js +51 -0
- package/lib/plugin-launch.js +86 -0
- package/lib/plugin-operation-store.js +101 -0
- package/lib/plugin-operations.js +28 -0
- package/lib/plugin-registry.js +109 -0
- package/lib/plugin-selection.js +15 -0
- package/lib/plugin-source-image.js +5 -0
- package/lib/plugin-web-assets.js +54 -0
- package/lib/plugins.d.ts +288 -0
- package/lib/plugins.js +58 -0
- package/lib/provider-adapter.js +5 -1
- package/lib/provider-adapters.js +56 -18
- package/lib/provider-catalog.js +40 -29
- package/lib/provider-config-service.js +105 -23
- package/lib/provider-diagnostics.js +56 -0
- package/lib/provider-extensions.js +29 -0
- package/lib/provider-health.js +8 -1
- package/lib/provider-monitor.js +79 -0
- package/lib/provider-protocol.js +70 -20
- package/lib/provider-registry.js +118 -0
- package/lib/provider-response.js +28 -0
- package/lib/provider-settings.js +134 -0
- package/lib/provider-task-runner.js +22 -9
- package/lib/skill-installation.js +23 -11
- package/lib/vision-model-routing.js +3 -0
- package/lib/web/app.js +642 -0
- package/lib/web/appearance.js +111 -0
- package/lib/web/comfyui.js +146 -0
- package/lib/web/connections.js +377 -0
- package/lib/web/creation.js +355 -0
- package/lib/web/flow-view.js +33 -0
- package/lib/web/gallery.js +132 -0
- package/lib/web/index.html +291 -0
- package/lib/web/media-client.js +204 -0
- package/lib/web/plugin-panel.js +221 -0
- package/lib/web/plugin-surface.css +17 -0
- package/lib/web/providers.js +492 -0
- package/lib/web/recovery.js +105 -0
- package/lib/web/startup.js +18 -0
- package/lib/web/styles.css +518 -0
- package/lib/web/transport.js +96 -0
- package/lib/web/workbench-login.js +165 -0
- package/lib/workbench-auth-cli.js +47 -0
- package/lib/workbench-auth.js +200 -0
- package/package.json +20 -5
- package/plugins/contact-sheet/index.d.mts +4 -0
- package/plugins/contact-sheet/index.mjs +33 -0
- package/plugins/contact-sheet/web/board.css +14 -0
- package/plugins/contact-sheet/web/board.mjs +93 -0
- package/skills/iris-core/SKILL.md +8 -4
- package/skills/iris-core/references/cli.md +2 -2
- package/skills/iris-core/references/comfyui.md +69 -0
- package/skills/iris-core/references/tasks.md +2 -0
- package/user_guide.md +5 -1
package/lib/flow-store.js
CHANGED
|
@@ -4,9 +4,12 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { atomicCreatePrivate, atomicWritePrivate } from './private-storage.js';
|
|
7
|
-
import { WRITER_LEASE_DIR, WRITER_INTENT_FILE } from './core-writer-lease.js';
|
|
7
|
+
import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, isAbandonedWriterPreparation } from './core-writer-lease.js';
|
|
8
8
|
import { normalizeGenerationInput } from './generation-input.js';
|
|
9
9
|
import { parseModelRef } from './models.js';
|
|
10
|
+
import { inspectLocalRequestRoot } from './local-request-root.js';
|
|
11
|
+
import { normalizeComfyParameters } from './comfyui-workflow.js';
|
|
12
|
+
import { AUDIO_RECIPES, isAudioRecipe, normalizeAudioFlowInput } from './audio-flow-input.js';
|
|
10
13
|
|
|
11
14
|
const RUN = /^run_[a-f0-9]{24}$/;
|
|
12
15
|
const TASK = /^task_[a-f0-9]{24}$/;
|
|
@@ -14,6 +17,7 @@ const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
|
|
|
14
17
|
const SHA = /^[a-f0-9]{64}$/;
|
|
15
18
|
const BINDING = /^sha256:[a-f0-9]{64}$/;
|
|
16
19
|
const RECIPE = 'image-edit/v0';
|
|
20
|
+
export const IMAGE_GENERATE_RECIPE = 'image-generate/v0';
|
|
17
21
|
|
|
18
22
|
export class FlowError extends Error {
|
|
19
23
|
constructor(code, message) { super(message); this.name = 'FlowError'; this.code = code; }
|
|
@@ -28,23 +32,81 @@ function iso(value) { return typeof value === 'string' && Number.isFinite(Date.p
|
|
|
28
32
|
const copy = value => Object.freeze(structuredClone(value));
|
|
29
33
|
export const flowId = prefix => prefix + '_' + crypto.randomBytes(12).toString('hex');
|
|
30
34
|
export const flowSha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
35
|
+
export const flowStepId = run => isAudioRecipe(run.recipe) ? AUDIO_RECIPES[run.recipe].step : run.recipe === IMAGE_GENERATE_RECIPE ? 'generate' : 'edit';
|
|
36
|
+
export const flowCapability = run => isAudioRecipe(run.recipe) ? AUDIO_RECIPES[run.recipe].capability : 'image';
|
|
37
|
+
export const flowStep = run => run[flowStepId(run)];
|
|
38
|
+
export function requireImageGenerationRoot(root) {
|
|
39
|
+
if (![2, 3].includes(inspectLocalRequestRoot(root, { allowAbsent: true })?.schemaVersion)) {
|
|
40
|
+
flowFail('IRIS_FLOW_GENERATION_ROOT_REQUIRED', '文生图需要新的专用执行根:execution init --image-generation;不迁移已有根');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function requireAudioRoot(root) {
|
|
44
|
+
if (inspectLocalRequestRoot(root, { allowAbsent: true })?.schemaVersion !== 3) {
|
|
45
|
+
flowFail('IRIS_FLOW_AUDIO_ROOT_REQUIRED', '音频需要新的专用执行根:execution init --audio;不迁移已有根');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
31
48
|
|
|
32
49
|
export function validFlowBinding(value) {
|
|
33
50
|
return keys(value, ['runId', 'stepId', 'inputDigest']) && RUN.test(value.runId)
|
|
34
|
-
&&
|
|
51
|
+
&& ['edit', 'generate', 'speak', 'transcribe'].includes(value.stepId) && BINDING.test(value.inputDigest);
|
|
35
52
|
}
|
|
36
|
-
export function flowTaskBinding(run) { return { runId: run.id, stepId:
|
|
53
|
+
export function flowTaskBinding(run) { return { runId: run.id, stepId: flowStepId(run), inputDigest: run.inputDigest }; }
|
|
37
54
|
|
|
38
55
|
function inputDigest(run) {
|
|
56
|
+
if (isAudioRecipe(run.recipe)) return 'sha256:' + flowSha256(JSON.stringify([run.recipe, run.providerBinding,
|
|
57
|
+
normalizeAudioFlowInput(run.recipe, run.input), run.source?.sha256 ?? null]));
|
|
58
|
+
if (run.recipe === IMAGE_GENERATE_RECIPE) return 'sha256:' + flowSha256(JSON.stringify([IMAGE_GENERATE_RECIPE,
|
|
59
|
+
run.input.model_ref, run.providerBinding, run.input.prompt, run.input.size ?? null, 1,
|
|
60
|
+
...(run.input.workflow_parameters !== undefined ? [['comfyui-parameters/v0', normalizeComfyParameters(run.input.workflow_parameters)]] : [])]));
|
|
39
61
|
return 'sha256:' + flowSha256(JSON.stringify([RECIPE, run.source.artifactId, run.source.sha256,
|
|
40
62
|
run.input.model_ref, run.providerBinding, run.input.prompt, run.input.size ?? null, 1]));
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
function reference(value) { return keys(value, ['artifactId', 'sha256']) && ARTIFACT.test(value.artifactId) && SHA.test(value.sha256); }
|
|
66
|
+
function validateAudio(run, expectedId) {
|
|
67
|
+
let normalized;
|
|
68
|
+
try { normalized = normalizeAudioFlowInput(run.recipe, run.input); }
|
|
69
|
+
catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '音频流程输入无效'); }
|
|
70
|
+
const step = flowStepId(run), transcribe = run.recipe === 'transcribe/v0';
|
|
71
|
+
if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', step, 'source', 'output'],
|
|
72
|
+
['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', step, ...(transcribe ? ['source'] : [])])
|
|
73
|
+
|| run.schemaVersion !== 3 || !RUN.test(run.id) || run.id !== expectedId || !Number.isSafeInteger(run.revision) || run.revision < 1
|
|
74
|
+
|| !iso(run.createdAt) || !iso(run.updatedAt) || !BINDING.test(run.providerBinding)
|
|
75
|
+
|| JSON.stringify(run.input) !== JSON.stringify(normalized)
|
|
76
|
+
|| transcribe && (!reference(run.source) || run.source.artifactId !== run.input.source_artifact_id)
|
|
77
|
+
|| !transcribe && run.source !== undefined
|
|
78
|
+
|| !keys(run[step], ['taskId', 'claimed']) || !TASK.test(run[step].taskId) || typeof run[step].claimed !== 'boolean'
|
|
79
|
+
|| run.inputDigest !== inputDigest(run) || run.output !== undefined && (!reference(run.output) || !run[step].claimed)) {
|
|
80
|
+
flowFail('IRIS_FLOW_RECORD_INVALID', '音频流程记录结构或绑定摘要冲突');
|
|
81
|
+
}
|
|
82
|
+
return run;
|
|
83
|
+
}
|
|
84
|
+
function validateGeneration(run, expectedId, normalized) {
|
|
85
|
+
if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding',
|
|
86
|
+
'inputDigest', 'generate', 'output', 'selection'],
|
|
87
|
+
['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'input', 'providerBinding', 'inputDigest', 'generate'])
|
|
88
|
+
|| run.schemaVersion !== (run.input?.workflow_parameters === undefined ? 1 : 2) || run.recipe !== IMAGE_GENERATE_RECIPE || !RUN.test(run.id) || run.id !== expectedId
|
|
89
|
+
|| !Number.isSafeInteger(run.revision) || run.revision < 1 || !iso(run.createdAt) || !iso(run.updatedAt)
|
|
90
|
+
|| !BINDING.test(run.providerBinding) || !parseModelRef(run.input.model_ref)
|
|
91
|
+
|| !keys(run.input, ['prompt', 'model_ref', 'n', 'size', 'workflow_parameters'], ['prompt', 'model_ref', 'n'])
|
|
92
|
+
|| normalized.modelRef !== run.input.model_ref || normalized.sourceArtifactId !== undefined
|
|
93
|
+
|| run.input.prompt !== normalized.providerInput.prompt || run.input.n !== 1
|
|
94
|
+
|| run.input.size !== normalized.providerInput.size
|
|
95
|
+
|| !keys(run.generate, ['taskId', 'claimed']) || !TASK.test(run.generate.taskId) || typeof run.generate.claimed !== 'boolean'
|
|
96
|
+
|| run.inputDigest !== inputDigest(run)
|
|
97
|
+
|| run.output !== undefined && (!reference(run.output) || !run.generate.claimed)
|
|
98
|
+
|| run.selection !== undefined && (!reference(run.selection) || !run.output
|
|
99
|
+
|| run.selection.artifactId !== run.output.artifactId || run.selection.sha256 !== run.output.sha256)) {
|
|
100
|
+
flowFail('IRIS_FLOW_RECORD_INVALID', '文生图记录结构或绑定摘要冲突');
|
|
101
|
+
}
|
|
102
|
+
return run;
|
|
103
|
+
}
|
|
44
104
|
function validate(run, expectedId) {
|
|
105
|
+
if (isAudioRecipe(run?.recipe)) return validateAudio(run, expectedId);
|
|
45
106
|
let normalized;
|
|
46
107
|
try { normalized = normalizeGenerationInput('image', run?.input); }
|
|
47
108
|
catch (_) { flowFail('IRIS_FLOW_RECORD_INVALID', '流程创作输入无效'); }
|
|
109
|
+
if (run.recipe === IMAGE_GENERATE_RECIPE) return validateGeneration(run, expectedId, normalized);
|
|
48
110
|
if (!keys(run, ['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding',
|
|
49
111
|
'inputDigest', 'edit', 'compare', 'output', 'comparison', 'selection'],
|
|
50
112
|
['schemaVersion', 'recipe', 'id', 'revision', 'createdAt', 'updatedAt', 'source', 'input', 'providerBinding', 'inputDigest', 'edit', 'compare'])
|
|
@@ -101,7 +163,7 @@ export function inspectFlowRoot(root) {
|
|
|
101
163
|
export function initializeFlowRoot(root) {
|
|
102
164
|
try { fs.lstatSync(path.join(root, 'flow-store')); return inspectFlowRoot(root); }
|
|
103
165
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
104
|
-
if (fs.readdirSync(root).some(name => ![WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name))) {
|
|
166
|
+
if (fs.readdirSync(root).some(name => ![WRITER_LEASE_DIR, WRITER_INTENT_FILE].includes(name) && !isAbandonedWriterPreparation(root, name))) {
|
|
105
167
|
flowFail('IRIS_FLOW_ROOT_NOT_EMPTY', '流程只在新的专用数据根启用;不迁移已有 Task/Artifact 数据根');
|
|
106
168
|
}
|
|
107
169
|
directory(root, 'runs', true);
|
|
@@ -122,16 +184,44 @@ export function readFlowRun(root, id) {
|
|
|
122
184
|
if (error.code === 'ENOENT') flowFail('IRIS_FLOW_NOT_FOUND', '流程记录不存在');
|
|
123
185
|
throw error;
|
|
124
186
|
}
|
|
125
|
-
|
|
187
|
+
validate(record, id);
|
|
188
|
+
if (isAudioRecipe(record.recipe)) requireAudioRoot(root);
|
|
189
|
+
if (record.recipe === IMAGE_GENERATE_RECIPE) requireImageGenerationRoot(root);
|
|
190
|
+
return copy(record);
|
|
126
191
|
}
|
|
127
192
|
|
|
128
193
|
/** Internal immutable creation intent; HTTP callers cannot choose these identities. */
|
|
129
|
-
export function prepareFlowCreation({ source, input, providerBinding }) {
|
|
194
|
+
export function prepareFlowCreation({ source, input, providerBinding, recipe }) {
|
|
195
|
+
if (isAudioRecipe(recipe)) return { recipe, runId: flowId('run'), taskId: flowId('task'), createdAt: new Date().toISOString(),
|
|
196
|
+
...(source ? { source } : {}), input, providerBinding };
|
|
197
|
+
if (!source) return { recipe: IMAGE_GENERATE_RECIPE, runId: flowId('run'), taskId: flowId('task'),
|
|
198
|
+
createdAt: new Date().toISOString(), input, providerBinding };
|
|
130
199
|
return { runId: flowId('run'), taskId: flowId('task'), comparisonArtifactId: flowId('artifact'),
|
|
131
200
|
createdAt: new Date().toISOString(), source, input, providerBinding };
|
|
132
201
|
}
|
|
133
202
|
|
|
134
203
|
export function validateFlowCreation(intent) {
|
|
204
|
+
if (isAudioRecipe(intent?.recipe)) {
|
|
205
|
+
if (!keys(intent, ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding', 'source'],
|
|
206
|
+
['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding', ...(intent.recipe === 'transcribe/v0' ? ['source'] : [])])) {
|
|
207
|
+
flowFail('IRIS_FLOW_RECORD_INVALID', '音频创建意图无效');
|
|
208
|
+
}
|
|
209
|
+
const record = { schemaVersion: 3, recipe: intent.recipe, id: intent.runId, revision: 1,
|
|
210
|
+
createdAt: intent.createdAt, updatedAt: intent.createdAt, input: intent.input, providerBinding: intent.providerBinding,
|
|
211
|
+
...(intent.source ? { source: intent.source } : {}), [AUDIO_RECIPES[intent.recipe].step]: { taskId: intent.taskId, claimed: false } };
|
|
212
|
+
record.inputDigest = inputDigest(record);
|
|
213
|
+
return validate(record, record.id);
|
|
214
|
+
}
|
|
215
|
+
if (intent?.recipe === IMAGE_GENERATE_RECIPE) {
|
|
216
|
+
if (!keys(intent, ['recipe', 'runId', 'taskId', 'createdAt', 'input', 'providerBinding'])) {
|
|
217
|
+
flowFail('IRIS_FLOW_RECORD_INVALID', '文生图创建意图无效');
|
|
218
|
+
}
|
|
219
|
+
const record = { schemaVersion: intent.input?.workflow_parameters === undefined ? 1 : 2, recipe: IMAGE_GENERATE_RECIPE, id: intent.runId, revision: 1,
|
|
220
|
+
createdAt: intent.createdAt, updatedAt: intent.createdAt, input: intent.input,
|
|
221
|
+
providerBinding: intent.providerBinding, generate: { taskId: intent.taskId, claimed: false } };
|
|
222
|
+
record.inputDigest = inputDigest(record);
|
|
223
|
+
return validate(record, record.id);
|
|
224
|
+
}
|
|
135
225
|
if (!keys(intent, ['runId', 'taskId', 'comparisonArtifactId', 'createdAt', 'source', 'input', 'providerBinding'])) {
|
|
136
226
|
flowFail('IRIS_FLOW_RECORD_INVALID', '流程创建意图无效');
|
|
137
227
|
}
|
|
@@ -146,12 +236,15 @@ export function validateFlowCreation(intent) {
|
|
|
146
236
|
export function commitFlowCreation(root, intent, { allowCreate = true } = {}) {
|
|
147
237
|
inspectFlowRoot(root);
|
|
148
238
|
const record = validateFlowCreation(intent);
|
|
239
|
+
if (isAudioRecipe(record.recipe)) requireAudioRoot(root);
|
|
240
|
+
if (record.recipe === IMAGE_GENERATE_RECIPE) requireImageGenerationRoot(root);
|
|
149
241
|
let current;
|
|
150
242
|
try { current = readFlowRun(root, record.id); }
|
|
151
243
|
catch (error) { if (error.code !== 'IRIS_FLOW_NOT_FOUND') throw error; }
|
|
152
244
|
if (current) {
|
|
153
245
|
if (current.createdAt !== record.createdAt || current.inputDigest !== record.inputDigest
|
|
154
|
-
|| current.
|
|
246
|
+
|| current.recipe !== record.recipe || flowStep(current).taskId !== flowStep(record).taskId
|
|
247
|
+
|| current.compare?.artifactId !== record.compare?.artifactId) {
|
|
155
248
|
flowFail('IRIS_FLOW_INPUT_CONFLICT', '原流程与预留创建身份不一致');
|
|
156
249
|
}
|
|
157
250
|
return current;
|
|
@@ -168,7 +261,9 @@ export function createFlowRun(root, input) {
|
|
|
168
261
|
export function updateFlowRun(root, previous, patch) {
|
|
169
262
|
const current = readFlowRun(root, previous.id);
|
|
170
263
|
if (current.revision !== previous.revision) flowFail('IRIS_FLOW_REVISION_CONFLICT', '流程在本次操作期间已改变');
|
|
171
|
-
|
|
264
|
+
const allowed = isAudioRecipe(current.recipe) ? [flowStepId(current), 'output']
|
|
265
|
+
: current.recipe === IMAGE_GENERATE_RECIPE ? ['generate', 'output', 'selection'] : ['edit', 'output', 'comparison', 'selection'];
|
|
266
|
+
if (!keys(patch, allowed, [])) throw new TypeError('未知流程更新字段');
|
|
172
267
|
const record = { ...current, ...patch, revision: current.revision + 1, updatedAt: new Date().toISOString() };
|
|
173
268
|
validate(record, current.id);
|
|
174
269
|
atomicWritePrivate(recordFile(root, record.id), JSON.stringify(record, null, 2) + '\n');
|
|
@@ -190,6 +285,6 @@ export function listFlowRuns(root) {
|
|
|
190
285
|
export function flowReferences(root) {
|
|
191
286
|
try { fs.lstatSync(path.join(root, 'flow-store')); }
|
|
192
287
|
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
193
|
-
return listFlowRuns(root).map(run => ({ run, taskIds: [run.
|
|
194
|
-
artifactIds: [...new Set([run.source
|
|
288
|
+
return listFlowRuns(root).map(run => ({ run, taskIds: [flowStep(run).taskId],
|
|
289
|
+
artifactIds: [...new Set([run.source?.artifactId, run.compare?.artifactId, run.output?.artifactId, run.selection?.artifactId].filter(Boolean))] }));
|
|
195
290
|
}
|
package/lib/generation-input.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { normalizeMusicOptions } from './music-options.js';
|
|
6
|
+
import { normalizeComfyParameters } from './comfyui-workflow.js';
|
|
6
7
|
|
|
7
8
|
const FIELDS = Object.freeze({
|
|
8
|
-
image: ['prompt', 'size', 'n', 'source_artifact_id'],
|
|
9
|
+
image: ['prompt', 'size', 'n', 'source_artifact_id', 'workflow_parameters'],
|
|
9
10
|
video: ['prompt', 'size', 'duration', 'img_data_url', 'first_frame_path', 'audio_path', 'resolution'],
|
|
10
11
|
tts: ['text', 'voice'],
|
|
11
12
|
music: ['prompt', 'timeout_ms', 'retry_502', 'retry_delay_ms'],
|
|
@@ -86,6 +87,14 @@ export function normalizeGenerationInput(capability, input, { allowAudioPath = f
|
|
|
86
87
|
const n = input.n === undefined ? 1 : Number(input.n);
|
|
87
88
|
if (!Number.isSafeInteger(n) || n < 1 || n > 4) fail('n 必须为 1–4 的整数');
|
|
88
89
|
providerInput.n = n;
|
|
90
|
+
if (input.workflow_parameters !== undefined) {
|
|
91
|
+
if (!modelRef || sourceArtifactId || n !== 1) fail('工作流参数需要明确 model_ref、n=1 且不带来源图片');
|
|
92
|
+
try { providerInput.workflow_parameters = Object.freeze(normalizeComfyParameters(input.workflow_parameters)); }
|
|
93
|
+
catch (error) { fail(error.message); }
|
|
94
|
+
if (Buffer.byteLength(JSON.stringify({ model_ref: modelRef, ...providerInput }, null, 2)) > 96 * 1024) {
|
|
95
|
+
fail('参数化流程输入最多 96 KiB;请缩短提示词或参数值');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
89
98
|
}
|
|
90
99
|
if (capability === 'video') {
|
|
91
100
|
if (input.duration !== undefined) {
|
package/lib/headless-vision.js
CHANGED
|
@@ -70,10 +70,10 @@ function completionResult(completion, routes) {
|
|
|
70
70
|
return { ...result, ...selectedModel(completion, routes) };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) {
|
|
74
|
-
const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref);
|
|
75
|
-
const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit, onModelAvailability }),
|
|
76
|
-
model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) }));
|
|
73
|
+
async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability, registry }) {
|
|
74
|
+
const routes = transcribeCandidatesFromCatalog(catalog, input.transcribe_model_ref, registry);
|
|
75
|
+
const candidates = routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider, { onRateLimit, onModelAvailability, registry }),
|
|
76
|
+
model: route.modelRef, selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider, registry) }));
|
|
77
77
|
const audio = await waitForModelWork(() => extractAudioTrack({ inputPath: input.video_path, signal: operation.signal }), operation.signal);
|
|
78
78
|
const abortRuntime = () => { void runtime.dispose().catch(() => {}); };
|
|
79
79
|
operation.signal.addEventListener('abort', abortRuntime, { once: true });
|
|
@@ -87,7 +87,7 @@ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit
|
|
|
87
87
|
providerInput: { audioUrl: prepared.url } }), operation.signal, 'invoke', 'unknown');
|
|
88
88
|
task = submitted.task;
|
|
89
89
|
if (task.acceptance === 'accepted' && task.outcome === 'none') {
|
|
90
|
-
const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task), { onRateLimit,
|
|
90
|
+
const adapter = createConfiguredProviderAdapter(providerForTaskFromCatalog(catalog, task, registry), { onRateLimit, registry,
|
|
91
91
|
modelId: routes.find(route => route.modelRef === task.modelRef)?.model });
|
|
92
92
|
while (task.outcome === 'none') {
|
|
93
93
|
task = await waitForModelWork(() => runner.observe(task.id, adapter), operation.signal, 'read', 'responded');
|
|
@@ -111,7 +111,7 @@ async function transcribeVideo({ catalog, input, runtime, operation, onRateLimit
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit, onModelAvailability }) {
|
|
114
|
+
export async function executeVisionCommand({ command, input, catalog, modelRef = '', runtime, signal, timeoutMs = VISION_BUDGET.timeoutMs, onRateLimit, onModelAvailability, registry }) {
|
|
115
115
|
normalizeVisionInput(command, input);
|
|
116
116
|
if (modelRef && !/^[^:]+::.+$/.test(modelRef)) invalid('--model-ref 必须是 providerId::modelId');
|
|
117
117
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > VISION_BUDGET.timeoutMs) invalid('--timeout-ms 必须是 1–120000 的整数');
|
|
@@ -140,7 +140,7 @@ export async function executeVisionCommand({ command, input, catalog, modelRef =
|
|
|
140
140
|
}
|
|
141
141
|
let transcript = input.transcribe_text || '', transcription = { status: transcript.trim() ? 'provided' : 'disabled' };
|
|
142
142
|
if (input.transcribe) {
|
|
143
|
-
const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability }) : { report: { status: 'no_audio' }, text: '' };
|
|
143
|
+
const asr = meta.hasAudio ? await transcribeVideo({ catalog, input, runtime, operation, onRateLimit, onModelAvailability, registry }) : { report: { status: 'no_audio' }, text: '' };
|
|
144
144
|
transcript = asr.text; transcription = asr.report;
|
|
145
145
|
}
|
|
146
146
|
const completion = await summarizeMedia({ ports, frames, question: input.question, transcript, operation, budget });
|
package/lib/image-edit-input.js
CHANGED
|
@@ -10,7 +10,7 @@ function invalid(message) {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function imageEditCandidates(candidates) {
|
|
13
|
-
const supported = candidate => supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
|
|
13
|
+
const supported = candidate => candidate.supportsImageEdit ?? supportsImageEdit(candidate.adapter.protocol, candidate.providerModel);
|
|
14
14
|
if (candidates.some(candidate => candidate.selectionReason === 'explicit'
|
|
15
15
|
&& !supported(candidate))) {
|
|
16
16
|
throw invalid('指定模型的图片协议不支持此类改图,请选择聊天生图模型或 DashScope Qwen 原生改图模型');
|
package/lib/image-flow.js
CHANGED
|
@@ -8,7 +8,10 @@ import { imageEditCandidates, readImageEditArtifact } from './image-edit-input.j
|
|
|
8
8
|
import { parseModelRef } from './models.js';
|
|
9
9
|
import { pixelDiff } from './pixels.js';
|
|
10
10
|
import { createProviderTaskRunner } from './provider-task-runner.js';
|
|
11
|
-
import {
|
|
11
|
+
import { prepareProviderInput } from './provider-adapter.js';
|
|
12
|
+
import { AUDIO_RECIPES, AUDIO_TYPES, isAudioRecipe, normalizeAudioFlowInput, readImportAudio } from './audio-flow-input.js';
|
|
13
|
+
import { commitFlowCreation, flowFail, flowSha256, flowStep, flowStepId, initializeFlowRoot, inspectFlowRoot,
|
|
14
|
+
IMAGE_GENERATE_RECIPE, requireImageGenerationRoot, requireAudioRoot, listFlowRuns, prepareFlowCreation, readFlowRun, updateFlowRun } from './flow-store.js';
|
|
12
15
|
|
|
13
16
|
function exact(input, allowed) {
|
|
14
17
|
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) {
|
|
@@ -24,14 +27,15 @@ function readReference(root, expected) {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function facts(root, run) {
|
|
27
|
-
const source = readReference(root, run.source);
|
|
30
|
+
const source = run.source ? readReference(root, run.source) : null;
|
|
28
31
|
const task = findFlowTask(root, run);
|
|
29
32
|
if (run.output) {
|
|
30
33
|
if (task.deliveryState !== 'ready' || task.artifactIds.length !== 1 || task.artifactIds[0] !== run.output.artifactId) {
|
|
31
34
|
flowFail('IRIS_FLOW_TASK_CONFLICT', '流程输出与原 Task 的交付事实不一致');
|
|
32
35
|
}
|
|
33
36
|
const { artifact } = readReference(root, run.output);
|
|
34
|
-
if (artifact.metadata.taskId !== task.id || !artifact.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === source.artifact.id)
|
|
37
|
+
if (artifact.metadata.taskId !== task.id || source && !artifact.relations.some(edge => edge.type === 'derived-from' && edge.artifactId === source.artifact.id)
|
|
38
|
+
|| !source && artifact.relations.length) {
|
|
35
39
|
flowFail('IRIS_FLOW_ARTIFACT_CONFLICT', '流程输出缺少原 Task 或来源关系');
|
|
36
40
|
}
|
|
37
41
|
}
|
|
@@ -45,35 +49,53 @@ function facts(root, run) {
|
|
|
45
49
|
}
|
|
46
50
|
let status = 'not_started';
|
|
47
51
|
if (task?.acceptance === 'unknown' || ['unknown', 'failed', 'canceled'].includes(task?.outcome)) status = 'needs_attention';
|
|
48
|
-
else if (task?.deliveryState === 'ready') status = run.
|
|
52
|
+
else if (task?.deliveryState === 'ready') status = isAudioRecipe(run.recipe) ? run.output ? 'ready' : 'needs_result'
|
|
53
|
+
: run.output && (run.recipe === IMAGE_GENERATE_RECIPE || run.comparison)
|
|
54
|
+
? run.selection ? 'selected' : 'awaiting_selection' : run.recipe === IMAGE_GENERATE_RECIPE ? 'needs_result' : 'needs_comparison';
|
|
49
55
|
else if (task?.deliveryState === 'failed') status = 'delivery_failed';
|
|
50
56
|
else if (task?.attempts.length) status = task.acceptance === 'accepted' ? 'running' : 'needs_attention';
|
|
51
57
|
return { contractVersion: 0, run, task, status, ...(comparison ? { metrics: comparison.metadata } : {}) };
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
/**
|
|
55
|
-
export function createImageFlowService(runtime, { resolveImageCandidates, resolveTaskAdapter } = {}) {
|
|
60
|
+
/** Fixed image/audio recipes, sharing operation-scoped leases and the original Task. */
|
|
61
|
+
export function createImageFlowService(runtime, { resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter } = {}) {
|
|
56
62
|
function creationInput(input) {
|
|
63
|
+
if (isAudioRecipe(input?.recipe)) {
|
|
64
|
+
const { recipe, ...value } = input;
|
|
65
|
+
return { recipe, ...normalizeAudioFlowInput(recipe, value) };
|
|
66
|
+
}
|
|
57
67
|
const normalized = normalizeGenerationInput('image', input);
|
|
58
|
-
if (!
|
|
59
|
-
flowFail('IRIS_FLOW_INPUT_INVALID', '
|
|
68
|
+
if (!parseModelRef(normalized.modelRef) || normalized.providerInput.n !== 1) {
|
|
69
|
+
flowFail('IRIS_FLOW_INPUT_INVALID', '图片流程需要明确 model_ref 和 n=1;改图还需来源 Artifact');
|
|
60
70
|
}
|
|
61
71
|
return { prompt: normalized.providerInput.prompt, model_ref: normalized.modelRef,
|
|
62
|
-
source_artifact_id: normalized.sourceArtifactId, n: 1,
|
|
63
|
-
...(normalized.providerInput.size !== undefined ? { size: normalized.providerInput.size } : {})
|
|
72
|
+
...(normalized.sourceArtifactId ? { source_artifact_id: normalized.sourceArtifactId } : {}), n: 1,
|
|
73
|
+
...(normalized.providerInput.size !== undefined ? { size: normalized.providerInput.size } : {}),
|
|
74
|
+
...(normalized.providerInput.workflow_parameters !== undefined ? { workflow_parameters: normalized.providerInput.workflow_parameters } : {}) };
|
|
64
75
|
}
|
|
65
76
|
async function prepareCreation(root, signal, input) {
|
|
66
77
|
const owned = creationInput(input);
|
|
67
78
|
inspectFlowRoot(root);
|
|
68
|
-
const
|
|
79
|
+
const audio = isAudioRecipe(owned.recipe);
|
|
80
|
+
if (audio) requireAudioRoot(root);
|
|
81
|
+
else if (!owned.source_artifact_id) requireImageGenerationRoot(root);
|
|
82
|
+
const source = owned.source_artifact_id ? audio ? readAudioSource(root, owned.source_artifact_id) : await readImageEditArtifact(root, owned.source_artifact_id, signal) : null;
|
|
69
83
|
const candidates = await candidatesFor(owned, signal);
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
const { recipe, ...audioInput } = owned;
|
|
85
|
+
return prepareFlowCreation({ ...(source ? { source: { artifactId: owned.source_artifact_id, sha256: flowSha256(source.bytes) } } : {}),
|
|
86
|
+
input: audio ? audioInput : owned, providerBinding: candidates[0].providerBinding, ...(audio ? { recipe } : {}) });
|
|
87
|
+
}
|
|
88
|
+
function readAudioSource(root, id) {
|
|
89
|
+
const source = readCoreArtifactBytes(root, id);
|
|
90
|
+
readImportAudio(source.bytes, source.artifact.mediaType);
|
|
91
|
+
return source;
|
|
72
92
|
}
|
|
73
93
|
async function candidatesFor(runInput, signal, binding) {
|
|
74
|
-
|
|
94
|
+
const audio = isAudioRecipe(runInput.recipe), resolver = audio ? resolveAudioCandidates : resolveImageCandidates;
|
|
95
|
+
if (!resolver) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '创建或首次提交流程需要显式 Provider 配置');
|
|
75
96
|
signal.throwIfAborted();
|
|
76
|
-
const candidates = await
|
|
97
|
+
const candidates = await resolver({ modelRef: runInput.model_ref, signal, input: runInput,
|
|
98
|
+
...(audio ? { capability: AUDIO_RECIPES[runInput.recipe].capability } : {}) });
|
|
77
99
|
signal.throwIfAborted();
|
|
78
100
|
const parsed = parseModelRef(runInput.model_ref);
|
|
79
101
|
if (!Array.isArray(candidates) || candidates.length !== 1 || candidates[0].model !== runInput.model_ref
|
|
@@ -81,7 +103,8 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
81
103
|
flowFail('IRIS_FLOW_INPUT_CONFLICT', '流程必须绑定单一明确模型和有效的连接摘要');
|
|
82
104
|
}
|
|
83
105
|
if (binding !== undefined && candidates[0].providerBinding !== binding) flowFail('IRIS_FLOW_PROVIDER_CHANGED', '连接或协议已改变;原流程不重新绑定或生成');
|
|
84
|
-
|
|
106
|
+
const resolved = [{ ...candidates[0], providerModel: parsed.modelId, selectionReason: 'explicit' }];
|
|
107
|
+
return runInput.source_artifact_id && !audio ? imageEditCandidates(resolved) : resolved;
|
|
85
108
|
}
|
|
86
109
|
|
|
87
110
|
async function adapterFor(task, signal) {
|
|
@@ -94,16 +117,26 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
94
117
|
async function continueRun(root, signal, id) {
|
|
95
118
|
let run = readFlowRun(root, id);
|
|
96
119
|
let task = facts(root, run).task;
|
|
97
|
-
if (task?.attempts.length && !run.
|
|
120
|
+
if (task?.attempts.length && !flowStep(run).claimed) flowFail('IRIS_FLOW_TASK_CONFLICT', '步骤检查点与已开始的 Task 冲突');
|
|
98
121
|
const runner = createProviderTaskRunner(runtime);
|
|
99
122
|
if (!task || !task.attempts.length) {
|
|
100
|
-
const
|
|
123
|
+
const audio = isAudioRecipe(run.recipe);
|
|
124
|
+
let candidates = await candidatesFor({ ...run.input, ...(audio ? { recipe: run.recipe } : {}) }, signal, run.providerBinding);
|
|
125
|
+
if (run.recipe === 'transcribe/v0') {
|
|
126
|
+
const source = readAudioSource(root, run.source.artifactId);
|
|
127
|
+
candidates = candidates.map(candidate => ({ ...candidate, prepareInput: async ({ signal }) => {
|
|
128
|
+
const prepared = await prepareProviderInput(candidate.adapter, { model: candidate.providerModel, bytes: source.bytes,
|
|
129
|
+
filename: source.artifact.id + '.' + AUDIO_TYPES[source.artifact.mediaType], signal });
|
|
130
|
+
return { audioUrl: prepared.url };
|
|
131
|
+
} }));
|
|
132
|
+
}
|
|
101
133
|
task = claimFlowTask(root, run.id);
|
|
102
|
-
if (!run.
|
|
134
|
+
if (!flowStep(run).claimed) run = updateFlowRun(root, run, { [flowStepId(run)]: { ...flowStep(run), claimed: true } });
|
|
103
135
|
signal.throwIfAborted();
|
|
104
|
-
const normalized =
|
|
105
|
-
|
|
106
|
-
|
|
136
|
+
const normalized = audio ? Object.fromEntries(Object.entries(run.input).filter(([key]) => key !== 'model_ref'))
|
|
137
|
+
: normalizeGenerationInput('image', run.input).providerInput;
|
|
138
|
+
task = (await runner.submit({ capability: audio ? AUDIO_RECIPES[run.recipe].capability : 'image', candidates, providerInput: normalized,
|
|
139
|
+
sourceArtifactId: run.source?.artifactId, flowRunId: run.id })).task;
|
|
107
140
|
} else {
|
|
108
141
|
task = recoverCoreTask(root, task.id);
|
|
109
142
|
assertFlowTaskMatches(task, run);
|
|
@@ -116,11 +149,11 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
116
149
|
}
|
|
117
150
|
signal.throwIfAborted();
|
|
118
151
|
if (task.deliveryState !== 'ready') return facts(root, run);
|
|
119
|
-
if (task.artifactIds.length !== 1) flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '
|
|
152
|
+
if (task.artifactIds.length !== 1) flowFail('IRIS_FLOW_RESULT_UNSUPPORTED', '当前流程只支持一个结果,原 Task 与作品已保留');
|
|
120
153
|
const output = readCoreArtifactBytes(root, task.artifactIds[0]);
|
|
121
154
|
if (!run.output) run = updateFlowRun(root, run, { output: reference(output.artifact) });
|
|
122
155
|
facts(root, run);
|
|
123
|
-
if (!run.comparison) {
|
|
156
|
+
if (run.source && !run.comparison && !isAudioRecipe(run.recipe)) {
|
|
124
157
|
const source = readReference(root, run.source);
|
|
125
158
|
const diff = await pixelDiff({ inputA: source.bytes, inputB: output.bytes, grid: 8, topRegions: 3 });
|
|
126
159
|
signal.throwIfAborted();
|
|
@@ -152,7 +185,7 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
152
185
|
return runtime.run('inspect', ({ dataRoot }) => {
|
|
153
186
|
const runs = listFlowRuns(dataRoot);
|
|
154
187
|
return { contractVersion: 0, total: runs.length, runs: runs.map(run => ({ id: run.id, recipe: run.recipe,
|
|
155
|
-
createdAt: run.createdAt, source: run.source, modelRef: run.input.model_ref, taskId: run.
|
|
188
|
+
createdAt: run.createdAt, ...(run.source ? { source: run.source } : {}), modelRef: run.input.model_ref, taskId: flowStep(run).taskId })) };
|
|
156
189
|
});
|
|
157
190
|
}
|
|
158
191
|
if (name === 'flow.inspect') {
|
|
@@ -164,13 +197,24 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
164
197
|
return runtime.run('execute', ({ dataRoot, signal }) => continueRun(dataRoot, signal, input.run_id));
|
|
165
198
|
}
|
|
166
199
|
if (name === 'flow.select' || name === 'flow.export') {
|
|
167
|
-
exact(input, name === 'flow.select' ? ['run_id', 'artifact_id'] : ['run_id', 'output_path']);
|
|
200
|
+
exact(input, name === 'flow.select' ? ['run_id', 'artifact_id', 'expected_revision'] : ['run_id', 'output_path']);
|
|
201
|
+
if (input.expected_revision !== undefined && (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 1)) {
|
|
202
|
+
flowFail('IRIS_FLOW_INPUT_INVALID', '预期流程版本必须是正整数');
|
|
203
|
+
}
|
|
168
204
|
return runtime.run('execute', ({ dataRoot }) => {
|
|
169
205
|
let run = readFlowRun(dataRoot, input.run_id);
|
|
206
|
+
if (input.expected_revision !== undefined && run.revision !== input.expected_revision) {
|
|
207
|
+
flowFail('IRIS_FLOW_REVISION_CONFLICT', '流程已改变,请读取当前版本后明确选择');
|
|
208
|
+
}
|
|
170
209
|
facts(dataRoot, run);
|
|
171
|
-
if (
|
|
210
|
+
if (isAudioRecipe(run.recipe)) {
|
|
211
|
+
if (name === 'flow.select') flowFail('IRIS_FLOW_SELECTION_INVALID', '音频和转写为单一结果,无需选择图片版本');
|
|
212
|
+
if (!run.output) flowFail('IRIS_FLOW_NOT_READY', '请先接回原任务的结果');
|
|
213
|
+
return { ...facts(dataRoot, run), export: exportCoreArtifact(dataRoot, run.output.artifactId, input.output_path) };
|
|
214
|
+
}
|
|
215
|
+
if (!run.output || run.source && !run.comparison) flowFail('IRIS_FLOW_NOT_READY', '请先保存生成结果;改图还需完成比较');
|
|
172
216
|
if (name === 'flow.select') {
|
|
173
|
-
const selected = [run.source, run.output].find(item => item.artifactId === input.artifact_id);
|
|
217
|
+
const selected = [run.source, run.output].filter(Boolean).find(item => item.artifactId === input.artifact_id);
|
|
174
218
|
if (!selected) flowFail('IRIS_FLOW_SELECTION_INVALID', '只能选择本流程的原图或新版');
|
|
175
219
|
if (run.selection?.artifactId !== selected.artifactId) run = updateFlowRun(dataRoot, run, { selection: selected });
|
|
176
220
|
return facts(dataRoot, run);
|
|
@@ -182,7 +226,7 @@ export function createImageFlowService(runtime, { resolveImageCandidates, resolv
|
|
|
182
226
|
throw new TypeError('未知的内部 Flow 命令');
|
|
183
227
|
}
|
|
184
228
|
return Object.freeze({ execute, prepareCreation, inspectCreation: (root, intent, options) => {
|
|
185
|
-
readReference(root, intent.source);
|
|
229
|
+
if (intent.source) readReference(root, intent.source);
|
|
186
230
|
return facts(root, commitFlowCreation(root, intent, options));
|
|
187
231
|
} });
|
|
188
232
|
}
|
package/lib/index.js
CHANGED
|
@@ -66,38 +66,47 @@ function outputDir() {
|
|
|
66
66
|
|
|
67
67
|
/* ---------------- 媒体路由:/iris/media/:taskId/:token/:name ---------------- */
|
|
68
68
|
|
|
69
|
+
let latestNativeRuntime;
|
|
69
70
|
function mountIrisRoutes(routeCtx, core, routes) {
|
|
70
71
|
// Cordis 服务只在 DSH 宿主边界探测;Command 与通用工具辅助函数不接收原始 ctx
|
|
71
72
|
const host = dshHost(routeCtx);
|
|
72
73
|
const reg = hasHostPort(host, 'routes') ? host.ports.routes : undefined;
|
|
73
74
|
if (!reg || typeof reg.register !== 'function' || routes.mounted) return;
|
|
75
|
+
const disposers = []; let released = false;
|
|
76
|
+
const release = () => {
|
|
77
|
+
if (released) return;
|
|
78
|
+
released = true;
|
|
79
|
+
const errors = [];
|
|
80
|
+
for (const dispose of disposers.reverse()) { try { if (typeof dispose === 'function') dispose(); } catch (error) { errors.push(error); } }
|
|
81
|
+
if (routes.mounted && latestNativeRuntime === core) closeAllSse();
|
|
82
|
+
routes.mounted = false;
|
|
83
|
+
if (errors.length) throw new AggregateError(errors, 'Iris 路由释放失败');
|
|
84
|
+
};
|
|
74
85
|
try {
|
|
75
86
|
const disposeMedia = reg.register({
|
|
76
87
|
kind: 'prefix',
|
|
77
88
|
path: '/iris/media',
|
|
78
89
|
handler: guarded(serveMedia)
|
|
79
90
|
});
|
|
91
|
+
disposers.push(disposeMedia);
|
|
80
92
|
const disposeApi = reg.register({
|
|
81
93
|
kind: 'prefix',
|
|
82
94
|
path: '/iris/api',
|
|
83
95
|
handler: guarded((req, res) => serveApi(req, res, dshHost(routeCtx), core))
|
|
84
96
|
});
|
|
97
|
+
disposers.push(disposeApi);
|
|
85
98
|
const disposeRender = reg.register({
|
|
86
99
|
kind: 'prefix',
|
|
87
100
|
path: '/iris/render',
|
|
88
101
|
handler: guarded(serveRender)
|
|
89
102
|
});
|
|
103
|
+
disposers.push(disposeRender);
|
|
104
|
+
routeCtx.effect(() => release, 'iris: media+api+actions+render routes');
|
|
90
105
|
routes.mounted = true;
|
|
91
106
|
recordHostRoutes(EXPECTED_IRIS_ROUTES);
|
|
92
|
-
routeCtx.effect(() => () => {
|
|
93
|
-
if (typeof disposeMedia === 'function') disposeMedia();
|
|
94
|
-
if (typeof disposeApi === 'function') disposeApi();
|
|
95
|
-
if (typeof disposeRender === 'function') disposeRender();
|
|
96
|
-
closeAllSse(); // SSE 长连接随插件停用一起关闭,不留悬挂连接
|
|
97
|
-
routes.mounted = false;
|
|
98
|
-
}, 'iris: media+api+actions+render routes');
|
|
99
107
|
console.log('[iris] 媒体+工作台+操作路由已挂载:/iris/media · /iris/api · /iris/api/actions · /iris/render');
|
|
100
108
|
} catch (err) {
|
|
109
|
+
try { release(); } catch (cleanup) { console.error('[iris] 路由回滚失败:', cleanup.message); }
|
|
101
110
|
console.error('[iris] 路由挂载失败:', err && err.message);
|
|
102
111
|
}
|
|
103
112
|
}
|
|
@@ -221,13 +230,24 @@ function safeRegister(host, definition) {
|
|
|
221
230
|
export async function apply(ctx) {
|
|
222
231
|
beginHostRuntime({ pluginId: IRIS_PLUGIN_ID, version: PACKAGE_VERSION });
|
|
223
232
|
const core = createDshRuntime({ dataRoot: dshCoreDataRoot(), getHost: () => dshHost(ctx) });
|
|
233
|
+
latestNativeRuntime = core;
|
|
224
234
|
ctx.provide('irisRuntime', core);
|
|
225
235
|
const routes = { mounted: false };
|
|
226
236
|
const { cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, projectCoreTaskForDsh, readCoreArtifactMediaForDsh, resumeProviderTaskWatchesForDsh } = core;
|
|
227
237
|
ctx.effect(() => () => {
|
|
228
|
-
tasks.stopWatchAll();
|
|
238
|
+
if (latestNativeRuntime === core) tasks.stopWatchAll();
|
|
229
239
|
return core.dispose();
|
|
230
240
|
}, 'iris: DSH runtime');
|
|
241
|
+
// Explicit additional connection for shared Flow tools. Legacy native media/skills/UI retain their own roots.
|
|
242
|
+
if (process.env.IRIS_CORE_CONNECTION_FILE) {
|
|
243
|
+
const { createDshServiceBridge } = await import('./dsh-service-bridge.js');
|
|
244
|
+
const bridge = createDshServiceBridge({ connectionFile: process.env.IRIS_CORE_CONNECTION_FILE,
|
|
245
|
+
requestedScopes: (process.env.IRIS_CORE_SCOPES || 'read').split(','), getHost: () => dshHost(ctx), harnessVersion: dshHost(ctx).version });
|
|
246
|
+
ctx.provide('irisCoreService', bridge);
|
|
247
|
+
ctx.effect(() => () => bridge.dispose(), 'iris: Core service connection');
|
|
248
|
+
try { await bridge.start(); }
|
|
249
|
+
catch (error) { console.error('[iris] Core 服务连接失败:', error?.code || 'IRIS_CORE_SERVICE_UNAVAILABLE'); }
|
|
250
|
+
}
|
|
231
251
|
guard('Iris 私有存储权限收紧', () => hardenPrivateTree(store.irisHome()));
|
|
232
252
|
|
|
233
253
|
guard('上传临时文件清理', () => purgeStaleUploads());
|
|
@@ -399,7 +419,7 @@ export async function apply(ctx) {
|
|
|
399
419
|
const refs = [];
|
|
400
420
|
for (const f of files) {
|
|
401
421
|
const mediaType = MEDIA_TYPES[path.extname(f).toLowerCase()] || 'image/png';
|
|
402
|
-
const ref = await attachmentService(
|
|
422
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({ data: new Uint8Array(fs.readFileSync(f)), mediaType, name: path.basename(f) });
|
|
403
423
|
refs.push(ref);
|
|
404
424
|
blocks.push({ type: 'image', attachment: ref });
|
|
405
425
|
}
|
|
@@ -647,7 +667,7 @@ export async function apply(ctx) {
|
|
|
647
667
|
const refs = [];
|
|
648
668
|
for (let i = 0; i < frames.length; i++) {
|
|
649
669
|
const f = frames[i];
|
|
650
|
-
const ref = await attachmentService(
|
|
670
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({
|
|
651
671
|
data: new Uint8Array(f.bytes),
|
|
652
672
|
mediaType,
|
|
653
673
|
name: `iris-frame-${Date.now()}-${i + 1}.${ext}`
|
|
@@ -744,7 +764,7 @@ export async function apply(ctx) {
|
|
|
744
764
|
});
|
|
745
765
|
// 成功摘要的同一张拼图作为显式工具产物发布。
|
|
746
766
|
if (exec.signal?.aborted) throw exec.signal.reason;
|
|
747
|
-
const ref = await attachmentService(
|
|
767
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({
|
|
748
768
|
data: new Uint8Array(sheet.buffer),
|
|
749
769
|
mediaType: 'image/png',
|
|
750
770
|
name: `iris-sheet-${Date.now()}.png`
|
|
@@ -801,7 +821,7 @@ export async function apply(ctx) {
|
|
|
801
821
|
async prepareImage(signal) {
|
|
802
822
|
if (source === 'artifact_id') return visionImageForDsh({ artifactId: args.artifact_id, signal });
|
|
803
823
|
const image = await readVisionImageFile(p, mt, signal);
|
|
804
|
-
await waitForModelWork(() => attachmentService(
|
|
824
|
+
await waitForModelWork(() => attachmentService(dshHost(ctx)).saveImage({ data: image.bytes, mediaType: mt, name: path.basename(p) }), signal);
|
|
805
825
|
return image;
|
|
806
826
|
}
|
|
807
827
|
});
|
|
@@ -833,10 +853,10 @@ export async function apply(ctx) {
|
|
|
833
853
|
return runVisionTool(dshHost(ctx), exec, { origin: 'relook', model: args.model, question,
|
|
834
854
|
async prepareImage(signal) {
|
|
835
855
|
// 会话扫描、解引用与读图都属于这次操作的总预算。
|
|
836
|
-
const hit = (await waitForModelWork(() => sessionAttachmentRef(
|
|
856
|
+
const hit = (await waitForModelWork(() => sessionAttachmentRef(dshHost(ctx), exec, id), signal)) || findOwnAttachment(id);
|
|
837
857
|
if (!hit) throw new Error('iris: 图片引用不可用');
|
|
838
858
|
if (hit.absPath) return readVisionImageFile(hit.absPath, hit.ref.mediaType || MEDIA_TYPES[path.extname(hit.absPath).toLowerCase()] || 'image/png', signal);
|
|
839
|
-
const stored = await waitForModelWork(() => attachmentService(
|
|
859
|
+
const stored = await waitForModelWork(() => attachmentService(dshHost(ctx)).readImage(hit.ref, signal), signal);
|
|
840
860
|
return { bytes: new Uint8Array(stored.data), mediaType: stored.mediaType || hit.ref.mediaType };
|
|
841
861
|
}
|
|
842
862
|
});
|
|
@@ -900,7 +920,7 @@ export async function apply(ctx) {
|
|
|
900
920
|
signal: exec.signal
|
|
901
921
|
});
|
|
902
922
|
if (exec.signal && exec.signal.aborted) throw new Error('已取消');
|
|
903
|
-
const ref = await attachmentService(
|
|
923
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({
|
|
904
924
|
data: new Uint8Array(result.bytes),
|
|
905
925
|
mediaType: result.mediaType,
|
|
906
926
|
name: `iris-crop-${Date.now()}.png`
|
|
@@ -946,7 +966,7 @@ export async function apply(ctx) {
|
|
|
946
966
|
grid: args.grid, topRegions: args.top_regions, signal: exec.signal
|
|
947
967
|
});
|
|
948
968
|
const { ratio, diffPixels, totalPixels, width, height, worstRegions } = result.metrics;
|
|
949
|
-
const ref = await attachmentService(
|
|
969
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({
|
|
950
970
|
data: new Uint8Array(result.bytes),
|
|
951
971
|
mediaType: result.mediaType,
|
|
952
972
|
name: `iris-diff-${Date.now()}.png`
|
|
@@ -1037,7 +1057,7 @@ export async function apply(ctx) {
|
|
|
1037
1057
|
});
|
|
1038
1058
|
const png = Buffer.from(rendered.bytes);
|
|
1039
1059
|
const { width: outW, height: outH } = await imageDimensions(png);
|
|
1040
|
-
const ref = await attachmentService(
|
|
1060
|
+
const ref = await attachmentService(dshHost(ctx)).saveImage({
|
|
1041
1061
|
data: new Uint8Array(png),
|
|
1042
1062
|
mediaType: 'image/png',
|
|
1043
1063
|
name: `iris-html-${Date.now()}.png`
|