@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.en.md +103 -312
  3. package/README.md +100 -310
  4. package/bin/dsh-iris.js +333 -65
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/AUDIO_FLOWS.md +71 -0
  9. package/docs/CONTACT_SHEET.md +62 -0
  10. package/docs/CORE_CONNECTIONS.md +99 -0
  11. package/docs/CORE_QUICKSTART.md +108 -0
  12. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  13. package/docs/CORE_SERVICE.md +97 -0
  14. package/docs/CORE_SKILLS.md +35 -3
  15. package/docs/DSH_COMPATIBILITY.md +10 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  17. package/docs/DSH_RUNTIME.md +6 -2
  18. package/docs/HEADLESS_CLI.md +2 -2
  19. package/docs/IMAGE_FLOW.md +4 -0
  20. package/docs/IMAGE_GENERATION.md +127 -0
  21. package/docs/LOCAL_API.md +4 -2
  22. package/docs/LOCAL_API_CONTRACT.md +39 -14
  23. package/docs/LOCAL_CREATION.md +7 -1
  24. package/docs/LOCAL_OPERATIONS.md +46 -0
  25. package/docs/LOCAL_SELECTION.md +57 -0
  26. package/docs/LOCAL_WEB.md +201 -0
  27. package/docs/MCP.md +191 -0
  28. package/docs/MUSIC.md +2 -0
  29. package/docs/PANEL_EXTENSIONS.md +64 -0
  30. package/docs/PLUGINS.md +189 -0
  31. package/docs/PLUGIN_STARTUP.md +100 -0
  32. package/docs/PROVIDER_ADAPTER_CONTRACT.md +1 -1
  33. package/docs/PROVIDER_EXTENSIONS.md +78 -0
  34. package/docs/PROVIDER_HEALTH.md +3 -1
  35. package/docs/PROVIDER_SETTINGS.md +85 -0
  36. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +6 -0
  37. package/docs/ROADMAP.md +17 -7
  38. package/docs/VISION_CLI.md +1 -1
  39. package/docs/WEB_PROTOTYPE.md +21 -6
  40. package/docs/WEB_RECOVERY.md +46 -0
  41. package/docs/WORKBENCH_LOGIN.md +63 -0
  42. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  43. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  44. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  45. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  46. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  47. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  48. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  49. package/docs/releases/0.3.0-alpha.1.md +51 -0
  50. package/docs/screenshots.md +41 -1
  51. package/lib/actions.js +53 -2
  52. package/lib/adapters.js +20 -11
  53. package/lib/application-runtime.js +34 -7
  54. package/lib/audio-flow-input.js +37 -0
  55. package/lib/client.js +346 -53
  56. package/lib/comfyui-adapter.js +94 -0
  57. package/lib/comfyui-client.js +51 -0
  58. package/lib/comfyui-inspection.js +73 -0
  59. package/lib/comfyui-workflow.js +94 -0
  60. package/lib/config.js +36 -1
  61. package/lib/configured-image-runtime.js +50 -0
  62. package/lib/connection-skill.js +89 -0
  63. package/lib/connection-template.js +23 -0
  64. package/lib/core-artifact-store.js +16 -2
  65. package/lib/core-bridge-selection.js +31 -0
  66. package/lib/core-bridge-tools.js +83 -0
  67. package/lib/core-capability-catalog.js +182 -0
  68. package/lib/core-connection-diagnostics.js +49 -0
  69. package/lib/core-connection-store.js +277 -0
  70. package/lib/core-connections.js +225 -0
  71. package/lib/core-maintenance.js +9 -3
  72. package/lib/core-runtime.js +2 -1
  73. package/lib/core-service-client.js +170 -0
  74. package/lib/core-tasks.js +29 -12
  75. package/lib/core-writer-lease.js +16 -0
  76. package/lib/creation-request-store.js +38 -64
  77. package/lib/dsh-host-adapter.js +4 -1
  78. package/lib/dsh-service-bridge.js +66 -0
  79. package/lib/execution-ownership.js +43 -0
  80. package/lib/flow-store.js +105 -10
  81. package/lib/generation-input.js +10 -1
  82. package/lib/headless-vision.js +7 -7
  83. package/lib/image-edit-input.js +1 -1
  84. package/lib/image-flow.js +73 -29
  85. package/lib/index.js +37 -17
  86. package/lib/local-api-projection.js +10 -5
  87. package/lib/local-api-token-directory.js +31 -0
  88. package/lib/local-api.js +769 -67
  89. package/lib/local-creation.js +89 -24
  90. package/lib/local-operations.js +36 -0
  91. package/lib/local-request-root.js +86 -0
  92. package/lib/local-web-assets.js +37 -0
  93. package/lib/mcp-bridge.js +58 -0
  94. package/lib/mcp-check.js +26 -0
  95. package/lib/mcp-config.js +42 -0
  96. package/lib/mcp-delivery.js +46 -0
  97. package/lib/mcp-http.js +101 -0
  98. package/lib/mcp-server.js +47 -0
  99. package/lib/model-directory-service.js +16 -6
  100. package/lib/model-directory.js +3 -3
  101. package/lib/models.js +2 -2
  102. package/lib/music-transport.js +2 -1
  103. package/lib/operation-runner.js +236 -0
  104. package/lib/operation-store.js +216 -0
  105. package/lib/panel-extension.js +36 -0
  106. package/lib/plugin-contract.js +194 -0
  107. package/lib/plugin-execution.js +51 -0
  108. package/lib/plugin-launch.js +86 -0
  109. package/lib/plugin-operation-store.js +101 -0
  110. package/lib/plugin-operations.js +28 -0
  111. package/lib/plugin-registry.js +109 -0
  112. package/lib/plugin-selection.js +15 -0
  113. package/lib/plugin-source-image.js +5 -0
  114. package/lib/plugin-web-assets.js +54 -0
  115. package/lib/plugins.d.ts +288 -0
  116. package/lib/plugins.js +58 -0
  117. package/lib/provider-adapter.js +5 -1
  118. package/lib/provider-adapters.js +56 -18
  119. package/lib/provider-catalog.js +40 -29
  120. package/lib/provider-config-service.js +105 -23
  121. package/lib/provider-diagnostics.js +56 -0
  122. package/lib/provider-extensions.js +29 -0
  123. package/lib/provider-health.js +8 -1
  124. package/lib/provider-monitor.js +79 -0
  125. package/lib/provider-protocol.js +70 -20
  126. package/lib/provider-registry.js +118 -0
  127. package/lib/provider-response.js +28 -0
  128. package/lib/provider-settings.js +134 -0
  129. package/lib/provider-task-runner.js +22 -9
  130. package/lib/skill-installation.js +23 -11
  131. package/lib/vision-model-routing.js +3 -0
  132. package/lib/web/app.js +642 -0
  133. package/lib/web/appearance.js +111 -0
  134. package/lib/web/comfyui.js +146 -0
  135. package/lib/web/connections.js +377 -0
  136. package/lib/web/creation.js +355 -0
  137. package/lib/web/flow-view.js +33 -0
  138. package/lib/web/gallery.js +132 -0
  139. package/lib/web/index.html +291 -0
  140. package/lib/web/media-client.js +204 -0
  141. package/lib/web/plugin-panel.js +221 -0
  142. package/lib/web/plugin-surface.css +17 -0
  143. package/lib/web/providers.js +492 -0
  144. package/lib/web/recovery.js +105 -0
  145. package/lib/web/startup.js +18 -0
  146. package/lib/web/styles.css +518 -0
  147. package/lib/web/transport.js +96 -0
  148. package/lib/web/workbench-login.js +165 -0
  149. package/lib/workbench-auth-cli.js +47 -0
  150. package/lib/workbench-auth.js +200 -0
  151. package/package.json +20 -5
  152. package/plugins/contact-sheet/index.d.mts +4 -0
  153. package/plugins/contact-sheet/index.mjs +33 -0
  154. package/plugins/contact-sheet/web/board.css +14 -0
  155. package/plugins/contact-sheet/web/board.mjs +93 -0
  156. package/skills/iris-core/SKILL.md +8 -4
  157. package/skills/iris-core/references/cli.md +2 -2
  158. package/skills/iris-core/references/comfyui.md +69 -0
  159. package/skills/iris-core/references/tasks.md +2 -0
  160. 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
- && value.stepId === 'edit' && BINDING.test(value.inputDigest);
51
+ && ['edit', 'generate', 'speak', 'transcribe'].includes(value.stepId) && BINDING.test(value.inputDigest);
35
52
  }
36
- export function flowTaskBinding(run) { return { runId: run.id, stepId: 'edit', inputDigest: run.inputDigest }; }
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
- return copy(validate(record, id));
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.edit.taskId !== record.edit.taskId || current.compare.artifactId !== record.compare.artifactId) {
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
- if (!keys(patch, ['edit', 'output', 'comparison', 'selection'], [])) throw new TypeError('未知流程更新字段');
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.edit.taskId],
194
- artifactIds: [...new Set([run.source.artifactId, run.compare.artifactId, run.output?.artifactId, run.selection?.artifactId].filter(Boolean))] }));
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
  }
@@ -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) {
@@ -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 });
@@ -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 { commitFlowCreation, flowFail, flowSha256, initializeFlowRoot, inspectFlowRoot, listFlowRuns, prepareFlowCreation, readFlowRun, updateFlowRun } from './flow-store.js';
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.comparison ? run.selection ? 'selected' : 'awaiting_selection' : 'needs_comparison';
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
- /** Internal fixed recipe. All state/leases belong to the supplied operation-scoped runtime. */
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 (!normalized.sourceArtifactId || !parseModelRef(normalized.modelRef) || normalized.providerInput.n !== 1) {
59
- flowFail('IRIS_FLOW_INPUT_INVALID', '首版流程需要来源 Artifact、明确 model_ref 和 n=1');
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 source = await readImageEditArtifact(root, owned.source_artifact_id, signal);
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
- return prepareFlowCreation({ source: { artifactId: owned.source_artifact_id, sha256: flowSha256(source.bytes) },
71
- input: owned, providerBinding: candidates[0].providerBinding });
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
- if (!resolveImageCandidates) flowFail('IRIS_FLOW_PROVIDER_REQUIRED', '创建或首次提交流程需要显式 Provider 配置');
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 resolveImageCandidates({ modelRef: runInput.model_ref, signal });
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
- return imageEditCandidates([{ ...candidates[0], providerModel: parsed.modelId, selectionReason: 'explicit' }]);
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.edit.claimed) flowFail('IRIS_FLOW_TASK_CONFLICT', '步骤检查点与已开始的 Task 冲突');
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 candidates = await candidatesFor(run.input, signal, run.providerBinding);
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.edit.claimed) run = updateFlowRun(root, run, { edit: { ...run.edit, claimed: true } });
134
+ if (!flowStep(run).claimed) run = updateFlowRun(root, run, { [flowStepId(run)]: { ...flowStep(run), claimed: true } });
103
135
  signal.throwIfAborted();
104
- const normalized = normalizeGenerationInput('image', run.input);
105
- task = (await runner.submit({ capability: 'image', candidates, providerInput: normalized.providerInput,
106
- sourceArtifactId: run.source.artifactId, flowRunId: run.id })).task;
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', '首版流程只支持一个改图结果,原 Task 与作品已保留');
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.edit.taskId })) };
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 (!run.comparison) flowFail('IRIS_FLOW_NOT_READY', '请先完成改图与比较,再选择结果');
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(host).saveImage({ data: new Uint8Array(fs.readFileSync(f)), mediaType, name: path.basename(f) });
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(host).saveImage({
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(host).saveImage({
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(host).saveImage({ data: image.bytes, mediaType: mt, name: path.basename(p) }), signal);
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(host, exec, id), signal)) || findOwnAttachment(id);
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(host).readImage(hit.ref, signal), signal);
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(host).saveImage({
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(host).saveImage({
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(host).saveImage({
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`