minimal-workflow 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minimal-workflow",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Visual editor for minimal-agent workflow YAML files",
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.tsx CHANGED
@@ -20,6 +20,10 @@ export default function App() {
20
20
  const setCurrentFile = useWorkflowStore((s) => s.setCurrentFile);
21
21
  const toWorkflowDef = useWorkflowStore((s) => s.toWorkflowDef);
22
22
  const markDirty = useWorkflowStore((s) => s.markDirty);
23
+ const undo = useWorkflowStore((s) => s.undo);
24
+ const redo = useWorkflowStore((s) => s.redo);
25
+ const canUndo = useWorkflowStore((s) => s.past.length > 0);
26
+ const canRedo = useWorkflowStore((s) => s.future.length > 0);
23
27
 
24
28
  const saveFile = useFilesStore((s) => s.save);
25
29
  const refreshFiles = useFilesStore((s) => s.refresh);
@@ -31,6 +35,37 @@ export default function App() {
31
35
  loadManifest().catch(() => {});
32
36
  }, [loadManifest]);
33
37
 
38
+ // 全局 Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z / Ctrl/Cmd+Y 快捷键
39
+ useEffect(() => {
40
+ const isEditable = (el: EventTarget | null): boolean => {
41
+ if (!(el instanceof HTMLElement)) return false;
42
+ if (
43
+ el instanceof HTMLInputElement ||
44
+ el instanceof HTMLTextAreaElement ||
45
+ el instanceof HTMLSelectElement
46
+ ) {
47
+ return true;
48
+ }
49
+ return el.isContentEditable;
50
+ };
51
+
52
+ const onKeyDown = (e: KeyboardEvent) => {
53
+ if (!(e.ctrlKey || e.metaKey)) return;
54
+ if (isEditable(e.target)) return; // 让原生 input undo 优先
55
+ const key = e.key.toLowerCase();
56
+ if (key === 'z' && !e.shiftKey) {
57
+ e.preventDefault();
58
+ undo();
59
+ } else if ((key === 'z' && e.shiftKey) || key === 'y') {
60
+ e.preventDefault();
61
+ redo();
62
+ }
63
+ };
64
+
65
+ window.addEventListener('keydown', onKeyDown);
66
+ return () => window.removeEventListener('keydown', onKeyDown);
67
+ }, [undo, redo]);
68
+
34
69
  const onNewWorkflow = () => {
35
70
  if (dirty && !confirm('当前修改未保存,确定要新建?')) return;
36
71
  loadWorkflow({
@@ -100,6 +135,20 @@ export default function App() {
100
135
  {saveStatus && (
101
136
  <span style={{ fontSize: 12, marginRight: 8 }}>{saveStatus}</span>
102
137
  )}
138
+ <button
139
+ onClick={undo}
140
+ disabled={!canUndo}
141
+ title="撤销 (Ctrl+Z)"
142
+ >
143
+ ↶ 撤销
144
+ </button>
145
+ <button
146
+ onClick={redo}
147
+ disabled={!canRedo}
148
+ title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
149
+ >
150
+ ↷ 重做
151
+ </button>
103
152
  <button onClick={onNewWorkflow}>新建</button>
104
153
  <button onClick={() => setYamlOpen((v) => !v)}>
105
154
  {yamlOpen ? '关闭 YAML 视图' : 'YAML 视图'}
@@ -45,26 +45,44 @@ export function WorkflowCanvas() {
45
45
  const setEdges = useWorkflowStore((s) => s.setEdges);
46
46
  const selectNode = useWorkflowStore((s) => s.selectNode);
47
47
  const addStep = useWorkflowStore((s) => s.addStep);
48
+ const commitHistory = useWorkflowStore((s) => s.commitHistory);
48
49
 
50
+ /**
51
+ * change-aware history:拖拽过程中 (position && dragging===true) 高频 emit,
52
+ * 不打点;拖拽结束 (position && dragging===false) / remove / replace / dimensions(初始化)
53
+ * 之外的真正"离散提交"才入栈。
54
+ */
49
55
  const onNodesChange = useCallback(
50
56
  (changes: NodeChange[]) => {
57
+ const shouldCommit = changes.some((c) => {
58
+ if (c.type === 'remove') return true;
59
+ // position:仅在拖拽结束(dragging 字段缺省或为 false 时)打点
60
+ if (c.type === 'position' && c.dragging === false) return true;
61
+ return false;
62
+ });
63
+ if (shouldCommit) commitHistory();
51
64
  setNodes(applyNodeChanges(changes, nodes) as typeof nodes);
52
65
  },
53
- [nodes, setNodes],
66
+ [nodes, setNodes, commitHistory],
54
67
  );
55
68
 
56
69
  const onEdgesChange = useCallback(
57
70
  (changes: EdgeChange[]) => {
71
+ const shouldCommit = changes.some(
72
+ (c) => c.type === 'remove' || c.type === 'add',
73
+ );
74
+ if (shouldCommit) commitHistory();
58
75
  setEdges(applyEdgeChanges(changes, edges));
59
76
  },
60
- [edges, setEdges],
77
+ [edges, setEdges, commitHistory],
61
78
  );
62
79
 
63
80
  const onConnect = useCallback(
64
81
  (conn: Connection) => {
82
+ commitHistory();
65
83
  setEdges(addEdge({ ...conn, type: 'sequential' }, edges));
66
84
  },
67
- [edges, setEdges],
85
+ [edges, setEdges, commitHistory],
68
86
  );
69
87
 
70
88
  const onNodeClick = useCallback(
@@ -14,7 +14,7 @@ export function LoopNode(props: NodeProps) {
14
14
  title="🔁 loop"
15
15
  body={summary}
16
16
  outputs={[
17
- { id: 'iter', label: 'iter' },
17
+ { id: 'do', label: 'do' },
18
18
  { id: 'done', label: 'done' },
19
19
  ]}
20
20
  />
@@ -192,7 +192,7 @@ function BranchForm({ step, onPatch }: FieldProps) {
192
192
  placeholder="${flag}"
193
193
  />
194
194
  <small style={{ color: '#6b7280', fontSize: 11 }}>
195
- then / else 子步骤在 yaml 视图里编辑
195
+ 从节点的 then / else 端口拉连线到子步骤节点
196
196
  </small>
197
197
  </div>
198
198
  );
@@ -217,7 +217,7 @@ function LoopForm({ step, onPatch }: FieldProps) {
217
217
  />
218
218
  </div>
219
219
  <small style={{ color: '#6b7280', fontSize: 11 }}>
220
- do 子步骤在 yaml 视图里编辑
220
+ 从节点的 do 端口拉连线到子步骤节点
221
221
  </small>
222
222
  </>
223
223
  );
@@ -1,6 +1,12 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useState, useEffect } from 'react';
2
2
  import { useManifestStore } from '../store/manifestStore.ts';
3
- import type { StepDef, ToolMeta } from '../types.ts';
3
+ import type { StepDef } from '../types.ts';
4
+ import {
5
+ parseSchema,
6
+ coerceNumberInput,
7
+ tryParseJson,
8
+ type FieldDescriptor,
9
+ } from './schemaForm.ts';
4
10
 
5
11
  interface Props {
6
12
  step: StepDef;
@@ -14,26 +20,25 @@ export function ToolNodeForm({ step, onPatch }: Props) {
14
20
  () => tools.find((t) => t.name === step.tool),
15
21
  [tools, step.tool],
16
22
  );
23
+ const fields = useMemo<FieldDescriptor[]>(
24
+ () => (currentTool ? parseSchema(currentTool.parameters) : []),
25
+ [currentTool],
26
+ );
17
27
 
18
- const argsText = useMemo(() => {
19
- try {
20
- return JSON.stringify(step.args ?? {}, null, 2);
21
- } catch {
22
- return '{}';
23
- }
24
- }, [step.args]);
25
-
26
- const onArgsChange = (text: string) => {
27
- try {
28
- const parsed = JSON.parse(text);
29
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
30
- onPatch({ args: parsed as Record<string, unknown> });
31
- }
32
- } catch {
33
- /* 静默忽略中间态 */
34
- }
28
+ const args = step.args ?? {};
29
+ const patchArg = (name: string, value: unknown) => {
30
+ const next = { ...args };
31
+ if (value === undefined) delete next[name];
32
+ else next[name] = value;
33
+ onPatch({ args: next });
35
34
  };
36
35
 
36
+ // 已声明字段的 name 集合,用于"高级 / 未知字段"展示
37
+ const knownNames = useMemo(() => new Set(fields.map((f) => f.name)), [fields]);
38
+ const unknownEntries = Object.entries(args).filter(
39
+ ([k]) => !knownNames.has(k),
40
+ );
41
+
37
42
  return (
38
43
  <>
39
44
  <div className="field">
@@ -56,65 +61,343 @@ export function ToolNodeForm({ step, onPatch }: Props) {
56
61
  )}
57
62
  </div>
58
63
 
59
- {currentTool && <ParamHints tool={currentTool} />}
64
+ {currentTool && fields.length === 0 && (
65
+ <div className="field">
66
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
67
+ (该工具未声明 parameters.properties,使用下方原始 JSON 编辑)
68
+ </small>
69
+ <RawArgsTextarea
70
+ value={args}
71
+ onChange={(next) => onPatch({ args: next })}
72
+ />
73
+ </div>
74
+ )}
60
75
 
61
- <div className="field">
62
- <label>args(JSON)</label>
63
- <textarea
64
- value={argsText}
65
- rows={6}
66
- spellCheck={false}
67
- onChange={(e) => onArgsChange(e.target.value)}
68
- style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
76
+ {fields.map((f) => (
77
+ <FieldRenderer
78
+ key={f.name}
79
+ field={f}
80
+ value={args[f.name]}
81
+ onChange={(v) => patchArg(f.name, v)}
82
+ />
83
+ ))}
84
+
85
+ {unknownEntries.length > 0 && (
86
+ <AdvancedSection
87
+ entries={unknownEntries}
88
+ onPatchUnknown={(nextUnknown) => {
89
+ // 用已知字段的当前值 + 新的未知字段拼回 args
90
+ const known: Record<string, unknown> = {};
91
+ for (const f of fields) {
92
+ if (f.name in args) known[f.name] = args[f.name];
93
+ }
94
+ onPatch({ args: { ...known, ...nextUnknown } });
95
+ }}
69
96
  />
97
+ )}
98
+ </>
99
+ );
100
+ }
101
+
102
+ interface FieldRendererProps {
103
+ field: FieldDescriptor;
104
+ value: unknown;
105
+ onChange: (v: unknown) => void;
106
+ }
107
+
108
+ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
109
+ const isEmpty =
110
+ value === undefined ||
111
+ value === null ||
112
+ (typeof value === 'string' && value === '');
113
+ const invalidRequired = field.required && isEmpty;
114
+
115
+ return (
116
+ <div className="field">
117
+ <label>
118
+ {field.name}
119
+ {field.required && <span style={{ color: '#dc2626' }}> *</span>}
120
+ <span style={{ color: '#9ca3af', fontWeight: 'normal' }}>
121
+ {' '}
122
+ ({field.kind})
123
+ </span>
124
+ </label>
125
+ {renderControl(field, value, onChange, invalidRequired)}
126
+ {field.description && (
70
127
  <small style={{ color: '#6b7280', fontSize: 11 }}>
71
- 支持 ${'{var}'} 模板引用前序 capture 变量
128
+ {field.description}
72
129
  </small>
73
- </div>
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function renderControl(
136
+ field: FieldDescriptor,
137
+ value: unknown,
138
+ onChange: (v: unknown) => void,
139
+ invalid: boolean,
140
+ ) {
141
+ const errStyle = invalid ? { borderColor: '#dc2626' } : undefined;
142
+
143
+ if (field.kind === 'enum') {
144
+ return (
145
+ <select
146
+ value={typeof value === 'string' ? value : ''}
147
+ onChange={(e) => onChange(e.target.value || undefined)}
148
+ style={errStyle}
149
+ >
150
+ <option value="">— 选择 —</option>
151
+ {field.enumValues?.map((v) => (
152
+ <option key={v} value={v}>
153
+ {v}
154
+ </option>
155
+ ))}
156
+ </select>
157
+ );
158
+ }
159
+
160
+ if (field.kind === 'boolean') {
161
+ return (
162
+ <input
163
+ type="checkbox"
164
+ checked={value === true}
165
+ onChange={(e) => onChange(e.target.checked)}
166
+ />
167
+ );
168
+ }
169
+
170
+ if (field.kind === 'number') {
171
+ // 受控字符串:number 表示为字符串以保留模板变量、半截输入
172
+ const text =
173
+ value === undefined || value === null
174
+ ? ''
175
+ : typeof value === 'string'
176
+ ? value
177
+ : String(value);
178
+ return (
179
+ <input
180
+ type="text"
181
+ inputMode="decimal"
182
+ value={text}
183
+ placeholder="数字 或 ${var}"
184
+ onChange={(e) => onChange(coerceNumberInput(e.target.value))}
185
+ style={{
186
+ fontFamily: 'ui-monospace, monospace',
187
+ fontSize: 12,
188
+ ...errStyle,
189
+ }}
190
+ />
191
+ );
192
+ }
193
+
194
+ if (field.kind === 'string') {
195
+ return (
196
+ <input
197
+ type="text"
198
+ value={typeof value === 'string' ? value : ''}
199
+ onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
200
+ style={errStyle}
201
+ />
202
+ );
203
+ }
204
+
205
+ // kind === 'json' —— fallback 单字段 JSON textarea + onBlur 校验
206
+ return <JsonFieldTextarea value={value} onChange={onChange} invalid={invalid} />;
207
+ }
208
+
209
+ interface JsonFieldProps {
210
+ value: unknown;
211
+ onChange: (v: unknown) => void;
212
+ invalid?: boolean;
213
+ }
214
+
215
+ function JsonFieldTextarea({ value, onChange, invalid }: JsonFieldProps) {
216
+ const initial = useMemo(() => {
217
+ if (value === undefined) return '';
218
+ try {
219
+ return JSON.stringify(value, null, 2);
220
+ } catch {
221
+ return '';
222
+ }
223
+ }, [value]);
224
+ const [text, setText] = useState(initial);
225
+ const [error, setError] = useState<string | null>(null);
226
+
227
+ // 外部 value 变化(如切换 step)时同步 textarea
228
+ useEffect(() => {
229
+ setText(initial);
230
+ setError(null);
231
+ }, [initial]);
232
+
233
+ const onBlur = () => {
234
+ const r = tryParseJson(text);
235
+ if (r.ok) {
236
+ setError(null);
237
+ onChange(r.value);
238
+ } else {
239
+ setError(r.error);
240
+ }
241
+ };
242
+
243
+ const borderColor = error || invalid ? '#dc2626' : undefined;
244
+
245
+ return (
246
+ <>
247
+ <textarea
248
+ value={text}
249
+ rows={4}
250
+ spellCheck={false}
251
+ onChange={(e) => setText(e.target.value)}
252
+ onBlur={onBlur}
253
+ style={{
254
+ fontFamily: 'ui-monospace, monospace',
255
+ fontSize: 12,
256
+ borderColor,
257
+ }}
258
+ />
259
+ {error && (
260
+ <small style={{ color: '#dc2626', fontSize: 11 }}>
261
+ ⚠ JSON 格式错误: {error}
262
+ </small>
263
+ )}
74
264
  </>
75
265
  );
76
266
  }
77
267
 
78
- function ParamHints({ tool }: { tool: ToolMeta }) {
79
- const params = tool.parameters;
80
- const props =
81
- params && typeof params === 'object'
82
- ? ((params as Record<string, unknown>).properties as
83
- | Record<string, unknown>
84
- | undefined)
85
- : undefined;
86
- if (!props || typeof props !== 'object') return null;
87
- const required =
88
- (params as { required?: string[] }).required ?? [];
89
- const entries = Object.entries(props);
90
- if (entries.length === 0) return null;
268
+ interface RawArgsProps {
269
+ value: Record<string, unknown>;
270
+ onChange: (next: Record<string, unknown>) => void;
271
+ }
272
+
273
+ /** tool 未声明 properties 时的完整 args JSON 编辑器 */
274
+ function RawArgsTextarea({ value, onChange }: RawArgsProps) {
275
+ const initial = useMemo(() => {
276
+ try {
277
+ return JSON.stringify(value ?? {}, null, 2);
278
+ } catch {
279
+ return '{}';
280
+ }
281
+ }, [value]);
282
+ const [text, setText] = useState(initial);
283
+ const [error, setError] = useState<string | null>(null);
284
+ useEffect(() => {
285
+ setText(initial);
286
+ setError(null);
287
+ }, [initial]);
288
+
289
+ const onBlur = () => {
290
+ const r = tryParseJson(text);
291
+ if (!r.ok) {
292
+ setError(r.error);
293
+ return;
294
+ }
295
+ if (r.value === undefined) {
296
+ setError(null);
297
+ onChange({});
298
+ return;
299
+ }
300
+ if (typeof r.value !== 'object' || r.value === null || Array.isArray(r.value)) {
301
+ setError('args 必须是 object({...})');
302
+ return;
303
+ }
304
+ setError(null);
305
+ onChange(r.value as Record<string, unknown>);
306
+ };
307
+
91
308
  return (
92
- <div
93
- style={{
94
- background: '#f9fafb',
95
- border: '1px solid #e5e7eb',
96
- borderRadius: 3,
97
- padding: 6,
98
- fontSize: 11,
99
- marginBottom: 8,
100
- }}
101
- >
102
- <div style={{ fontWeight: 600, marginBottom: 4 }}>参数提示</div>
103
- {entries.map(([k, v]) => {
104
- const desc = (v as { description?: string }).description ?? '';
105
- const type = (v as { type?: string }).type ?? 'any';
106
- const isReq = required.includes(k);
107
- return (
108
- <div key={k} style={{ marginBottom: 2 }}>
109
- <code>{k}</code>
110
- <span style={{ color: '#6b7280' }}>
111
- {' '}
112
- ({type}){isReq ? ' *' : ''}
113
- </span>
114
- {desc && <span style={{ color: '#6b7280' }}> — {desc}</span>}
115
- </div>
116
- );
117
- })}
309
+ <>
310
+ <textarea
311
+ value={text}
312
+ rows={6}
313
+ spellCheck={false}
314
+ onChange={(e) => setText(e.target.value)}
315
+ onBlur={onBlur}
316
+ style={{
317
+ fontFamily: 'ui-monospace, monospace',
318
+ fontSize: 12,
319
+ borderColor: error ? '#dc2626' : undefined,
320
+ }}
321
+ />
322
+ {error && (
323
+ <small style={{ color: '#dc2626', fontSize: 11 }}>
324
+ JSON 格式错误: {error}
325
+ </small>
326
+ )}
327
+ </>
328
+ );
329
+ }
330
+
331
+ interface AdvancedProps {
332
+ entries: [string, unknown][];
333
+ onPatchUnknown: (next: Record<string, unknown>) => void;
334
+ }
335
+
336
+ /** 折叠区域:展示 manifest 不声明、但用户已写在 yaml 里的字段,避免切 tool 时静默丢失 */
337
+ function AdvancedSection({ entries, onPatchUnknown }: AdvancedProps) {
338
+ const [open, setOpen] = useState(false);
339
+ const raw = useMemo(() => {
340
+ try {
341
+ return JSON.stringify(Object.fromEntries(entries), null, 2);
342
+ } catch {
343
+ return '{}';
344
+ }
345
+ }, [entries]);
346
+ const [text, setText] = useState(raw);
347
+ const [error, setError] = useState<string | null>(null);
348
+
349
+ useEffect(() => {
350
+ setText(raw);
351
+ setError(null);
352
+ }, [raw]);
353
+
354
+ const onBlur = () => {
355
+ const r = tryParseJson(text);
356
+ if (!r.ok) {
357
+ setError(r.error);
358
+ return;
359
+ }
360
+ const val = r.value ?? {};
361
+ if (typeof val !== 'object' || val === null || Array.isArray(val)) {
362
+ setError('必须是 object({...})');
363
+ return;
364
+ }
365
+ setError(null);
366
+ onPatchUnknown(val as Record<string, unknown>);
367
+ };
368
+
369
+ return (
370
+ <div className="field">
371
+ <label
372
+ style={{ cursor: 'pointer', userSelect: 'none' }}
373
+ onClick={() => setOpen(!open)}
374
+ >
375
+ {open ? '▼' : '▶'} 高级 / 未声明字段({entries.length})
376
+ </label>
377
+ {open && (
378
+ <>
379
+ <textarea
380
+ value={text}
381
+ rows={4}
382
+ spellCheck={false}
383
+ onChange={(e) => setText(e.target.value)}
384
+ onBlur={onBlur}
385
+ style={{
386
+ fontFamily: 'ui-monospace, monospace',
387
+ fontSize: 12,
388
+ borderColor: error ? '#dc2626' : undefined,
389
+ }}
390
+ />
391
+ {error && (
392
+ <small style={{ color: '#dc2626', fontSize: 11 }}>
393
+ ⚠ JSON 格式错误: {error}
394
+ </small>
395
+ )}
396
+ <small style={{ color: '#6b7280', fontSize: 11 }}>
397
+ 当前 tool 的 manifest 未声明的字段;onBlur 时校验并写回 args
398
+ </small>
399
+ </>
400
+ )}
118
401
  </div>
119
402
  );
120
403
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * editor/src/inspector/schemaForm.ts —— JSON Schema → 表单字段描述符的纯函数转换。
3
+ *
4
+ * 抽离自 ToolNodeForm.tsx,便于纯函数测试(不依赖 React)。
5
+ * 输入:tool.parameters(JSON Schema 形如 { type: 'object', properties: {...}, required: [...] })
6
+ * 输出:FieldDescriptor[] —— UI 层据此渲染 input / select / checkbox / textarea
7
+ */
8
+
9
+ export type FieldKind =
10
+ | 'string'
11
+ | 'enum'
12
+ | 'number'
13
+ | 'boolean'
14
+ | 'json'; // fallback:array / object / 未知
15
+
16
+ export interface FieldDescriptor {
17
+ /** properties 里的 key */
18
+ name: string;
19
+ /** 字段控件类型 */
20
+ kind: FieldKind;
21
+ /** required 列表是否包含此字段 */
22
+ required: boolean;
23
+ /** 字段描述,渲染到 small */
24
+ description: string;
25
+ /** kind === 'enum' 时的选项 */
26
+ enumValues?: string[];
27
+ /** 原始 schema 节点,留给高级用途(默认值等) */
28
+ raw: Record<string, unknown>;
29
+ }
30
+
31
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
32
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
33
+ }
34
+
35
+ function isStringArray(v: unknown): v is string[] {
36
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
37
+ }
38
+
39
+ /**
40
+ * 解析 JSON Schema 顶层 properties,返回字段描述数组。
41
+ * 不识别的 schema 形态返回空数组(caller 退回到原 JSON 编辑模式)。
42
+ */
43
+ export function parseSchema(parameters: unknown): FieldDescriptor[] {
44
+ if (!isPlainObject(parameters)) return [];
45
+ const props = parameters.properties;
46
+ if (!isPlainObject(props)) return [];
47
+ const requiredRaw = parameters.required;
48
+ const required = isStringArray(requiredRaw) ? requiredRaw : [];
49
+
50
+ const fields: FieldDescriptor[] = [];
51
+ for (const [name, rawNode] of Object.entries(props)) {
52
+ if (!isPlainObject(rawNode)) continue;
53
+ const type = typeof rawNode.type === 'string' ? rawNode.type : 'any';
54
+ const description =
55
+ typeof rawNode.description === 'string' ? rawNode.description : '';
56
+ const enumValuesRaw = rawNode.enum;
57
+ const enumValues = isStringArray(enumValuesRaw) ? enumValuesRaw : undefined;
58
+
59
+ let kind: FieldKind;
60
+ if (type === 'string' && enumValues) kind = 'enum';
61
+ else if (type === 'string') kind = 'string';
62
+ else if (type === 'number' || type === 'integer') kind = 'number';
63
+ else if (type === 'boolean') kind = 'boolean';
64
+ else kind = 'json';
65
+
66
+ fields.push({
67
+ name,
68
+ kind,
69
+ required: required.includes(name),
70
+ description,
71
+ enumValues,
72
+ raw: rawNode,
73
+ });
74
+ }
75
+ return fields;
76
+ }
77
+
78
+ /**
79
+ * number 字段的智能值解析:
80
+ * - 空串 → undefined(删除字段)
81
+ * - 以 `$` 开头(模板变量 ${var})→ 原样 string 透传,不 parseFloat
82
+ * - 其他 → parseFloat;NaN 时回退为原始 string(用户半截输入)
83
+ */
84
+ export function coerceNumberInput(raw: string): number | string | undefined {
85
+ if (raw === '') return undefined;
86
+ if (raw.startsWith('$')) return raw;
87
+ const n = Number(raw);
88
+ return Number.isFinite(n) ? n : raw;
89
+ }
90
+
91
+ /**
92
+ * 尝试把 JSON textarea 的文本解析为 JS 值。
93
+ * 失败返回 { ok: false, error },成功返回 { ok: true, value }。
94
+ */
95
+ export type JsonParseResult =
96
+ | { ok: true; value: unknown }
97
+ | { ok: false; error: string };
98
+
99
+ export function tryParseJson(text: string): JsonParseResult {
100
+ const trimmed = text.trim();
101
+ if (trimmed === '') return { ok: true, value: undefined };
102
+ try {
103
+ return { ok: true, value: JSON.parse(trimmed) };
104
+ } catch (e) {
105
+ return { ok: false, error: (e as Error).message };
106
+ }
107
+ }
@@ -1,6 +1,21 @@
1
1
  import { create } from 'zustand';
2
2
  import type { Edge, Node } from '@xyflow/react';
3
- import type { CanvasNodeData, StepDef, WorkflowDef } from '../types.ts';
3
+ import type {
4
+ CanvasNodeData,
5
+ StepDef,
6
+ WorkflowDef,
7
+ WorkflowEdgeMeta,
8
+ } from '../types.ts';
9
+
10
+ /** Undo/Redo history 快照:只存图模型,不含 UI 态。 */
11
+ interface HistoryEntry {
12
+ nodes: Node<CanvasNodeData>[];
13
+ edges: Edge[];
14
+ meta: Pick<WorkflowDef, 'name' | 'description' | 'version' | 'inputs'>;
15
+ }
16
+
17
+ /** history 上限,防内存爆炸。 */
18
+ const HISTORY_LIMIT = 100;
4
19
 
5
20
  interface WorkflowState {
6
21
  /** 当前打开的 workflow 文件名 */
@@ -15,17 +30,30 @@ interface WorkflowState {
15
30
  selectedNodeId: string | null;
16
31
  /** YAML 是否未保存 */
17
32
  dirty: boolean;
33
+ /** Undo 栈(最老的在前) */
34
+ past: HistoryEntry[];
35
+ /** Redo 栈(最近 undo 的在末尾) */
36
+ future: HistoryEntry[];
18
37
 
19
38
  setCurrentFile: (file: string | null) => void;
20
39
  loadWorkflow: (def: WorkflowDef) => void;
21
- setNodes: (nodes: Node<CanvasNodeData>[]) => void;
22
- setEdges: (edges: Edge[]) => void;
40
+ setNodes: (
41
+ nodes: Node<CanvasNodeData>[],
42
+ options?: { history?: boolean },
43
+ ) => void;
44
+ setEdges: (edges: Edge[], options?: { history?: boolean }) => void;
23
45
  selectNode: (id: string | null) => void;
24
46
  updateStep: (id: string, patch: Partial<StepDef>) => void;
25
47
  addStep: (step: StepDef, position: { x: number; y: number }) => void;
26
48
  removeStep: (id: string) => void;
27
49
  markDirty: (dirty: boolean) => void;
28
- /** 生成当前 workflow 完整 def(含 __meta.layout) */
50
+ /** 显式把当前 nodes/edges/meta 快照入 history(拖拽结束等离散操作调用) */
51
+ commitHistory: () => void;
52
+ /** 撤销 */
53
+ undo: () => void;
54
+ /** 重做 */
55
+ redo: () => void;
56
+ /** 生成当前 workflow 完整 def(含 __meta.layout/edges/warnings) */
29
57
  toWorkflowDef: () => WorkflowDef;
30
58
  }
31
59
 
@@ -36,6 +64,31 @@ const initialMeta = {
36
64
  inputs: [],
37
65
  };
38
66
 
67
+ /** branch/loop 用来挂载子步骤的 sourceHandle id。 */
68
+ const CHILD_HANDLES = new Set(['then', 'else', 'do']);
69
+
70
+ /**
71
+ * 在状态突变前把当前快照推入 past,并清空 future。
72
+ * 用 get()/set() 闭包封装,避免每个 action 复制粘贴。
73
+ * 不修改 nodes/edges/meta 本身——仅维护 past/future 栈。
74
+ */
75
+ function pushHistorySnapshot(
76
+ get: () => WorkflowState,
77
+ set: (
78
+ partial:
79
+ | Partial<WorkflowState>
80
+ | ((s: WorkflowState) => Partial<WorkflowState>),
81
+ ) => void,
82
+ ): void {
83
+ const { nodes, edges, meta, past } = get();
84
+ const entry: HistoryEntry = { nodes, edges, meta };
85
+ const nextPast =
86
+ past.length >= HISTORY_LIMIT
87
+ ? [...past.slice(past.length - HISTORY_LIMIT + 1), entry]
88
+ : [...past, entry];
89
+ set({ past: nextPast, future: [] });
90
+ }
91
+
39
92
  export const useWorkflowStore = create<WorkflowState>((set, get) => ({
40
93
  currentFile: null,
41
94
  meta: initialMeta,
@@ -43,27 +96,114 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
43
96
  edges: [],
44
97
  selectedNodeId: null,
45
98
  dirty: false,
99
+ past: [],
100
+ future: [],
46
101
 
47
102
  setCurrentFile: (file) => set({ currentFile: file }),
48
103
 
49
104
  loadWorkflow: (def) => {
50
- const steps = def.steps ?? [];
51
105
  const layout = def.__meta?.layout ?? [];
52
- const nodes: Node<CanvasNodeData>[] = steps.map((step, i) => {
53
- const pos = layout.find((l) => l.id === step.id);
106
+ const metaEdges = def.__meta?.edges;
107
+
108
+ // 1) 展平所有 step(顶层 + branch.then/else + loop.do),保持父子关系
109
+ // 并清洗掉 data.step 上残留的 then/else/do 字段(子步骤改由 edges 表达)
110
+ interface FlatEntry {
111
+ step: StepDef;
112
+ childEdges: { handle: 'then' | 'else' | 'do'; targets: string[] }[];
113
+ }
114
+ const flat: FlatEntry[] = [];
115
+
116
+ const flatten = (steps: StepDef[]): void => {
117
+ for (const raw of steps) {
118
+ const { then: thenChildren, else: elseChildren, do: doChildren, ...rest } =
119
+ raw;
120
+ const cleaned: StepDef = { ...rest };
121
+ const childEdges: FlatEntry['childEdges'] = [];
122
+
123
+ if (Array.isArray(thenChildren) && thenChildren.length > 0) {
124
+ childEdges.push({
125
+ handle: 'then',
126
+ targets: thenChildren.map((c) => c.id),
127
+ });
128
+ }
129
+ if (Array.isArray(elseChildren) && elseChildren.length > 0) {
130
+ childEdges.push({
131
+ handle: 'else',
132
+ targets: elseChildren.map((c) => c.id),
133
+ });
134
+ }
135
+ if (Array.isArray(doChildren) && doChildren.length > 0) {
136
+ childEdges.push({
137
+ handle: 'do',
138
+ targets: doChildren.map((c) => c.id),
139
+ });
140
+ }
141
+ flat.push({ step: cleaned, childEdges });
142
+
143
+ if (thenChildren) flatten(thenChildren);
144
+ if (elseChildren) flatten(elseChildren);
145
+ if (doChildren) flatten(doChildren);
146
+ }
147
+ };
148
+ flatten(def.steps ?? []);
149
+
150
+ // 2) 生成 nodes
151
+ const nodes: Node<CanvasNodeData>[] = flat.map((entry, i) => {
152
+ const pos = layout.find((l) => l.id === entry.step.id);
153
+ const kind = detectKind(entry.step);
54
154
  return {
55
- id: step.id,
56
- type: detectKind(step),
155
+ id: entry.step.id,
156
+ type: kind,
57
157
  position: pos ? { x: pos.x, y: pos.y } : { x: 100, y: 80 + i * 120 },
58
- data: { step, kind: detectKind(step) },
158
+ data: { step: entry.step, kind },
59
159
  };
60
160
  });
61
- const edges: Edge[] = nodes.slice(0, -1).map((n, i) => ({
62
- id: `seq-${n.id}-${nodes[i + 1].id}`,
63
- source: n.id,
64
- target: nodes[i + 1].id,
65
- type: 'sequential',
66
- }));
161
+
162
+ // 3) 还原 edges
163
+ let edges: Edge[];
164
+ if (Array.isArray(metaEdges) && metaEdges.length > 0) {
165
+ // 新格式:__meta.edges 是 single source of truth
166
+ edges = metaEdges.map((e) => buildEdge(e));
167
+ } else if (flat.some((f) => f.childEdges.length > 0)) {
168
+ // 旧 YAML 没有 __meta.edges,但有 nested children
169
+ // → 重建:父→第一个子步骤(带 sourceHandle),剩余子步骤之间用顺序 edge
170
+ // → 顶层 step 之间也按出现顺序串成线性 edge
171
+ edges = [];
172
+ for (const { step, childEdges } of flat) {
173
+ for (const { handle, targets } of childEdges) {
174
+ if (targets.length === 0) continue;
175
+ edges.push(
176
+ buildEdge({
177
+ source: step.id,
178
+ target: targets[0],
179
+ sourceHandle: handle,
180
+ }),
181
+ );
182
+ for (let i = 0; i < targets.length - 1; i++) {
183
+ edges.push(
184
+ buildEdge({ source: targets[i], target: targets[i + 1] }),
185
+ );
186
+ }
187
+ }
188
+ }
189
+ // 顶层 steps 之间按 def.steps 顺序串线性 edge
190
+ const topLevelIds = (def.steps ?? []).map((s) => s.id);
191
+ for (let i = 0; i < topLevelIds.length - 1; i++) {
192
+ edges.push(
193
+ buildEdge({ source: topLevelIds[i], target: topLevelIds[i + 1] }),
194
+ );
195
+ }
196
+ } else {
197
+ // 完全平铺的旧格式:按节点出现顺序串成线性 edge(向后兼容旧脑补逻辑)
198
+ edges = [];
199
+ for (let i = 0; i < nodes.length - 1; i++) {
200
+ edges.push(
201
+ buildEdge({ source: nodes[i].id, target: nodes[i + 1].id }),
202
+ );
203
+ }
204
+ }
205
+
206
+ // loadWorkflow 是 reset 而非 step:清空 history 两端
67
207
  set({
68
208
  meta: {
69
209
  name: def.name,
@@ -75,14 +215,27 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
75
215
  edges,
76
216
  selectedNodeId: null,
77
217
  dirty: false,
218
+ past: [],
219
+ future: [],
78
220
  });
79
221
  },
80
222
 
81
- setNodes: (nodes) => set({ nodes, dirty: true }),
82
- setEdges: (edges) => set({ edges, dirty: true }),
223
+ /**
224
+ * 默认 history=false:拖拽期间高频 setNodes 不打点,
225
+ * 由 WorkflowCanvas 在拖拽结束/节点增删时显式调 commitHistory()。
226
+ */
227
+ setNodes: (nodes, options) => {
228
+ if (options?.history) pushHistorySnapshot(get, set);
229
+ set({ nodes, dirty: true });
230
+ },
231
+ setEdges: (edges, options) => {
232
+ if (options?.history) pushHistorySnapshot(get, set);
233
+ set({ edges, dirty: true });
234
+ },
83
235
  selectNode: (id) => set({ selectedNodeId: id }),
84
236
 
85
237
  updateStep: (id, patch) => {
238
+ pushHistorySnapshot(get, set);
86
239
  const nodes = get().nodes.map((n) =>
87
240
  n.id === id
88
241
  ? { ...n, data: { ...n.data, step: { ...n.data.step, ...patch } } }
@@ -92,17 +245,25 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
92
245
  },
93
246
 
94
247
  addStep: (step, position) => {
248
+ pushHistorySnapshot(get, set);
249
+ // 子步骤归属由 edges 决定,store 内部的 step 不能再带 nested then/else/do
250
+ const { then: _t, else: _e, do: _d, ...clean } = step;
251
+ void _t;
252
+ void _e;
253
+ void _d;
254
+ const kind = detectKind(clean);
95
255
  const node: Node<CanvasNodeData> = {
96
- id: step.id,
97
- type: detectKind(step),
256
+ id: clean.id,
257
+ type: kind,
98
258
  position,
99
- data: { step, kind: detectKind(step) },
259
+ data: { step: clean, kind },
100
260
  };
101
261
  const nodes = [...get().nodes, node];
102
262
  set({ nodes, dirty: true });
103
263
  },
104
264
 
105
265
  removeStep: (id) => {
266
+ pushHistorySnapshot(get, set);
106
267
  const nodes = get().nodes.filter((n) => n.id !== id);
107
268
  const edges = get()
108
269
  .edges.filter((e) => e.source !== id && e.target !== id);
@@ -111,23 +272,48 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
111
272
 
112
273
  markDirty: (dirty) => set({ dirty }),
113
274
 
275
+ commitHistory: () => {
276
+ pushHistorySnapshot(get, set);
277
+ },
278
+
279
+ undo: () => {
280
+ const { past, future, nodes, edges, meta } = get();
281
+ if (past.length === 0) return;
282
+ const previous = past[past.length - 1];
283
+ const nextPast = past.slice(0, past.length - 1);
284
+ const currentSnapshot: HistoryEntry = { nodes, edges, meta };
285
+ set({
286
+ past: nextPast,
287
+ future: [...future, currentSnapshot],
288
+ nodes: previous.nodes,
289
+ edges: previous.edges,
290
+ meta: previous.meta,
291
+ selectedNodeId: null,
292
+ dirty: true,
293
+ });
294
+ },
295
+
296
+ redo: () => {
297
+ const { past, future, nodes, edges, meta } = get();
298
+ if (future.length === 0) return;
299
+ const next = future[future.length - 1];
300
+ const nextFuture = future.slice(0, future.length - 1);
301
+ const currentSnapshot: HistoryEntry = { nodes, edges, meta };
302
+ set({
303
+ past: [...past, currentSnapshot],
304
+ future: nextFuture,
305
+ nodes: next.nodes,
306
+ edges: next.edges,
307
+ meta: next.meta,
308
+ selectedNodeId: null,
309
+ dirty: true,
310
+ });
311
+ },
312
+
114
313
  toWorkflowDef: () => {
115
- const { meta, nodes } = get();
116
- const ordered = [...nodes].sort(
117
- (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
118
- );
119
- return {
120
- ...meta,
121
- name: meta.name || 'untitled',
122
- steps: ordered.map((n) => n.data.step),
123
- __meta: {
124
- layout: ordered.map((n) => ({
125
- id: n.id,
126
- x: Math.round(n.position.x),
127
- y: Math.round(n.position.y),
128
- })),
129
- },
130
- };
314
+ const { meta, nodes, edges } = get();
315
+ const result = assembleWorkflow(meta, nodes, edges);
316
+ return result;
131
317
  },
132
318
  }));
133
319
 
@@ -138,3 +324,203 @@ function detectKind(step: StepDef): string {
138
324
  if (step.llm) return 'llm';
139
325
  return 'assert';
140
326
  }
327
+
328
+ /** 把 store 内部 edge 形状压扁为 __meta.edges 形状。 */
329
+ function edgeToMeta(e: Edge): WorkflowEdgeMeta {
330
+ const m: WorkflowEdgeMeta = { source: e.source, target: e.target };
331
+ if (e.sourceHandle) m.sourceHandle = e.sourceHandle;
332
+ return m;
333
+ }
334
+
335
+ /** 由 meta edge 构造 React Flow Edge。 */
336
+ function buildEdge(m: WorkflowEdgeMeta): Edge {
337
+ const handleSuffix = m.sourceHandle ? `-${m.sourceHandle}` : '';
338
+ return {
339
+ id: `e-${m.source}${handleSuffix}-${m.target}`,
340
+ source: m.source,
341
+ target: m.target,
342
+ sourceHandle: m.sourceHandle,
343
+ type: 'sequential',
344
+ };
345
+ }
346
+
347
+ /**
348
+ * 拓扑组装:依据 edges 把 nodes 还原成 nested WorkflowDef。
349
+ *
350
+ * 算法:
351
+ * 1) 拆分 edges → childEdges(sourceHandle ∈ {then/else/do})+ siblingEdges(其余)
352
+ * 2) 对每个 branch/loop 节点,沿 child handle 出发,再顺着 siblingEdges 链下去
353
+ * 构成 then[]/else[]/do[] 子步骤;子步骤集合从顶层候选里扣掉
354
+ * 3) 顶层 steps:roots(没被 siblingEdges 指向,也不是子步骤)出发拓扑 BFS
355
+ * 4) 成环:把剩余未访问节点按 Y 坐标 fallback,并写入 __meta.warnings
356
+ * 5) 孤儿(无入无出 + 未被分配)按 Y 坐标追加到顶层
357
+ */
358
+ function assembleWorkflow(
359
+ meta: Pick<WorkflowDef, 'name' | 'description' | 'version' | 'inputs'>,
360
+ nodes: Node<CanvasNodeData>[],
361
+ edges: Edge[],
362
+ ): WorkflowDef {
363
+ const warnings: string[] = [];
364
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
365
+
366
+ // childAdj[nodeId][handle] = [targetId, ...] (来自 sourceHandle 是 then/else/do 的 edge)
367
+ const childAdj = new Map<string, Map<string, string[]>>();
368
+ // siblingAdj[nodeId] = [targetId, ...] (来自 sourceHandle 为空/done/其它的 edge)
369
+ const siblingAdj = new Map<string, string[]>();
370
+ // siblingInDeg:被 siblingEdges 指向的次数(用于找根)
371
+ const siblingInDeg = new Map<string, number>();
372
+ for (const n of nodes) siblingInDeg.set(n.id, 0);
373
+
374
+ for (const e of edges) {
375
+ if (!nodeById.has(e.source) || !nodeById.has(e.target)) continue;
376
+ if (e.sourceHandle && CHILD_HANDLES.has(e.sourceHandle)) {
377
+ let m = childAdj.get(e.source);
378
+ if (!m) {
379
+ m = new Map();
380
+ childAdj.set(e.source, m);
381
+ }
382
+ const arr = m.get(e.sourceHandle) ?? [];
383
+ if (!arr.includes(e.target)) arr.push(e.target);
384
+ m.set(e.sourceHandle, arr);
385
+ } else {
386
+ const arr = siblingAdj.get(e.source) ?? [];
387
+ if (!arr.includes(e.target)) arr.push(e.target);
388
+ siblingAdj.set(e.source, arr);
389
+ siblingInDeg.set(e.target, (siblingInDeg.get(e.target) ?? 0) + 1);
390
+ }
391
+ }
392
+
393
+ // 给同一节点的多条 sibling 出边按目标 Y 坐标稳定排序(提升拓扑确定性)
394
+ for (const [src, arr] of siblingAdj) {
395
+ arr.sort((a, b) => {
396
+ const ay = nodeById.get(a)?.position.y ?? 0;
397
+ const by = nodeById.get(b)?.position.y ?? 0;
398
+ return ay - by;
399
+ });
400
+ siblingAdj.set(src, arr);
401
+ }
402
+
403
+ // 沿 siblingAdj 从起点收集一条线性链(DFS,遇到已访问/无出边/分歧只走第一支)
404
+ // 返回的链是 visited(in-place 标记) + 链上节点 id 列表
405
+ const collectChain = (
406
+ startId: string,
407
+ claimed: Set<string>,
408
+ visiting: Set<string>,
409
+ ): string[] => {
410
+ const chain: string[] = [];
411
+ let cur: string | undefined = startId;
412
+ while (cur !== undefined && !claimed.has(cur) && !visiting.has(cur)) {
413
+ visiting.add(cur);
414
+ claimed.add(cur);
415
+ chain.push(cur);
416
+ const nexts = siblingAdj.get(cur) ?? [];
417
+ // 链只走第一个 sibling 后继(DAG 上的"线性"分支)
418
+ cur = nexts[0];
419
+ }
420
+ return chain;
421
+ };
422
+
423
+ // 第一遍:找到所有 branch/loop 节点的子步骤集合,登记到 claimed
424
+ const claimed = new Set<string>();
425
+ // childMap:每个 branch/loop 节点的 then/else/do 已抽出的子 step
426
+ const childMap = new Map<
427
+ string,
428
+ { then?: StepDef[]; else?: StepDef[]; do?: StepDef[] }
429
+ >();
430
+
431
+ for (const node of nodes) {
432
+ const kind = node.data.kind;
433
+ if (kind !== 'branch' && kind !== 'loop') continue;
434
+ const handles = childAdj.get(node.id);
435
+ if (!handles) continue;
436
+ const entry: { then?: StepDef[]; else?: StepDef[]; do?: StepDef[] } = {};
437
+ for (const [handle, targets] of handles) {
438
+ // 每个 target 拉一条链
439
+ const collected: StepDef[] = [];
440
+ const localVisiting = new Set<string>();
441
+ for (const t of targets) {
442
+ const chain = collectChain(t, claimed, localVisiting);
443
+ for (const id of chain) {
444
+ const n = nodeById.get(id);
445
+ if (n) collected.push(n.data.step);
446
+ }
447
+ }
448
+ if (collected.length > 0) {
449
+ if (handle === 'then') entry.then = collected;
450
+ else if (handle === 'else') entry.else = collected;
451
+ else if (handle === 'do') entry.do = collected;
452
+ }
453
+ }
454
+ childMap.set(node.id, entry);
455
+ }
456
+
457
+ // 第二遍:顶层 steps —— roots = 未 claimed 且 siblingInDeg === 0 的节点
458
+ const topLevel: StepDef[] = [];
459
+ const visited = new Set<string>(claimed);
460
+
461
+ const roots = nodes
462
+ .filter((n) => !claimed.has(n.id) && (siblingInDeg.get(n.id) ?? 0) === 0)
463
+ .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
464
+
465
+ // Kahn BFS:从 roots 出发,按 siblingAdj 拓扑访问;遇到已访问跳过
466
+ const queue: string[] = roots.map((n) => n.id);
467
+ while (queue.length > 0) {
468
+ const id = queue.shift()!;
469
+ if (visited.has(id)) continue;
470
+ visited.add(id);
471
+ const node = nodeById.get(id);
472
+ if (!node) continue;
473
+ const stepWithChildren = withChildren(node.data.step, childMap.get(id));
474
+ topLevel.push(stepWithChildren);
475
+ for (const next of siblingAdj.get(id) ?? []) {
476
+ if (!visited.has(next)) queue.push(next);
477
+ }
478
+ }
479
+
480
+ // 剩余未访问的节点 —— 要么在环里,要么是被 sibling 指向但无人能到达(孤立环 + 孤儿)
481
+ const leftover = nodes
482
+ .filter((n) => !visited.has(n.id))
483
+ .sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
484
+
485
+ if (leftover.length > 0) {
486
+ // 区分:是否真在环里 —— 若节点 siblingInDeg > 0 且没被访问,可能是环
487
+ const inCycle = leftover.some((n) => (siblingInDeg.get(n.id) ?? 0) > 0);
488
+ if (inCycle) {
489
+ warnings.push(
490
+ `cycle detected: ${leftover.map((n) => n.id).join(' -> ')}`,
491
+ );
492
+ }
493
+ for (const n of leftover) {
494
+ visited.add(n.id);
495
+ topLevel.push(withChildren(n.data.step, childMap.get(n.id)));
496
+ }
497
+ }
498
+
499
+ const def: WorkflowDef = {
500
+ ...meta,
501
+ name: meta.name || 'untitled',
502
+ steps: topLevel,
503
+ __meta: {
504
+ layout: nodes.map((n) => ({
505
+ id: n.id,
506
+ x: Math.round(n.position.x),
507
+ y: Math.round(n.position.y),
508
+ })),
509
+ edges: edges.map(edgeToMeta),
510
+ },
511
+ };
512
+ if (warnings.length > 0) def.__meta!.warnings = warnings;
513
+ return def;
514
+ }
515
+
516
+ function withChildren(
517
+ step: StepDef,
518
+ children: { then?: StepDef[]; else?: StepDef[]; do?: StepDef[] } | undefined,
519
+ ): StepDef {
520
+ if (!children) return step;
521
+ const out: StepDef = { ...step };
522
+ if (children.then) out.then = children.then;
523
+ if (children.else) out.else = children.else;
524
+ if (children.do) out.do = children.do;
525
+ return out;
526
+ }
package/src/types.ts CHANGED
@@ -41,6 +41,12 @@ export interface StepDef {
41
41
  maxTurns?: number;
42
42
  }
43
43
 
44
+ export interface WorkflowEdgeMeta {
45
+ source: string;
46
+ target: string;
47
+ sourceHandle?: string;
48
+ }
49
+
44
50
  export interface WorkflowDef {
45
51
  name: string;
46
52
  description?: string;
@@ -49,6 +55,13 @@ export interface WorkflowDef {
49
55
  steps: StepDef[];
50
56
  __meta?: {
51
57
  layout?: { id: string; x: number; y: number }[];
58
+ /**
59
+ * 画布原始连线,保存时序列化、加载时还原。runner 严格忽略 __meta,
60
+ * 因此追加这两个字段不会破坏运行时。
61
+ */
62
+ edges?: WorkflowEdgeMeta[];
63
+ /** toWorkflowDef 期间检测到的结构问题(如成环)。 */
64
+ warnings?: string[];
52
65
  };
53
66
  }
54
67