@fieldwangai/agentflow 0.1.157 → 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 (46) 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 +75 -12
  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/locales/en.json +4 -0
  8. package/bin/lib/locales/zh.json +4 -0
  9. package/bin/lib/marketplace.mjs +10 -2
  10. package/bin/lib/mcp-server.mjs +1 -0
  11. package/bin/lib/node-package-manifest.mjs +7 -2
  12. package/bin/lib/node-ui-kit.mjs +156 -0
  13. package/bin/lib/paths.mjs +2 -0
  14. package/bin/lib/workspace-flow-store.mjs +11 -2
  15. package/bin/lib/workspace-graph-merge.mjs +3 -0
  16. package/bin/lib/workspace-routes.mjs +10 -1
  17. package/bin/lib/workspace-server.mjs +601 -15
  18. package/bin/pipeline/validate-flow.mjs +3 -3
  19. package/builtin/nodes/control_parse_json.md +25 -0
  20. package/builtin/nodes/control_subflow_call.md +27 -0
  21. package/builtin/nodes/control_while.md +124 -0
  22. package/builtin/nodes/display_code.md +39 -0
  23. package/builtin/nodes/provide_json.md +14 -0
  24. package/builtin/nodes/workspace_subflow_input.md +14 -0
  25. package/builtin/pipelines/subflow-preview/workspace.flow.js +29 -0
  26. package/builtin/pipelines/subflow-preview/workspace.layout.json +38 -0
  27. package/builtin/pipelines/subflow-preview/workspace.nodes.json +19 -0
  28. package/builtin/pipelines/subflow-preview/workspace.state.json +93 -0
  29. package/builtin/pipelines/while-subflow-preview/workspace.flow.js +55 -0
  30. package/builtin/pipelines/while-subflow-preview/workspace.layout.json +66 -0
  31. package/builtin/pipelines/while-subflow-preview/workspace.nodes.json +49 -0
  32. package/builtin/pipelines/while-subflow-preview/workspace.state.json +194 -0
  33. package/builtin/web-ui/dist/assets/{WorkflowAssistantThread-g_ljSViJ.js → WorkflowAssistantThread-Bfo9Ythw.js} +1 -1
  34. package/builtin/web-ui/dist/assets/index-XbeI5foV.js +872 -0
  35. package/builtin/web-ui/dist/assets/index-e1omCEau.css +1 -0
  36. package/builtin/web-ui/dist/index.html +2 -2
  37. package/package.json +2 -1
  38. package/reference/flow-control-capabilities.md +41 -16
  39. package/shared/slot-types.js +58 -0
  40. package/skills/agentflow-flow-dsl/SKILL.md +61 -7
  41. package/skills/agentflow-flow-dsl/references/node-calls.md +4 -0
  42. package/skills/agentflow-flow-dsl/references/subflow-authoring.md +230 -0
  43. package/skills/agentflow-node-dsl/SKILL.md +39 -0
  44. package/skills/agentflow-node-reference/references/builtin-nodes.md +33 -0
  45. package/builtin/web-ui/dist/assets/index-C0iq6zHl.js +0 -870
  46. package/builtin/web-ui/dist/assets/index-mmXs3H9P.css +0 -1
@@ -18,6 +18,7 @@ import fs from "fs";
18
18
  import path from "path";
19
19
  import { parse as acornParse } from "acorn";
20
20
 
21
+ import { slotTypeCompatibility } from "../../../shared/slot-types.js";
21
22
  import { CTRL_SLOTS, RUN_DEFINITIONS, DEFINITIONS, definitionOf } from "./defs.mjs";
22
23
  import { packageResolverFor, scanAvailableNodePackages } from "./packages.mjs";
23
24
  import { FLOW_SOURCE_FILENAME } from "./index.mjs";
@@ -43,7 +44,21 @@ const BANNED_SYNTAX = {
43
44
  };
44
45
 
45
46
  /** 这两类节点的槽位由实例自己定义,不受定义表约束。 */
46
- const CUSTOM_SLOTS_ALLOWED = new Set(["agent_subAgent", "tool_nodejs"]);
47
+ const CUSTOM_SLOTS_ALLOWED = new Set(["agent_subAgent", "tool_nodejs", "control_subflow_call"]);
48
+
49
+ function declaredSlot(node, kind, name) {
50
+ const slots = node?.packageDef?.[kind]
51
+ || definitionOf(node?.definitionId)?.[kind === "input" ? "input" : "output"]
52
+ || [];
53
+ return slots.find((slot) => String(slot?.name || "") === String(name || "")) || null;
54
+ }
55
+
56
+ function declaredSlotType(node, kind, name) {
57
+ const slot = declaredSlot(node, kind, name);
58
+ if (slot?.type) return String(slot.type);
59
+ if (kind === "input" && node?.inputTypes?.[name]) return String(node.inputTypes[name]);
60
+ return "";
61
+ }
47
62
 
48
63
  function walk(node, visit) {
49
64
  if (!node || typeof node !== "object") return;
@@ -131,6 +146,28 @@ export function lintFlowDir(flowDir, opts = {}) {
131
146
  }
132
147
 
133
148
  const N = ir.nodes;
149
+ const subflows = ir?.subflows && typeof ir.subflows === "object" ? ir.subflows : {};
150
+
151
+ const ownerOf = new Map();
152
+ for (const [subflowId, subflow] of Object.entries(subflows)) {
153
+ if (!(subflow.roots || []).length) errors.push(`${subflowId}: 子流程没有执行入口`);
154
+ for (const nodeId of subflow.nodeIds || []) {
155
+ if (!N[nodeId]) errors.push(`${subflowId}: 成员 ${nodeId} 不存在`);
156
+ if (ownerOf.has(nodeId) && ownerOf.get(nodeId) !== subflowId) {
157
+ errors.push(`${nodeId}: 不能同时属于子流程 ${ownerOf.get(nodeId)} 和 ${subflowId}`);
158
+ } else ownerOf.set(nodeId, subflowId);
159
+ }
160
+ for (const [name, binding] of Object.entries(subflow.inputs || {})) {
161
+ if (N[binding.nodeId]?.definitionId !== "workspace_subflow_input") {
162
+ errors.push(`${subflowId}.${name}: 输入代理 ${binding.nodeId} 不是 flow.input`);
163
+ }
164
+ }
165
+ for (const [name, binding] of Object.entries(subflow.outputs || {})) {
166
+ if (!ownerOf.has(binding.nodeId) && !(subflow.nodeIds || []).includes(binding.nodeId)) {
167
+ errors.push(`${subflowId}.${name}: 输出来源 ${binding.nodeId} 不在子流程内`);
168
+ }
169
+ }
170
+ }
134
171
 
135
172
  for (const [id, node] of Object.entries(N)) {
136
173
  const definitionId = node.definitionId;
@@ -139,6 +176,53 @@ export function lintFlowDir(flowDir, opts = {}) {
139
176
  errors.push(`${id}: 未知节点类型 ${definitionId}`);
140
177
  continue;
141
178
  }
179
+ if (definitionId === "control_subflow_call" && !subflows[node.attrs?.subflowId]) {
180
+ errors.push(`${id}: 引用的子流程 ${node.attrs?.subflowId || "(empty)"} 不存在`);
181
+ }
182
+ if (definitionId === "control_while") {
183
+ const conditionId = String(node.attrs?.conditionSubflowId || "");
184
+ const bodyId = String(node.attrs?.bodySubflowId || "");
185
+ const hasSubflowRefs = Boolean(conditionId || bodyId);
186
+ if (hasSubflowRefs && node.script) {
187
+ errors.push(`${id}: control.while 不能同时声明 step 脚本和 Condition/Body 子流程`);
188
+ } else if (hasSubflowRefs) {
189
+ if (!conditionId || !bodyId) {
190
+ errors.push(`${id}: control.while 必须同时声明 Condition 和 Body 子流程`);
191
+ } else if (conditionId === bodyId) {
192
+ errors.push(`${id}: control.while 的 Condition 和 Body 必须是不同子流程`);
193
+ }
194
+ const condition = subflows[conditionId];
195
+ const body = subflows[bodyId];
196
+ if (!condition) errors.push(`${id}: Condition 子流程 ${conditionId || "(empty)"} 不存在`);
197
+ if (!body) errors.push(`${id}: Body 子流程 ${bodyId || "(empty)"} 不存在`);
198
+ for (const name of ["state", "iteration"]) {
199
+ if (condition && !condition.inputs?.[name]) errors.push(`${id}: Condition 子流程 ${conditionId} 缺少输入 ${name}`);
200
+ }
201
+ if (condition && !condition.outputs?.decision) {
202
+ errors.push(`${id}: Condition 子流程 ${conditionId} 缺少输出 decision`);
203
+ }
204
+ for (const name of ["state", "iteration", "idempotencyKey"]) {
205
+ if (body && !body.inputs?.[name]) errors.push(`${id}: Body 子流程 ${bodyId} 缺少输入 ${name}`);
206
+ }
207
+ if (body && !body.outputs?.state) errors.push(`${id}: Body 子流程 ${bodyId} 缺少输出 state`);
208
+ for (const [contract, name, expected] of [
209
+ [condition?.inputs, "state", "json"],
210
+ [condition?.inputs, "iteration", "text"],
211
+ [condition?.outputs, "decision", "text"],
212
+ [body?.inputs, "state", "json"],
213
+ [body?.inputs, "iteration", "text"],
214
+ [body?.inputs, "idempotencyKey", "text"],
215
+ [body?.outputs, "state", "json"],
216
+ ]) {
217
+ const actual = String(contract?.[name]?.type || "");
218
+ if (actual && actual !== expected) {
219
+ errors.push(`${id}: While 契约 ${name} 必须是 ${expected},当前是 ${actual}`);
220
+ }
221
+ }
222
+ } else if (!node.script) {
223
+ errors.push(`${id}: control.while 需要 step 脚本,或 Condition/Body 两个子流程`);
224
+ }
225
+ }
142
226
  // 运行时支持程度来自各节点 .md 的 runtime: 字段,不是这里的第二份清单
143
227
  if (def.runtime === "degraded") {
144
228
  warnings.push(`${id}: ${definitionId} 无专用 handler,靠通用 agent + 输出契约工作;结果必须恰好是 true/false`);
@@ -171,6 +255,11 @@ export function lintFlowDir(flowDir, opts = {}) {
171
255
  errors.push(`边引用了未声明的节点 ${dst}`);
172
256
  continue;
173
257
  }
258
+ const srcOwner = ownerOf.get(src) || "";
259
+ const dstOwner = ownerOf.get(dst) || "";
260
+ if (srcOwner !== dstOwner) {
261
+ errors.push(`子流程边界禁止直接连线: ${src}.${fromSlot} (${srcOwner || "父流程"}) -> ${dst}.${toSlot} (${dstOwner || "父流程"});请通过 flow.call 契约传值`);
262
+ }
174
263
  const def = lookupDef(N[src].definitionId);
175
264
  if (def && !CTRL_SLOTS.has(fromSlot)
176
265
  && !def.output.some((s) => s.name === fromSlot)
@@ -186,6 +275,18 @@ export function lintFlowDir(flowDir, opts = {}) {
186
275
  if (CTRL_SLOTS.has(fromSlot) && def && !def.output.some((s) => s.name === fromSlot)) {
187
276
  errors.push(`${src}[${N[src].definitionId}] 没有 ${fromSlot} 槽,控制流串不下去`);
188
277
  }
278
+ const sourceType = declaredSlotType(N[src], "output", fromSlot);
279
+ // 自定义输入槽没有独立声明时会跟随上游类型,因此只对有明确目标类型的边做校验。
280
+ const targetType = declaredSlotType(N[dst], "input", toSlot);
281
+ if (sourceType && targetType) {
282
+ const compatibility = slotTypeCompatibility(sourceType, targetType);
283
+ if (!compatibility.compatible) {
284
+ errors.push(
285
+ `类型不兼容: ${src}.${fromSlot}(${compatibility.source}) -> `
286
+ + `${dst}.${toSlot}(${compatibility.target});请改用同类型引脚或显式转换节点`,
287
+ );
288
+ }
289
+ }
189
290
  const target = `${dst}|${toSlot}`;
190
291
  if (inputSeen.has(target)) {
191
292
  errors.push(`fan-in 禁止: ${dst}.${toSlot} 被 ${inputSeen.get(target)} 和 ${src} 同时连入`);
@@ -211,6 +312,32 @@ export function lintFlowDir(flowDir, opts = {}) {
211
312
  };
212
313
  for (const id of Object.keys(N)) if (!color.has(id)) visit(id);
213
314
 
315
+ // 图本身仍是 DAG,但子流程调用关系也不能递归,否则会形成运行时调用环。
316
+ const callGraph = new Map(Object.keys(subflows).map((id) => [id, []]));
317
+ for (const [nodeId, node] of Object.entries(N)) {
318
+ const owner = ownerOf.get(nodeId);
319
+ if (!owner) continue;
320
+ if (node.definitionId === "control_subflow_call") {
321
+ const target = String(node.attrs?.subflowId || "");
322
+ if (target) callGraph.get(owner)?.push(target);
323
+ }
324
+ if (node.definitionId === "control_while") {
325
+ for (const target of [node.attrs?.conditionSubflowId, node.attrs?.bodySubflowId]) {
326
+ if (target) callGraph.get(owner)?.push(String(target));
327
+ }
328
+ }
329
+ }
330
+ const callColor = new Map();
331
+ const visitCall = (id) => {
332
+ callColor.set(id, 1);
333
+ for (const next of callGraph.get(id) || []) {
334
+ if (callColor.get(next) === 1) errors.push(`子流程递归调用禁止: ${id} -> ${next}`);
335
+ else if (!callColor.has(next)) visitCall(next);
336
+ }
337
+ callColor.set(id, 2);
338
+ };
339
+ for (const id of callGraph.keys()) if (!callColor.has(id)) visitCall(id);
340
+
214
341
  for (const [id, node] of Object.entries(N)) {
215
342
  if (node.definitionId !== "control_if") continue;
216
343
  const edge = ir.edges.find((e) => e.endsWith(`|${id}|prediction`));
@@ -218,8 +345,6 @@ export function lintFlowDir(flowDir, opts = {}) {
218
345
  errors.push(`${id}: control.if 的 prediction 未接线`);
219
346
  } else {
220
347
  const [src, slot] = edge.split("|");
221
- const type = lookupDef(N[src]?.definitionId)?.output.find((s) => s.name === slot)?.type;
222
- if (type && type !== "bool") errors.push(`${id}: prediction 只能接 bool,${src}.${slot} 是 ${type}`);
223
348
  }
224
349
  for (const [slot, label] of [["next1", "then"], ["next2", "else"]]) {
225
350
  if (!ir.edges.some((e) => e.startsWith(`${id}|${slot}|`))) warnings.push(`${id}: control.if 缺 ${label} 分支`);
@@ -122,6 +122,8 @@ export function parseFlowSource(source, opts = {}) {
122
122
  const pendingIf = [];
123
123
  const runDecls = [];
124
124
  const destructures = [];
125
+ const subflows = {};
126
+ const subflowOf = new Map();
125
127
 
126
128
  // 先扫一遍解构声明:`const { storyId } = node;` 让后面引用 storyId 时能还原成边
127
129
  for (const stmt of ast.body) {
@@ -151,7 +153,7 @@ export function parseFlowSource(source, opts = {}) {
151
153
  if (tpl?.type !== "TemplateLiteral" || !tpl.expressions.length) return null;
152
154
  // 本节点自己就有这个名字 -> `${x}` 是运行时占位符,原样留在正文里,不是 JS 插值
153
155
  const def = definitionOf(definitionId);
154
- const isScript = definitionId === "tool_nodejs";
156
+ const isScript = definitionId === "tool_nodejs" || definitionId === "control_while";
155
157
  const ownSlot = (name) => def.input.some((s) => s.name === name)
156
158
  || nodes[id].extraIn.includes(name)
157
159
  || nodes[id].inputs[name] !== undefined
@@ -258,6 +260,35 @@ export function parseFlowSource(source, opts = {}) {
258
260
  return out;
259
261
  };
260
262
 
263
+ const flatItemIds = (items, out = []) => {
264
+ for (const item of items || []) {
265
+ if (typeof item === "string") out.push(item);
266
+ else if (item?.fork) for (const branch of item.fork) flatItemIds(branch, out);
267
+ }
268
+ return out;
269
+ };
270
+
271
+ const rootItemIds = (items) => {
272
+ const first = items?.[0];
273
+ if (typeof first === "string") return [first];
274
+ if (!first?.fork) return [];
275
+ return first.fork.map((branch) => flatItemIds(branch)[0]).filter(Boolean);
276
+ };
277
+
278
+ const objectEntries = (node, context) => {
279
+ if (!node || node.type !== "ObjectExpression") {
280
+ unresolvedAt(node, `${context} 必须是对象字面量`);
281
+ return [];
282
+ }
283
+ return node.properties.map((prop) => {
284
+ if (prop.type !== "Property" || prop.computed || prop.kind !== "init") {
285
+ unresolvedAt(prop, `${context} 只能包含静态字段`);
286
+ return null;
287
+ }
288
+ return [String(prop.key.name ?? prop.key.value ?? ""), prop.value];
289
+ }).filter(Boolean);
290
+ };
291
+
261
292
  function linkChain(head, items) {
262
293
  let prev = head;
263
294
  let slot = "next";
@@ -300,11 +331,98 @@ export function parseFlowSource(source, opts = {}) {
300
331
  const path = apiCalleePath(init.callee);
301
332
  const args = [...init.arguments];
302
333
 
334
+ if (path === "flow.input") {
335
+ const name = stringOf(args[0]);
336
+ const type = stringOf(args[1]) || "text";
337
+ if (!name) unresolvedAt(init, `${id}: flow.input 的第一个参数必须是输入名`);
338
+ nodes[id] = {
339
+ definitionId: "workspace_subflow_input",
340
+ inputs: {},
341
+ inputTypes: {},
342
+ outputs: {},
343
+ extraIn: [],
344
+ extraOut: [],
345
+ declaredOut: [],
346
+ attrs: { subflowInputName: name || id, subflowInputType: type },
347
+ packageDef: { input: [], output: [{ name: "value", type }] },
348
+ label: name || id,
349
+ };
350
+ continue;
351
+ }
352
+
303
353
  const first = args[0];
304
354
  const hasLabel = first
305
355
  && ((first.type === "Literal" && typeof first.value === "string") || first.type === "TemplateLiteral");
306
356
  const label = hasLabel ? stringOf(args.shift()) : null;
307
357
 
358
+ if (path === "flow.subflow") {
359
+ const inputObject = args.shift();
360
+ const sequence = args.shift();
361
+ const outputObject = args.shift();
362
+ const items = sequence?.type === "CallExpression" && apiCalleePath(sequence.callee) === "flow"
363
+ ? itemsOf(sequence)
364
+ : [];
365
+ // The visual editor persists an empty flow() while its START/RETURN
366
+ // contract is still being wired. Keep that draft round-trippable;
367
+ // lint already reports a missing execution root and runtime refuses it.
368
+ const inputs = {};
369
+ for (const [name, value] of objectEntries(inputObject, `${id} inputs`)) {
370
+ if (value.type !== "Identifier" || nodes[value.name]?.definitionId !== "workspace_subflow_input") {
371
+ unresolvedAt(value, `${id}.${name}: 子流程输入必须引用 flow.input 变量`);
372
+ continue;
373
+ }
374
+ const proxy = nodes[value.name];
375
+ inputs[name] = {
376
+ nodeId: value.name,
377
+ slot: "value",
378
+ type: String(proxy.attrs?.subflowInputType || "text"),
379
+ };
380
+ }
381
+ const outputs = {};
382
+ for (const [name, value] of objectEntries(outputObject, `${id} outputs`)) {
383
+ const member = memberPath(value);
384
+ const ref = member || (value.type === "Identifier" ? varOf.get(value.name) : null);
385
+ if (!ref) {
386
+ unresolvedAt(value, `${id}.${name}: 子流程输出必须引用内部节点输出`);
387
+ continue;
388
+ }
389
+ const sourceNode = nodes[ref[0]];
390
+ const declaredSlot = sourceNode?.packageDef?.output?.find((slot) => slot.name === ref[1])
391
+ || definitionOf(sourceNode?.definitionId).output.find((slot) => slot.name === ref[1]);
392
+ outputs[name] = { nodeId: ref[0], slot: ref[1], type: String(declaredSlot?.type || "text") };
393
+ }
394
+ const roots = rootItemIds(items);
395
+ subflows[id] = { id, label: label || id, inputs, outputs, roots, nodeIds: [] };
396
+ subflowOf.set(id, subflows[id]);
397
+ linkChain(null, items);
398
+ continue;
399
+ }
400
+
401
+ if (path === "flow.call") {
402
+ const ref = args.shift();
403
+ const subflow = ref?.type === "Identifier" ? subflowOf.get(ref.name) : null;
404
+ if (!subflow) unresolvedAt(ref, `${id}: flow.call 第二个参数必须引用前面声明的 flow.subflow`);
405
+ const inputSlots = Object.entries(subflow?.inputs || {}).map(([name, binding]) => ({ name, type: binding.type || "text" }));
406
+ const outputSlots = Object.entries(subflow?.outputs || {}).map(([name, binding]) => ({ name, type: binding.type || "text" }));
407
+ nodes[id] = {
408
+ definitionId: "control_subflow_call",
409
+ inputs: {},
410
+ inputTypes: {},
411
+ outputs: {},
412
+ extraIn: inputSlots.map((slot) => slot.name),
413
+ extraOut: outputSlots.map((slot) => slot.name),
414
+ declaredOut: outputSlots.map((slot) => slot.name),
415
+ attrs: { subflowId: ref?.name || "" },
416
+ packageDef: {
417
+ input: [{ name: "prev", type: "node" }, ...inputSlots],
418
+ output: [{ name: "next", type: "node" }, ...outputSlots],
419
+ },
420
+ };
421
+ if (label) nodes[id].label = label;
422
+ readPins(id, "control_subflow_call", args[0]);
423
+ continue;
424
+ }
425
+
308
426
  if (path === "flow" || path === "flow.schedule") {
309
427
  const body = path === "flow.schedule" ? stringOf(args.shift()) : null;
310
428
  runDecls.push({
@@ -364,7 +482,22 @@ export function parseFlowSource(source, opts = {}) {
364
482
  }
365
483
  readPins(id, definitionId, args[0]);
366
484
 
367
- if (definitionId === "control_if") {
485
+ if (definitionId === "control_while" && args[1]?.type === "Identifier") {
486
+ const conditionRef = args[1];
487
+ const bodyRef = args[2];
488
+ const conditionSubflow = subflowOf.get(conditionRef.name);
489
+ const bodySubflow = bodyRef?.type === "Identifier" ? subflowOf.get(bodyRef.name) : null;
490
+ if (!conditionSubflow) {
491
+ unresolvedAt(conditionRef, `${id}: control.while 第三个参数必须引用前面声明的 Condition 子流程`);
492
+ }
493
+ if (!bodySubflow) {
494
+ unresolvedAt(bodyRef || args[1], `${id}: control.while 第四个参数必须引用前面声明的 Body 子流程`);
495
+ }
496
+ nodes[id].attrs.conditionSubflowId = conditionRef.name || "";
497
+ nodes[id].attrs.bodySubflowId = bodyRef?.type === "Identifier" ? bodyRef.name : "";
498
+ } else if (definitionId === "control_while" && args[2]) {
499
+ unresolvedAt(args[2], `${id}: control.while 只能使用一个 step 脚本,或依次传入 Condition/Body 两个子流程`);
500
+ } else if (definitionId === "control_if") {
368
501
  if (args[1]) pendingIf.push({ id, slot: "next1", call: args[1] });
369
502
  if (args[2]) pendingIf.push({ id, slot: "next2", call: args[2] });
370
503
  } else if (args[1]) {
@@ -396,7 +529,7 @@ export function parseFlowSource(source, opts = {}) {
396
529
  }
397
530
  }
398
531
  if (body !== null) {
399
- if (definitionId === "tool_nodejs") nodes[id].script = body;
532
+ if (definitionId === "tool_nodejs" || definitionId === "control_while") nodes[id].script = body;
400
533
  else nodes[id].body = body;
401
534
  }
402
535
  }
@@ -460,5 +593,36 @@ export function parseFlowSource(source, opts = {}) {
460
593
  node.extraOut = [...new Set(node.extraOut)];
461
594
  }
462
595
 
463
- return { nodes, edges: [...new Set(edges)].sort(), unresolved };
596
+ // 子流程成员 = 显式控制链 + 输出来源 + 它们的数据依赖。边仍保存在统一 IR 中,
597
+ // 但成员清单让运行时能为每次 flow.call 建立隔离的调用帧。
598
+ const upstream = new Map();
599
+ for (const edge of edges) {
600
+ const [src, fromSlot, dst, toSlot] = edge.split("|");
601
+ if (toSlot === "prev") continue;
602
+ if (!upstream.has(dst)) upstream.set(dst, []);
603
+ upstream.get(dst).push(src);
604
+ }
605
+ for (const subflow of Object.values(subflows)) {
606
+ const members = new Set([
607
+ ...Object.values(subflow.inputs).map((binding) => binding.nodeId),
608
+ ...Object.values(subflow.outputs).map((binding) => binding.nodeId),
609
+ ]);
610
+ const queue = [...subflow.roots, ...members];
611
+ const visited = new Set();
612
+ while (queue.length) {
613
+ const current = queue.shift();
614
+ if (!current || visited.has(current)) continue;
615
+ visited.add(current);
616
+ members.add(current);
617
+ for (const dep of upstream.get(current) || []) queue.push(dep);
618
+ for (const edge of edges) {
619
+ const [src, fromSlot, dst, toSlot] = edge.split("|");
620
+ if (src === current && toSlot === "prev") queue.push(dst);
621
+ }
622
+ }
623
+ subflow.nodeIds = [...members].filter((nodeId) => nodes[nodeId]).sort();
624
+ for (const nodeId of subflow.nodeIds) nodes[nodeId].attrs.subflowId = subflow.id;
625
+ }
626
+
627
+ return { nodes, edges: [...new Set(edges)].sort(), subflows, unresolved };
464
628
  }
@@ -351,6 +351,10 @@
351
351
  "displayName": "Markdown Display",
352
352
  "description": "Render Markdown content on the Workspace canvas and pass the text downstream"
353
353
  },
354
+ "display_code": {
355
+ "displayName": "Code Display",
356
+ "description": "Render highlighted source code with line numbers, copy, wrap, and download controls, then pass the code downstream"
357
+ },
354
358
  "display_mermaid": {
355
359
  "displayName": "Mermaid Display",
356
360
  "description": "Render Mermaid diagram source on the Workspace canvas and pass the source downstream"
@@ -351,6 +351,10 @@
351
351
  "displayName": "Markdown 展示",
352
352
  "description": "在 Workspace 画布中渲染 Markdown 内容,并将文本继续传给下游"
353
353
  },
354
+ "display_code": {
355
+ "displayName": "代码展示",
356
+ "description": "在 Workspace 画布中高亮展示代码,支持行号、复制、换行和下载,并将代码继续传给下游"
357
+ },
354
358
  "display_mermaid": {
355
359
  "displayName": "Mermaid 展示",
356
360
  "description": "在 Workspace 画布中渲染 Mermaid 图,并将源码继续传给下游"
@@ -11,6 +11,7 @@ import {
11
11
  isFlowDir,
12
12
  } from "./paths.mjs";
13
13
  import { NODE_PACKAGE_ENTRY, isNodePackageDir, readNodePackageManifest } from "./node-package-manifest.mjs";
14
+ import { normalizeNodeUiForSlots } from "./node-ui-kit.mjs";
14
15
  import {
15
16
  NODE_PACKAGE_METADATA_FILENAME,
16
17
  createNodePackageArchive,
@@ -118,6 +119,9 @@ function normalizeManifest(raw, packageDir, source = "workspace") {
118
119
  : runtime.type != null && String(runtime.type).trim() !== ""
119
120
  ? String(runtime.type).trim()
120
121
  : "";
122
+ const input = normalizeSlotList(raw.input || raw.inputs);
123
+ const output = normalizeSlotList(raw.output || raw.outputs);
124
+ const ui = normalizeNodeUiForSlots(raw.ui, input, output);
121
125
  return {
122
126
  ...raw,
123
127
  id,
@@ -127,9 +131,10 @@ function normalizeManifest(raw, packageDir, source = "workspace") {
127
131
  baseDefinitionId,
128
132
  displayName: raw.displayName != null ? String(raw.displayName) : raw.name != null ? String(raw.name) : id,
129
133
  description: raw.description != null ? String(raw.description) : "",
130
- input: normalizeSlotList(raw.input || raw.inputs),
131
- output: normalizeSlotList(raw.output || raw.outputs),
134
+ input,
135
+ output,
132
136
  runtime,
137
+ ...(ui ? { ui } : {}),
133
138
  source,
134
139
  };
135
140
  }
@@ -474,6 +479,7 @@ export function listMarketplacePackages(workspaceRoot, opts = {}) {
474
479
  description: n.description,
475
480
  inputs: n.input,
476
481
  outputs: n.output,
482
+ ui: n.ui,
477
483
  packagedFiles: Array.isArray(n.packagedFiles) ? n.packagedFiles : [],
478
484
  fileList: Array.isArray(n.fileList) ? n.fileList : [],
479
485
  fileCount: Number(n.fileCount) || 0,
@@ -943,6 +949,7 @@ export function publishNodeFromInstance(workspaceRoot, payload = {}, options = {
943
949
  const implementationMode = String(payload.implementationMode || "").trim();
944
950
  const body = String(payload.body || "").trim();
945
951
  const description = String(payload.description || body || `Published from node ${label}`).trim();
952
+ const ui = normalizeNodeUiForSlots(payload.ui, inputs, outputs);
946
953
  const dest = path.join(workspacePackageRoot(workspaceRoot), "nodes", id, version);
947
954
  const existingManifest = readYamlObject(path.join(dest, NODE_MANIFEST));
948
955
  if (existingManifest) {
@@ -984,6 +991,7 @@ export function publishNodeFromInstance(workspaceRoot, payload = {}, options = {
984
991
  runtime,
985
992
  inputs,
986
993
  outputs,
994
+ ...(ui ? { ui } : {}),
987
995
  ownerUserId,
988
996
  createdBy: ownerUserId,
989
997
  createdAt: existingManifest?.createdAt || now,
@@ -70,6 +70,7 @@ function query(params = {}) {
70
70
  function displayKind(definitionId) {
71
71
  const id = String(definitionId || "");
72
72
  if (id === "display_markdown") return "markdown";
73
+ if (id === "display_code") return "code";
73
74
  if (id === "display_mermaid") return "mermaid";
74
75
  if (id === "display_ascii") return "ascii";
75
76
  if (id === "display_html") return "html";
@@ -24,6 +24,7 @@
24
24
  import fs from "fs";
25
25
  import path from "path";
26
26
  import { parse as acornParse } from "acorn";
27
+ import { normalizeNodeUiForSlots } from "./node-ui-kit.mjs";
27
28
 
28
29
  export const NODE_PACKAGE_ENTRY = "index.mjs";
29
30
 
@@ -135,15 +136,19 @@ export function nodePackageDeclarationToManifest(decl, packageDir) {
135
136
  const id = decl.id != null ? String(decl.id).trim() : path.basename(packageDir);
136
137
  const version = decl.version != null ? String(decl.version).trim() : "";
137
138
  if (!id || !version) return null;
139
+ const input = slotMapToList(decl.inputs, "input");
140
+ const output = slotMapToList(decl.outputs, "output");
141
+ const ui = normalizeNodeUiForSlots(decl.ui, input, output);
138
142
  return {
139
143
  id,
140
144
  version,
141
145
  displayName: decl.name != null ? String(decl.name) : id,
142
146
  description: decl.description != null ? String(decl.description) : "",
143
- input: slotMapToList(decl.inputs, "input"),
144
- output: slotMapToList(decl.outputs, "output"),
147
+ input,
148
+ output,
145
149
  baseDefinitionId: "tool_nodejs",
146
150
  runtime: { type: "tool_nodejs", entry: NODE_PACKAGE_ENTRY, mode: "module" },
151
+ ...(ui ? { ui } : {}),
147
152
  ...(decl.ownerUserId != null ? { ownerUserId: String(decl.ownerUserId) } : {}),
148
153
  ...(decl.createdBy != null ? { createdBy: String(decl.createdBy) } : {}),
149
154
  };
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Node UI Kit v1.
3
+ *
4
+ * 节点包来自 Marketplace,UI 声明必须和节点声明一样是纯数据。这里把可接受的卡片组件收敛成
5
+ * 白名单,避免节点包把任意 HTML / React / 事件处理器带进 Workspace。
6
+ */
7
+
8
+ const CARD_TEMPLATES = new Set(["details", "state-machine"]);
9
+ const TONES = new Set(["neutral", "blue", "purple", "green", "amber", "red"]);
10
+ const SECTION_TYPES = new Set(["binding", "code", "decision", "metrics", "summary", "history", "subflow", "loop"]);
11
+ const CODE_FIELDS = new Set(["script", "scriptRef", "body", "implementationRef"]);
12
+
13
+ function object(value) {
14
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
15
+ }
16
+
17
+ function shortText(value, max = 160) {
18
+ return String(value ?? "").trim().slice(0, max);
19
+ }
20
+
21
+ function token(value, max = 80) {
22
+ const text = shortText(value, max);
23
+ return /^[A-Za-z_][A-Za-z0-9_.:-]*$/.test(text) ? text : "";
24
+ }
25
+
26
+ function tone(value, fallback = "neutral") {
27
+ const valueText = shortText(value, 20).toLowerCase();
28
+ return TONES.has(valueText) ? valueText : fallback;
29
+ }
30
+
31
+ function normalizeDecisionOptions(value) {
32
+ if (!Array.isArray(value)) return [];
33
+ return value.slice(0, 8).map((raw) => {
34
+ const item = object(raw);
35
+ const optionValue = token(item?.value, 40);
36
+ if (!item || !optionValue) return null;
37
+ return {
38
+ value: optionValue,
39
+ label: shortText(item.label || optionValue, 60),
40
+ tone: tone(item.tone),
41
+ ...(item.description != null ? { description: shortText(item.description, 180) } : {}),
42
+ };
43
+ }).filter(Boolean);
44
+ }
45
+
46
+ function normalizeMetricItems(value) {
47
+ if (!Array.isArray(value)) return [];
48
+ return value.slice(0, 6).map((raw) => {
49
+ const item = object(raw);
50
+ if (!item) return null;
51
+ const input = token(item.input);
52
+ const output = token(item.output);
53
+ if (!input && !output) return null;
54
+ const maxInput = token(item.maxInput);
55
+ return {
56
+ label: shortText(item.label || output || input, 60),
57
+ ...(input ? { input } : {}),
58
+ ...(output ? { output } : {}),
59
+ ...(maxInput ? { maxInput } : {}),
60
+ ...(item.suffix != null ? { suffix: shortText(item.suffix, 24) } : {}),
61
+ };
62
+ }).filter(Boolean);
63
+ }
64
+
65
+ function normalizeSection(raw) {
66
+ const item = object(raw);
67
+ const type = shortText(item?.type, 30).toLowerCase();
68
+ if (!item || !SECTION_TYPES.has(type)) return null;
69
+ const label = shortText(item.label || type, 60);
70
+ if (type === "subflow") return { type, label };
71
+ if (type === "loop") {
72
+ const field = shortText(item.field || "script", 40);
73
+ return CODE_FIELDS.has(field) ? { type, label, field } : null;
74
+ }
75
+ if (type === "binding") {
76
+ const input = token(item.input);
77
+ return input ? { type, label, input } : null;
78
+ }
79
+ if (type === "code") {
80
+ const field = shortText(item.field, 40);
81
+ return CODE_FIELDS.has(field) ? { type, label, field } : null;
82
+ }
83
+ if (type === "decision") {
84
+ const output = token(item.output);
85
+ if (!output) return null;
86
+ return {
87
+ type,
88
+ label,
89
+ output,
90
+ ...(item.source != null ? { source: shortText(item.source, 120) } : {}),
91
+ options: normalizeDecisionOptions(item.options),
92
+ };
93
+ }
94
+ if (type === "metrics") {
95
+ const items = normalizeMetricItems(item.items);
96
+ return items.length ? { type, label, items } : null;
97
+ }
98
+ if (type === "summary" || type === "history") {
99
+ const output = token(item.output);
100
+ if (!output) return null;
101
+ return {
102
+ type,
103
+ label,
104
+ output,
105
+ ...(type === "history" ? { limit: Math.min(8, Math.max(1, Number(item.limit) || 3)) } : {}),
106
+ };
107
+ }
108
+ return null;
109
+ }
110
+
111
+ export function normalizeNodeUi(value) {
112
+ const root = object(value);
113
+ const rawCard = object(root?.card);
114
+ if (!root || !rawCard) return undefined;
115
+ const templateText = shortText(rawCard.template, 40).toLowerCase();
116
+ const template = CARD_TEMPLATES.has(templateText) ? templateText : "details";
117
+ const icon = token(rawCard.icon, 48);
118
+ const sections = (Array.isArray(rawCard.sections) ? rawCard.sections : [])
119
+ .slice(0, 12)
120
+ .map(normalizeSection)
121
+ .filter(Boolean);
122
+ if (!icon && sections.length === 0) return undefined;
123
+ return {
124
+ version: 1,
125
+ card: {
126
+ template,
127
+ tone: tone(rawCard.tone),
128
+ ...(icon ? { icon } : {}),
129
+ sections,
130
+ },
131
+ };
132
+ }
133
+
134
+ export function normalizeNodeUiForSlots(value, inputs = [], outputs = []) {
135
+ const ui = normalizeNodeUi(value);
136
+ if (!ui) return undefined;
137
+ const inputNames = new Set((Array.isArray(inputs) ? inputs : []).map((slot) => String(slot?.name || "")));
138
+ const outputNames = new Set((Array.isArray(outputs) ? outputs : []).map((slot) => String(slot?.name || "")));
139
+ const sections = ui.card.sections.map((section) => {
140
+ if (section.type === "binding") return inputNames.has(section.input) ? section : null;
141
+ if (section.type === "decision" || section.type === "summary" || section.type === "history") {
142
+ return outputNames.has(section.output) ? section : null;
143
+ }
144
+ if (section.type === "metrics") {
145
+ const items = section.items.filter((item) => (
146
+ (!item.input || inputNames.has(item.input))
147
+ && (!item.output || outputNames.has(item.output))
148
+ && (!item.maxInput || inputNames.has(item.maxInput))
149
+ ));
150
+ return items.length ? { ...section, items } : null;
151
+ }
152
+ return section;
153
+ }).filter(Boolean);
154
+ if (!ui.card.icon && sections.length === 0) return undefined;
155
+ return { ...ui, card: { ...ui.card, sections } };
156
+ }