minimal-workflow 0.2.1 → 0.3.0
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 +1 -1
- package/src/App.tsx +49 -0
- package/src/canvas/WorkflowCanvas.tsx +21 -3
- package/src/canvas/nodes/LoopNode.tsx +1 -1
- package/src/inspector/Inspector.tsx +2 -2
- package/src/inspector/ToolNodeForm.tsx +352 -69
- package/src/inspector/schemaForm.ts +107 -0
- package/src/store/workflowStore.ts +423 -37
- package/src/types.ts +13 -0
package/package.json
CHANGED
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(
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 &&
|
|
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
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
128
|
+
{field.description}
|
|
72
129
|
</small>
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 {
|
|
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: (
|
|
22
|
-
|
|
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
|
-
/**
|
|
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
|
|
53
|
-
|
|
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:
|
|
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
|
|
158
|
+
data: { step: entry.step, kind },
|
|
59
159
|
};
|
|
60
160
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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:
|
|
97
|
-
type:
|
|
256
|
+
id: clean.id,
|
|
257
|
+
type: kind,
|
|
98
258
|
position,
|
|
99
|
-
data: { step, kind
|
|
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
|
|
117
|
-
|
|
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
|
|