@mindlogic-ai/logician-ui 4.0.0-alpha.36 → 4.0.0-alpha.38
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/USAGE.md +17 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.js +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.js.map +1 -1
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs.map +1 -1
- package/dist/components/Toast/Toast.d.ts +7 -3
- package/dist/components/Toast/Toast.d.ts.map +1 -1
- package/dist/components/Toast/Toast.js +7 -3
- package/dist/components/Toast/Toast.js.map +1 -1
- package/dist/components/Toast/Toast.mjs +8 -4
- package/dist/components/Toast/Toast.mjs.map +1 -1
- package/dist/components/Toast/Toast.styles.d.ts +8 -0
- package/dist/components/Toast/Toast.styles.d.ts.map +1 -1
- package/dist/components/Toast/Toast.styles.js +59 -0
- package/dist/components/Toast/Toast.styles.js.map +1 -1
- package/dist/components/Toast/Toast.styles.mjs +57 -1
- package/dist/components/Toast/Toast.styles.mjs.map +1 -1
- package/dist/components/Toast/Toast.types.d.ts +25 -1
- package/dist/components/Toast/Toast.types.d.ts.map +1 -1
- package/dist/components/Toast/Toaster.d.ts.map +1 -1
- package/dist/components/Toast/Toaster.js +9 -1
- package/dist/components/Toast/Toaster.js.map +1 -1
- package/dist/components/Toast/Toaster.mjs +9 -1
- package/dist/components/Toast/Toaster.mjs.map +1 -1
- package/dist/components/Toast/useToast.d.ts +1 -1
- package/dist/components/Toast/useToast.d.ts.map +1 -1
- package/dist/components/Toast/useToast.js +9 -1
- package/dist/components/Toast/useToast.js.map +1 -1
- package/dist/components/Toast/useToast.mjs +10 -2
- package/dist/components/Toast/useToast.mjs.map +1 -1
- package/dist/components/Workflow/Workflow.d.ts +1 -1
- package/dist/components/Workflow/Workflow.d.ts.map +1 -1
- package/dist/components/Workflow/Workflow.js +2 -2
- package/dist/components/Workflow/Workflow.js.map +1 -1
- package/dist/components/Workflow/Workflow.mjs +2 -2
- package/dist/components/Workflow/Workflow.mjs.map +1 -1
- package/dist/components/Workflow/Workflow.types.d.ts +9 -0
- package/dist/components/Workflow/Workflow.types.d.ts.map +1 -1
- package/dist/components/Workflow/Workflow.types.js.map +1 -1
- package/dist/components/Workflow/Workflow.types.mjs.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts +6 -2
- package/dist/components/Workflow/canvas/Canvas/Canvas.d.ts.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.js +29 -23
- package/dist/components/Workflow/canvas/Canvas/Canvas.js.map +1 -1
- package/dist/components/Workflow/canvas/Canvas/Canvas.mjs +31 -25
- package/dist/components/Workflow/canvas/Canvas/Canvas.mjs.map +1 -1
- package/dist/components/Workflow/createNode.d.ts +25 -1
- package/dist/components/Workflow/createNode.d.ts.map +1 -1
- package/dist/components/Workflow/createNode.js +39 -1
- package/dist/components/Workflow/createNode.js.map +1 -1
- package/dist/components/Workflow/createNode.mjs +38 -2
- package/dist/components/Workflow/createNode.mjs.map +1 -1
- package/dist/components/Workflow/index.d.ts +2 -0
- package/dist/components/Workflow/index.d.ts.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.d.ts +10 -0
- package/dist/components/Workflow/layout/autoLayout.d.ts.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.js +4 -0
- package/dist/components/Workflow/layout/autoLayout.js.map +1 -1
- package/dist/components/Workflow/layout/autoLayout.mjs +1 -1
- package/dist/components/Workflow/layout/autoLayout.mjs.map +1 -1
- package/dist/components/Workflow/placement.d.ts +65 -0
- package/dist/components/Workflow/placement.d.ts.map +1 -0
- package/dist/components/Workflow/placement.js +100 -0
- package/dist/components/Workflow/placement.js.map +1 -0
- package/dist/components/Workflow/placement.mjs +96 -0
- package/dist/components/Workflow/placement.mjs.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.d.ts +54 -0
- package/dist/components/Workflow/useWorkflowActions.d.ts.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.js +105 -0
- package/dist/components/Workflow/useWorkflowActions.js.map +1 -0
- package/dist/components/Workflow/useWorkflowActions.mjs +103 -0
- package/dist/components/Workflow/useWorkflowActions.mjs.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/Toast/Toast.action.test.tsx +135 -0
- package/src/components/Toast/Toast.stories.tsx +111 -0
- package/src/components/Toast/Toast.styles.ts +60 -0
- package/src/components/Toast/Toast.tsx +16 -3
- package/src/components/Toast/Toast.types.ts +26 -1
- package/src/components/Toast/Toaster.tsx +11 -0
- package/src/components/Toast/useToast.tsx +17 -2
- package/src/components/Workflow/README.md +64 -0
- package/src/components/Workflow/Workflow.tsx +2 -0
- package/src/components/Workflow/Workflow.types.ts +10 -0
- package/src/components/Workflow/canvas/Canvas/Canvas.tsx +57 -53
- package/src/components/Workflow/createNode.test.ts +40 -2
- package/src/components/Workflow/createNode.ts +70 -2
- package/src/components/Workflow/index.ts +8 -0
- package/src/components/Workflow/layout/autoLayout.ts +4 -4
- package/src/components/Workflow/placement.test.ts +156 -0
- package/src/components/Workflow/placement.ts +172 -0
- package/src/components/Workflow/stories/Workflow.stories.tsx +79 -0
- package/src/components/Workflow/useWorkflowActions.test.tsx +345 -0
- package/src/components/Workflow/useWorkflowActions.ts +196 -0
- package/src/guidelines/FeedbackActions.mdx +133 -0
|
@@ -182,6 +182,69 @@ to keep the built-in drawer but also highlight the selection elsewhere.
|
|
|
182
182
|
|
|
183
183
|
---
|
|
184
184
|
|
|
185
|
+
## Adding nodes from your own UI (`useWorkflowActions`)
|
|
186
|
+
|
|
187
|
+
The palette is drag-and-drop. For anything else — a tap-to-add sheet on touch
|
|
188
|
+
devices, a "+" beside a node, a command menu — call `useWorkflowActions()` from a
|
|
189
|
+
component rendered inside `<Workflow>` (its `children` or
|
|
190
|
+
`renderSelectedNodeToolbar`). It uses the same node builder as a palette drop
|
|
191
|
+
(`{kind}_{N}` id; `defaultConfig` → `localizeDefaults` → `hostDefaults`), so you
|
|
192
|
+
never rebuild ids, configs or placement, and you never import `@xyflow/react`.
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
import {
|
|
196
|
+
useWorkflow,
|
|
197
|
+
useWorkflowActions,
|
|
198
|
+
Workflow,
|
|
199
|
+
} from '@mindlogic-ai/logician-ui';
|
|
200
|
+
|
|
201
|
+
function AddAgentButton() {
|
|
202
|
+
const { editor } = useWorkflow();
|
|
203
|
+
const { addNode, getAddAfterSource } = useWorkflowActions();
|
|
204
|
+
const after = getAddAfterSource(editor.selectedNodeId);
|
|
205
|
+
return (
|
|
206
|
+
<button
|
|
207
|
+
onClick={() =>
|
|
208
|
+
addNode(
|
|
209
|
+
'agent',
|
|
210
|
+
after ? { after: after.nodeId } : { at: 'viewportCenter' }
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
{after ? `Add after ${after.label}` : 'Add at centre'}
|
|
215
|
+
</button>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
<Workflow
|
|
220
|
+
nodeTypes={NODE_TYPES}
|
|
221
|
+
renderSelectedNodeToolbar={(node) => <PlusAfter nodeId={node.id} />}
|
|
222
|
+
>
|
|
223
|
+
<AddAgentButton />
|
|
224
|
+
</Workflow>;
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- `addNode(kind, target, { inspector? })` → `{ nodeId, edgeId | null } | null`.
|
|
228
|
+
- `{ after: nodeId }` places the node to that node's right and wires it from the
|
|
229
|
+
first free exit. No edge when the new kind has no entry (Note) or a
|
|
230
|
+
`canConnect` hook / connection rule rejects it. A node with no free exit (End,
|
|
231
|
+
Note, every exit taken) falls back to the viewport centre.
|
|
232
|
+
- `{ at: 'viewportCenter' }` places it unconnected in the middle of the view.
|
|
233
|
+
- Either way the node moves down past anything already there, becomes the
|
|
234
|
+
selection, and the view pans to it. Node + edge are **one undo step**.
|
|
235
|
+
- `inspector`: `'follow'` (default — an open inspector moves to the new node),
|
|
236
|
+
`'open'`, or `'close'`.
|
|
237
|
+
- Returns `null` without changing anything when read-only, the kind is not
|
|
238
|
+
registered, or its id is taken (a second `start`).
|
|
239
|
+
- `getAddAfterSource(nodeId)` → `{ nodeId, sourceHandle, label } | null` — the
|
|
240
|
+
exit an add-after would use; `null` means the add would land at the centre.
|
|
241
|
+
`canAddAfter(nodeId)` is the boolean form.
|
|
242
|
+
- `renderSelectedNodeToolbar(node)` (a `<Workflow>` prop) renders your content
|
|
243
|
+
beside the selected node's exit side, in screen space (constant size at any
|
|
244
|
+
zoom). Return `null` to show nothing.
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
185
248
|
## Controlled vs uncontrolled graph
|
|
186
249
|
|
|
187
250
|
- **Controlled:** pass `graph` and `onGraphChange`. You own the source of truth.
|
|
@@ -335,6 +398,7 @@ surfaces.
|
|
|
335
398
|
| `onArrange` | `(g: Graph) => void` | After one-click auto-arrange. |
|
|
336
399
|
| `onHistoryNavigate` | `(g: Graph) => void` | After undo/redo. |
|
|
337
400
|
| `children` | `ReactNode` | Canvas overlay slot (mount `<NodeInspector>` here). |
|
|
401
|
+
| `renderSelectedNodeToolbar` | `(node) => ReactNode` | Content beside the selected node (e.g. a "+"). |
|
|
338
402
|
| `footer` | `ReactNode` | Slot below the canvas. |
|
|
339
403
|
| `categoryTokens` | `CategoryTokenMap` | Override category → color tokens. |
|
|
340
404
|
| `hostBridge` | `unknown` | Opaque host data for inspectors. |
|
|
@@ -36,6 +36,7 @@ export function Workflow({
|
|
|
36
36
|
onEdgeClick,
|
|
37
37
|
onSelectionChange,
|
|
38
38
|
onIssuesChange,
|
|
39
|
+
renderSelectedNodeToolbar,
|
|
39
40
|
footer,
|
|
40
41
|
children,
|
|
41
42
|
categoryTokens,
|
|
@@ -169,6 +170,7 @@ export function Workflow({
|
|
|
169
170
|
showPalette={showPalette && !readOnly}
|
|
170
171
|
onNodeClick={onNodeClick}
|
|
171
172
|
onEdgeClick={onEdgeClick}
|
|
173
|
+
renderSelectedNodeToolbar={renderSelectedNodeToolbar}
|
|
172
174
|
>
|
|
173
175
|
{children}
|
|
174
176
|
</Canvas>
|
|
@@ -443,6 +443,16 @@ export type WorkflowProps = {
|
|
|
443
443
|
*/
|
|
444
444
|
children?: ReactNode;
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Content rendered beside the selected node, on its exit (right) side — e.g.
|
|
448
|
+
* a "+" that adds a node after it via `useWorkflowActions().addNode`. Called
|
|
449
|
+
* with the selected node whenever one is selected; return `null` to show
|
|
450
|
+
* nothing. The library positions it in screen space (it keeps its size at
|
|
451
|
+
* any zoom) and renders it inside the editor, so the content may call
|
|
452
|
+
* `useWorkflow` / `useWorkflowActions`. Hosts never touch React Flow.
|
|
453
|
+
*/
|
|
454
|
+
renderSelectedNodeToolbar?: (node: GraphNode) => ReactNode;
|
|
455
|
+
|
|
446
456
|
/**
|
|
447
457
|
* Notified whenever the issues array identity changes. Mostly a Storybook
|
|
448
458
|
* convenience now that validation is owned entirely by the backend — prod
|
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
IsValidConnection,
|
|
22
22
|
MiniMap,
|
|
23
23
|
NodeChange,
|
|
24
|
+
NodeToolbar,
|
|
24
25
|
NodeTypes,
|
|
26
|
+
Position as HandleSide,
|
|
25
27
|
ReactFlow,
|
|
26
28
|
ReactFlowProvider,
|
|
27
29
|
useReactFlow,
|
|
@@ -34,7 +36,11 @@ import {
|
|
|
34
36
|
isValidConnection as isValidWorkflowConnection,
|
|
35
37
|
soleEdgeToHandle,
|
|
36
38
|
} from '../../connectionRules';
|
|
37
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
createNodeFromType,
|
|
41
|
+
genId,
|
|
42
|
+
toDefaultsTranslate,
|
|
43
|
+
} from '../../createNode';
|
|
38
44
|
import {
|
|
39
45
|
autoLayout,
|
|
40
46
|
estimateLabelOverhang,
|
|
@@ -47,7 +53,6 @@ import type {
|
|
|
47
53
|
GraphNode,
|
|
48
54
|
Position,
|
|
49
55
|
} from '../../Workflow.types';
|
|
50
|
-
import { resolveDefaultConfig } from '../../Workflow.types';
|
|
51
56
|
import { useWorkflow, useWorkflowTranslate } from '../../WorkflowContext';
|
|
52
57
|
import type { WorkflowReactFlowNode } from '../GenericNode';
|
|
53
58
|
import { GenericNode } from '../GenericNode';
|
|
@@ -107,17 +112,22 @@ function graphToReactFlow(
|
|
|
107
112
|
};
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
type CanvasProps = {
|
|
116
|
+
showPalette?: boolean;
|
|
117
|
+
onNodeClick?: (node: GraphNode) => void;
|
|
118
|
+
onEdgeClick?: (edge: GraphEdge) => void;
|
|
119
|
+
/** See `WorkflowProps.renderSelectedNodeToolbar`. */
|
|
120
|
+
renderSelectedNodeToolbar?: (node: GraphNode) => ReactNode;
|
|
121
|
+
children?: ReactNode;
|
|
122
|
+
};
|
|
123
|
+
|
|
110
124
|
function CanvasInner({
|
|
111
|
-
showPalette,
|
|
125
|
+
showPalette = true,
|
|
112
126
|
onNodeClick: onNodeClickHost,
|
|
113
127
|
onEdgeClick: onEdgeClickHost,
|
|
128
|
+
renderSelectedNodeToolbar,
|
|
114
129
|
children,
|
|
115
|
-
}: {
|
|
116
|
-
showPalette: boolean;
|
|
117
|
-
onNodeClick?: (node: GraphNode) => void;
|
|
118
|
-
onEdgeClick?: (edge: GraphEdge) => void;
|
|
119
|
-
children?: ReactNode;
|
|
120
|
-
}) {
|
|
130
|
+
}: CanvasProps) {
|
|
121
131
|
const {
|
|
122
132
|
graph,
|
|
123
133
|
dispatch,
|
|
@@ -144,13 +154,6 @@ function CanvasInner({
|
|
|
144
154
|
const [isPaletteOpen, setIsPaletteOpen] = useState(true);
|
|
145
155
|
|
|
146
156
|
const translate = useWorkflowTranslate();
|
|
147
|
-
// Tear off only the string lookup so `localizeDefaults` doesn't depend
|
|
148
|
-
// on the React-fragment interpolation path.
|
|
149
|
-
const t = useCallback(
|
|
150
|
-
(key: string, params?: Record<string, string>) =>
|
|
151
|
-
translate(key, params) as string,
|
|
152
|
-
[translate]
|
|
153
|
-
);
|
|
154
157
|
|
|
155
158
|
useWorkflowKeyboard();
|
|
156
159
|
|
|
@@ -606,32 +609,37 @@ function CanvasInner({
|
|
|
606
609
|
if (!kind) return;
|
|
607
610
|
const def = getNodeType(kind);
|
|
608
611
|
if (!def) return;
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
// it. Absent on a generic host, leaving the static defaults in place.
|
|
618
|
-
const bridged = def.hostDefaults?.(hostBridge) as
|
|
619
|
-
| Record<string, unknown>
|
|
620
|
-
| undefined;
|
|
621
|
-
const node = {
|
|
622
|
-
id: genId(kind),
|
|
623
|
-
kind,
|
|
624
|
-
position,
|
|
625
|
-
config: { ...base, ...overlay, ...bridged },
|
|
626
|
-
};
|
|
612
|
+
// Same builder `useWorkflowActions().addNode` uses, so a drop and a
|
|
613
|
+
// programmatic add get the same `{kind}_{N}` id and starting config.
|
|
614
|
+
const node = createNodeFromType(def, {
|
|
615
|
+
position: screenToFlowPosition({ x: e.clientX, y: e.clientY }),
|
|
616
|
+
nodes: graph.nodes,
|
|
617
|
+
translate: toDefaultsTranslate(translate),
|
|
618
|
+
hostBridge,
|
|
619
|
+
});
|
|
627
620
|
|
|
628
621
|
// Dropped nodes land unconnected — authors wire edges explicitly by
|
|
629
622
|
// dragging from an exit point.
|
|
630
623
|
dispatch({ type: 'addNode', node });
|
|
631
624
|
},
|
|
632
|
-
[
|
|
625
|
+
[
|
|
626
|
+
dispatch,
|
|
627
|
+
getNodeType,
|
|
628
|
+
screenToFlowPosition,
|
|
629
|
+
graph.nodes,
|
|
630
|
+
translate,
|
|
631
|
+
hostBridge,
|
|
632
|
+
]
|
|
633
633
|
);
|
|
634
634
|
|
|
635
|
+
const selectedNode = selectedNodeId
|
|
636
|
+
? graph.nodes.find((n) => n.id === selectedNodeId)
|
|
637
|
+
: undefined;
|
|
638
|
+
const selectedNodeToolbar =
|
|
639
|
+
selectedNode && renderSelectedNodeToolbar
|
|
640
|
+
? renderSelectedNodeToolbar(selectedNode)
|
|
641
|
+
: null;
|
|
642
|
+
|
|
635
643
|
// Does the canvas top-left corner actually hold overlay chrome the zoom
|
|
636
644
|
// controls should tuck under? The palette toggle docks there when the palette
|
|
637
645
|
// is collapsed, and the error banner when the (editable) graph has issues. If
|
|
@@ -716,6 +724,18 @@ function CanvasInner({
|
|
|
716
724
|
proOptions={{ hideAttribution: true }}
|
|
717
725
|
>
|
|
718
726
|
<Background />
|
|
727
|
+
{selectedNode && selectedNodeToolbar ? (
|
|
728
|
+
// Positioned in screen space beside the node's exit side, so the
|
|
729
|
+
// host's content keeps its size at any zoom.
|
|
730
|
+
<NodeToolbar
|
|
731
|
+
nodeId={selectedNode.id}
|
|
732
|
+
isVisible
|
|
733
|
+
position={HandleSide.Right}
|
|
734
|
+
offset={4}
|
|
735
|
+
>
|
|
736
|
+
{selectedNodeToolbar}
|
|
737
|
+
</NodeToolbar>
|
|
738
|
+
) : null}
|
|
719
739
|
<CanvasControls
|
|
720
740
|
readOnly={readOnly}
|
|
721
741
|
onAutoArrange={onAutoArrange}
|
|
@@ -748,26 +768,10 @@ function CanvasInner({
|
|
|
748
768
|
);
|
|
749
769
|
}
|
|
750
770
|
|
|
751
|
-
export function Canvas({
|
|
752
|
-
showPalette = true,
|
|
753
|
-
onNodeClick,
|
|
754
|
-
onEdgeClick,
|
|
755
|
-
children,
|
|
756
|
-
}: {
|
|
757
|
-
showPalette?: boolean;
|
|
758
|
-
onNodeClick?: (node: GraphNode) => void;
|
|
759
|
-
onEdgeClick?: (edge: GraphEdge) => void;
|
|
760
|
-
children?: ReactNode;
|
|
761
|
-
}) {
|
|
771
|
+
export function Canvas(props: CanvasProps) {
|
|
762
772
|
return (
|
|
763
773
|
<ReactFlowProvider>
|
|
764
|
-
<CanvasInner
|
|
765
|
-
showPalette={showPalette}
|
|
766
|
-
onNodeClick={onNodeClick}
|
|
767
|
-
onEdgeClick={onEdgeClick}
|
|
768
|
-
>
|
|
769
|
-
{children}
|
|
770
|
-
</CanvasInner>
|
|
774
|
+
<CanvasInner {...props} />
|
|
771
775
|
</ReactFlowProvider>
|
|
772
776
|
);
|
|
773
777
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { nextNodeId } from './createNode';
|
|
4
|
-
import type { GraphNode } from './Workflow.types';
|
|
3
|
+
import { createNodeFromType, nextNodeId } from './createNode';
|
|
4
|
+
import type { GraphNode, NodeTypeDef } from './Workflow.types';
|
|
5
5
|
|
|
6
6
|
const node = (id: string, kind: string): GraphNode => ({
|
|
7
7
|
id,
|
|
@@ -59,3 +59,41 @@ describe('nextNodeId', () => {
|
|
|
59
59
|
expect(nextNodeId('agent', nodes)).toBe('agent_4');
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
describe('nextNodeId with namespaced kinds', () => {
|
|
64
|
+
it('treats the dot in a kind literally', () => {
|
|
65
|
+
// Unescaped, `math.add` would match `mathXadd_7` and jump to _8.
|
|
66
|
+
const nodes = [
|
|
67
|
+
node('mathXadd_7', 'math.add'),
|
|
68
|
+
node('math.add_2', 'math.add'),
|
|
69
|
+
];
|
|
70
|
+
expect(nextNodeId('math.add', nodes)).toBe('math.add_3');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('createNodeFromType', () => {
|
|
75
|
+
const def = {
|
|
76
|
+
kind: 'agent',
|
|
77
|
+
defaultConfig: () => ({ name: 'Agent', model: 'static', temp: 1 }),
|
|
78
|
+
localizeDefaults: (t: (k: string) => string) => ({ name: t('agent_name') }),
|
|
79
|
+
hostDefaults: (bridge: unknown) => ({
|
|
80
|
+
model: (bridge as { model: string }).model,
|
|
81
|
+
}),
|
|
82
|
+
handles: () => ({ inputs: [], outputs: [] }),
|
|
83
|
+
} as unknown as NodeTypeDef;
|
|
84
|
+
|
|
85
|
+
it('layers defaultConfig, then localizeDefaults, then hostDefaults', () => {
|
|
86
|
+
const created = createNodeFromType(def, {
|
|
87
|
+
position: { x: 1, y: 2 },
|
|
88
|
+
nodes: [node('agent_4', 'agent')],
|
|
89
|
+
translate: (key) => `t:${key}`,
|
|
90
|
+
hostBridge: { model: 'live' },
|
|
91
|
+
});
|
|
92
|
+
expect(created).toEqual({
|
|
93
|
+
id: 'agent_5',
|
|
94
|
+
kind: 'agent',
|
|
95
|
+
position: { x: 1, y: 2 },
|
|
96
|
+
config: { name: 't:agent_name', model: 'live', temp: 1 },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GraphNode,
|
|
3
|
+
NodeTypeDef,
|
|
4
|
+
Position,
|
|
5
|
+
WorkflowTranslate,
|
|
6
|
+
} from './Workflow.types';
|
|
7
|
+
import { resolveDefaultConfig } from './Workflow.types';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Unique id for edges and other internal graph elements that aren't referenced
|
|
@@ -29,7 +35,10 @@ export function nextNodeId(
|
|
|
29
35
|
nodes: ReadonlyArray<GraphNode>
|
|
30
36
|
): string {
|
|
31
37
|
if (kind === 'start') return 'start';
|
|
32
|
-
|
|
38
|
+
// Escape the kind: namespaced kinds (`math.add`) would otherwise read `.` as
|
|
39
|
+
// a wildcard and count unrelated ids toward the counter.
|
|
40
|
+
const escaped = kind.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
const re = new RegExp(`^${escaped}_(\\d+)$`);
|
|
33
42
|
let max = 0;
|
|
34
43
|
for (const n of nodes) {
|
|
35
44
|
const m = re.exec(n.id);
|
|
@@ -65,3 +74,62 @@ export function cloneNode(
|
|
|
65
74
|
config: structuredClone(node.config),
|
|
66
75
|
};
|
|
67
76
|
}
|
|
77
|
+
|
|
78
|
+
/** String-only translator handed to `NodeTypeDef.localizeDefaults`. */
|
|
79
|
+
export type DefaultsTranslate = (
|
|
80
|
+
key: string,
|
|
81
|
+
params?: Record<string, string>
|
|
82
|
+
) => string;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Tear the string lookup off the host translator so `localizeDefaults` doesn't
|
|
86
|
+
* depend on the React-fragment interpolation path.
|
|
87
|
+
*/
|
|
88
|
+
export function toDefaultsTranslate(
|
|
89
|
+
translate: WorkflowTranslate
|
|
90
|
+
): DefaultsTranslate {
|
|
91
|
+
return (key, params) => translate(key, params) as string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a fresh node of `def`'s kind — the ONE place a node is created from a
|
|
96
|
+
* node type, shared by the palette drop and `useWorkflowActions().addNode`, so
|
|
97
|
+
* both paths produce the same id and starting config:
|
|
98
|
+
*
|
|
99
|
+
* - id: `nextNodeId` (`{kind}_{N}`), so authors can type it into `{{...}}`
|
|
100
|
+
* references — the same scheme duplicate/paste use;
|
|
101
|
+
* - config: `defaultConfig`, then `localizeDefaults(translate)`, then
|
|
102
|
+
* `hostDefaults(hostBridge)`, each shallow-merged over the previous.
|
|
103
|
+
*/
|
|
104
|
+
export function createNodeFromType(
|
|
105
|
+
def: NodeTypeDef,
|
|
106
|
+
{
|
|
107
|
+
position,
|
|
108
|
+
nodes,
|
|
109
|
+
translate,
|
|
110
|
+
hostBridge,
|
|
111
|
+
}: {
|
|
112
|
+
position: Position;
|
|
113
|
+
/** Current node list — continues the per-kind id counter. */
|
|
114
|
+
nodes: ReadonlyArray<GraphNode>;
|
|
115
|
+
translate: DefaultsTranslate;
|
|
116
|
+
hostBridge?: unknown;
|
|
117
|
+
}
|
|
118
|
+
): GraphNode {
|
|
119
|
+
const base = resolveDefaultConfig(def) as Record<string, unknown>;
|
|
120
|
+
const overlay = def.localizeDefaults?.(translate) as
|
|
121
|
+
| Record<string, unknown>
|
|
122
|
+
| undefined;
|
|
123
|
+
// Host-data overlay (e.g. a starting LLM picked from the tenant's live model
|
|
124
|
+
// list) — applied last so it wins over any static placeholder in
|
|
125
|
+
// `defaultConfig`. Absent on a generic host, leaving the static defaults.
|
|
126
|
+
const bridged = def.hostDefaults?.(hostBridge) as
|
|
127
|
+
| Record<string, unknown>
|
|
128
|
+
| undefined;
|
|
129
|
+
return {
|
|
130
|
+
id: nextNodeId(def.kind, nodes),
|
|
131
|
+
kind: def.kind,
|
|
132
|
+
position,
|
|
133
|
+
config: { ...base, ...overlay, ...bridged },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -35,6 +35,14 @@ export {
|
|
|
35
35
|
} from './graphObserver';
|
|
36
36
|
export type { GraphAction } from './graphReducer';
|
|
37
37
|
export { emptyGraph, graphReducer } from './graphReducer';
|
|
38
|
+
export type {
|
|
39
|
+
AddAfterSource,
|
|
40
|
+
AddNodeOptions,
|
|
41
|
+
AddNodeResult,
|
|
42
|
+
AddNodeTarget,
|
|
43
|
+
WorkflowActions,
|
|
44
|
+
} from './useWorkflowActions';
|
|
45
|
+
export { useWorkflowActions } from './useWorkflowActions';
|
|
38
46
|
export { Workflow } from './Workflow';
|
|
39
47
|
export type {
|
|
40
48
|
ConnectionCtx,
|
|
@@ -122,11 +122,11 @@ function orderEdgesForDagre(
|
|
|
122
122
|
* height is a typical two-row card. Callers should pass real measured sizes
|
|
123
123
|
* when available so ranks pack tightly.
|
|
124
124
|
*/
|
|
125
|
-
const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
|
|
126
|
-
const FALLBACK_HEIGHT = 76;
|
|
125
|
+
export const FALLBACK_WIDTH = parseInt(CARD_WIDTH, 10);
|
|
126
|
+
export const FALLBACK_HEIGHT = 76;
|
|
127
127
|
|
|
128
|
-
const DEFAULT_RANK_GAP = 80;
|
|
129
|
-
const DEFAULT_NODE_GAP = 40;
|
|
128
|
+
export const DEFAULT_RANK_GAP = 80;
|
|
129
|
+
export const DEFAULT_NODE_GAP = 40;
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* Compute a tidy, hierarchical layout for a workflow graph and return a new
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { findFreeExit, planNodePlacement } from './placement';
|
|
4
|
+
import type { Graph, GraphNode, NodeTypeDef } from './Workflow.types';
|
|
5
|
+
|
|
6
|
+
// Handle shapes of FactChat's start / agent / end / note / classify types.
|
|
7
|
+
const HANDLES: Record<string, NodeTypeDef['handles']> = {
|
|
8
|
+
start: () => ({ inputs: [], outputs: [{ id: 'out' }] }),
|
|
9
|
+
agent: () => ({ inputs: [{ id: 'in' }], outputs: [{ id: 'out' }] }),
|
|
10
|
+
end: () => ({ inputs: [{ id: 'in' }], outputs: [] }),
|
|
11
|
+
note: () => ({ inputs: [], outputs: [] }),
|
|
12
|
+
classify: () => ({
|
|
13
|
+
inputs: [{ id: 'in' }],
|
|
14
|
+
outputs: [{ id: 'cat_a' }, { id: 'cat_b' }],
|
|
15
|
+
}),
|
|
16
|
+
};
|
|
17
|
+
const getNodeType = (kind: string) =>
|
|
18
|
+
HANDLES[kind] ? ({ kind, handles: HANDLES[kind] } as NodeTypeDef) : undefined;
|
|
19
|
+
|
|
20
|
+
const node = (id: string, kind: string, x = 0, y = 0): GraphNode => ({
|
|
21
|
+
id,
|
|
22
|
+
kind,
|
|
23
|
+
position: { x, y },
|
|
24
|
+
config: {},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const plan = (
|
|
28
|
+
graph: Graph,
|
|
29
|
+
kind: string,
|
|
30
|
+
after: string | null,
|
|
31
|
+
types: typeof getNodeType = getNodeType
|
|
32
|
+
) =>
|
|
33
|
+
planNodePlacement({
|
|
34
|
+
graph,
|
|
35
|
+
getNodeType: types,
|
|
36
|
+
node: node(`${kind}_9`, kind),
|
|
37
|
+
target: after ? { after } : { at: 'viewportCenter' },
|
|
38
|
+
viewportCenter: { x: 500, y: 300 },
|
|
39
|
+
edgeId: 'e_new',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('planNodePlacement', () => {
|
|
43
|
+
const start = node('start', 'start');
|
|
44
|
+
|
|
45
|
+
it('places the node right of the source and connects its free exit', () => {
|
|
46
|
+
const result = plan({ nodes: [start], edges: [] }, 'agent', 'start');
|
|
47
|
+
|
|
48
|
+
// 174 (card width) + 80 (rank gap).
|
|
49
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
50
|
+
expect(result.edge).toEqual({
|
|
51
|
+
id: 'e_new',
|
|
52
|
+
source: 'start',
|
|
53
|
+
target: 'agent_9',
|
|
54
|
+
sourceHandle: 'out',
|
|
55
|
+
targetHandle: 'in',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses the measured source width when React Flow has one', () => {
|
|
60
|
+
const result = planNodePlacement({
|
|
61
|
+
graph: { nodes: [start], edges: [] },
|
|
62
|
+
getNodeType,
|
|
63
|
+
node: node('agent_9', 'agent'),
|
|
64
|
+
target: { after: 'start' },
|
|
65
|
+
viewportCenter: { x: 0, y: 0 },
|
|
66
|
+
edgeId: 'e_new',
|
|
67
|
+
getNodeSize: (id) =>
|
|
68
|
+
id === 'start' ? { width: 300, height: 90 } : undefined,
|
|
69
|
+
});
|
|
70
|
+
expect(result.position).toEqual({ x: 380, y: 0 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('moves the node down when the spot to the right is taken', () => {
|
|
74
|
+
const blocker = node('agent_1', 'agent', 254, 0);
|
|
75
|
+
const result = plan({ nodes: [start, blocker], edges: [] }, 'end', 'start');
|
|
76
|
+
|
|
77
|
+
// 0 + 76 (fallback height) + 40 (node gap).
|
|
78
|
+
expect(result.position).toEqual({ x: 254, y: 116 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('centres an unconnected node in the viewport for { at: viewportCenter }', () => {
|
|
82
|
+
const result = plan({ nodes: [start], edges: [] }, 'agent', null);
|
|
83
|
+
|
|
84
|
+
expect(result.position).toEqual({ x: 413, y: 262 });
|
|
85
|
+
expect(result.edge).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('falls back to the viewport centre when the node has no free exit', () => {
|
|
89
|
+
const end = node('end_1', 'end', 400, 0);
|
|
90
|
+
const taken: Graph = {
|
|
91
|
+
nodes: [start, end],
|
|
92
|
+
edges: [{ id: 'e_1', source: 'start', target: 'end_1' }],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// From End (no exits), and from Start whose only exit is taken.
|
|
96
|
+
for (const after of ['end_1', 'start', 'missing']) {
|
|
97
|
+
const result = plan(taken, 'agent', after);
|
|
98
|
+
expect(result.edge).toBeNull();
|
|
99
|
+
expect(result.position).toEqual({ x: 413, y: 262 });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('places a Note right of the source but adds no edge into it', () => {
|
|
104
|
+
const result = plan({ nodes: [start], edges: [] }, 'note', 'start');
|
|
105
|
+
|
|
106
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
107
|
+
expect(result.edge).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('wires the next free exit of a multi-exit node', () => {
|
|
111
|
+
const classify = node('classify_1', 'classify');
|
|
112
|
+
const graph: Graph = {
|
|
113
|
+
nodes: [classify],
|
|
114
|
+
edges: [
|
|
115
|
+
{ id: 'e_1', source: 'classify_1', target: 'x', sourceHandle: 'cat_a' },
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
expect(plan(graph, 'agent', 'classify_1').edge?.sourceHandle).toBe('cat_b');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('respects canConnect on either endpoint', () => {
|
|
122
|
+
const rejectingTarget = (kind: string) => {
|
|
123
|
+
const def = getNodeType(kind);
|
|
124
|
+
return def && kind === 'agent'
|
|
125
|
+
? { ...def, canConnect: () => 'agents cannot follow start' }
|
|
126
|
+
: def;
|
|
127
|
+
};
|
|
128
|
+
const result = plan(
|
|
129
|
+
{ nodes: [start], edges: [] },
|
|
130
|
+
'agent',
|
|
131
|
+
'start',
|
|
132
|
+
rejectingTarget
|
|
133
|
+
);
|
|
134
|
+
// Still placed after the source, just not wired.
|
|
135
|
+
expect(result.position).toEqual({ x: 254, y: 0 });
|
|
136
|
+
expect(result.edge).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('findFreeExit', () => {
|
|
141
|
+
it('treats an edge without sourceHandle as sitting on the first exit', () => {
|
|
142
|
+
const graph: Graph = {
|
|
143
|
+
nodes: [node('classify_1', 'classify')],
|
|
144
|
+
edges: [{ id: 'e_1', source: 'classify_1', target: 'x' }],
|
|
145
|
+
};
|
|
146
|
+
expect(findFreeExit(graph, getNodeType, 'classify_1')?.sourceHandle).toBe(
|
|
147
|
+
'cat_b'
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null for no node id', () => {
|
|
152
|
+
expect(findFreeExit({ nodes: [], edges: [] }, getNodeType, null)).toBe(
|
|
153
|
+
null
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|