minimal-workflow 0.4.5 → 0.5.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/canvas/WorkflowCanvas.tsx +14 -1
- package/src/inspector/InputsPanel.tsx +335 -0
- package/src/inspector/Inspector.tsx +3 -6
- package/src/inspector/LlmNodeForm.tsx +278 -21
- package/src/store/workflowStore.ts +44 -0
- package/src/types.ts +7 -2
- package/src/yaml-view/sync.ts +413 -51
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import '@xyflow/react/dist/style.css';
|
|
15
15
|
|
|
16
16
|
import { useWorkflowStore } from '../store/workflowStore.ts';
|
|
17
|
+
import { validateWorkflowByNode } from '../yaml-view/sync.ts';
|
|
17
18
|
import { ToolNode } from './nodes/ToolNode.tsx';
|
|
18
19
|
import { LlmNode } from './nodes/LlmNode.tsx';
|
|
19
20
|
import { SkillNode } from './nodes/SkillNode.tsx';
|
|
@@ -50,6 +51,8 @@ export function WorkflowCanvas() {
|
|
|
50
51
|
const selectNode = useWorkflowStore((s) => s.selectNode);
|
|
51
52
|
const addStep = useWorkflowStore((s) => s.addStep);
|
|
52
53
|
const commitHistory = useWorkflowStore((s) => s.commitHistory);
|
|
54
|
+
const toWorkflowDef = useWorkflowStore((s) => s.toWorkflowDef);
|
|
55
|
+
const setNodeErrors = useWorkflowStore((s) => s.setNodeErrors);
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* change-aware history:拖拽过程中 (position && dragging===true) 高频 emit,
|
|
@@ -128,6 +131,16 @@ export function WorkflowCanvas() {
|
|
|
128
131
|
[nodes, addStep],
|
|
129
132
|
);
|
|
130
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 节点级错误接线:nodes/edges 变化 → 组装 def → 逐节点结构校验 →
|
|
136
|
+
* 把错误写回各 node.data.errors(BaseNode 渲染"N 错误"badge)。
|
|
137
|
+
* setNodeErrors 内部幂等(errors 无变化不 set),故不会死循环。
|
|
138
|
+
*/
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const def = toWorkflowDef();
|
|
141
|
+
setNodeErrors(validateWorkflowByNode(def));
|
|
142
|
+
}, [nodes, edges, toWorkflowDef, setNodeErrors]);
|
|
143
|
+
|
|
131
144
|
const defaultViewport = useMemo(() => ({ x: 0, y: 0, zoom: 1 }), []);
|
|
132
145
|
|
|
133
146
|
return (
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor/src/inspector/InputsPanel.tsx —— workflow 级属性编辑面板。
|
|
3
|
+
*
|
|
4
|
+
* 在未选中任何节点时渲染(Inspector 空态),编辑 workflow 对外契约:
|
|
5
|
+
* - 元信息:name / description / version
|
|
6
|
+
* - inputs[]:每项 name / type(string|number|enum) / required / default / description;
|
|
7
|
+
* type==='enum' 时额外编辑 values[](候选值,必须非空,否则后端 loader 拒绝)。
|
|
8
|
+
*
|
|
9
|
+
* 所有改动统一走 store 的 updateMeta({...})(进 undo history + 置 dirty),
|
|
10
|
+
* 不直接 mutate meta。inputs 的增/删/改逻辑抽成纯函数(见下方 reducer 段),
|
|
11
|
+
* 既复用于组件、又可被 test/inputsPanel.test.ts 单测(editor 无 @testing-library/react)。
|
|
12
|
+
*
|
|
13
|
+
* D2 红线:本文件**禁止** import minimal-agent/src/* 或 plugins/*,只依赖 editor 自有类型与 store。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useWorkflowStore } from '../store/workflowStore.ts';
|
|
17
|
+
import type { WorkflowInput } from '../types.ts';
|
|
18
|
+
|
|
19
|
+
/* ───────────────────────── 纯函数 reducer(可单测) ───────────────────────── */
|
|
20
|
+
|
|
21
|
+
export type InputType = NonNullable<WorkflowInput['type']>;
|
|
22
|
+
|
|
23
|
+
/** 追加一个新 input(默认 string、非必填)。 */
|
|
24
|
+
export function addInput(inputs: WorkflowInput[]): WorkflowInput[] {
|
|
25
|
+
return [...inputs, { name: '', type: 'string' }];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 删除第 idx 个 input。 */
|
|
29
|
+
export function removeInput(
|
|
30
|
+
inputs: WorkflowInput[],
|
|
31
|
+
idx: number,
|
|
32
|
+
): WorkflowInput[] {
|
|
33
|
+
return inputs.filter((_, i) => i !== idx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 局部 patch 第 idx 个 input。
|
|
38
|
+
* 当把 type 切到非 enum 时,顺手清掉残留的 values(避免 yaml 里留下无意义的候选值);
|
|
39
|
+
* 切到 enum 时如果还没有 values 则补一个空数组(引导用户去填)。
|
|
40
|
+
*/
|
|
41
|
+
export function patchInput(
|
|
42
|
+
inputs: WorkflowInput[],
|
|
43
|
+
idx: number,
|
|
44
|
+
patch: Partial<WorkflowInput>,
|
|
45
|
+
): WorkflowInput[] {
|
|
46
|
+
return inputs.map((inp, i) => {
|
|
47
|
+
if (i !== idx) return inp;
|
|
48
|
+
const next: WorkflowInput = { ...inp, ...patch };
|
|
49
|
+
if (patch.type !== undefined) {
|
|
50
|
+
if (patch.type === 'enum') {
|
|
51
|
+
if (!Array.isArray(next.values)) next.values = [];
|
|
52
|
+
} else if (next.values !== undefined) {
|
|
53
|
+
delete next.values;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return next;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 给第 idx 个 enum input 追加一个空候选值。 */
|
|
61
|
+
export function addEnumValue(
|
|
62
|
+
inputs: WorkflowInput[],
|
|
63
|
+
idx: number,
|
|
64
|
+
): WorkflowInput[] {
|
|
65
|
+
return inputs.map((inp, i) =>
|
|
66
|
+
i === idx ? { ...inp, values: [...(inp.values ?? []), ''] } : inp,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 修改第 idx 个 enum input 的第 vIdx 个候选值。 */
|
|
71
|
+
export function setEnumValue(
|
|
72
|
+
inputs: WorkflowInput[],
|
|
73
|
+
idx: number,
|
|
74
|
+
vIdx: number,
|
|
75
|
+
value: string,
|
|
76
|
+
): WorkflowInput[] {
|
|
77
|
+
return inputs.map((inp, i) => {
|
|
78
|
+
if (i !== idx) return inp;
|
|
79
|
+
const values = (inp.values ?? []).map((v, j) => (j === vIdx ? value : v));
|
|
80
|
+
return { ...inp, values };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 删除第 idx 个 enum input 的第 vIdx 个候选值。 */
|
|
85
|
+
export function removeEnumValue(
|
|
86
|
+
inputs: WorkflowInput[],
|
|
87
|
+
idx: number,
|
|
88
|
+
vIdx: number,
|
|
89
|
+
): WorkflowInput[] {
|
|
90
|
+
return inputs.map((inp, i) => {
|
|
91
|
+
if (i !== idx) return inp;
|
|
92
|
+
const values = (inp.values ?? []).filter((_, j) => j !== vIdx);
|
|
93
|
+
return { ...inp, values };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ───────────────────────────── React 组件 ───────────────────────────── */
|
|
98
|
+
|
|
99
|
+
const SMALL: React.CSSProperties = { color: '#6b7280', fontSize: 11 };
|
|
100
|
+
const WARN: React.CSSProperties = { color: '#dc2626', fontSize: 11 };
|
|
101
|
+
|
|
102
|
+
export function InputsPanel() {
|
|
103
|
+
const meta = useWorkflowStore((s) => s.meta);
|
|
104
|
+
const updateMeta = useWorkflowStore((s) => s.updateMeta);
|
|
105
|
+
|
|
106
|
+
const inputs = meta.inputs ?? [];
|
|
107
|
+
const writeInputs = (next: WorkflowInput[]) => updateMeta({ inputs: next });
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<aside className="inspector">
|
|
111
|
+
<h2>workflow 属性</h2>
|
|
112
|
+
|
|
113
|
+
{/* ── 元信息 ── */}
|
|
114
|
+
<div className="field">
|
|
115
|
+
<label>name(workflow 名)</label>
|
|
116
|
+
<input
|
|
117
|
+
value={meta.name ?? ''}
|
|
118
|
+
placeholder="untitled"
|
|
119
|
+
onChange={(e) => updateMeta({ name: e.target.value })}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="field">
|
|
123
|
+
<label>description(说明)</label>
|
|
124
|
+
<textarea
|
|
125
|
+
value={meta.description ?? ''}
|
|
126
|
+
rows={2}
|
|
127
|
+
onChange={(e) => updateMeta({ description: e.target.value })}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="field">
|
|
131
|
+
<label>version</label>
|
|
132
|
+
<input
|
|
133
|
+
value={meta.version ?? ''}
|
|
134
|
+
placeholder="0.1"
|
|
135
|
+
onChange={(e) => updateMeta({ version: e.target.value })}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* ── inputs 列表 ── */}
|
|
140
|
+
<div className="field" style={{ marginTop: 16 }}>
|
|
141
|
+
<label>inputs(对外参数契约,{inputs.length} 个)</label>
|
|
142
|
+
<small style={SMALL}>
|
|
143
|
+
决定 <code>/workflow <name> [位置参数]</code> 的取值与顺序;
|
|
144
|
+
重排会破坏现有 CLI 位置参数调用。
|
|
145
|
+
</small>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{inputs.map((inp, i) => (
|
|
149
|
+
<InputRow
|
|
150
|
+
key={i}
|
|
151
|
+
input={inp}
|
|
152
|
+
onPatch={(patch) => writeInputs(patchInput(inputs, i, patch))}
|
|
153
|
+
onRemove={() => writeInputs(removeInput(inputs, i))}
|
|
154
|
+
onAddValue={() => writeInputs(addEnumValue(inputs, i))}
|
|
155
|
+
onSetValue={(vIdx, v) =>
|
|
156
|
+
writeInputs(setEnumValue(inputs, i, vIdx, v))
|
|
157
|
+
}
|
|
158
|
+
onRemoveValue={(vIdx) =>
|
|
159
|
+
writeInputs(removeEnumValue(inputs, i, vIdx))
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
|
|
164
|
+
<div className="field">
|
|
165
|
+
<button
|
|
166
|
+
onClick={() => writeInputs(addInput(inputs))}
|
|
167
|
+
style={{ fontSize: 11, padding: '2px 8px' }}
|
|
168
|
+
>
|
|
169
|
+
+ 添加 input
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</aside>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface InputRowProps {
|
|
177
|
+
input: WorkflowInput;
|
|
178
|
+
onPatch: (patch: Partial<WorkflowInput>) => void;
|
|
179
|
+
onRemove: () => void;
|
|
180
|
+
onAddValue: () => void;
|
|
181
|
+
onSetValue: (vIdx: number, value: string) => void;
|
|
182
|
+
onRemoveValue: (vIdx: number) => void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function InputRow({
|
|
186
|
+
input,
|
|
187
|
+
onPatch,
|
|
188
|
+
onRemove,
|
|
189
|
+
onAddValue,
|
|
190
|
+
onSetValue,
|
|
191
|
+
onRemoveValue,
|
|
192
|
+
}: InputRowProps) {
|
|
193
|
+
const type: InputType = input.type ?? 'string';
|
|
194
|
+
const isEnum = type === 'enum';
|
|
195
|
+
const values = input.values ?? [];
|
|
196
|
+
const enumEmpty = isEnum && values.filter((v) => v.trim() !== '').length === 0;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
className="field"
|
|
201
|
+
style={{
|
|
202
|
+
border: '1px solid #e5e7eb',
|
|
203
|
+
borderRadius: 4,
|
|
204
|
+
padding: 8,
|
|
205
|
+
marginBottom: 6,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<div
|
|
209
|
+
style={{
|
|
210
|
+
display: 'flex',
|
|
211
|
+
gap: 6,
|
|
212
|
+
alignItems: 'center',
|
|
213
|
+
justifyContent: 'space-between',
|
|
214
|
+
marginBottom: 6,
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<code style={{ fontSize: 12 }}>{input.name || '(未命名)'}</code>
|
|
218
|
+
<button
|
|
219
|
+
onClick={onRemove}
|
|
220
|
+
title="删除该 input"
|
|
221
|
+
style={{
|
|
222
|
+
fontSize: 11,
|
|
223
|
+
padding: '2px 6px',
|
|
224
|
+
background: '#fef2f2',
|
|
225
|
+
color: '#dc2626',
|
|
226
|
+
border: '1px solid #fca5a5',
|
|
227
|
+
borderRadius: 3,
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
×
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div className="field">
|
|
235
|
+
<label>name</label>
|
|
236
|
+
<input
|
|
237
|
+
value={input.name}
|
|
238
|
+
placeholder="param_name"
|
|
239
|
+
onChange={(e) => onPatch({ name: e.target.value })}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="field">
|
|
244
|
+
<label>type</label>
|
|
245
|
+
<select
|
|
246
|
+
value={type}
|
|
247
|
+
onChange={(e) => onPatch({ type: e.target.value as InputType })}
|
|
248
|
+
>
|
|
249
|
+
<option value="string">string</option>
|
|
250
|
+
<option value="number">number</option>
|
|
251
|
+
<option value="enum">enum</option>
|
|
252
|
+
</select>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div className="field">
|
|
256
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
257
|
+
<input
|
|
258
|
+
type="checkbox"
|
|
259
|
+
checked={input.required === true}
|
|
260
|
+
onChange={(e) => onPatch({ required: e.target.checked })}
|
|
261
|
+
style={{ width: 'auto' }}
|
|
262
|
+
/>
|
|
263
|
+
required(必填)
|
|
264
|
+
</label>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div className="field">
|
|
268
|
+
<label>default(默认值,可选)</label>
|
|
269
|
+
<input
|
|
270
|
+
value={defaultToText(input.default)}
|
|
271
|
+
placeholder={isEnum ? '需为某个候选值' : ''}
|
|
272
|
+
onChange={(e) =>
|
|
273
|
+
onPatch({
|
|
274
|
+
default: e.target.value === '' ? undefined : e.target.value,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="field">
|
|
281
|
+
<label>description(说明,可选)</label>
|
|
282
|
+
<input
|
|
283
|
+
value={input.description ?? ''}
|
|
284
|
+
onChange={(e) =>
|
|
285
|
+
onPatch({
|
|
286
|
+
description: e.target.value === '' ? undefined : e.target.value,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{isEnum && (
|
|
293
|
+
<div className="field">
|
|
294
|
+
<label>values(候选值,必须非空)</label>
|
|
295
|
+
{values.map((v, vIdx) => (
|
|
296
|
+
<div
|
|
297
|
+
key={vIdx}
|
|
298
|
+
style={{ display: 'flex', gap: 4, marginBottom: 4 }}
|
|
299
|
+
>
|
|
300
|
+
<input
|
|
301
|
+
value={v}
|
|
302
|
+
placeholder="候选值"
|
|
303
|
+
onChange={(e) => onSetValue(vIdx, e.target.value)}
|
|
304
|
+
/>
|
|
305
|
+
<button
|
|
306
|
+
onClick={() => onRemoveValue(vIdx)}
|
|
307
|
+
style={{ fontSize: 10 }}
|
|
308
|
+
>
|
|
309
|
+
×
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
))}
|
|
313
|
+
<button
|
|
314
|
+
onClick={onAddValue}
|
|
315
|
+
style={{ fontSize: 11, padding: '2px 8px' }}
|
|
316
|
+
>
|
|
317
|
+
+ 候选值
|
|
318
|
+
</button>
|
|
319
|
+
{enumEmpty && (
|
|
320
|
+
<small style={WARN}>
|
|
321
|
+
⚠ enum 至少需要一个非空候选值,否则后端 loader 会拒绝该 workflow
|
|
322
|
+
</small>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** default 值渲染成输入框文本(非字符串值序列化展示,便于编辑后回填字符串)。 */
|
|
331
|
+
function defaultToText(value: unknown): string {
|
|
332
|
+
if (value === undefined || value === null) return '';
|
|
333
|
+
if (typeof value === 'string') return value;
|
|
334
|
+
return String(value);
|
|
335
|
+
}
|
|
@@ -3,6 +3,7 @@ import { useWorkflowStore } from '../store/workflowStore.ts';
|
|
|
3
3
|
import { ToolNodeForm } from './ToolNodeForm.tsx';
|
|
4
4
|
import { LlmNodeForm } from './LlmNodeForm.tsx';
|
|
5
5
|
import { SkillNodeForm } from './SkillNodeForm.tsx';
|
|
6
|
+
import { InputsPanel } from './InputsPanel.tsx';
|
|
6
7
|
import type { StepDef, StepKind } from '../types.ts';
|
|
7
8
|
|
|
8
9
|
export function Inspector() {
|
|
@@ -12,13 +13,9 @@ export function Inspector() {
|
|
|
12
13
|
const removeStep = useWorkflowStore((s) => s.removeStep);
|
|
13
14
|
|
|
14
15
|
const node = nodes.find((n) => n.id === selectedId);
|
|
16
|
+
// 未选中节点时编辑 workflow 级属性(name/description/version/inputs),符合直觉
|
|
15
17
|
if (!node) {
|
|
16
|
-
return
|
|
17
|
-
<aside className="inspector">
|
|
18
|
-
<h2>属性</h2>
|
|
19
|
-
<div className="empty">选中一个节点以编辑参数</div>
|
|
20
|
-
</aside>
|
|
21
|
-
);
|
|
18
|
+
return <InputsPanel />;
|
|
22
19
|
}
|
|
23
20
|
const step = node.data.step;
|
|
24
21
|
const kind = node.data.kind;
|
|
@@ -1,30 +1,27 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
1
2
|
import type { StepDef } from '../types.ts';
|
|
3
|
+
import { tryParseJson } from './schemaForm.ts';
|
|
2
4
|
|
|
3
5
|
interface Props {
|
|
4
6
|
step: StepDef;
|
|
5
7
|
onPatch: (patch: Partial<StepDef>) => void;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
type ContextFile = { path: string; hint?: string };
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
|
-
* llm
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* llm 节点表单。
|
|
14
|
+
*
|
|
15
|
+
* A2 收敛:后端 execLlmStep 第一行 `if (typeof step.llm !== 'string') throw`,
|
|
16
|
+
* 只接受 string —— object 形态(per-step model/temperature)后端零支持(provider
|
|
17
|
+
* 是全局的,runner 用 ctx.provider)。所以这里**只写回 string**。读取旧 object YAML
|
|
18
|
+
* 时由 extractPrompt 运行时降级提取 .prompt,向后兼容不丢数据。
|
|
12
19
|
*
|
|
13
|
-
*
|
|
20
|
+
* B1 暴露 ADR-05 三个 llm-only 字段(后端 loader 对 skill 节点会拒绝它们,故只在
|
|
21
|
+
* llm 节点出现):output_schema / context_files / allowed_tools。
|
|
14
22
|
*/
|
|
15
23
|
export function LlmNodeForm({ step, onPatch }: Props) {
|
|
16
24
|
const prompt = extractPrompt(step.llm);
|
|
17
|
-
const isObjectForm = step.llm !== null && typeof step.llm === 'object';
|
|
18
|
-
|
|
19
|
-
const onPromptChange = (text: string) => {
|
|
20
|
-
if (isObjectForm) {
|
|
21
|
-
onPatch({
|
|
22
|
-
llm: { ...(step.llm as Record<string, unknown>), prompt: text },
|
|
23
|
-
});
|
|
24
|
-
} else {
|
|
25
|
-
onPatch({ llm: text });
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
25
|
|
|
29
26
|
return (
|
|
30
27
|
<>
|
|
@@ -35,21 +32,243 @@ export function LlmNodeForm({ step, onPatch }: Props) {
|
|
|
35
32
|
rows={8}
|
|
36
33
|
spellCheck={false}
|
|
37
34
|
placeholder="请用一句话评价 ${book}"
|
|
38
|
-
|
|
35
|
+
// A2:一律写回纯 string(删除了旧 isObjectForm 写 object 的分支)
|
|
36
|
+
onChange={(e) => onPatch({ llm: e.target.value })}
|
|
39
37
|
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
40
38
|
/>
|
|
41
39
|
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
42
|
-
支持 ${'{var}'}
|
|
43
|
-
{isObjectForm
|
|
44
|
-
? '对象形态 — model / temperature 等字段请在 YAML 视图编辑'
|
|
45
|
-
: '默认走主 provider'}
|
|
40
|
+
支持 ${'{var}'} 模板;默认走主 provider
|
|
46
41
|
</small>
|
|
47
42
|
</div>
|
|
43
|
+
|
|
44
|
+
<AdvancedAdr05Section step={step} onPatch={onPatch} />
|
|
48
45
|
</>
|
|
49
46
|
);
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
|
|
49
|
+
/**
|
|
50
|
+
* 折叠的「高级(ADR-05)」区,默认收起,保持表单清爽(折叠写法对齐
|
|
51
|
+
* ToolNodeForm.tsx 的 AdvancedSection)。聚合 output_schema / context_files /
|
|
52
|
+
* allowed_tools 三个 llm 专属字段。
|
|
53
|
+
*/
|
|
54
|
+
function AdvancedAdr05Section({ step, onPatch }: Props) {
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
|
|
57
|
+
// 已配置任一字段时,徽标提示数量,便于折叠态下也能看出"里面有料"
|
|
58
|
+
const activeCount =
|
|
59
|
+
(step.output_schema !== undefined ? 1 : 0) +
|
|
60
|
+
(step.context_files && step.context_files.length > 0 ? 1 : 0) +
|
|
61
|
+
(step.allowed_tools && step.allowed_tools.length > 0 ? 1 : 0);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="field">
|
|
65
|
+
<label
|
|
66
|
+
style={{ cursor: 'pointer', userSelect: 'none' }}
|
|
67
|
+
onClick={() => setOpen(!open)}
|
|
68
|
+
>
|
|
69
|
+
{open ? '▼' : '▶'} 高级(ADR-05)
|
|
70
|
+
{activeCount > 0 && (
|
|
71
|
+
<span style={{ color: '#9ca3af', fontWeight: 'normal' }}>
|
|
72
|
+
{' '}
|
|
73
|
+
(已配置 {activeCount})
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</label>
|
|
77
|
+
{open && (
|
|
78
|
+
<>
|
|
79
|
+
<OutputSchemaField
|
|
80
|
+
value={step.output_schema}
|
|
81
|
+
onChange={(v) => onPatch({ output_schema: v })}
|
|
82
|
+
/>
|
|
83
|
+
<ContextFilesField
|
|
84
|
+
value={step.context_files ?? []}
|
|
85
|
+
onChange={(arr) =>
|
|
86
|
+
onPatch({ context_files: arr.length > 0 ? arr : undefined })
|
|
87
|
+
}
|
|
88
|
+
/>
|
|
89
|
+
<AllowedToolsField
|
|
90
|
+
value={step.allowed_tools ?? []}
|
|
91
|
+
onChange={(arr) =>
|
|
92
|
+
onPatch({ allowed_tools: arr.length > 0 ? arr : undefined })
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface OutputSchemaProps {
|
|
102
|
+
value: Record<string, unknown> | undefined;
|
|
103
|
+
onChange: (v: Record<string, unknown> | undefined) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** output_schema:JSON textarea,onBlur 校验(必须是 object)。 */
|
|
107
|
+
function OutputSchemaField({ value, onChange }: OutputSchemaProps) {
|
|
108
|
+
const initial = useMemo(() => {
|
|
109
|
+
if (value === undefined) return '';
|
|
110
|
+
try {
|
|
111
|
+
return JSON.stringify(value, null, 2);
|
|
112
|
+
} catch {
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
}, [value]);
|
|
116
|
+
const [text, setText] = useState(initial);
|
|
117
|
+
const [error, setError] = useState<string | null>(null);
|
|
118
|
+
|
|
119
|
+
// 外部 value 变化(切换 step)时同步 textarea
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
setText(initial);
|
|
122
|
+
setError(null);
|
|
123
|
+
}, [initial]);
|
|
124
|
+
|
|
125
|
+
const onBlur = () => {
|
|
126
|
+
const r = tryParseJson(text);
|
|
127
|
+
if (!r.ok) {
|
|
128
|
+
setError(r.error);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// 清空 → undefined(关掉结构化输出)
|
|
132
|
+
if (r.value === undefined) {
|
|
133
|
+
setError(null);
|
|
134
|
+
onChange(undefined);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (typeof r.value !== 'object' || r.value === null || Array.isArray(r.value)) {
|
|
138
|
+
setError('output_schema 必须是 object(JSON Schema,{...})');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
setError(null);
|
|
142
|
+
onChange(r.value as Record<string, unknown>);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="field">
|
|
147
|
+
<label>output_schema</label>
|
|
148
|
+
<textarea
|
|
149
|
+
value={text}
|
|
150
|
+
rows={6}
|
|
151
|
+
spellCheck={false}
|
|
152
|
+
placeholder={'{\n "type": "object",\n "properties": { ... }\n}'}
|
|
153
|
+
onChange={(e) => setText(e.target.value)}
|
|
154
|
+
onBlur={onBlur}
|
|
155
|
+
style={{
|
|
156
|
+
fontFamily: 'ui-monospace, monospace',
|
|
157
|
+
fontSize: 12,
|
|
158
|
+
borderColor: error ? '#dc2626' : undefined,
|
|
159
|
+
}}
|
|
160
|
+
/>
|
|
161
|
+
{error && (
|
|
162
|
+
<small style={{ color: '#dc2626', fontSize: 11 }}>
|
|
163
|
+
⚠ {error}
|
|
164
|
+
</small>
|
|
165
|
+
)}
|
|
166
|
+
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
167
|
+
provider-native 结构化输出;留空 = 普通文本输出
|
|
168
|
+
</small>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
interface ContextFilesProps {
|
|
174
|
+
value: ContextFile[];
|
|
175
|
+
onChange: (arr: ContextFile[]) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** context_files:path(必填)+ hint(可选)的可增删行列表。 */
|
|
179
|
+
function ContextFilesField({ value, onChange }: ContextFilesProps) {
|
|
180
|
+
return (
|
|
181
|
+
<div className="field">
|
|
182
|
+
<label>context_files</label>
|
|
183
|
+
{value.map((row, i) => (
|
|
184
|
+
// 行顺序稳定且无业务 key,用 index 作 key 可接受
|
|
185
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: 行无稳定 id
|
|
186
|
+
<div
|
|
187
|
+
key={i}
|
|
188
|
+
style={{ display: 'flex', gap: 6, marginBottom: 4, alignItems: 'center' }}
|
|
189
|
+
>
|
|
190
|
+
<input
|
|
191
|
+
value={row.path}
|
|
192
|
+
placeholder="path(支持 ${var})"
|
|
193
|
+
onChange={(e) =>
|
|
194
|
+
onChange(updateContextFileRow(value, i, { path: e.target.value }))
|
|
195
|
+
}
|
|
196
|
+
style={{ flex: 2, fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
197
|
+
/>
|
|
198
|
+
<input
|
|
199
|
+
value={row.hint ?? ''}
|
|
200
|
+
placeholder="hint(可选)"
|
|
201
|
+
onChange={(e) =>
|
|
202
|
+
onChange(updateContextFileRow(value, i, { hint: e.target.value }))
|
|
203
|
+
}
|
|
204
|
+
style={{ flex: 3, fontSize: 12 }}
|
|
205
|
+
/>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
title="删除此行"
|
|
209
|
+
onClick={() => onChange(removeContextFileRow(value, i))}
|
|
210
|
+
style={{ flex: '0 0 auto', cursor: 'pointer' }}
|
|
211
|
+
>
|
|
212
|
+
✕
|
|
213
|
+
</button>
|
|
214
|
+
</div>
|
|
215
|
+
))}
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => onChange(addContextFileRow(value))}
|
|
219
|
+
style={{ cursor: 'pointer', fontSize: 12 }}
|
|
220
|
+
>
|
|
221
|
+
+ 添加文件
|
|
222
|
+
</button>
|
|
223
|
+
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
224
|
+
runner 会用 Read 按需读取这些文件(RAG);支持 ${'{var}'} 模板
|
|
225
|
+
</small>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface AllowedToolsProps {
|
|
231
|
+
value: string[];
|
|
232
|
+
onChange: (arr: string[]) => void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** allowed_tools:逗号分隔单 input(取简单方案)。 */
|
|
236
|
+
function AllowedToolsField({ value, onChange }: AllowedToolsProps) {
|
|
237
|
+
// 受控为字符串,保留用户半截输入(尾随逗号/空格),onChange 时即时解析写回
|
|
238
|
+
const [text, setText] = useState(value.join(', '));
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
setText(value.join(', '));
|
|
242
|
+
}, [value]);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className="field">
|
|
246
|
+
<label>allowed_tools</label>
|
|
247
|
+
<input
|
|
248
|
+
value={text}
|
|
249
|
+
placeholder="Read, Grep"
|
|
250
|
+
onChange={(e) => {
|
|
251
|
+
setText(e.target.value);
|
|
252
|
+
onChange(parseCsvList(e.target.value));
|
|
253
|
+
}}
|
|
254
|
+
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
255
|
+
/>
|
|
256
|
+
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
257
|
+
与 context_files 配对的工具白名单,缺省 ['Read']
|
|
258
|
+
</small>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ===== 纯函数(可单测,不依赖 React)=====
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 从 step.llm 提取 prompt 文本(运行时防御,入参 unknown):
|
|
267
|
+
* - string → 直接用
|
|
268
|
+
* - object → 取 .prompt(向后兼容旧 object 形态 YAML,不丢数据)
|
|
269
|
+
* - 其它 → ''
|
|
270
|
+
*/
|
|
271
|
+
export function extractPrompt(llm: unknown): string {
|
|
53
272
|
if (typeof llm === 'string') return llm;
|
|
54
273
|
if (llm && typeof llm === 'object') {
|
|
55
274
|
const p = (llm as Record<string, unknown>).prompt;
|
|
@@ -57,3 +276,41 @@ function extractPrompt(llm: StepDef['llm']): string {
|
|
|
57
276
|
}
|
|
58
277
|
return '';
|
|
59
278
|
}
|
|
279
|
+
|
|
280
|
+
/** 逗号分隔字符串 → 去空白、去空项的 string[]。 */
|
|
281
|
+
export function parseCsvList(raw: string): string[] {
|
|
282
|
+
return raw
|
|
283
|
+
.split(',')
|
|
284
|
+
.map((s) => s.trim())
|
|
285
|
+
.filter((s) => s.length > 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 末尾追加一个空 context_files 行(返回新数组,不原地改)。 */
|
|
289
|
+
export function addContextFileRow(rows: ContextFile[]): ContextFile[] {
|
|
290
|
+
return [...rows, { path: '' }];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 更新第 i 行的 path / hint(返回新数组)。
|
|
295
|
+
* hint 被清空('')时从对象删除该键,保持产出干净(与后端可选字段对齐)。
|
|
296
|
+
*/
|
|
297
|
+
export function updateContextFileRow(
|
|
298
|
+
rows: ContextFile[],
|
|
299
|
+
i: number,
|
|
300
|
+
patch: Partial<ContextFile>,
|
|
301
|
+
): ContextFile[] {
|
|
302
|
+
return rows.map((row, idx) => {
|
|
303
|
+
if (idx !== i) return row;
|
|
304
|
+
const next: ContextFile = { ...row, ...patch };
|
|
305
|
+
if (next.hint === undefined || next.hint === '') delete next.hint;
|
|
306
|
+
return next;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 删除第 i 行(返回新数组)。 */
|
|
311
|
+
export function removeContextFileRow(
|
|
312
|
+
rows: ContextFile[],
|
|
313
|
+
i: number,
|
|
314
|
+
): ContextFile[] {
|
|
315
|
+
return rows.filter((_, idx) => idx !== i);
|
|
316
|
+
}
|
|
@@ -55,6 +55,17 @@ interface WorkflowState {
|
|
|
55
55
|
redo: () => void;
|
|
56
56
|
/** 生成当前 workflow 完整 def(含 __meta.layout/edges/warnings) */
|
|
57
57
|
toWorkflowDef: () => WorkflowDef;
|
|
58
|
+
/** 编辑 workflow 级元信息(name/description/version/inputs)。进 history + 置 dirty。 */
|
|
59
|
+
updateMeta: (
|
|
60
|
+
patch: Partial<
|
|
61
|
+
Pick<WorkflowDef, 'name' | 'description' | 'version' | 'inputs'>
|
|
62
|
+
>,
|
|
63
|
+
) => void;
|
|
64
|
+
/**
|
|
65
|
+
* 把校验错误按节点 id 写回各 node.data.errors(派生态)。
|
|
66
|
+
* 不进 history、不置 dirty;幂等:与当前 errors 相同则不触发渲染(防 validate→setNodeErrors→re-render 死循环)。
|
|
67
|
+
*/
|
|
68
|
+
setNodeErrors: (errorsByNodeId: Map<string, string[]>) => void;
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
const initialMeta = {
|
|
@@ -322,8 +333,41 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|
|
322
333
|
const result = assembleWorkflow(meta, nodes, edges);
|
|
323
334
|
return result;
|
|
324
335
|
},
|
|
336
|
+
|
|
337
|
+
updateMeta: (patch) => {
|
|
338
|
+
pushHistorySnapshot(get, set);
|
|
339
|
+
set({ meta: { ...get().meta, ...patch }, dirty: true });
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
setNodeErrors: (errorsByNodeId) => {
|
|
343
|
+
const { nodes } = get();
|
|
344
|
+
let changed = false;
|
|
345
|
+
const next = nodes.map((n) => {
|
|
346
|
+
const incoming = errorsByNodeId.get(n.id);
|
|
347
|
+
const normalized = incoming && incoming.length > 0 ? incoming : undefined;
|
|
348
|
+
if (sameErrors(n.data.errors, normalized)) return n;
|
|
349
|
+
changed = true;
|
|
350
|
+
return { ...n, data: { ...n.data, errors: normalized } };
|
|
351
|
+
});
|
|
352
|
+
if (!changed) return; // 幂等:无变化不 set,避免 nodes 引用变动触发 effect 死循环
|
|
353
|
+
set({ nodes: next });
|
|
354
|
+
},
|
|
325
355
|
}));
|
|
326
356
|
|
|
357
|
+
/** errors 数组浅比较(都空视为相等),供 setNodeErrors 幂等判断。 */
|
|
358
|
+
function sameErrors(
|
|
359
|
+
a: string[] | undefined,
|
|
360
|
+
b: string[] | undefined,
|
|
361
|
+
): boolean {
|
|
362
|
+
const aa = a ?? [];
|
|
363
|
+
const bb = b ?? [];
|
|
364
|
+
if (aa.length !== bb.length) return false;
|
|
365
|
+
for (let i = 0; i < aa.length; i++) {
|
|
366
|
+
if (aa[i] !== bb[i]) return false;
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
327
371
|
function detectKind(step: StepDef): string {
|
|
328
372
|
if (step.type) return step.type;
|
|
329
373
|
if (step.tool) return 'tool';
|
package/src/types.ts
CHANGED
|
@@ -16,16 +16,21 @@ export type StepKind =
|
|
|
16
16
|
|
|
17
17
|
export interface WorkflowInput {
|
|
18
18
|
name: string;
|
|
19
|
-
type?: 'string' | 'number' | '
|
|
19
|
+
type?: 'string' | 'number' | 'enum';
|
|
20
20
|
required?: boolean;
|
|
21
21
|
default?: unknown;
|
|
22
|
+
/** type=enum 时的候选值清单(与后端 InputDef.values 对齐;enum 必填非空) */
|
|
23
|
+
values?: string[];
|
|
24
|
+
/** 输入项说明,渲染到表单与变量提示 */
|
|
25
|
+
description?: string;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export interface StepDef {
|
|
25
29
|
id: string;
|
|
26
30
|
type?: StepKind;
|
|
27
31
|
tool?: string;
|
|
28
|
-
|
|
32
|
+
/** 后端 execLlmStep 只接受 string;object 形态(model/temperature per-step)后端零支持,故收敛到 string。读取旧 object YAML 时由 LlmNodeForm 运行时降级提取 prompt。 */
|
|
33
|
+
llm?: string;
|
|
29
34
|
skill?: string;
|
|
30
35
|
args?: Record<string, unknown>;
|
|
31
36
|
input?: string;
|
package/src/yaml-view/sync.ts
CHANGED
|
@@ -111,90 +111,449 @@ export function stringifyWorkflowYaml(def: WorkflowDef): string {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/* ─────────────────────────────────────────────────────────────────────────
|
|
114
|
-
* validateWorkflow
|
|
114
|
+
* validateWorkflow / validateWorkflowByNode
|
|
115
|
+
*
|
|
116
|
+
* 这两个导出共享同一套**递归**结构校验核心 collectStepErrors,精确镜像后端
|
|
117
|
+
* plugins/workflow-runner/src/loader.ts 的 validate / validateSteps(D2 解耦
|
|
118
|
+
* 红线:不能 import 后端代码,规则靠手抄)。
|
|
119
|
+
*
|
|
120
|
+
* - validateWorkflowByNode → Map<step.id, string[]>:供画布节点级 badge 接线。
|
|
121
|
+
* branch/loop 的 then/else/do 子步骤在画布上**有独立节点** → 错误归各自 id;
|
|
122
|
+
* parallel/vote 的 fork 子步骤**无独立画布节点** → 错误归并到父节点 id。
|
|
123
|
+
* - validateWorkflow → 扁平 string[]:= 顶层 name/description/steps 错误 +
|
|
124
|
+
* byNode 所有 value 拍平。签名(接收 def、返回 string[])**保持不变**,
|
|
125
|
+
* App.tsx::onSave 依赖它。
|
|
115
126
|
* ──────────────────────────────────────────────────────────────────────── */
|
|
116
127
|
|
|
128
|
+
// ADR-05:控制流 type 集合——这些节点的"动作"由 type 自身定义,
|
|
129
|
+
// 不能附带 tool/skill/llm,也不能配 output_schema / context_files / allowed_tools。
|
|
130
|
+
const CONTROL_FLOW_TYPES = new Set<string>([
|
|
131
|
+
'assert',
|
|
132
|
+
'branch',
|
|
133
|
+
'loop',
|
|
134
|
+
'pause',
|
|
135
|
+
'parallel',
|
|
136
|
+
'vote',
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
// 合法的 step.type 枚举(与后端 VALID_STEP_TYPES 同集)。
|
|
140
|
+
const VALID_STEP_TYPES = new Set<string>([
|
|
141
|
+
'assert',
|
|
142
|
+
'branch',
|
|
143
|
+
'loop',
|
|
144
|
+
'pause',
|
|
145
|
+
'parallel',
|
|
146
|
+
'vote',
|
|
147
|
+
]);
|
|
148
|
+
|
|
117
149
|
/**
|
|
118
|
-
* 校验 WorkflowDef
|
|
150
|
+
* 校验 WorkflowDef 结构;返回扁平 errors 数组(空 = OK)。
|
|
119
151
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* - `${var}` 引用未在前序 step 的 capture values / inputs 里出现
|
|
152
|
+
* = 顶层字段错误(name / description / steps)+ 所有 step(含递归子步骤)的
|
|
153
|
+
* 结构错误 + 变量引用错误,全部拍平。
|
|
154
|
+
*
|
|
155
|
+
* 硬约束:签名不可变(App.tsx::onSave 依赖扁平 string[])。
|
|
125
156
|
*/
|
|
126
157
|
export function validateWorkflow(def: WorkflowDef): string[] {
|
|
127
|
-
const errors: string[] = [];
|
|
128
|
-
|
|
129
158
|
if (!def || typeof def !== 'object') {
|
|
130
159
|
return ['Workflow 不是对象'];
|
|
131
160
|
}
|
|
161
|
+
|
|
162
|
+
const errors: string[] = [];
|
|
163
|
+
errors.push(...topLevelErrors(def));
|
|
164
|
+
|
|
165
|
+
if (!Array.isArray(def.steps)) {
|
|
166
|
+
// steps 缺失:顶层错误已记录,无法继续递归
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 递归收集每个 step 的结构错误(已按 id 归类,这里只取 value 拍平)。
|
|
171
|
+
const byId = collectStepErrors(def.steps, makeInitialKnownVars(def), '');
|
|
172
|
+
for (const list of byId.values()) errors.push(...list);
|
|
173
|
+
|
|
174
|
+
return errors;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 校验 WorkflowDef 结构;返回 Map<step.id, string[]>,供画布节点级错误 badge。
|
|
179
|
+
*
|
|
180
|
+
* 仅含 step 维度的错误(不含顶层 name/description/steps —— 那些没有对应节点)。
|
|
181
|
+
* fork 子步骤错误归并到父 parallel/vote 节点的 id;then/else/do 子步骤归各自 id。
|
|
182
|
+
*/
|
|
183
|
+
export function validateWorkflowByNode(
|
|
184
|
+
def: WorkflowDef,
|
|
185
|
+
): Map<string, string[]> {
|
|
186
|
+
if (!def || typeof def !== 'object' || !Array.isArray(def.steps)) {
|
|
187
|
+
return new Map();
|
|
188
|
+
}
|
|
189
|
+
return collectStepErrors(def.steps, makeInitialKnownVars(def), '');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 顶层字段(name / description / steps / inputs)结构校验,返回扁平错误。 */
|
|
193
|
+
function topLevelErrors(def: WorkflowDef): string[] {
|
|
194
|
+
const errors: string[] = [];
|
|
195
|
+
|
|
132
196
|
if (typeof def.name !== 'string' || !def.name.trim()) {
|
|
133
197
|
errors.push('Workflow 缺少 name');
|
|
134
198
|
}
|
|
199
|
+
if (typeof def.description !== 'string' || !def.description.trim()) {
|
|
200
|
+
errors.push('Workflow 缺少 description');
|
|
201
|
+
}
|
|
135
202
|
if (!Array.isArray(def.steps)) {
|
|
136
203
|
errors.push('Workflow 缺少 steps 数组');
|
|
137
|
-
|
|
204
|
+
} else if (def.steps.length === 0) {
|
|
205
|
+
errors.push('Workflow 的 steps 不能为空');
|
|
138
206
|
}
|
|
139
207
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
// inputs 若存在:必须数组;每项 name 非空;type 枚举;enum 必须非空 values
|
|
209
|
+
if (def.inputs !== undefined) {
|
|
210
|
+
if (!Array.isArray(def.inputs)) {
|
|
211
|
+
errors.push('inputs 必须是数组');
|
|
212
|
+
} else {
|
|
213
|
+
def.inputs.forEach((item, i) => {
|
|
214
|
+
if (!item || typeof item !== 'object') {
|
|
215
|
+
errors.push(`inputs[${i}] 必须是对象`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const r = item as Record<string, unknown>;
|
|
219
|
+
if (typeof r.name !== 'string' || r.name.length === 0) {
|
|
220
|
+
errors.push(`inputs[${i}].name 必填(非空 string)`);
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
r.type !== undefined &&
|
|
224
|
+
r.type !== 'string' &&
|
|
225
|
+
r.type !== 'number' &&
|
|
226
|
+
r.type !== 'enum'
|
|
227
|
+
) {
|
|
228
|
+
errors.push(`inputs[${i}].type 必须是 string / number / enum`);
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
r.type === 'enum' &&
|
|
232
|
+
(!Array.isArray(r.values) || r.values.length === 0)
|
|
233
|
+
) {
|
|
234
|
+
errors.push(`inputs[${i}].values 必填(type=enum 时,非空数组)`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
145
237
|
}
|
|
146
238
|
}
|
|
147
239
|
|
|
148
|
-
|
|
240
|
+
return errors;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 已知变量初始集合:inputs.* 全名。 */
|
|
244
|
+
function makeInitialKnownVars(def: WorkflowDef): Set<string> {
|
|
245
|
+
const known = new Set<string>();
|
|
246
|
+
for (const inp of def.inputs ?? []) {
|
|
247
|
+
if (inp?.name) known.add(`inputs.${inp.name}`);
|
|
248
|
+
}
|
|
249
|
+
return known;
|
|
250
|
+
}
|
|
149
251
|
|
|
150
|
-
|
|
151
|
-
|
|
252
|
+
/**
|
|
253
|
+
* 递归校验 steps,返回 Map<归属节点 id, 错误列表>。
|
|
254
|
+
*
|
|
255
|
+
* 镜像后端 validateSteps:
|
|
256
|
+
* - 同级 id 唯一(fork/then/else/do 各自子作用域内独立判重)
|
|
257
|
+
* - type 枚举、动作字段恰好一个、各控制流必填字段、ADR-05 约束
|
|
258
|
+
* - 递归进 then/else/do/fork
|
|
259
|
+
*
|
|
260
|
+
* 错误归属规则:
|
|
261
|
+
* - then/else/do 子步骤 → 子步骤自己的 id(画布有独立节点)
|
|
262
|
+
* - fork 子步骤 → 父 parallel/vote 节点的 id(画布无独立节点)
|
|
263
|
+
*
|
|
264
|
+
* 变量检查(editor 增值,与结构校验冲突时以不误报为先):
|
|
265
|
+
* knownVars 在**同级链**上顺序累积(前序 step 的 capture values 注入),
|
|
266
|
+
* loop 的 `as` 在其 do 子作用域内视为已知。
|
|
267
|
+
*
|
|
268
|
+
* @param ownerId 当前 steps 数组的「归属节点」;非空表示这些 step 无独立画布
|
|
269
|
+
* 节点(fork 场景),错误应归并到 ownerId。空串表示子步骤各归己 id。
|
|
270
|
+
*/
|
|
271
|
+
function collectStepErrors(
|
|
272
|
+
steps: StepDef[],
|
|
273
|
+
inheritedVars: Set<string>,
|
|
274
|
+
ownerId: string,
|
|
275
|
+
): Map<string, string[]> {
|
|
276
|
+
const out = new Map<string, string[]>();
|
|
277
|
+
const push = (id: string, msg: string): void => {
|
|
278
|
+
const key = ownerId || id;
|
|
279
|
+
const list = out.get(key);
|
|
280
|
+
if (list) list.push(msg);
|
|
281
|
+
else out.set(key, [msg]);
|
|
282
|
+
};
|
|
283
|
+
const merge = (child: Map<string, string[]>): void => {
|
|
284
|
+
for (const [id, list] of child) {
|
|
285
|
+
// ownerId 非空时,子步骤错误一并归并到 ownerId
|
|
286
|
+
const key = ownerId || id;
|
|
287
|
+
const existing = out.get(key);
|
|
288
|
+
if (existing) existing.push(...list);
|
|
289
|
+
else out.set(key, [...list]);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// 同级 id 唯一(仅在当前 steps 数组内强制)
|
|
294
|
+
const seenIds = new Set<string>();
|
|
295
|
+
// 同级链上顺序累积的已知变量(capture 注入)
|
|
296
|
+
const knownVars = new Set<string>(inheritedVars);
|
|
152
297
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
298
|
+
steps.forEach((item, i) => {
|
|
299
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
300
|
+
push(ownerId || `<step-${i}>`, `steps[${i}] 必须是对象`);
|
|
301
|
+
return;
|
|
156
302
|
}
|
|
157
|
-
|
|
158
|
-
|
|
303
|
+
const s = item as StepDef & Record<string, unknown>;
|
|
304
|
+
const sid =
|
|
305
|
+
typeof s.id === 'string' && s.id.length > 0 ? s.id : `<step-${i}>`;
|
|
306
|
+
|
|
307
|
+
// id 非空
|
|
308
|
+
if (typeof s.id !== 'string' || s.id.length === 0) {
|
|
309
|
+
push(sid, `Step "${sid}" 缺少 id`);
|
|
159
310
|
} else {
|
|
160
|
-
seenIds.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
311
|
+
if (seenIds.has(s.id)) {
|
|
312
|
+
push(sid, `Step id 重复: "${s.id}"`);
|
|
313
|
+
} else {
|
|
314
|
+
seenIds.add(s.id);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// type 枚举(提前于动作互斥,给更准确的错误)
|
|
319
|
+
if (
|
|
320
|
+
s.type !== undefined &&
|
|
321
|
+
(typeof s.type !== 'string' || !VALID_STEP_TYPES.has(s.type))
|
|
322
|
+
) {
|
|
323
|
+
push(
|
|
324
|
+
sid,
|
|
325
|
+
`Step "${sid}" 的 type "${String(s.type)}" 非法;合法值: ${[
|
|
326
|
+
...VALID_STEP_TYPES,
|
|
327
|
+
].join(' / ')}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 动作字段恰好一个:tool / skill / llm / type
|
|
332
|
+
const actionFields = ['tool', 'skill', 'llm', 'type'] as const;
|
|
333
|
+
const present = actionFields.filter((k) => s[k] !== undefined);
|
|
334
|
+
if (present.length === 0) {
|
|
335
|
+
push(
|
|
336
|
+
sid,
|
|
337
|
+
`Step "${sid}" 必须有以下字段之一: tool / skill / llm / type`,
|
|
172
338
|
);
|
|
339
|
+
} else if (present.length > 1) {
|
|
340
|
+
push(
|
|
341
|
+
sid,
|
|
342
|
+
`Step "${sid}" 同时设置了 ${present.join(', ')};只允许其中一个`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── 控制流字段一致性 ──
|
|
347
|
+
if (s.type === 'branch' && s.condition === undefined) {
|
|
348
|
+
push(sid, `Step "${sid}" type=branch 必须带 condition`);
|
|
349
|
+
}
|
|
350
|
+
if (s.type === 'assert' && s.condition === undefined) {
|
|
351
|
+
push(sid, `Step "${sid}" type=assert 必须带 condition`);
|
|
352
|
+
}
|
|
353
|
+
if (s.type === 'branch') {
|
|
354
|
+
if (s.then !== undefined && !Array.isArray(s.then)) {
|
|
355
|
+
push(sid, `Step "${sid}" 的 then 必须是数组`);
|
|
356
|
+
}
|
|
357
|
+
if (s.else !== undefined && !Array.isArray(s.else)) {
|
|
358
|
+
push(sid, `Step "${sid}" 的 else 必须是数组`);
|
|
359
|
+
}
|
|
360
|
+
// then/else 子步骤:画布有独立节点 → 错误归各自 id(ownerId 传空串)
|
|
361
|
+
if (Array.isArray(s.then)) {
|
|
362
|
+
merge(collectStepErrors(s.then as StepDef[], knownVars, ''));
|
|
363
|
+
}
|
|
364
|
+
if (Array.isArray(s.else)) {
|
|
365
|
+
merge(collectStepErrors(s.else as StepDef[], knownVars, ''));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (s.type === 'loop') {
|
|
369
|
+
if (s.over === undefined) {
|
|
370
|
+
push(sid, `Step "${sid}" type=loop 必须带 over`);
|
|
371
|
+
}
|
|
372
|
+
if (typeof s.as !== 'string' || s.as.length === 0) {
|
|
373
|
+
push(sid, `Step "${sid}" type=loop 必须带 as(非空 string)`);
|
|
374
|
+
}
|
|
375
|
+
if (!Array.isArray(s.do)) {
|
|
376
|
+
push(sid, `Step "${sid}" type=loop 必须带 do 数组`);
|
|
377
|
+
} else {
|
|
378
|
+
// loop 的 as 在 do 子作用域内视为已知变量
|
|
379
|
+
const doVars = new Set<string>(knownVars);
|
|
380
|
+
if (typeof s.as === 'string' && s.as.length > 0) doVars.add(s.as);
|
|
381
|
+
merge(collectStepErrors(s.do as StepDef[], doVars, ''));
|
|
382
|
+
}
|
|
173
383
|
}
|
|
174
384
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
385
|
+
// ── ADR-05:parallel ──
|
|
386
|
+
if (s.type === 'parallel') {
|
|
387
|
+
if (!Array.isArray(s.fork) || s.fork.length === 0) {
|
|
388
|
+
push(
|
|
389
|
+
sid,
|
|
390
|
+
`Step "${sid}" type=parallel 必须有 fork: [...](至少 1 个子步骤)`,
|
|
391
|
+
);
|
|
392
|
+
} else {
|
|
393
|
+
// fork 子步骤:画布无独立节点 → 错误归并到父节点 id(ownerId 传 sid)
|
|
394
|
+
merge(collectStepErrors(s.fork as StepDef[], knownVars, sid));
|
|
395
|
+
}
|
|
396
|
+
} else if (s.fork !== undefined) {
|
|
397
|
+
push(sid, `Step "${sid}" 的 fork 只能用在 type=parallel 节点上`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── ADR-05:vote ──
|
|
401
|
+
if (s.type === 'vote') {
|
|
402
|
+
const vc = s.voters_count;
|
|
403
|
+
const rules = s.rules;
|
|
404
|
+
const input = s.input;
|
|
405
|
+
const bad =
|
|
406
|
+
typeof vc !== 'number' ||
|
|
407
|
+
!Number.isInteger(vc) ||
|
|
408
|
+
vc < 2 ||
|
|
409
|
+
vc > 10 ||
|
|
410
|
+
typeof rules !== 'string' ||
|
|
411
|
+
rules.length === 0 ||
|
|
412
|
+
typeof input !== 'string' ||
|
|
413
|
+
input.length === 0;
|
|
414
|
+
if (bad) {
|
|
415
|
+
push(
|
|
416
|
+
sid,
|
|
417
|
+
`Step "${sid}" type=vote 要求 voters_count (2~10 整数), rules, input 三个字段`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (s.threshold !== undefined) {
|
|
421
|
+
const t = s.threshold;
|
|
422
|
+
if (
|
|
423
|
+
typeof t !== 'number' ||
|
|
424
|
+
!Number.isInteger(t) ||
|
|
425
|
+
t < 1 ||
|
|
426
|
+
(typeof vc === 'number' && t > vc)
|
|
427
|
+
) {
|
|
428
|
+
push(
|
|
429
|
+
sid,
|
|
430
|
+
`Step "${sid}" threshold=${String(
|
|
431
|
+
t,
|
|
432
|
+
)} 超出范围;必须 1 <= threshold <= voters_count`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// 非 vote 节点不应携带 vote-only 字段
|
|
438
|
+
for (const k of ['voters_count', 'rules', 'threshold'] as const) {
|
|
439
|
+
if (s[k] !== undefined) {
|
|
440
|
+
push(sid, `Step "${sid}" 的 ${k} 只能用在 type=vote 节点上`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const isControlFlow =
|
|
446
|
+
typeof s.type === 'string' && CONTROL_FLOW_TYPES.has(s.type);
|
|
447
|
+
|
|
448
|
+
// ── ADR-05:output_schema ──
|
|
449
|
+
if (s.output_schema !== undefined) {
|
|
450
|
+
if (
|
|
451
|
+
typeof s.output_schema !== 'object' ||
|
|
452
|
+
s.output_schema === null ||
|
|
453
|
+
Array.isArray(s.output_schema)
|
|
454
|
+
) {
|
|
455
|
+
push(sid, `Step "${sid}" 的 output_schema 必须是对象`);
|
|
456
|
+
}
|
|
457
|
+
if (isControlFlow) {
|
|
458
|
+
push(
|
|
459
|
+
sid,
|
|
460
|
+
`Step "${sid}" 的 output_schema 不能用在 type=${s.type} 控制流节点上`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (s.skill !== undefined) {
|
|
464
|
+
push(
|
|
465
|
+
sid,
|
|
466
|
+
`Step "${sid}" 的 output_schema 暂不支持 skill 节点(仅 llm 节点生效)`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── ADR-05:context_files ──
|
|
472
|
+
if (s.context_files !== undefined) {
|
|
473
|
+
if (!Array.isArray(s.context_files)) {
|
|
474
|
+
push(sid, `Step "${sid}" 的 context_files 必须是数组`);
|
|
475
|
+
} else {
|
|
476
|
+
(s.context_files as unknown[]).forEach((cf, j) => {
|
|
477
|
+
if (!cf || typeof cf !== 'object' || Array.isArray(cf)) {
|
|
478
|
+
push(
|
|
479
|
+
sid,
|
|
480
|
+
`Step "${sid}" 的 context_files[${j}] 必须是 { path: string, hint?: string } 对象`,
|
|
481
|
+
);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const r = cf as Record<string, unknown>;
|
|
485
|
+
if (typeof r.path !== 'string' || r.path.length === 0) {
|
|
486
|
+
push(
|
|
487
|
+
sid,
|
|
488
|
+
`Step "${sid}" 的 context_files[${j}].path 必填(非空 string)`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (r.hint !== undefined && typeof r.hint !== 'string') {
|
|
492
|
+
push(
|
|
493
|
+
sid,
|
|
494
|
+
`Step "${sid}" 的 context_files[${j}].hint 必须是 string`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (isControlFlow) {
|
|
500
|
+
push(
|
|
501
|
+
sid,
|
|
502
|
+
`Step "${sid}" 的 context_files 不能用在 type=${s.type} 控制流节点上`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (s.skill !== undefined) {
|
|
506
|
+
push(
|
|
507
|
+
sid,
|
|
508
|
+
`Step "${sid}" 的 context_files 暂不支持 skill 节点(仅 llm 节点生效)`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ── ADR-05:allowed_tools ──
|
|
514
|
+
if (s.allowed_tools !== undefined) {
|
|
515
|
+
if (
|
|
516
|
+
!Array.isArray(s.allowed_tools) ||
|
|
517
|
+
!(s.allowed_tools as unknown[]).every(
|
|
518
|
+
(x) => typeof x === 'string' && x.length > 0,
|
|
519
|
+
)
|
|
520
|
+
) {
|
|
521
|
+
push(sid, `Step "${sid}" 的 allowed_tools 必须是非空 string 数组`);
|
|
522
|
+
}
|
|
523
|
+
if (isControlFlow) {
|
|
524
|
+
push(
|
|
525
|
+
sid,
|
|
526
|
+
`Step "${sid}" 的 allowed_tools 不能用在 type=${s.type} 控制流节点上`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (s.skill !== undefined) {
|
|
530
|
+
push(
|
|
531
|
+
sid,
|
|
532
|
+
`Step "${sid}" 的 allowed_tools 暂不支持 skill 节点(仅 llm 节点生效)`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── 变量引用检查(editor 增值;冲突时以不误报为先)──
|
|
538
|
+
for (const ref of collectVarRefs(s)) {
|
|
179
539
|
if (knownVars.has(ref)) continue;
|
|
180
540
|
const head = ref.split('.')[0];
|
|
181
541
|
if (knownVars.has(head)) continue;
|
|
182
|
-
|
|
183
|
-
|
|
542
|
+
push(
|
|
543
|
+
sid,
|
|
544
|
+
`Step "${sid}" 引用了未在 capture 里出现的变量: \${${ref}}`,
|
|
184
545
|
);
|
|
185
546
|
}
|
|
186
547
|
|
|
187
|
-
// 把本 step 的 capture values
|
|
188
|
-
if (
|
|
189
|
-
for (const v of Object.values(
|
|
190
|
-
if (typeof v === 'string' && v.trim())
|
|
191
|
-
knownVars.add(v);
|
|
192
|
-
}
|
|
548
|
+
// 把本 step 的 capture values 注入同级已知集合,供后续 step 引用
|
|
549
|
+
if (s.capture && typeof s.capture === 'object') {
|
|
550
|
+
for (const v of Object.values(s.capture)) {
|
|
551
|
+
if (typeof v === 'string' && v.trim()) knownVars.add(v);
|
|
193
552
|
}
|
|
194
553
|
}
|
|
195
|
-
}
|
|
554
|
+
});
|
|
196
555
|
|
|
197
|
-
return
|
|
556
|
+
return out;
|
|
198
557
|
}
|
|
199
558
|
|
|
200
559
|
/** 递归遍历 step 所有字符串字段,收集 `${...}` 内的变量名。 */
|
|
@@ -216,9 +575,12 @@ function collectVarRefs(step: StepDef): string[] {
|
|
|
216
575
|
}
|
|
217
576
|
};
|
|
218
577
|
|
|
219
|
-
//
|
|
578
|
+
// 不扫:id(自己)/ capture(value 是变量定义而非引用)/
|
|
579
|
+
// then/else/do/fork(子步骤有独立作用域,由 collectStepErrors 递归各自检查,
|
|
580
|
+
// 否则子作用域才有的变量——如 loop 的 as、子步骤前序 capture——会在父级误报)
|
|
581
|
+
const SKIP_KEYS = new Set(['id', 'capture', 'then', 'else', 'do', 'fork']);
|
|
220
582
|
for (const [k, v] of Object.entries(step)) {
|
|
221
|
-
if (k
|
|
583
|
+
if (SKIP_KEYS.has(k)) continue;
|
|
222
584
|
visit(v);
|
|
223
585
|
}
|
|
224
586
|
return refs;
|