minimal-workflow 0.3.0 → 0.4.1
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 -0
- package/src/canvas/nodes/ParallelNode.tsx +23 -0
- package/src/canvas/nodes/VoteNode.tsx +23 -0
- package/src/index.css +8 -0
- package/src/inspector/Inspector.tsx +240 -1
- package/src/palette/ControlFlowSection.tsx +12 -0
- package/src/store/workflowStore.ts +10 -0
- package/src/types.ts +12 -1
package/package.json
CHANGED
|
@@ -21,6 +21,8 @@ import { AssertNode } from './nodes/AssertNode.tsx';
|
|
|
21
21
|
import { PauseNode } from './nodes/PauseNode.tsx';
|
|
22
22
|
import { BranchNode } from './nodes/BranchNode.tsx';
|
|
23
23
|
import { LoopNode } from './nodes/LoopNode.tsx';
|
|
24
|
+
import { ParallelNode } from './nodes/ParallelNode.tsx';
|
|
25
|
+
import { VoteNode } from './nodes/VoteNode.tsx';
|
|
24
26
|
import { SequentialEdge } from './edges/SequentialEdge.tsx';
|
|
25
27
|
import type { StepDef, StepKind } from '../types.ts';
|
|
26
28
|
|
|
@@ -32,6 +34,8 @@ const nodeTypes = {
|
|
|
32
34
|
pause: PauseNode,
|
|
33
35
|
branch: BranchNode,
|
|
34
36
|
loop: LoopNode,
|
|
37
|
+
parallel: ParallelNode,
|
|
38
|
+
vote: VoteNode,
|
|
35
39
|
};
|
|
36
40
|
|
|
37
41
|
const edgeTypes = {
|
|
@@ -175,5 +179,15 @@ function makeStepTemplate(
|
|
|
175
179
|
return { id, type: 'branch', condition: 'false', then: [], else: [] };
|
|
176
180
|
case 'loop':
|
|
177
181
|
return { id, type: 'loop', over: '[]', as: 'item', do: [] };
|
|
182
|
+
case 'parallel':
|
|
183
|
+
return { id, type: 'parallel', fork: [] };
|
|
184
|
+
case 'vote':
|
|
185
|
+
return {
|
|
186
|
+
id,
|
|
187
|
+
type: 'vote',
|
|
188
|
+
voters_count: 3,
|
|
189
|
+
rules: '',
|
|
190
|
+
input: '${all_findings}',
|
|
191
|
+
};
|
|
178
192
|
}
|
|
179
193
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NodeProps } from '@xyflow/react';
|
|
2
|
+
import { BaseNode } from './BaseNode.tsx';
|
|
3
|
+
import type { CanvasNodeData } from '../../types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ADR-05 parallel 节点:fan-out 多分支 step 并行执行。
|
|
7
|
+
* 子步骤(fork[])不暴露为画布 sourceHandle,在 inspector 列表里编辑。
|
|
8
|
+
* 节点只有默认的 single Bottom source handle(后继 step)+ Top target handle。
|
|
9
|
+
*/
|
|
10
|
+
export function ParallelNode(props: NodeProps) {
|
|
11
|
+
const data = props.data as CanvasNodeData;
|
|
12
|
+
const step = data.step;
|
|
13
|
+
const forkCount = step.fork?.length ?? 0;
|
|
14
|
+
return (
|
|
15
|
+
<BaseNode
|
|
16
|
+
{...props}
|
|
17
|
+
data={data}
|
|
18
|
+
variant="parallel"
|
|
19
|
+
title="⫶ parallel"
|
|
20
|
+
body={`fork × ${forkCount}(子步骤在 inspector 编辑)`}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NodeProps } from '@xyflow/react';
|
|
2
|
+
import { BaseNode } from './BaseNode.tsx';
|
|
3
|
+
import type { CanvasNodeData } from '../../types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ADR-05 vote 节点:多 voter LLM 评估同一输入,按 threshold 决断保留。
|
|
7
|
+
* 节点只有默认的 single Bottom source / Top target handle。
|
|
8
|
+
*/
|
|
9
|
+
export function VoteNode(props: NodeProps) {
|
|
10
|
+
const data = props.data as CanvasNodeData;
|
|
11
|
+
const step = data.step;
|
|
12
|
+
const voters = step.voters_count ?? 3;
|
|
13
|
+
const threshold = step.threshold ?? Math.ceil(voters / 2);
|
|
14
|
+
return (
|
|
15
|
+
<BaseNode
|
|
16
|
+
{...props}
|
|
17
|
+
data={data}
|
|
18
|
+
variant="vote"
|
|
19
|
+
title="⚖ vote"
|
|
20
|
+
body={`${voters} voters, threshold=${threshold}`}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
package/src/index.css
CHANGED
|
@@ -288,6 +288,14 @@ select {
|
|
|
288
288
|
.canvas-node.loop {
|
|
289
289
|
border-color: #06b6d4;
|
|
290
290
|
}
|
|
291
|
+
.canvas-node.parallel {
|
|
292
|
+
border-color: #d946ef;
|
|
293
|
+
background: #fdf4ff;
|
|
294
|
+
}
|
|
295
|
+
.canvas-node.vote {
|
|
296
|
+
border-color: #ea580c;
|
|
297
|
+
background: #fff7ed;
|
|
298
|
+
}
|
|
291
299
|
|
|
292
300
|
/* 文件列表 / 工作流列表(叠在 palette 上方) */
|
|
293
301
|
.file-list {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { useWorkflowStore } from '../store/workflowStore.ts';
|
|
2
3
|
import { ToolNodeForm } from './ToolNodeForm.tsx';
|
|
3
4
|
import { LlmNodeForm } from './LlmNodeForm.tsx';
|
|
4
5
|
import { SkillNodeForm } from './SkillNodeForm.tsx';
|
|
5
|
-
import type { StepDef } from '../types.ts';
|
|
6
|
+
import type { StepDef, StepKind } from '../types.ts';
|
|
6
7
|
|
|
7
8
|
export function Inspector() {
|
|
8
9
|
const selectedId = useWorkflowStore((s) => s.selectedNodeId);
|
|
@@ -38,6 +39,8 @@ export function Inspector() {
|
|
|
38
39
|
{kind === 'pause' && <PauseForm step={step} onPatch={onPatch} />}
|
|
39
40
|
{kind === 'branch' && <BranchForm step={step} onPatch={onPatch} />}
|
|
40
41
|
{kind === 'loop' && <LoopForm step={step} onPatch={onPatch} />}
|
|
42
|
+
{kind === 'parallel' && <ParallelForm step={step} onPatch={onPatch} />}
|
|
43
|
+
{kind === 'vote' && <VoteForm step={step} onPatch={onPatch} />}
|
|
41
44
|
|
|
42
45
|
<CaptureField step={step} onPatch={onPatch} />
|
|
43
46
|
|
|
@@ -222,3 +225,239 @@ function LoopForm({ step, onPatch }: FieldProps) {
|
|
|
222
225
|
</>
|
|
223
226
|
);
|
|
224
227
|
}
|
|
228
|
+
|
|
229
|
+
/* ─────────────────────── ADR-05: parallel / vote ─────────────────────── */
|
|
230
|
+
|
|
231
|
+
/** parallel 节点支持的 fork 子 step 类型(不允许 branch/loop/pause/parallel/vote 嵌套)。 */
|
|
232
|
+
const FORK_CHILD_KINDS: StepKind[] = ['tool', 'llm', 'skill', 'assert'];
|
|
233
|
+
|
|
234
|
+
function detectChildKind(child: StepDef): StepKind {
|
|
235
|
+
if (child.type) return child.type;
|
|
236
|
+
if (child.tool) return 'tool';
|
|
237
|
+
if (child.skill) return 'skill';
|
|
238
|
+
if (child.llm) return 'llm';
|
|
239
|
+
return 'assert';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ParallelForm({ step, onPatch }: FieldProps) {
|
|
243
|
+
const fork = step.fork ?? [];
|
|
244
|
+
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
|
245
|
+
|
|
246
|
+
const updateForkAt = (i: number, patch: Partial<StepDef>) => {
|
|
247
|
+
const next = fork.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
|
|
248
|
+
onPatch({ fork: next });
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const removeForkAt = (i: number) => {
|
|
252
|
+
const next = fork.filter((_, idx) => idx !== i);
|
|
253
|
+
onPatch({ fork: next });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const addFork = (kind: StepKind) => {
|
|
257
|
+
const baseId = nextForkId(fork, kind);
|
|
258
|
+
let child: StepDef;
|
|
259
|
+
switch (kind) {
|
|
260
|
+
case 'tool':
|
|
261
|
+
child = { id: baseId, tool: 'Read', args: {} };
|
|
262
|
+
break;
|
|
263
|
+
case 'llm':
|
|
264
|
+
child = { id: baseId, llm: '' };
|
|
265
|
+
break;
|
|
266
|
+
case 'skill':
|
|
267
|
+
child = { id: baseId, skill: 'commit', input: '' };
|
|
268
|
+
break;
|
|
269
|
+
case 'assert':
|
|
270
|
+
child = { id: baseId, type: 'assert', condition: 'true', onFail: '' };
|
|
271
|
+
break;
|
|
272
|
+
default:
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
onPatch({ fork: [...fork, child] });
|
|
276
|
+
setExpanded((m) => ({ ...m, [fork.length]: true }));
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<>
|
|
281
|
+
<div className="field">
|
|
282
|
+
<label>fork(并行子步骤,{fork.length} 个)</label>
|
|
283
|
+
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
284
|
+
所有子步骤并发执行;结果聚合后写回当前 step 的 result。子步骤不参与画布连线。
|
|
285
|
+
</small>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{fork.map((child, i) => {
|
|
289
|
+
const childKind = detectChildKind(child);
|
|
290
|
+
const open = expanded[i] ?? false;
|
|
291
|
+
return (
|
|
292
|
+
<div
|
|
293
|
+
key={i}
|
|
294
|
+
className="field"
|
|
295
|
+
style={{
|
|
296
|
+
border: '1px solid #e5e7eb',
|
|
297
|
+
borderRadius: 4,
|
|
298
|
+
padding: 8,
|
|
299
|
+
marginBottom: 6,
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
<div
|
|
303
|
+
style={{
|
|
304
|
+
display: 'flex',
|
|
305
|
+
gap: 6,
|
|
306
|
+
alignItems: 'center',
|
|
307
|
+
justifyContent: 'space-between',
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
<span
|
|
311
|
+
style={{ cursor: 'pointer', userSelect: 'none', flex: 1 }}
|
|
312
|
+
onClick={() => setExpanded((m) => ({ ...m, [i]: !open }))}
|
|
313
|
+
>
|
|
314
|
+
{open ? '▼' : '▶'}{' '}
|
|
315
|
+
<code style={{ fontSize: 12 }}>{child.id}</code>
|
|
316
|
+
<span style={{ color: '#9ca3af', fontSize: 11 }}>
|
|
317
|
+
{' '}
|
|
318
|
+
· {childKind}
|
|
319
|
+
</span>
|
|
320
|
+
</span>
|
|
321
|
+
<button
|
|
322
|
+
onClick={() => removeForkAt(i)}
|
|
323
|
+
style={{
|
|
324
|
+
fontSize: 11,
|
|
325
|
+
padding: '2px 6px',
|
|
326
|
+
background: '#fef2f2',
|
|
327
|
+
color: '#dc2626',
|
|
328
|
+
border: '1px solid #fca5a5',
|
|
329
|
+
borderRadius: 3,
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
×
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
{open && (
|
|
336
|
+
<div style={{ marginTop: 6 }}>
|
|
337
|
+
<div className="field">
|
|
338
|
+
<label>id</label>
|
|
339
|
+
<input
|
|
340
|
+
value={child.id}
|
|
341
|
+
onChange={(e) => updateForkAt(i, { id: e.target.value })}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
{childKind === 'tool' && (
|
|
345
|
+
<ToolNodeForm
|
|
346
|
+
step={child}
|
|
347
|
+
onPatch={(p) => updateForkAt(i, p)}
|
|
348
|
+
/>
|
|
349
|
+
)}
|
|
350
|
+
{childKind === 'llm' && (
|
|
351
|
+
<LlmNodeForm
|
|
352
|
+
step={child}
|
|
353
|
+
onPatch={(p) => updateForkAt(i, p)}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
{childKind === 'skill' && (
|
|
357
|
+
<SkillNodeForm
|
|
358
|
+
step={child}
|
|
359
|
+
onPatch={(p) => updateForkAt(i, p)}
|
|
360
|
+
/>
|
|
361
|
+
)}
|
|
362
|
+
{childKind === 'assert' && (
|
|
363
|
+
<AssertForm
|
|
364
|
+
step={child}
|
|
365
|
+
onPatch={(p) => updateForkAt(i, p)}
|
|
366
|
+
/>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
})}
|
|
373
|
+
|
|
374
|
+
<div className="field">
|
|
375
|
+
<label>添加 fork</label>
|
|
376
|
+
<div style={{ display: 'flex', gap: 4 }}>
|
|
377
|
+
{FORK_CHILD_KINDS.map((k) => (
|
|
378
|
+
<button
|
|
379
|
+
key={k}
|
|
380
|
+
onClick={() => addFork(k)}
|
|
381
|
+
style={{ fontSize: 11, padding: '2px 8px' }}
|
|
382
|
+
>
|
|
383
|
+
+ {k}
|
|
384
|
+
</button>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
</>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function nextForkId(fork: StepDef[], kind: StepKind): string {
|
|
393
|
+
const taken = new Set(fork.map((s) => s.id));
|
|
394
|
+
let i = 1;
|
|
395
|
+
while (taken.has(`${kind}_${i}`)) i++;
|
|
396
|
+
return `${kind}_${i}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function VoteForm({ step, onPatch }: FieldProps) {
|
|
400
|
+
const voters = step.voters_count ?? 3;
|
|
401
|
+
const defaultThreshold = Math.ceil(voters / 2);
|
|
402
|
+
return (
|
|
403
|
+
<>
|
|
404
|
+
<div className="field">
|
|
405
|
+
<label>voters_count(投票 LLM 数量)</label>
|
|
406
|
+
<input
|
|
407
|
+
type="number"
|
|
408
|
+
min={2}
|
|
409
|
+
max={10}
|
|
410
|
+
value={voters}
|
|
411
|
+
onChange={(e) => {
|
|
412
|
+
const v = Number(e.target.value);
|
|
413
|
+
if (Number.isFinite(v) && v >= 2 && v <= 10) {
|
|
414
|
+
onPatch({ voters_count: v });
|
|
415
|
+
}
|
|
416
|
+
}}
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
<div className="field">
|
|
420
|
+
<label>threshold(通过票数,留空 = 默认)</label>
|
|
421
|
+
<input
|
|
422
|
+
type="number"
|
|
423
|
+
min={1}
|
|
424
|
+
value={step.threshold ?? ''}
|
|
425
|
+
placeholder={`默认 Math.ceil(voters_count/2) = ${defaultThreshold}`}
|
|
426
|
+
onChange={(e) => {
|
|
427
|
+
const v = e.target.value;
|
|
428
|
+
if (v === '') {
|
|
429
|
+
onPatch({ threshold: undefined });
|
|
430
|
+
} else {
|
|
431
|
+
const n = Number(v);
|
|
432
|
+
if (Number.isFinite(n) && n >= 1) onPatch({ threshold: n });
|
|
433
|
+
}
|
|
434
|
+
}}
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
<div className="field">
|
|
438
|
+
<label>rules(评估规则 prompt)</label>
|
|
439
|
+
<textarea
|
|
440
|
+
value={step.rules ?? ''}
|
|
441
|
+
rows={5}
|
|
442
|
+
spellCheck={false}
|
|
443
|
+
placeholder="评估这个 finding 是否值得保留..."
|
|
444
|
+
onChange={(e) => onPatch({ rules: e.target.value })}
|
|
445
|
+
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
446
|
+
/>
|
|
447
|
+
</div>
|
|
448
|
+
<div className="field">
|
|
449
|
+
<label>input(要被投票评估的输入)</label>
|
|
450
|
+
<input
|
|
451
|
+
value={step.input ?? ''}
|
|
452
|
+
placeholder="${all_findings}"
|
|
453
|
+
onChange={(e) =>
|
|
454
|
+
onPatch({ input: e.target.value === '' ? undefined : e.target.value })
|
|
455
|
+
}
|
|
456
|
+
/>
|
|
457
|
+
<small style={{ color: '#6b7280', fontSize: 11 }}>
|
|
458
|
+
支持 ${'{var}'} 模板;通常引用上游 parallel 的聚合 result
|
|
459
|
+
</small>
|
|
460
|
+
</div>
|
|
461
|
+
</>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
@@ -13,6 +13,18 @@ const ITEMS: FlowItem[] = [
|
|
|
13
13
|
{ kind: 'pause', icon: '⏸', label: 'pause', desc: 'HITL 暂停点(非交互模式跳过)' },
|
|
14
14
|
{ kind: 'branch', icon: '🔀', label: 'branch', desc: '二选一分支(then / else)' },
|
|
15
15
|
{ kind: 'loop', icon: '🔁', label: 'loop', desc: '遍历数组,对每项执行子步' },
|
|
16
|
+
{
|
|
17
|
+
kind: 'parallel',
|
|
18
|
+
icon: '⫶',
|
|
19
|
+
label: 'parallel',
|
|
20
|
+
desc: 'fan-out 并行执行多个子步骤(fork 列表在 inspector 编辑)',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
kind: 'vote',
|
|
24
|
+
icon: '⚖',
|
|
25
|
+
label: 'vote',
|
|
26
|
+
desc: '多 voter LLM 评估同一输入,按 threshold 决断',
|
|
27
|
+
},
|
|
16
28
|
];
|
|
17
29
|
|
|
18
30
|
export function ControlFlowSection() {
|
|
@@ -115,6 +115,13 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
|
|
|
115
115
|
|
|
116
116
|
const flatten = (steps: StepDef[]): void => {
|
|
117
117
|
for (const raw of steps) {
|
|
118
|
+
// parallel / vote:fork[] / 内部子步骤**不展平**,整个 step 原样作为
|
|
119
|
+
// 单个 canvas node(子步骤在 inspector 列表内编辑)。
|
|
120
|
+
if (raw.type === 'parallel' || raw.type === 'vote') {
|
|
121
|
+
flat.push({ step: raw, childEdges: [] });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
const { then: thenChildren, else: elseChildren, do: doChildren, ...rest } =
|
|
119
126
|
raw;
|
|
120
127
|
const cleaned: StepDef = { ...rest };
|
|
@@ -517,6 +524,9 @@ function withChildren(
|
|
|
517
524
|
step: StepDef,
|
|
518
525
|
children: { then?: StepDef[]; else?: StepDef[]; do?: StepDef[] } | undefined,
|
|
519
526
|
): StepDef {
|
|
527
|
+
// ADR-05:parallel / vote 不挂 then/else/do 子步骤,原样返回
|
|
528
|
+
// (fork / voters_count 等字段已在 step 上)
|
|
529
|
+
if (step.type === 'parallel' || step.type === 'vote') return step;
|
|
520
530
|
if (!children) return step;
|
|
521
531
|
const out: StepDef = { ...step };
|
|
522
532
|
if (children.then) out.then = children.then;
|
package/src/types.ts
CHANGED
|
@@ -10,7 +10,9 @@ export type StepKind =
|
|
|
10
10
|
| 'assert'
|
|
11
11
|
| 'branch'
|
|
12
12
|
| 'loop'
|
|
13
|
-
| 'pause'
|
|
13
|
+
| 'pause'
|
|
14
|
+
| 'parallel'
|
|
15
|
+
| 'vote';
|
|
14
16
|
|
|
15
17
|
export interface WorkflowInput {
|
|
16
18
|
name: string;
|
|
@@ -39,6 +41,15 @@ export interface StepDef {
|
|
|
39
41
|
onError?: 'halt' | 'continue';
|
|
40
42
|
onFail?: string;
|
|
41
43
|
maxTurns?: number;
|
|
44
|
+
|
|
45
|
+
// ADR-05 字段(parallel / vote / 通用 LLM 增强)
|
|
46
|
+
output_schema?: Record<string, unknown>;
|
|
47
|
+
fork?: StepDef[];
|
|
48
|
+
voters_count?: number;
|
|
49
|
+
rules?: string;
|
|
50
|
+
threshold?: number;
|
|
51
|
+
context_files?: Array<{ path: string; hint?: string }>;
|
|
52
|
+
allowed_tools?: string[];
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export interface WorkflowEdgeMeta {
|