@fieldwangai/agentflow 0.1.159 → 0.1.160

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 (42) hide show
  1. package/bin/lib/catalog-flows.mjs +10 -1
  2. package/bin/lib/control-while.mjs +336 -0
  3. package/bin/lib/flow-dsl/codegen.mjs +74 -11
  4. package/bin/lib/flow-dsl/ir.mjs +33 -2
  5. package/bin/lib/flow-dsl/lint.mjs +128 -3
  6. package/bin/lib/flow-dsl/parser.mjs +168 -4
  7. package/bin/lib/marketplace.mjs +10 -2
  8. package/bin/lib/node-package-manifest.mjs +7 -2
  9. package/bin/lib/node-ui-kit.mjs +156 -0
  10. package/bin/lib/paths.mjs +2 -0
  11. package/bin/lib/workspace-flow-store.mjs +11 -2
  12. package/bin/lib/workspace-graph-merge.mjs +3 -0
  13. package/bin/lib/workspace-routes.mjs +10 -1
  14. package/bin/lib/workspace-server.mjs +592 -11
  15. package/bin/pipeline/validate-flow.mjs +3 -3
  16. package/builtin/nodes/control_parse_json.md +25 -0
  17. package/builtin/nodes/control_subflow_call.md +27 -0
  18. package/builtin/nodes/control_while.md +124 -0
  19. package/builtin/nodes/provide_json.md +14 -0
  20. package/builtin/nodes/workspace_subflow_input.md +14 -0
  21. package/builtin/pipelines/subflow-preview/workspace.flow.js +29 -0
  22. package/builtin/pipelines/subflow-preview/workspace.layout.json +38 -0
  23. package/builtin/pipelines/subflow-preview/workspace.nodes.json +19 -0
  24. package/builtin/pipelines/subflow-preview/workspace.state.json +93 -0
  25. package/builtin/pipelines/while-subflow-preview/workspace.flow.js +55 -0
  26. package/builtin/pipelines/while-subflow-preview/workspace.layout.json +66 -0
  27. package/builtin/pipelines/while-subflow-preview/workspace.nodes.json +49 -0
  28. package/builtin/pipelines/while-subflow-preview/workspace.state.json +194 -0
  29. package/builtin/web-ui/dist/assets/{WorkflowAssistantThread-CY19DsYq.js → WorkflowAssistantThread-Bfo9Ythw.js} +1 -1
  30. package/builtin/web-ui/dist/assets/index-XbeI5foV.js +872 -0
  31. package/builtin/web-ui/dist/assets/index-e1omCEau.css +1 -0
  32. package/builtin/web-ui/dist/index.html +2 -2
  33. package/package.json +2 -1
  34. package/reference/flow-control-capabilities.md +41 -16
  35. package/shared/slot-types.js +58 -0
  36. package/skills/agentflow-flow-dsl/SKILL.md +60 -6
  37. package/skills/agentflow-flow-dsl/references/node-calls.md +3 -0
  38. package/skills/agentflow-flow-dsl/references/subflow-authoring.md +230 -0
  39. package/skills/agentflow-node-dsl/SKILL.md +39 -0
  40. package/skills/agentflow-node-reference/references/builtin-nodes.md +25 -0
  41. package/builtin/web-ui/dist/assets/index-B9ppXv7e.css +0 -1
  42. package/builtin/web-ui/dist/assets/index-CdEQWRrp.js +0 -872
@@ -29,6 +29,7 @@ import {
29
29
  } from "./marketplace.mjs";
30
30
  import { isWorkspacePreviewDir } from "./workspace-preview.mjs";
31
31
  import { RETIRED_NODE_IDS } from "./legacy-flow-execution.mjs";
32
+ import { normalizeNodeUiForSlots } from "./node-ui-kit.mjs";
32
33
 
33
34
  /** 从指定目录收集含 flow.yaml 的子目录名。 */
34
35
  export function collectPipelineNamesFromDir(dirPath) {
@@ -219,6 +220,7 @@ export function parseNodeFrontmatter(raw) {
219
220
  displayName: undefined,
220
221
  description: undefined,
221
222
  guide: undefined,
223
+ ui: undefined,
222
224
  runtime: "native",
223
225
  type: "",
224
226
  paletteHidden: false,
@@ -242,6 +244,7 @@ export function parseNodeFrontmatter(raw) {
242
244
  data.paletteHidden = String(parsed.palette ?? "").trim().toLowerCase() === "hidden";
243
245
  data.input = normalizeFrontmatterSlots(parsed.input);
244
246
  data.output = normalizeFrontmatterSlots(parsed.output);
247
+ data.ui = normalizeNodeUiForSlots(parsed.ui, data.input, data.output);
245
248
  return data;
246
249
  }
247
250
  } catch {
@@ -320,7 +323,7 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
320
323
  // frontmatter 的 type: 优先于按 id 前缀的推断(workspace_run 等不带前缀的节点靠它归类)
321
324
  if (data.type) type = data.type;
322
325
  // frontmatter 的 palette: hidden 与 RETIRED_NODE_IDS 等价,让节点自带可见性
323
- if (data.paletteHidden) continue;
326
+ if (data.paletteHidden && !opts.includeHidden) continue;
324
327
  const strippedId =
325
328
  id.replace(/^agent_?/i, "").replace(/^control_?/i, "").replace(/^provide_?/i, "").replace(/^tool_?/i, "") || id;
326
329
  const label = data.displayName ?? strippedId;
@@ -343,6 +346,8 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
343
346
  displayName: translatedDisplayName || data.displayName,
344
347
  description: translatedDescription || data.description,
345
348
  guide: translatedGuide || data.guide,
349
+ ui: data.ui,
350
+ paletteHidden: Boolean(data.paletteHidden),
346
351
  inputs: data.input,
347
352
  outputs: data.output,
348
353
  source: flowIdOpt ? "flow" : "project",
@@ -364,6 +369,7 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
364
369
  label: manifest.displayName,
365
370
  displayName: manifest.displayName,
366
371
  description: manifest.description,
372
+ ui: manifest.ui,
367
373
  inputs: manifest.input,
368
374
  outputs: manifest.output,
369
375
  source: flowIdOpt ? "flow" : "project",
@@ -392,6 +398,7 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
392
398
  label: manifest.displayName,
393
399
  displayName: manifest.displayName,
394
400
  description: manifest.description,
401
+ ui: manifest.ui,
395
402
  inputs: manifest.input,
396
403
  outputs: manifest.output,
397
404
  source: manifest.source || "marketplace",
@@ -664,6 +671,7 @@ export function readNodeJson(workspaceRoot, nodeId, flowId, flowSource, opts = {
664
671
  version: resolved.version,
665
672
  packageDir: resolved.packageDir,
666
673
  runtime: resolved.runtime,
674
+ ui: resolved.ui,
667
675
  };
668
676
  }
669
677
  const fileName = nodeId.endsWith(".md") ? nodeId : `${nodeId}.md`;
@@ -724,6 +732,7 @@ export function readNodeJson(workspaceRoot, nodeId, flowId, flowSource, opts = {
724
732
  executionLogic: content || undefined,
725
733
  description: data.description,
726
734
  guide: data.guide,
735
+ ui: data.ui,
727
736
  };
728
737
  } catch (_) {}
729
738
  }
@@ -0,0 +1,336 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const DECISIONS = new Set(["continue", "wait", "done", "fail"]);
4
+ const STEP_RESULT_KEYS = new Set(["decision", "state", "summary"]);
5
+
6
+ export const DEFAULT_CONTROL_WHILE_MAX_ITERATIONS = 20;
7
+ export const DEFAULT_CONTROL_WHILE_TIMEOUT_MS = 30 * 60 * 1000;
8
+ export const MAX_CONTROL_WHILE_ITERATIONS = 1000;
9
+ export const MAX_CONTROL_WHILE_TIMEOUT_MS = 7 * 24 * 60 * 60 * 1000;
10
+ export const MAX_CONTROL_WHILE_STEP_STDOUT_BYTES = 1024 * 1024;
11
+ export const MAX_CONTROL_WHILE_STEP_STDERR_BYTES = 256 * 1024;
12
+ export const MAX_CONTROL_WHILE_SUMMARY_LENGTH = 4000;
13
+ export const MAX_CONTROL_WHILE_STATE_BYTES = 1024 * 1024;
14
+
15
+ export function parseControlWhileDurationMs(raw, fallback = DEFAULT_CONTROL_WHILE_TIMEOUT_MS) {
16
+ const text = String(raw ?? "").trim().toLowerCase();
17
+ if (!text) return fallback;
18
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
19
+ if (!match) throw new Error(`control.while invalid timeout: ${raw}`);
20
+ const value = Number(match[1]);
21
+ const unit = match[2] || "s";
22
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
23
+ const result = Math.round(value * factor);
24
+ if (!Number.isFinite(result) || result <= 0) throw new Error(`control.while timeout must be greater than zero: ${raw}`);
25
+ if (result > MAX_CONTROL_WHILE_TIMEOUT_MS) throw new Error("control.while timeout must not exceed 7d");
26
+ return result;
27
+ }
28
+
29
+ export function normalizeControlWhileConfig(inputs = {}) {
30
+ const rawMax = String(inputs.maxIterations ?? "").trim();
31
+ const maxIterations = rawMax ? Number(rawMax) : DEFAULT_CONTROL_WHILE_MAX_ITERATIONS;
32
+ if (!Number.isInteger(maxIterations) || maxIterations < 1 || maxIterations > MAX_CONTROL_WHILE_ITERATIONS) {
33
+ throw new Error(`control.while maxIterations must be an integer between 1 and ${MAX_CONTROL_WHILE_ITERATIONS}`);
34
+ }
35
+ return {
36
+ maxIterations,
37
+ timeoutMs: parseControlWhileDurationMs(inputs.timeout, DEFAULT_CONTROL_WHILE_TIMEOUT_MS),
38
+ };
39
+ }
40
+
41
+ export function normalizeControlWhileInitialState(raw) {
42
+ if (raw == null || String(raw).trim() === "") return null;
43
+ if (typeof raw === "object") {
44
+ assertControlWhileStateSize(raw);
45
+ return raw;
46
+ }
47
+ try {
48
+ const parsed = JSON.parse(String(raw));
49
+ assertControlWhileStateSize(parsed);
50
+ return parsed;
51
+ } catch (error) {
52
+ if (String(error?.message || "").startsWith("control.while state")) throw error;
53
+ throw new Error(`control.while state must be valid JSON: ${error.message}`);
54
+ }
55
+ }
56
+
57
+ export function serializeControlWhileState(value) {
58
+ const serialized = JSON.stringify(value ?? null);
59
+ if (serialized === undefined) throw new Error("control.while state must be JSON serializable");
60
+ if (Buffer.byteLength(serialized, "utf-8") > MAX_CONTROL_WHILE_STATE_BYTES) {
61
+ throw new Error(`control.while state must not exceed ${MAX_CONTROL_WHILE_STATE_BYTES} bytes`);
62
+ }
63
+ return serialized;
64
+ }
65
+
66
+ function assertControlWhileStateSize(value) {
67
+ serializeControlWhileState(value);
68
+ }
69
+
70
+ export function parseControlWhileStepResult(raw, currentState = null) {
71
+ const text = String(raw ?? "").trim();
72
+ if (!text) throw new Error("control.while step returned empty stdout; expected one JSON object");
73
+ if (Buffer.byteLength(text, "utf-8") > MAX_CONTROL_WHILE_STEP_STDOUT_BYTES) {
74
+ throw new Error(`control.while step stdout must not exceed ${MAX_CONTROL_WHILE_STEP_STDOUT_BYTES} bytes`);
75
+ }
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(text);
79
+ } catch (error) {
80
+ throw new Error(`control.while step stdout must be exactly one JSON object: ${error.message}`);
81
+ }
82
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
83
+ throw new Error("control.while step stdout must be a JSON object");
84
+ }
85
+ const unknownKeys = Object.keys(parsed).filter((key) => !STEP_RESULT_KEYS.has(key));
86
+ if (unknownKeys.length) {
87
+ throw new Error(`control.while step result contains unsupported fields: ${unknownKeys.join(", ")}`);
88
+ }
89
+ if (typeof parsed.decision !== "string") {
90
+ throw new Error("control.while decision must be a string");
91
+ }
92
+ const decision = parsed.decision;
93
+ if (!DECISIONS.has(decision)) {
94
+ throw new Error(`control.while decision must be continue, wait, done, or fail; received ${JSON.stringify(parsed.decision)}`);
95
+ }
96
+ if (parsed.summary != null && typeof parsed.summary !== "string") {
97
+ throw new Error("control.while summary must be a string");
98
+ }
99
+ const summary = parsed.summary || "";
100
+ if (summary.length > MAX_CONTROL_WHILE_SUMMARY_LENGTH) {
101
+ throw new Error(`control.while summary must not exceed ${MAX_CONTROL_WHILE_SUMMARY_LENGTH} characters`);
102
+ }
103
+ const state = Object.prototype.hasOwnProperty.call(parsed, "state") ? parsed.state : currentState;
104
+ assertControlWhileStateSize(state);
105
+ return {
106
+ decision,
107
+ state,
108
+ summary,
109
+ };
110
+ }
111
+
112
+ export function normalizeControlWhileHistory(raw) {
113
+ if (raw == null || String(raw).trim() === "") return [];
114
+ let parsed = raw;
115
+ if (!Array.isArray(raw)) {
116
+ try {
117
+ parsed = JSON.parse(String(raw));
118
+ } catch (error) {
119
+ throw new Error(`control.while checkpoint history must be valid JSON: ${error.message}`);
120
+ }
121
+ }
122
+ if (!Array.isArray(parsed)) throw new Error("control.while checkpoint history must be an array");
123
+ if (parsed.length > MAX_CONTROL_WHILE_ITERATIONS) {
124
+ throw new Error(`control.while checkpoint history must not exceed ${MAX_CONTROL_WHILE_ITERATIONS} entries`);
125
+ }
126
+ let previousIteration = 0;
127
+ return parsed.map((rawEntry, index) => {
128
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
129
+ throw new Error(`control.while checkpoint history entry ${index + 1} must be an object`);
130
+ }
131
+ const iteration = Number(rawEntry.iteration);
132
+ if (!Number.isInteger(iteration) || iteration < 1 || iteration <= previousIteration) {
133
+ throw new Error("control.while checkpoint iterations must be positive and strictly increasing");
134
+ }
135
+ const decision = rawEntry.decision;
136
+ if (typeof decision !== "string" || !DECISIONS.has(decision)) {
137
+ throw new Error(`control.while checkpoint entry ${iteration} has an invalid decision`);
138
+ }
139
+ if (rawEntry.summary != null && typeof rawEntry.summary !== "string") {
140
+ throw new Error(`control.while checkpoint entry ${iteration} has an invalid summary`);
141
+ }
142
+ const elapsedMs = Number(rawEntry.elapsedMs ?? 0);
143
+ if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
144
+ throw new Error(`control.while checkpoint entry ${iteration} has an invalid elapsedMs`);
145
+ }
146
+ const idempotencyKey = rawEntry.idempotencyKey == null ? "" : String(rawEntry.idempotencyKey);
147
+ if (idempotencyKey.length > 128) {
148
+ throw new Error(`control.while checkpoint entry ${iteration} has an invalid idempotencyKey`);
149
+ }
150
+ previousIteration = iteration;
151
+ return {
152
+ iteration,
153
+ decision,
154
+ summary: rawEntry.summary || "",
155
+ elapsedMs: Math.round(elapsedMs),
156
+ ...(idempotencyKey ? { idempotencyKey } : {}),
157
+ };
158
+ });
159
+ }
160
+
161
+ export function resolveControlWhileCheckpoint({
162
+ previousDecision = "",
163
+ state = "",
164
+ history = "",
165
+ iterations = "",
166
+ fingerprint = "",
167
+ expectedFingerprint = "",
168
+ allowResume = true,
169
+ } = {}) {
170
+ if (String(previousDecision || "") !== "wait") return { resumable: false, reason: "not_waiting" };
171
+ if (!allowResume) return { resumable: false, reason: "resume_disabled" };
172
+ if (!fingerprint || fingerprint !== expectedFingerprint) return { resumable: false, reason: "fingerprint_mismatch" };
173
+
174
+ const parsedHistory = normalizeControlWhileHistory(history);
175
+ const last = parsedHistory[parsedHistory.length - 1];
176
+ const recordedIterations = Number(iterations);
177
+ if (!last || last.decision !== "wait") {
178
+ throw new Error("control.while waiting checkpoint is corrupt: history must end with wait");
179
+ }
180
+ if (!Number.isInteger(recordedIterations) || recordedIterations !== last.iteration) {
181
+ throw new Error("control.while waiting checkpoint is corrupt: iterations do not match history");
182
+ }
183
+ return {
184
+ resumable: true,
185
+ reason: "waiting_checkpoint",
186
+ state: normalizeControlWhileInitialState(state),
187
+ history: parsedHistory,
188
+ nextIteration: last.iteration + 1,
189
+ elapsedMs: parsedHistory.reduce((total, entry) => total + entry.elapsedMs, 0),
190
+ };
191
+ }
192
+
193
+ export function controlWhileCheckpointFingerprint({ inputFingerprint = "", initialState = null } = {}) {
194
+ return crypto.createHash("sha256")
195
+ .update(`${String(inputFingerprint || "")}\u0000${serializeControlWhileState(initialState)}`)
196
+ .digest("hex")
197
+ .slice(0, 24);
198
+ }
199
+
200
+ export function controlWhileIdempotencyKey({ checkpointFingerprint = "", nodeId = "", iteration = 1 } = {}) {
201
+ const absoluteIteration = Number(iteration);
202
+ if (!checkpointFingerprint || !nodeId || !Number.isInteger(absoluteIteration) || absoluteIteration < 1) {
203
+ throw new Error("control.while cannot create idempotency key without checkpoint fingerprint, nodeId, and iteration");
204
+ }
205
+ const digest = crypto.createHash("sha256")
206
+ .update(`${checkpointFingerprint}\u0000${nodeId}\u0000${absoluteIteration}`)
207
+ .digest("hex")
208
+ .slice(0, 32);
209
+ return `afw_${digest}`;
210
+ }
211
+
212
+ function controlWhileTimeoutError(timeoutMs) {
213
+ const error = new Error(`control.while timed out after ${timeoutMs}ms`);
214
+ error.code = "CONTROL_WHILE_TIMEOUT";
215
+ return error;
216
+ }
217
+
218
+ /**
219
+ * Execute one deterministic step repeatedly. The graph stays acyclic: iteration is entirely
220
+ * represented by this state machine and its per-iteration events.
221
+ */
222
+ export async function runControlWhile({
223
+ initialState = null,
224
+ initialHistory = [],
225
+ initialElapsedMs = 0,
226
+ startIteration = null,
227
+ maxIterations = DEFAULT_CONTROL_WHILE_MAX_ITERATIONS,
228
+ timeoutMs = DEFAULT_CONTROL_WHILE_TIMEOUT_MS,
229
+ executeStep,
230
+ idempotencyKeyForIteration = null,
231
+ signal = null,
232
+ onIterationStart = null,
233
+ onIterationDone = null,
234
+ now = () => Date.now(),
235
+ } = {}) {
236
+ if (typeof executeStep !== "function") throw new Error("control.while executeStep is required");
237
+ const history = normalizeControlWhileHistory(initialHistory);
238
+ const inferredStartIteration = history.length ? history[history.length - 1].iteration + 1 : 1;
239
+ const firstIteration = startIteration == null ? inferredStartIteration : Number(startIteration);
240
+ if (!Number.isInteger(firstIteration) || firstIteration < 1 || firstIteration !== inferredStartIteration) {
241
+ throw new Error("control.while startIteration must continue directly after checkpoint history");
242
+ }
243
+ const elapsedBeforeRun = Number(initialElapsedMs);
244
+ if (!Number.isFinite(elapsedBeforeRun) || elapsedBeforeRun < 0) {
245
+ throw new Error("control.while initialElapsedMs must be a non-negative number");
246
+ }
247
+ const startedAt = now();
248
+ let state = initialState;
249
+
250
+ for (let iteration = firstIteration; iteration <= maxIterations; iteration += 1) {
251
+ if (signal?.aborted) {
252
+ const error = new Error("Workspace run stopped");
253
+ error.code = "WORKSPACE_RUN_ABORTED";
254
+ throw error;
255
+ }
256
+ const elapsedBefore = elapsedBeforeRun + Math.max(0, now() - startedAt);
257
+ const remainingMs = timeoutMs - elapsedBefore;
258
+ if (remainingMs <= 0) return {
259
+ decision: "fail",
260
+ state,
261
+ summary: `Reached timeout after ${timeoutMs}ms`,
262
+ iterations: history.length ? history[history.length - 1].iteration : firstIteration - 1,
263
+ history,
264
+ reason: "timeout",
265
+ };
266
+
267
+ const idempotencyKey = typeof idempotencyKeyForIteration === "function"
268
+ ? String(idempotencyKeyForIteration({ iteration, state }) || "")
269
+ : "";
270
+ onIterationStart?.({ iteration, state, elapsedMs: elapsedBefore, remainingMs, idempotencyKey });
271
+ const iterationStartedAt = now();
272
+ const controller = new AbortController();
273
+ let timedOut = false;
274
+ const abortFromParent = () => controller.abort(signal?.reason);
275
+ signal?.addEventListener("abort", abortFromParent, { once: true });
276
+ const timer = setTimeout(() => {
277
+ timedOut = true;
278
+ controller.abort(controlWhileTimeoutError(timeoutMs));
279
+ }, remainingMs);
280
+
281
+ let raw;
282
+ try {
283
+ raw = await executeStep({ iteration, state, remainingMs, signal: controller.signal, idempotencyKey });
284
+ } catch (error) {
285
+ if (signal?.aborted) {
286
+ const stopped = new Error("Workspace run stopped");
287
+ stopped.code = "WORKSPACE_RUN_ABORTED";
288
+ throw stopped;
289
+ }
290
+ if (timedOut) return {
291
+ decision: "fail",
292
+ state,
293
+ summary: `Reached timeout after ${timeoutMs}ms`,
294
+ iterations: history.length ? history[history.length - 1].iteration : firstIteration - 1,
295
+ history,
296
+ reason: "timeout",
297
+ };
298
+ throw error;
299
+ } finally {
300
+ clearTimeout(timer);
301
+ signal?.removeEventListener("abort", abortFromParent);
302
+ }
303
+
304
+ const step = parseControlWhileStepResult(raw, state);
305
+ state = step.state;
306
+ const entry = {
307
+ iteration,
308
+ decision: step.decision,
309
+ summary: step.summary,
310
+ elapsedMs: Math.max(0, now() - iterationStartedAt),
311
+ ...(idempotencyKey ? { idempotencyKey } : {}),
312
+ };
313
+ history.push(entry);
314
+ onIterationDone?.({ ...entry, state });
315
+
316
+ if (step.decision !== "continue") {
317
+ return {
318
+ decision: step.decision,
319
+ state,
320
+ summary: step.summary,
321
+ iterations: iteration,
322
+ history,
323
+ reason: step.decision,
324
+ };
325
+ }
326
+ }
327
+
328
+ return {
329
+ decision: "fail",
330
+ state,
331
+ summary: `Reached maxIterations (${maxIterations}) without done or wait`,
332
+ iterations: maxIterations,
333
+ history,
334
+ reason: "max_iterations",
335
+ };
336
+ }
@@ -64,6 +64,8 @@ function displayFileExt(kind) {
64
64
  */
65
65
  export function generateFlowSource(ir, opts = {}) {
66
66
  const N = ir.nodes;
67
+ const subflows = ir?.subflows && typeof ir.subflows === "object" ? ir.subflows : {};
68
+ const subflowMemberIds = new Set(Object.values(subflows).flatMap((subflow) => subflow?.nodeIds || []));
67
69
  const files = [];
68
70
  const emitFile = (rel, text) => {
69
71
  files.push({ path: rel, text });
@@ -153,7 +155,9 @@ export function generateFlowSource(ir, opts = {}) {
153
155
  const callee = (id) => bindingOf.get(id)?.name || apiCall(apiName(N[id].definitionId));
154
156
 
155
157
  const bodyTextOf = (id) => String(
156
- (N[id].definitionId === "tool_nodejs" && N[id].script) ? N[id].script : (N[id].body || ""),
158
+ ((N[id].definitionId === "tool_nodejs" || N[id].definitionId === "control_while") && N[id].script)
159
+ ? N[id].script
160
+ : (N[id].body || ""),
157
161
  );
158
162
 
159
163
  /**
@@ -246,13 +250,14 @@ export function generateFlowSource(ir, opts = {}) {
246
250
  }
247
251
 
248
252
  // 把控制链展开成嵌套序列;分叉处变成数组,由 printItem 打成 flow.fork(...)
249
- function expandChain(id) {
253
+ function expandChain(id, allowed = null) {
250
254
  const kids = (controlNext.get(id) || [])
251
255
  .filter((x) => !RUN_DEFINITIONS.has(N[x.to].definitionId))
256
+ .filter((x) => !allowed || allowed.has(x.to))
252
257
  .map((x) => x.to);
253
258
  if (isIf(id) || !kids.length) return [id];
254
- if (kids.length === 1) return [id, ...expandChain(kids[0])];
255
- return [id, kids.map(expandChain)];
259
+ if (kids.length === 1) return [id, ...expandChain(kids[0], allowed)];
260
+ return [id, kids.map((kid) => expandChain(kid, allowed))];
256
261
  }
257
262
  const collectIds = (seq, acc = []) => {
258
263
  for (const item of seq) {
@@ -268,16 +273,39 @@ export function generateFlowSource(ir, opts = {}) {
268
273
  );
269
274
 
270
275
  const declared = new Set();
276
+ const declaredSubflows = new Set();
271
277
  const out = [];
272
278
 
273
- function chainFrom(roots) {
279
+ function chainFrom(roots, allowed = null) {
274
280
  const seq = roots.length === 1
275
- ? expandChain(roots[0])
276
- : (roots.length ? [roots.map(expandChain)] : []);
281
+ ? expandChain(roots[0], allowed)
282
+ : (roots.length ? [roots.map((root) => expandChain(root, allowed))] : []);
277
283
  for (const id of collectIds(seq)) declare(id, false);
278
284
  return seq;
279
285
  }
280
286
 
287
+ const outputRef = (nodeId, slot) => outVar.get(`${nodeId}|${slot}`) || `${nodeId}.${slot}`;
288
+
289
+ function declareSubflow(subflowId) {
290
+ if (declaredSubflows.has(subflowId)) return;
291
+ const subflow = subflows[subflowId];
292
+ if (!subflow) return;
293
+ declaredSubflows.add(subflowId);
294
+ const allowed = new Set(subflow.nodeIds || []);
295
+ for (const binding of Object.values(subflow.inputs || {})) declare(binding.nodeId, false);
296
+ const seq = chainFrom((subflow.roots || []).filter((id) => allowed.has(id)), allowed).map(printItem);
297
+ for (const binding of Object.values(subflow.outputs || {})) declare(binding.nodeId, false);
298
+ const inputEntries = Object.entries(subflow.inputs || {}).map(([name, binding]) => (
299
+ `${isIdentifier(name) ? name : JSON.stringify(name)}: ${binding.nodeId}`
300
+ ));
301
+ const outputEntries = Object.entries(subflow.outputs || {}).map(([name, binding]) => (
302
+ `${isIdentifier(name) ? name : JSON.stringify(name)}: ${outputRef(binding.nodeId, binding.slot)}`
303
+ ));
304
+ const inputObject = inputEntries.length ? `{ ${inputEntries.join(", ")} }` : "{}";
305
+ const outputObject = outputEntries.length ? `{ ${outputEntries.join(", ")} }` : "{}";
306
+ out.push(`export const ${subflowId} = ${apiCall("flow.subflow")}(${literal(subflow.label || subflowId)}, ${inputObject}, ${apiCall("flow")}(${seq.join(", ")}), ${outputObject});\n`);
307
+ }
308
+
281
309
  function declare(id, exported) {
282
310
  if (declared.has(id)) return;
283
311
  declared.add(id);
@@ -285,18 +313,50 @@ export function generateFlowSource(ir, opts = {}) {
285
313
  for (const dep of dataIn.get(id) || []) if (!declared.has(dep.from)) declare(dep.from, true);
286
314
 
287
315
  const node = N[id];
316
+ if (node.definitionId === "workspace_subflow_input") {
317
+ const name = String(node.attrs?.subflowInputName || node.label || id);
318
+ const type = String(node.attrs?.subflowInputType || node.packageDef?.output?.[0]?.type || "text");
319
+ out.push(`const ${id} = ${apiCall("flow.input")}(${literal(name)}, ${literal(type)});\n`);
320
+ return;
321
+ }
322
+ if (node.definitionId === "control_subflow_call") {
323
+ const subflowId = String(node.attrs?.subflowId || "");
324
+ declareSubflow(subflowId);
325
+ const args = [];
326
+ if (node.label) args.push(literal(node.label));
327
+ args.push(subflowId);
328
+ args.push(pinsObject(id));
329
+ out.push(`${exported ? "export " : ""}const ${id} = ${apiCall("flow.call")}(${args.join(", ")});\n`);
330
+ const bindings = (destructured.get(id) || []).map((slot) => {
331
+ const variable = outVar.get(`${id}|${slot}`);
332
+ return variable === slot ? slot : `${isIdentifier(slot) ? slot : JSON.stringify(slot)}: ${variable}`;
333
+ });
334
+ if (bindings.length) out.push(`const { ${bindings.join(", ")} } = ${id};\n`);
335
+ return;
336
+ }
288
337
  const folds = isIf(id) ? new Map() : bodyFolds(id);
289
338
  const args = [];
290
339
  if (node.label) args.push(literal(node.label));
291
340
  args.push(pinsObject(id, folds));
292
341
 
293
- if (isIf(id)) {
342
+ const conditionSubflowId = node.definitionId === "control_while"
343
+ ? String(node.attrs?.conditionSubflowId || "")
344
+ : "";
345
+ const bodySubflowId = node.definitionId === "control_while"
346
+ ? String(node.attrs?.bodySubflowId || "")
347
+ : "";
348
+ if (conditionSubflowId || bodySubflowId) {
349
+ declareSubflow(conditionSubflowId);
350
+ declareSubflow(bodySubflowId);
351
+ args.push(conditionSubflowId || "undefined");
352
+ args.push(bodySubflowId || "undefined");
353
+ } else if (isIf(id)) {
294
354
  const thenIds = (controlNext.get(id) || []).filter((x) => x.slot === "next1").map((x) => x.to);
295
355
  const elseIds = (controlNext.get(id) || []).filter((x) => x.slot === "next2").map((x) => x.to);
296
356
  args.push(`${apiCall("flow")}(${chainFrom(thenIds).map(printItem).join(", ")})`);
297
357
  args.push(`${apiCall("flow")}(${chainFrom(elseIds).map(printItem).join(", ")})`);
298
358
  } else {
299
- const usesScript = node.definitionId === "tool_nodejs" && node.script;
359
+ const usesScript = (node.definitionId === "tool_nodejs" || node.definitionId === "control_while") && node.script;
300
360
  const body = usesScript ? node.script : node.body;
301
361
  if (body) {
302
362
  args.push(folds.size
@@ -319,6 +379,9 @@ export function generateFlowSource(ir, opts = {}) {
319
379
 
320
380
  const ids = Object.keys(N).sort();
321
381
 
382
+ // 子流程先于父流程调用声明;内部节点仍是普通 DSL 节点,只是拥有独立作用域。
383
+ for (const subflowId of Object.keys(subflows).sort()) declareSubflow(subflowId);
384
+
322
385
  for (const runId of ids) {
323
386
  const definitionId = N[runId].definitionId;
324
387
  if (!RUN_DEFINITIONS.has(definitionId)) continue;
@@ -344,12 +407,12 @@ export function generateFlowSource(ir, opts = {}) {
344
407
  // 没有 run 入口、但自成控制链的孤儿链条——语料里真的有,丢掉就等于删图
345
408
  const controlTargets = new Set(ir.edges.map((e) => e.split("|")).filter((p) => p[3] === "prev").map((p) => p[2]));
346
409
  for (const id of ids) {
347
- if (declared.has(id) || RUN_DEFINITIONS.has(N[id].definitionId) || controlTargets.has(id)) continue;
410
+ if (declared.has(id) || subflowMemberIds.has(id) || RUN_DEFINITIONS.has(N[id].definitionId) || controlTargets.has(id)) continue;
348
411
  if (!(controlNext.get(id) || []).length) continue;
349
412
  out.push(`${apiCall("flow.detached")}(${chainFrom([id]).map(printItem).join(", ")});\n`);
350
413
  }
351
414
  for (const id of ids) {
352
- if (!declared.has(id) && !RUN_DEFINITIONS.has(N[id].definitionId)) declare(id, true);
415
+ if (!declared.has(id) && !subflowMemberIds.has(id) && !RUN_DEFINITIONS.has(N[id].definitionId)) declare(id, true);
353
416
  }
354
417
 
355
418
  const flowImports = flowApiRoots.map((root) => {
@@ -46,6 +46,11 @@ export const NODE_META_KEYS = [
46
46
  "marketplacePackageId",
47
47
  "marketplaceVersion",
48
48
  "sourceContextRunNodeId",
49
+ "subflowId",
50
+ "subflowInputName",
51
+ "subflowInputType",
52
+ "conditionSubflowId",
53
+ "bodySubflowId",
49
54
  ];
50
55
 
51
56
  function handleIndex(handle) {
@@ -100,6 +105,21 @@ export function graphToIr(graph) {
100
105
  node.extraIn = [];
101
106
  node.extraOut = [];
102
107
 
108
+ if (definitionId === "control_subflow_call" || definitionId === "workspace_subflow_input") {
109
+ node.packageDef = {
110
+ input: (instance.input || []).map((slot) => ({
111
+ name: String(slot?.name || ""),
112
+ type: String(slot?.type || "text"),
113
+ ...(slot?.required ? { required: true } : {}),
114
+ })).filter((slot) => slot.name),
115
+ output: (instance.output || []).map((slot) => ({
116
+ name: String(slot?.name || ""),
117
+ type: String(slot?.type || "text"),
118
+ ...(slot?.required ? { required: true } : {}),
119
+ })).filter((slot) => slot.name),
120
+ };
121
+ }
122
+
103
123
  for (const slot of instance.input || []) {
104
124
  const name = String(slot?.name || "");
105
125
  if (!name) continue;
@@ -144,7 +164,12 @@ export function graphToIr(graph) {
144
164
  };
145
165
  }
146
166
 
147
- return { nodes, edges: [...new Set(edges)].sort(), slotOrder };
167
+ return {
168
+ nodes,
169
+ edges: [...new Set(edges)].sort(),
170
+ slotOrder,
171
+ subflows: graph?.subflows && typeof graph.subflows === "object" ? graph.subflows : {},
172
+ };
148
173
  }
149
174
 
150
175
  /**
@@ -299,7 +324,13 @@ export function irToGraph(ir, layout = { nodes: {} }, nodeMeta = { nodes: {} })
299
324
  }
300
325
  if (layout.viewport) ui.viewport = layout.viewport;
301
326
 
302
- return { version: 1, instances, edges, ui };
327
+ return {
328
+ version: 1,
329
+ instances,
330
+ edges,
331
+ ui,
332
+ subflows: ir?.subflows && typeof ir.subflows === "object" ? ir.subflows : {},
333
+ };
303
334
  }
304
335
 
305
336
  export { CTRL_SLOTS, STD_SLOTS };