@signalsafe/tree-spec-editor 0.1.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/README.md +45 -0
- package/dist/TreeSpecGraphEditor.d.ts +16 -0
- package/dist/TreeSpecGraphEditor.d.ts.map +1 -0
- package/dist/TreeSpecGraphEditor.js +256 -0
- package/dist/editorHelpers.d.ts +16 -0
- package/dist/editorHelpers.d.ts.map +1 -0
- package/dist/editorHelpers.js +63 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/model.d.ts +23 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +1 -0
- package/dist/treeLayout.d.ts +11 -0
- package/dist/treeLayout.d.ts.map +1 -0
- package/dist/treeLayout.js +67 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# `@signalsafe/tree-spec-editor`
|
|
2
|
+
|
|
3
|
+
Reusable **TreeSpec** graph editor (React Flow): canvas, selection, node/edge editing, and authoring helpers (compile/decompile, lint, layout).
|
|
4
|
+
|
|
5
|
+
**Platform architecture:** see **[docs/development/reusable-packages-architecture.md](../../docs/development/reusable-packages-architecture.md)** (editor flow vs simulator runtime).
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
- Embed graph authoring in admin or content apps without pulling simulator runtime or API layers.
|
|
10
|
+
|
|
11
|
+
## What belongs here
|
|
12
|
+
|
|
13
|
+
- `TreeSpecGraphEditor` (default export), editor model types (`EditorTree`, `GraphSelection`, …), `lintEditorTree`, `autoLayoutTree`, `getNextSpawnPosition`, transition helpers.
|
|
14
|
+
- Pure wire helpers such as `compileTreeSpec`, `decompileTreeSpec`, `isTreeSpecWire`, and `lintTreeSpecWire` come from `@signalsafe/tree-spec`.
|
|
15
|
+
|
|
16
|
+
## What does **not** belong here
|
|
17
|
+
|
|
18
|
+
- Simulator runtime, session stepping, **routing**, **autosave**, **HTTP/API**, or tenant/company-specific UI.
|
|
19
|
+
|
|
20
|
+
## Dependency direction
|
|
21
|
+
|
|
22
|
+
- Depends on **`@signalsafe/tree-spec`** and peer **`react`**, **`react-dom`**, **`reactflow`**.
|
|
23
|
+
- **Does not** depend on `simulator-core` or app packages.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import TreeSpecGraphEditor, { lintEditorTree } from '@signalsafe/tree-spec-editor';
|
|
29
|
+
import { compileTreeSpec } from '@signalsafe/tree-spec';
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Consumer expectations
|
|
33
|
+
|
|
34
|
+
- Install `react`, `react-dom`, and `reactflow` in the consuming app.
|
|
35
|
+
- This package ships a built `dist/` artifact and supports only the package root import.
|
|
36
|
+
- A consumer is expected to provide its own bundler/runtime wiring; this package does not include host routing, autosave, or API transport.
|
|
37
|
+
|
|
38
|
+
## Public API
|
|
39
|
+
|
|
40
|
+
Only the **entry** (`src/index.ts`) is supported for semver — **no** `package.json` subpath `exports` (use the barrel to avoid accidental deep imports).
|
|
41
|
+
|
|
42
|
+
## Publishing notes
|
|
43
|
+
|
|
44
|
+
- `files: ["dist", "README.md"]`.
|
|
45
|
+
- Private-registry rollout uses the same release workflow as `@signalsafe/tree-spec` and `@signalsafe/simulator-core`: `release:check`, `pack:local`, `pack:check`, `smoke:external`, `publish:dry-run`, `release:verify`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import 'reactflow/dist/style.css';
|
|
2
|
+
import { type EditorTree, type GraphSelection, type GraphEditorIssue } from './model';
|
|
3
|
+
export type TreeSpecGraphEditorProps = {
|
|
4
|
+
tree: EditorTree;
|
|
5
|
+
onChange: (next: EditorTree) => void;
|
|
6
|
+
issues?: GraphEditorIssue[];
|
|
7
|
+
showMiniMap?: boolean;
|
|
8
|
+
selected?: GraphSelection;
|
|
9
|
+
onSelect?: (sel: GraphSelection) => void;
|
|
10
|
+
focusNodeId?: string | null;
|
|
11
|
+
fitViewNonce?: number;
|
|
12
|
+
/** Optional class for the outer container (default includes h-70vh border rounded). */
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
export default function TreeSpecGraphEditor(props: TreeSpecGraphEditorProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
//# sourceMappingURL=TreeSpecGraphEditor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TreeSpecGraphEditor.d.ts","sourceRoot":"","sources":["../src/TreeSpecGraphEditor.tsx"],"names":[],"mappings":"AAiBA,OAAO,0BAA0B,CAAC;AAGlC,OAAO,EAGH,KAAK,UAAU,EAEf,KAAK,cAAc,EACnB,KAAK,gBAAgB,EAGxB,MAAM,SAAS,CAAC;AAEjB,MAAM,MAAM,wBAAwB,GAAG;IACnC,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;IACrC,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACzC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AA2XF,MAAM,CAAC,OAAO,UAAU,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,2CAM1E"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import ReactFlow, { Background, Controls, Handle, MiniMap, Position, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, } from 'reactflow';
|
|
4
|
+
import 'reactflow/dist/style.css';
|
|
5
|
+
import { safeUUID } from './editorHelpers';
|
|
6
|
+
import { END_NODE_ID, } from './model';
|
|
7
|
+
function PromptNode({ data }) {
|
|
8
|
+
const n = data.node;
|
|
9
|
+
const choices = n.choices ?? [];
|
|
10
|
+
const hasErrors = data.issuesErrors > 0;
|
|
11
|
+
return (_jsxs("div", { className: `card card-min-width-280 ${hasErrors ? 'border-danger' : data.issuesWarnings > 0 ? 'border-warning' : ''}`, children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-default" }), _jsxs("div", { className: "card-body p-2", children: [_jsxs("div", { className: "d-flex justify-content-between align-items-center", children: [_jsxs("div", { children: [_jsxs("div", { className: "fw-bold font-size-13", children: [data.isStart ? '▶ ' : '', n.type, data.issuesTotal > 0 ? (_jsxs("span", { className: "ms-2", title: `${data.issuesErrors} errors, ${data.issuesWarnings} warnings, ${data.issuesInfo} info`, children: [data.issuesErrors > 0 ? _jsx("span", { className: "badge bg-danger me-1", children: data.issuesErrors }) : null, data.issuesWarnings > 0 ? (_jsx("span", { className: "badge bg-warning text-dark me-1", children: data.issuesWarnings })) : null, data.issuesInfo > 0 ? _jsx("span", { className: "badge bg-info text-dark", children: data.issuesInfo }) : null] })) : null] }), _jsx("div", { className: "font-size-12 opacity-90 text-truncate", children: n.prompt || _jsx("em", { className: "text-muted", children: "(empty prompt)" }) })] }), _jsx("div", { className: "text-muted font-size-11", children: n.id.slice(0, 8) })] }), _jsx("hr", { className: "my-2" }), _jsx("div", { className: "font-size-12", children: choices.length === 0 ? (_jsx("div", { className: "text-muted", children: _jsx("em", { children: "No choices" }) })) : (_jsx("ul", { className: "list-unstyled mb-0", children: choices.map((c) => (_jsxs("li", { className: "d-flex justify-content-between align-items-center position-relative pr-18", children: [_jsx("span", { className: "text-truncate max-w-210", children: c.label }), _jsx("span", { className: "badge bg-light text-dark", children: c.id }), _jsx(Handle, { type: "source", position: Position.Right, id: `choice:${c.id}`, className: "graph-editor-handle", style: {
|
|
12
|
+
position: 'absolute',
|
|
13
|
+
right: -6,
|
|
14
|
+
top: '50%',
|
|
15
|
+
transform: 'translateY(-50%)',
|
|
16
|
+
} })] }, c.id))) })) })] })] }));
|
|
17
|
+
}
|
|
18
|
+
function EndNode() {
|
|
19
|
+
return (_jsxs("div", { className: "card border-danger w-180", children: [_jsx(Handle, { type: "target", position: Position.Left, id: "in", className: "handle-bg-danger" }), _jsxs("div", { className: "card-body p-2 text-center", children: [_jsx("div", { className: "fw-bold text-danger", children: "END" }), _jsx("div", { className: "text-muted font-size-12", children: "Outcome required" })] })] }));
|
|
20
|
+
}
|
|
21
|
+
function choiceIdFromHandle(h) {
|
|
22
|
+
if (!h)
|
|
23
|
+
return '';
|
|
24
|
+
if (h.startsWith('choice:'))
|
|
25
|
+
return h.slice('choice:'.length);
|
|
26
|
+
return h;
|
|
27
|
+
}
|
|
28
|
+
function edgeLabelForTransition(tree, t) {
|
|
29
|
+
const node = tree.nodes[t.fromNodeId];
|
|
30
|
+
const choice = node?.choices?.find((c) => c.id === t.fromChoiceId);
|
|
31
|
+
const base = choice?.label || t.fromChoiceId;
|
|
32
|
+
if (t.toNodeId === END_NODE_ID) {
|
|
33
|
+
const oc = t.outcome ?? 'at_risk';
|
|
34
|
+
return `${base} → END (${oc})`;
|
|
35
|
+
}
|
|
36
|
+
return base;
|
|
37
|
+
}
|
|
38
|
+
function buildEdgesFromTransitions(tree) {
|
|
39
|
+
return tree.transitions.map((t) => ({
|
|
40
|
+
id: t.id,
|
|
41
|
+
source: t.fromNodeId,
|
|
42
|
+
target: t.toNodeId,
|
|
43
|
+
sourceHandle: `choice:${t.fromChoiceId}`,
|
|
44
|
+
label: edgeLabelForTransition(tree, t),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
function buildTransitionsFromEdges(edges, existing) {
|
|
48
|
+
const existingById = new Map(existing.map((t) => [t.id, t]));
|
|
49
|
+
return edges
|
|
50
|
+
.filter((e) => e.source && e.target && e.source !== END_NODE_ID)
|
|
51
|
+
.map((e) => {
|
|
52
|
+
const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
|
|
53
|
+
const prior = existingById.get(String(e.id));
|
|
54
|
+
return {
|
|
55
|
+
id: e.id || safeUUID(),
|
|
56
|
+
fromNodeId: String(e.source),
|
|
57
|
+
fromChoiceId: fromChoiceId,
|
|
58
|
+
toNodeId: String(e.target),
|
|
59
|
+
outcome: String(e.target) === END_NODE_ID ? (prior?.outcome ?? 'at_risk') : undefined,
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
.filter((t) => t.fromChoiceId.length > 0);
|
|
63
|
+
}
|
|
64
|
+
function TreeSpecGraphInner({ tree, onChange, issues = [], selected, onSelect, focusNodeId, showMiniMap = true, fitViewNonce, className = 'h-70vh border rounded', }) {
|
|
65
|
+
const rf = useReactFlow();
|
|
66
|
+
const isDraggingRef = useRef(false);
|
|
67
|
+
const nodesRef = useRef([]);
|
|
68
|
+
const edgesRef = useRef([]);
|
|
69
|
+
const issuesByNode = useMemo(() => {
|
|
70
|
+
const m = new Map();
|
|
71
|
+
for (const i of issues) {
|
|
72
|
+
if (!i.node_id)
|
|
73
|
+
continue;
|
|
74
|
+
const cur = m.get(i.node_id) ?? { total: 0, errors: 0, warnings: 0, info: 0 };
|
|
75
|
+
cur.total += 1;
|
|
76
|
+
const sev = String(i.severity ?? '').toLowerCase();
|
|
77
|
+
if (sev === 'warning')
|
|
78
|
+
cur.warnings += 1;
|
|
79
|
+
else if (sev === 'info')
|
|
80
|
+
cur.info += 1;
|
|
81
|
+
else
|
|
82
|
+
cur.errors += 1;
|
|
83
|
+
m.set(i.node_id, cur);
|
|
84
|
+
}
|
|
85
|
+
return m;
|
|
86
|
+
}, [issues]);
|
|
87
|
+
const issueKeySet = useMemo(() => {
|
|
88
|
+
const s = new Set();
|
|
89
|
+
for (const i of issues) {
|
|
90
|
+
if (!i.node_id || !i.choice_id)
|
|
91
|
+
continue;
|
|
92
|
+
s.add(`${i.node_id}::${i.choice_id}`);
|
|
93
|
+
}
|
|
94
|
+
return s;
|
|
95
|
+
}, [issues]);
|
|
96
|
+
const initialNodes = useMemo(() => {
|
|
97
|
+
const arr = [];
|
|
98
|
+
for (const n of Object.values(tree.nodes)) {
|
|
99
|
+
arr.push({
|
|
100
|
+
id: n.id,
|
|
101
|
+
type: 'promptNode',
|
|
102
|
+
position: n.position ?? { x: 0, y: 0 },
|
|
103
|
+
data: {
|
|
104
|
+
node: n,
|
|
105
|
+
isStart: tree.start_node === n.id,
|
|
106
|
+
issuesTotal: issuesByNode.get(n.id)?.total ?? 0,
|
|
107
|
+
issuesErrors: issuesByNode.get(n.id)?.errors ?? 0,
|
|
108
|
+
issuesWarnings: issuesByNode.get(n.id)?.warnings ?? 0,
|
|
109
|
+
issuesInfo: issuesByNode.get(n.id)?.info ?? 0,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
arr.push({
|
|
114
|
+
id: END_NODE_ID,
|
|
115
|
+
type: 'endNode',
|
|
116
|
+
position: { x: 700, y: 0 },
|
|
117
|
+
data: {},
|
|
118
|
+
selectable: true,
|
|
119
|
+
draggable: true,
|
|
120
|
+
});
|
|
121
|
+
return arr;
|
|
122
|
+
}, [tree.nodes, tree.start_node, issuesByNode]);
|
|
123
|
+
const initialEdges = useMemo(() => buildEdgesFromTransitions(tree), [tree]);
|
|
124
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
125
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
nodesRef.current = nodes;
|
|
128
|
+
}, [nodes]);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
edgesRef.current = edges;
|
|
131
|
+
}, [edges]);
|
|
132
|
+
const lastTreeRef = useRef('');
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const nextKey = JSON.stringify({ tree, issues: issues.length });
|
|
135
|
+
if (nextKey === lastTreeRef.current)
|
|
136
|
+
return;
|
|
137
|
+
lastTreeRef.current = nextKey;
|
|
138
|
+
setNodes(initialNodes);
|
|
139
|
+
setEdges(initialEdges);
|
|
140
|
+
}, [tree, issues.length, initialNodes, initialEdges, setNodes, setEdges]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!focusNodeId)
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
const n = rf.getNode(focusNodeId);
|
|
146
|
+
if (!n)
|
|
147
|
+
return;
|
|
148
|
+
rf.setCenter(n.position.x + 150, n.position.y + 60, { zoom: 1.0, duration: 300 });
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// ignore
|
|
152
|
+
}
|
|
153
|
+
}, [focusNodeId, rf]);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!fitViewNonce)
|
|
156
|
+
return;
|
|
157
|
+
const id = setTimeout(() => {
|
|
158
|
+
try {
|
|
159
|
+
rf.fitView({ padding: 0.2, duration: 300 });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
}, 100);
|
|
165
|
+
return () => clearTimeout(id);
|
|
166
|
+
}, [fitViewNonce, rf]);
|
|
167
|
+
const nodeTypes = useMemo(() => ({
|
|
168
|
+
promptNode: PromptNode,
|
|
169
|
+
endNode: EndNode,
|
|
170
|
+
}), []);
|
|
171
|
+
const commit = useCallback((nextNodes, nextEdges) => {
|
|
172
|
+
if (isDraggingRef.current)
|
|
173
|
+
return;
|
|
174
|
+
const updatedNodes = { ...tree.nodes };
|
|
175
|
+
for (const n of nextNodes) {
|
|
176
|
+
if (n.id === END_NODE_ID)
|
|
177
|
+
continue;
|
|
178
|
+
const existing = updatedNodes[n.id];
|
|
179
|
+
if (!existing)
|
|
180
|
+
continue;
|
|
181
|
+
updatedNodes[n.id] = {
|
|
182
|
+
...existing,
|
|
183
|
+
position: { x: n.position.x, y: n.position.y },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const transitions = buildTransitionsFromEdges(nextEdges, tree.transitions);
|
|
187
|
+
onChange({
|
|
188
|
+
...tree,
|
|
189
|
+
nodes: updatedNodes,
|
|
190
|
+
transitions,
|
|
191
|
+
});
|
|
192
|
+
}, [tree, onChange]);
|
|
193
|
+
const onConnect = useCallback((conn) => {
|
|
194
|
+
const choiceId = choiceIdFromHandle(conn.sourceHandle);
|
|
195
|
+
if (!conn.source || !conn.target || !choiceId)
|
|
196
|
+
return;
|
|
197
|
+
const newEdge = {
|
|
198
|
+
id: safeUUID(),
|
|
199
|
+
source: conn.source,
|
|
200
|
+
target: conn.target,
|
|
201
|
+
sourceHandle: conn.sourceHandle,
|
|
202
|
+
label: choiceId,
|
|
203
|
+
};
|
|
204
|
+
setEdges((eds) => {
|
|
205
|
+
const next = addEdge(newEdge, eds);
|
|
206
|
+
commit(nodesRef.current, next);
|
|
207
|
+
return next;
|
|
208
|
+
});
|
|
209
|
+
}, [setEdges, commit]);
|
|
210
|
+
const onNodesChangeWrapped = useCallback((changes) => {
|
|
211
|
+
onNodesChange(changes);
|
|
212
|
+
const shouldCommit = changes.some((c) => {
|
|
213
|
+
if (c?.type === 'select' || c?.type === 'dimensions')
|
|
214
|
+
return false;
|
|
215
|
+
if (c?.type === 'position' && c?.dragging)
|
|
216
|
+
return false;
|
|
217
|
+
return true;
|
|
218
|
+
});
|
|
219
|
+
if (shouldCommit)
|
|
220
|
+
queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
|
|
221
|
+
}, [onNodesChange, commit]);
|
|
222
|
+
const onEdgesChangeWrapped = useCallback((changes) => {
|
|
223
|
+
onEdgesChange(changes);
|
|
224
|
+
const shouldCommit = changes.some((c) => c?.type !== 'select');
|
|
225
|
+
if (shouldCommit)
|
|
226
|
+
queueMicrotask(() => commit(nodesRef.current, edgesRef.current));
|
|
227
|
+
}, [onEdgesChange, commit]);
|
|
228
|
+
const onNodeDragStart = useCallback(() => {
|
|
229
|
+
isDraggingRef.current = true;
|
|
230
|
+
}, []);
|
|
231
|
+
const onNodeDragStop = useCallback(() => {
|
|
232
|
+
isDraggingRef.current = false;
|
|
233
|
+
commit(nodesRef.current, edgesRef.current);
|
|
234
|
+
}, [commit]);
|
|
235
|
+
const onNodeClick = useCallback((_evt, node) => {
|
|
236
|
+
onSelect?.({ kind: 'node', id: node.id });
|
|
237
|
+
}, [onSelect]);
|
|
238
|
+
const onEdgeClick = useCallback((_evt, edge) => {
|
|
239
|
+
onSelect?.({ kind: 'edge', id: edge.id });
|
|
240
|
+
}, [onSelect]);
|
|
241
|
+
return (_jsx("div", { className: className, children: _jsxs(ReactFlow, { nodes: nodes.map((n) => ({
|
|
242
|
+
...n,
|
|
243
|
+
selected: selected?.kind === 'node' && selected.id === n.id,
|
|
244
|
+
})), edges: edges.map((e) => {
|
|
245
|
+
const fromChoiceId = choiceIdFromHandle(e.sourceHandle);
|
|
246
|
+
const hasIssue = fromChoiceId ? issueKeySet.has(`${e.source}::${fromChoiceId}`) : false;
|
|
247
|
+
return {
|
|
248
|
+
...e,
|
|
249
|
+
selected: selected?.kind === 'edge' && selected.id === e.id,
|
|
250
|
+
style: hasIssue ? { ...(e.style ?? {}), strokeWidth: 2, strokeDasharray: '6 4' } : e.style,
|
|
251
|
+
};
|
|
252
|
+
}), onNodesChange: onNodesChangeWrapped, onEdgesChange: onEdgesChangeWrapped, onConnect: onConnect, onNodeDragStart: onNodeDragStart, onNodeDragStop: onNodeDragStop, onNodeClick: onNodeClick, onEdgeClick: onEdgeClick, nodeTypes: nodeTypes, children: [showMiniMap ? _jsx(MiniMap, {}) : null, _jsx(Controls, {}), _jsx(Background, {})] }) }));
|
|
253
|
+
}
|
|
254
|
+
export default function TreeSpecGraphEditor(props) {
|
|
255
|
+
return (_jsx(ReactFlowProvider, { children: _jsx(TreeSpecGraphInner, { ...props }) }));
|
|
256
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TreeSpecIssue } from '@signalsafe/tree-spec';
|
|
2
|
+
import { type EditorTree, type EditorTransition } from './model';
|
|
3
|
+
/** Common node `type` presets for authoring UIs. */
|
|
4
|
+
export declare const TREE_SPEC_NODE_TYPE_PRESETS: readonly ["prompt", "email", "sms", "call", "web", "attachment"];
|
|
5
|
+
export declare function safeUUID(): string;
|
|
6
|
+
export declare function getTransition(tree: EditorTree, fromNodeId: string, fromChoiceId: string): EditorTransition | undefined;
|
|
7
|
+
export declare function upsertTransition(tree: EditorTree, next: EditorTransition): EditorTree;
|
|
8
|
+
export declare function deleteTransitionsForChoice(tree: EditorTree, fromNodeId: string, fromChoiceId: string): EditorTree;
|
|
9
|
+
/** Extra structural checks on the decompiled editor tree (beyond wire lint). */
|
|
10
|
+
export declare function lintEditorTree(t: EditorTree): TreeSpecIssue[];
|
|
11
|
+
/**
|
|
12
|
+
* Parse backend Pydantic validation messages into actionable issues with node_id/choice_id.
|
|
13
|
+
* Hosts can use this when server returns a generic error string.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parsePydanticOutcomeErrors(msg: string): TreeSpecIssue[] | null;
|
|
16
|
+
//# sourceMappingURL=editorHelpers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"editorHelpers.d.ts","sourceRoot":"","sources":["../src/editorHelpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAe,KAAK,UAAU,EAAE,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAE9E,oDAAoD;AACpD,eAAO,MAAM,2BAA2B,kEAAmE,CAAC;AAE5G,wBAAgB,QAAQ,IAAI,MAAM,CAKjC;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAEtH;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,gBAAgB,GAAG,UAAU,CAMrF;AAED,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,UAAU,CAKjH;AAED,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,CAAC,EAAE,UAAU,GAAG,aAAa,EAAE,CAa7D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,EAAE,GAAG,IAAI,CAiB9E"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { END_NODE_ID } from './model';
|
|
2
|
+
/** Common node `type` presets for authoring UIs. */
|
|
3
|
+
export const TREE_SPEC_NODE_TYPE_PRESETS = ['prompt', 'email', 'sms', 'call', 'web', 'attachment'];
|
|
4
|
+
export function safeUUID() {
|
|
5
|
+
const crypto = globalThis.crypto;
|
|
6
|
+
return crypto && typeof crypto.randomUUID === 'function'
|
|
7
|
+
? crypto.randomUUID()
|
|
8
|
+
: `${Date.now()}-${Math.random()}`;
|
|
9
|
+
}
|
|
10
|
+
export function getTransition(tree, fromNodeId, fromChoiceId) {
|
|
11
|
+
return tree.transitions.find((t) => t.fromNodeId === fromNodeId && t.fromChoiceId === fromChoiceId);
|
|
12
|
+
}
|
|
13
|
+
export function upsertTransition(tree, next) {
|
|
14
|
+
const idx = tree.transitions.findIndex((t) => t.id === next.id);
|
|
15
|
+
const arr = [...tree.transitions];
|
|
16
|
+
if (idx >= 0)
|
|
17
|
+
arr[idx] = next;
|
|
18
|
+
else
|
|
19
|
+
arr.push(next);
|
|
20
|
+
return { ...tree, transitions: arr };
|
|
21
|
+
}
|
|
22
|
+
export function deleteTransitionsForChoice(tree, fromNodeId, fromChoiceId) {
|
|
23
|
+
return {
|
|
24
|
+
...tree,
|
|
25
|
+
transitions: tree.transitions.filter((t) => !(t.fromNodeId === fromNodeId && t.fromChoiceId === fromChoiceId)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Extra structural checks on the decompiled editor tree (beyond wire lint). */
|
|
29
|
+
export function lintEditorTree(t) {
|
|
30
|
+
const issues = [];
|
|
31
|
+
for (const tr of t.transitions) {
|
|
32
|
+
if (tr.toNodeId === END_NODE_ID && !tr.outcome) {
|
|
33
|
+
issues.push({
|
|
34
|
+
severity: 'error',
|
|
35
|
+
message: 'Transition to END is missing outcome (safe / at_risk / compromised).',
|
|
36
|
+
node_id: tr.fromNodeId,
|
|
37
|
+
choice_id: tr.fromChoiceId,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse backend Pydantic validation messages into actionable issues with node_id/choice_id.
|
|
45
|
+
* Hosts can use this when server returns a generic error string.
|
|
46
|
+
*/
|
|
47
|
+
export function parsePydanticOutcomeErrors(msg) {
|
|
48
|
+
const issues = [];
|
|
49
|
+
const re = /input_value=\{'from': \['([^']+)', '([^']+)'\],[^}]*\}/g;
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(msg)) !== null) {
|
|
52
|
+
const node_id = m[1];
|
|
53
|
+
const choice_id = m[2];
|
|
54
|
+
const lower = msg.toLowerCase();
|
|
55
|
+
const message = lower.includes('transition to end must include outcome')
|
|
56
|
+
? 'Transition to END must include outcome (safe / at_risk / compromised).'
|
|
57
|
+
: lower.includes('non-end transition must not include outcome')
|
|
58
|
+
? 'Non-END transition must not include outcome.'
|
|
59
|
+
: 'Validation error';
|
|
60
|
+
issues.push({ severity: 'error', message, node_id, choice_id });
|
|
61
|
+
}
|
|
62
|
+
return issues.length ? issues : null;
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* TreeSpec graph editor (React Flow) and authoring helpers. No API, routing, or simulator runtime.
|
|
4
|
+
*/
|
|
5
|
+
export { default } from './TreeSpecGraphEditor';
|
|
6
|
+
export type { TreeSpecGraphEditorProps } from './TreeSpecGraphEditor';
|
|
7
|
+
export { END_NODE_ID } from './model';
|
|
8
|
+
export type { EditorChoice, EditorNode, EditorTransition, EditorTree, GraphEditorIssue, GraphSelection, ReactFlowEdgeChange, ReactFlowNodeChange, } from './model';
|
|
9
|
+
export { safeUUID, getTransition, upsertTransition, deleteTransitionsForChoice, lintEditorTree, parsePydanticOutcomeErrors, TREE_SPEC_NODE_TYPE_PRESETS, } from './editorHelpers';
|
|
10
|
+
export { autoLayoutTree, getNextSpawnPosition } from './treeLayout';
|
|
11
|
+
export type { TreeSpecIssue, TreeSpecWire } from '@signalsafe/tree-spec';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAEhD,YAAY,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAEtE,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACtC,YAAY,EACR,YAAY,EACZ,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,cAAc,EACd,mBAAmB,EACnB,mBAAmB,GACtB,MAAM,SAAS,CAAC;AAEjB,OAAO,EACH,QAAQ,EACR,aAAa,EACb,gBAAgB,EAChB,0BAA0B,EAC1B,cAAc,EACd,0BAA0B,EAC1B,2BAA2B,GAC9B,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AAEpE,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* TreeSpec graph editor (React Flow) and authoring helpers. No API, routing, or simulator runtime.
|
|
4
|
+
*/
|
|
5
|
+
export { default } from './TreeSpecGraphEditor';
|
|
6
|
+
export { END_NODE_ID } from './model';
|
|
7
|
+
export { safeUUID, getTransition, upsertTransition, deleteTransitionsForChoice, lintEditorTree, parsePydanticOutcomeErrors, TREE_SPEC_NODE_TYPE_PRESETS, } from './editorHelpers';
|
|
8
|
+
export { autoLayoutTree, getNextSpawnPosition } from './treeLayout';
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { END_NODE_ID, type TreeGraphChoice as EditorChoice, type TreeGraphNode as EditorNode, type TreeGraphTransition as EditorTransition, type TreeGraph as EditorTree, } from '@signalsafe/tree-spec';
|
|
2
|
+
/** Selection in the graph editor (node or edge, with id). */
|
|
3
|
+
export type GraphSelection = {
|
|
4
|
+
kind: 'node' | 'edge' | null;
|
|
5
|
+
id: string | null;
|
|
6
|
+
};
|
|
7
|
+
/** ReactFlow nodes change entry (type, dragging). */
|
|
8
|
+
export interface ReactFlowNodeChange {
|
|
9
|
+
type?: string;
|
|
10
|
+
dragging?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** ReactFlow edges change entry (type). */
|
|
13
|
+
export interface ReactFlowEdgeChange {
|
|
14
|
+
type?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Validation/issue entry for graph editor (severity, message, optional node/choice). */
|
|
17
|
+
export interface GraphEditorIssue {
|
|
18
|
+
severity: string;
|
|
19
|
+
message: string;
|
|
20
|
+
node_id?: string;
|
|
21
|
+
choice_id?: string;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=model.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EACX,KAAK,eAAe,IAAI,YAAY,EACpC,KAAK,aAAa,IAAI,UAAU,EAChC,KAAK,mBAAmB,IAAI,gBAAgB,EAC5C,KAAK,SAAS,IAAI,UAAU,GAC/B,MAAM,uBAAuB,CAAC;AAE/B,6DAA6D;AAC7D,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAEjF,qDAAqD;AACrD,MAAM,WAAW,mBAAmB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,2CAA2C;AAC3C,MAAM,WAAW,mBAAmB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,yFAAyF;AACzF,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB"}
|
package/dist/model.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { END_NODE_ID, } from '@signalsafe/tree-spec';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type EditorTree } from './model';
|
|
2
|
+
/** Next default position for a newly added node (simple grid down the canvas). */
|
|
3
|
+
export declare function getNextSpawnPosition(tree: EditorTree): {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Assign node positions in a layered layout from the start node (BFS), then fit-style grid.
|
|
9
|
+
*/
|
|
10
|
+
export declare function autoLayoutTree(tree: EditorTree): EditorTree;
|
|
11
|
+
//# sourceMappingURL=treeLayout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"treeLayout.d.ts","sourceRoot":"","sources":["../src/treeLayout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgC,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAExE,kFAAkF;AAClF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,UAAU,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAG/E;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,UAAU,CA4D3D"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { END_NODE_ID } from './model';
|
|
2
|
+
/** Next default position for a newly added node (simple grid down the canvas). */
|
|
3
|
+
export function getNextSpawnPosition(tree) {
|
|
4
|
+
const count = Object.keys(tree.nodes).length;
|
|
5
|
+
return { x: 80, y: 80 + count * 44 };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Assign node positions in a layered layout from the start node (BFS), then fit-style grid.
|
|
9
|
+
*/
|
|
10
|
+
export function autoLayoutTree(tree) {
|
|
11
|
+
const level = {};
|
|
12
|
+
const byLevel = {};
|
|
13
|
+
const q = [];
|
|
14
|
+
const start = tree.start_node;
|
|
15
|
+
level[start] = 0;
|
|
16
|
+
q.push(start);
|
|
17
|
+
const outgoing = (id) => {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const t of tree.transitions) {
|
|
20
|
+
if (t.fromNodeId !== id)
|
|
21
|
+
continue;
|
|
22
|
+
if (!t.toNodeId || t.toNodeId === END_NODE_ID)
|
|
23
|
+
continue;
|
|
24
|
+
out.push(t.toNodeId);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
};
|
|
28
|
+
while (q.length) {
|
|
29
|
+
const cur = q.shift();
|
|
30
|
+
const curLevel = level[cur] ?? 0;
|
|
31
|
+
for (const nxt of outgoing(cur)) {
|
|
32
|
+
if (level[nxt] !== undefined)
|
|
33
|
+
continue;
|
|
34
|
+
level[nxt] = curLevel + 1;
|
|
35
|
+
q.push(nxt);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const allNodeIds = Object.keys(tree.nodes);
|
|
39
|
+
const maxLevel = Math.max(0, ...Object.values(level));
|
|
40
|
+
for (const id of allNodeIds) {
|
|
41
|
+
if (level[id] === undefined) {
|
|
42
|
+
level[id] = maxLevel + 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const id of allNodeIds) {
|
|
46
|
+
const l = level[id] ?? 0;
|
|
47
|
+
if (!byLevel[l])
|
|
48
|
+
byLevel[l] = [];
|
|
49
|
+
byLevel[l].push(id);
|
|
50
|
+
}
|
|
51
|
+
const spacingX = 420;
|
|
52
|
+
const spacingY = 180;
|
|
53
|
+
const marginX = 60;
|
|
54
|
+
const marginY = 40;
|
|
55
|
+
const nextNodes = { ...tree.nodes };
|
|
56
|
+
for (const [lvlStr, ids] of Object.entries(byLevel)) {
|
|
57
|
+
const lvl = Number(lvlStr);
|
|
58
|
+
ids.sort();
|
|
59
|
+
ids.forEach((id, idx) => {
|
|
60
|
+
const n = nextNodes[id];
|
|
61
|
+
if (!n)
|
|
62
|
+
return;
|
|
63
|
+
n.position = { x: marginX + lvl * spacingX, y: marginY + idx * spacingY };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { ...tree, nodes: nextNodes };
|
|
67
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@signalsafe/tree-spec-editor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Reusable TreeSpec graph editor (React Flow) and authoring helpers (no API or routing)",
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"tree-spec",
|
|
10
|
+
"react-flow",
|
|
11
|
+
"editor",
|
|
12
|
+
"signalsafe"
|
|
13
|
+
],
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "restricted"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.build.json",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"pack:local": "npm pack --pack-destination ../../dist/reusable-npm",
|
|
35
|
+
"release:check": "node ../../scripts/run-private-package-release-checks.mjs --mode prepublish",
|
|
36
|
+
"release:verify": "node ../../scripts/run-private-package-release-checks.mjs --mode postpublish",
|
|
37
|
+
"publish:preflight": "node ../../scripts/check-private-registry-preflight.mjs --scope @signalsafe",
|
|
38
|
+
"publish:dry-run": "npm publish --dry-run",
|
|
39
|
+
"publish:verify": "node ../../scripts/check-private-package-postpublish.mjs --scope @signalsafe --package @signalsafe/tree-spec-editor --version $npm_package_version",
|
|
40
|
+
"pack:check": "node ../../scripts/check-package-tarball.mjs --tgz \"../../dist/reusable-npm/signalsafe-tree-spec-editor-$npm_package_version.tgz\" --expect package/dist/index.js --expect package/dist/index.d.ts --expect package/package.json --expect package/README.md",
|
|
41
|
+
"smoke:external": "node ../../scripts/smoke-tree-spec-editor-external-consumer.mjs",
|
|
42
|
+
"smoke:published": "node ../../scripts/smoke-tree-spec-editor-external-consumer.mjs --source registry",
|
|
43
|
+
"prepublishOnly": "npm run build",
|
|
44
|
+
"prepare": "npm run build",
|
|
45
|
+
"test": "cd ../../frontend && yarn vitest run --config vitest.tree-spec-editor.config.ts"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@signalsafe/tree-spec": "^0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^18.0.0",
|
|
52
|
+
"react-dom": "^18.0.0",
|
|
53
|
+
"reactflow": "^11.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/react": "^18.2.66",
|
|
57
|
+
"@types/react-dom": "^18.2.22",
|
|
58
|
+
"react": "^18.3.1",
|
|
59
|
+
"react-dom": "^18.3.1",
|
|
60
|
+
"reactflow": "^11.11.4",
|
|
61
|
+
"typescript": "^5.4.2"
|
|
62
|
+
}
|
|
63
|
+
}
|