@iloveagents/foundry-web-graph 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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/adapters/index.d.ts +1 -0
  4. package/dist/adapters/index.js +1 -0
  5. package/dist/adapters/neo4j.d.ts +52 -0
  6. package/dist/adapters/neo4j.js +149 -0
  7. package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
  8. package/dist/assistant-ui/graph-client-tools.js +261 -0
  9. package/dist/assistant-ui/graph-context.d.ts +38 -0
  10. package/dist/assistant-ui/graph-context.js +113 -0
  11. package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
  12. package/dist/assistant-ui/graph-panel-content.js +28 -0
  13. package/dist/assistant-ui/graph-panel.d.ts +11 -0
  14. package/dist/assistant-ui/graph-panel.js +121 -0
  15. package/dist/assistant-ui/graph-provenance.d.ts +17 -0
  16. package/dist/assistant-ui/graph-provenance.js +31 -0
  17. package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
  18. package/dist/assistant-ui/graph-result-seeder.js +157 -0
  19. package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
  20. package/dist/assistant-ui/graph-tool-registry.js +15 -0
  21. package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
  22. package/dist/assistant-ui/graph-tool-ui.js +77 -0
  23. package/dist/assistant-ui/index.d.ts +17 -0
  24. package/dist/assistant-ui/index.js +17 -0
  25. package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
  26. package/dist/assistant-ui/merge-into-panel.js +72 -0
  27. package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
  28. package/dist/assistant-ui/register-graph-panel.js +18 -0
  29. package/dist/caption-placement.d.ts +119 -0
  30. package/dist/caption-placement.js +146 -0
  31. package/dist/graph-canvas-paint.d.ts +90 -0
  32. package/dist/graph-canvas-paint.js +186 -0
  33. package/dist/graph-canvas.d.ts +49 -0
  34. package/dist/graph-canvas.js +533 -0
  35. package/dist/graph-inspector.d.ts +42 -0
  36. package/dist/graph-inspector.js +106 -0
  37. package/dist/graph-legend.d.ts +19 -0
  38. package/dist/graph-legend.js +20 -0
  39. package/dist/graph-notice.d.ts +19 -0
  40. package/dist/graph-notice.js +30 -0
  41. package/dist/graph-table.d.ts +28 -0
  42. package/dist/graph-table.js +57 -0
  43. package/dist/graph-toolbar.d.ts +22 -0
  44. package/dist/graph-toolbar.js +8 -0
  45. package/dist/graph-tooltip.d.ts +4 -0
  46. package/dist/graph-tooltip.js +55 -0
  47. package/dist/graph-view.d.ts +94 -0
  48. package/dist/graph-view.js +338 -0
  49. package/dist/graph-workspace.d.ts +55 -0
  50. package/dist/graph-workspace.js +100 -0
  51. package/dist/index.d.ts +21 -0
  52. package/dist/index.js +21 -0
  53. package/dist/model.d.ts +206 -0
  54. package/dist/model.js +369 -0
  55. package/dist/styles.css +97 -0
  56. package/dist/theme.d.ts +36 -0
  57. package/dist/theme.js +83 -0
  58. package/dist/use-element-size.d.ts +13 -0
  59. package/dist/use-element-size.js +32 -0
  60. package/dist/use-graph-model.d.ts +101 -0
  61. package/dist/use-graph-model.js +164 -0
  62. package/dist/use-graph-styling.d.ts +55 -0
  63. package/dist/use-graph-styling.js +156 -0
  64. package/dist/use-graph-theme.d.ts +11 -0
  65. package/dist/use-graph-theme.js +54 -0
  66. package/dist/use-graph-view-state.d.ts +42 -0
  67. package/dist/use-graph-view-state.js +145 -0
  68. package/package.json +83 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iLoveAgents, a brand of Leitwolf GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # @iloveagents/foundry-web-graph
2
+
3
+ Interactive graph visualization for [Foundry UI](https://github.com/iLoveAgents/foundry-ui).
4
+
5
+ A force-directed graph view with the interactions people expect from a graph
6
+ explorer — colour and caption by type, a filtering legend, hover, select,
7
+ expand, search, pin, zoom-to-fit — plus an optional assistant-ui tool card and
8
+ side-panel renderer so an agent can put a graph in front of the user.
9
+
10
+ **Source-neutral by construction.** The root export takes a plain payload and
11
+ knows nothing about where the data came from. Neo4j is one adapter, in its own
12
+ entry point; the chat integration is in another. Import only what you use.
13
+
14
+ ```bash
15
+ pnpm add @iloveagents/foundry-web-graph
16
+ ```
17
+
18
+ ### Tailwind v4 hosts: point Tailwind at this package
19
+
20
+ `styles.css` carries the `--graph-1..8` palette and the rules Tailwind cannot
21
+ express — it is **not** the component styling. `GraphView` is built from
22
+ utility classes, and Tailwind v4 does not scan `node_modules`, so without a
23
+ `@source` directive the graph renders with no spacing, borders or typography:
24
+
25
+ ```css
26
+ @import "tailwindcss";
27
+ @source "../node_modules/@iloveagents/foundry-web-graph/dist";
28
+
29
+ @import "@iloveagents/foundry-web-graph/styles.css";
30
+ ```
31
+
32
+ (Adjust the relative path to your stylesheet\'s location. Apps scaffolded by
33
+ `create-foundry-ui-app --preset neo4j` already have this.)
34
+
35
+ ## Drawing a graph
36
+
37
+ ```tsx
38
+ import { GraphView } from "@iloveagents/foundry-web-graph";
39
+ import "@iloveagents/foundry-web-graph/styles.css";
40
+
41
+ const data = {
42
+ kind: "foundry.graph",
43
+ version: 1,
44
+ nodes: [
45
+ { id: "1", labels: ["Person"], properties: { name: "Ada" } },
46
+ { id: "2", labels: ["Project"], properties: { name: "Analytical Engine" } },
47
+ ],
48
+ edges: [{ id: "e1", source: "1", target: "2", type: "WORKED_ON" }],
49
+ } as const;
50
+
51
+ <GraphView data={data} height={480} onExpandNode={(node) => loadNeighbours(node.id)} />;
52
+ ```
53
+
54
+ `onExpandNode` is a callback: the view never issues a query of its own. Feed a
55
+ larger payload back through `data` and the layout keeps the positions of nodes
56
+ that were already there.
57
+
58
+ Colours come from your app's design tokens, so light and dark mode work without
59
+ configuration. The stylesheet is optional — without it the graph falls back to
60
+ the host's `--chart-*` tokens, and then to a built-in palette.
61
+
62
+ ## In a chat
63
+
64
+ ```tsx
65
+ import { defineChatModule } from "@iloveagents/foundry-web-shell";
66
+ import {
67
+ makeGraphToolUI,
68
+ registerGraphPanelRenderer,
69
+ } from "@iloveagents/foundry-web-graph/assistant-ui";
70
+
71
+ const CypherToolUI = makeGraphToolUI({ toolName: "graph_read_cypher" });
72
+
73
+ export const graphModule = defineChatModule({
74
+ name: "graph",
75
+ useInit: registerGraphPanelRenderer,
76
+ toolUIs: <CypherToolUI />,
77
+ });
78
+ ```
79
+
80
+ `makeGraphToolUI` is a factory because assistant-ui binds a literal tool name at
81
+ construction — so the card works for any tool that returns the payload, whatever
82
+ it is called. A result that is not graph-shaped degrades to an ordinary tool
83
+ card rather than throwing.
84
+
85
+ `registerGraphPanelRenderer()` teaches the tool panel to render
86
+ `type: "graph"` content, which is what the card's **Open in panel** action
87
+ produces. It is idempotent.
88
+
89
+ ## Letting the agent work in the graph
90
+
91
+ The panel can be a workspace the agent and the user share rather than a picture
92
+ the agent hands over. `registerGraphClientTools()` adds four browser-executed
93
+ tools:
94
+
95
+ | Tool | What the agent can do |
96
+ | ------------------- | -------------------------------------------------------------------------------------------------------------- |
97
+ | `graph_get_view` | Read what is on screen: counts, types, the selected node, the active search |
98
+ | `graph_show` | Put a new graph in the panel — positions of nodes already there are kept, so a refined query reads as a change |
99
+ | `graph_select_node` | Select and centre a node, by id or by caption |
100
+ | `graph_search` | Highlight matches and dim the rest |
101
+
102
+ ```tsx
103
+ import {
104
+ registerGraphClientTools,
105
+ registerGraphPanelRenderer,
106
+ } from "@iloveagents/foundry-web-graph/assistant-ui";
107
+
108
+ export const graphModule = defineChatModule({
109
+ name: "graph",
110
+ useInit: () => {
111
+ registerGraphPanelRenderer();
112
+ registerGraphClientTools();
113
+ },
114
+ toolUIs: <CypherToolUI />,
115
+ });
116
+ ```
117
+
118
+ The user's own clicks land in the same state, so "tell me about this node"
119
+ works without either side restating which node. Read `graphWorkspaceStore`
120
+ directly if you want to build your own UI on top of it.
121
+
122
+ ## From Neo4j
123
+
124
+ ```ts
125
+ import { graphFromNeo4j } from "@iloveagents/foundry-web-graph/adapters/neo4j";
126
+
127
+ const payload = graphFromNeo4j(records);
128
+ ```
129
+
130
+ Structurally typed — it does not import `neo4j-driver`, so a visualization
131
+ package never drags a database client into your bundle. It walks arbitrary
132
+ result rows and collects anything node- or relationship-shaped, so `RETURN *`
133
+ works as well as a careful projection.
134
+
135
+ Most apps will not need this: a backend that emits the payload directly is the
136
+ better arrangement. See the `neo4j` starter — `npm create foundry-ui-app my-app -- --preset neo4j`.
137
+
138
+ ## The payload
139
+
140
+ ```ts
141
+ interface GraphPayload {
142
+ kind: "foundry.graph";
143
+ version: 1;
144
+ nodes: {
145
+ id: string;
146
+ labels?: string[];
147
+ caption?: string;
148
+ properties?: Record<string, unknown>;
149
+ }[];
150
+ edges: {
151
+ id: string;
152
+ source: string;
153
+ target: string;
154
+ type?: string;
155
+ properties?: Record<string, unknown>;
156
+ }[];
157
+ truncated?: boolean;
158
+ stats?: { nodeCount?: number; edgeCount?: number };
159
+ legend?: Record<string, { color?: string; captionKey?: string }>;
160
+ }
161
+ ```
162
+
163
+ `kind` and `version` are what let a tool card look at an arbitrary result and
164
+ decide whether to render it. `isGraphPayload` checks both strictly;
165
+ `coerceGraphPayload` is the tolerant version that also accepts a JSON string or
166
+ a `{ graph: … }` envelope.
167
+
168
+ A payload must never carry a top-level `error` key — the AG-UI runner treats
169
+ that as a failed tool call. Report partial results with `truncated`. The type
170
+ declares `error?: never` so this fails at compile time rather than in a demo.
171
+
172
+ ## Large graphs
173
+
174
+ `GraphView` caps what it draws (1,500 nodes / 3,000 relationships by default,
175
+ via `limits`). When a payload overflows it keeps the **connected core** — seeded
176
+ at the best-connected node and expanded through its neighbours — rather than an
177
+ arbitrary prefix, and reports what was withheld. Taking the first N instead
178
+ produces a screen of unconnected dots, which reads as "there is nothing here"
179
+ rather than "there is too much here".
180
+
181
+ The canvas is not readable by assistive technology, so the same graph is always
182
+ available as a table (`GraphTable`, or `forceTable` on `GraphView`).
183
+
184
+ ## Peer dependencies
185
+
186
+ `react`, `react-dom` and `lucide-react` are required. `@assistant-ui/react` is
187
+ optional and only needed for the `/assistant-ui` entry point.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1 @@
1
+ export { graphFromNeo4j, isNeo4jNode, isNeo4jRelationship, toGraphEdge, toGraphNode, type Neo4jNodeLike, type Neo4jRelationshipLike, } from "./neo4j.js";
@@ -0,0 +1 @@
1
+ export { graphFromNeo4j, isNeo4jNode, isNeo4jRelationship, toGraphEdge, toGraphNode, } from "./neo4j.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Neo4j → {@link GraphPayload}.
3
+ *
4
+ * The ONLY module in this package that knows Neo4j exists. Everything under
5
+ * `src/` outside `adapters/` stays store-agnostic; `src/__tests__/no-store-leak.test.ts`
6
+ * enforces that, so the visualizer keeps working for callers who have never
7
+ * heard of Cypher.
8
+ *
9
+ * This mirrors the server-side mapping the `neo4j-aura` starter preset does in
10
+ * `agent/app/graph_payload.py`. Most apps will not need it — their backend
11
+ * already emits the payload. It exists for the ones that query Neo4j straight
12
+ * from the browser (an HTTP Query API call, a fixture, a test).
13
+ */
14
+ import { type GraphEdge, type GraphNode, type GraphPayload } from "../model.js";
15
+ /**
16
+ * Structural shape of a driver node. Deliberately NOT the driver's own type —
17
+ * importing `neo4j-driver` for a type would put a store client in the
18
+ * dependency tree of a visualization package. Anything with these fields
19
+ * works, including the plain JSON the HTTP Query API returns.
20
+ */
21
+ export interface Neo4jNodeLike {
22
+ elementId?: string;
23
+ identity?: unknown;
24
+ labels?: string[];
25
+ properties?: Record<string, unknown>;
26
+ }
27
+ export interface Neo4jRelationshipLike {
28
+ elementId?: string;
29
+ identity?: unknown;
30
+ type?: string;
31
+ start?: unknown;
32
+ end?: unknown;
33
+ startNodeElementId?: string;
34
+ endNodeElementId?: string;
35
+ properties?: Record<string, unknown>;
36
+ }
37
+ export declare const isNeo4jNode: (value: unknown) => value is Neo4jNodeLike;
38
+ export declare const isNeo4jRelationship: (value: unknown) => value is Neo4jRelationshipLike;
39
+ export declare function toGraphNode(node: Neo4jNodeLike): GraphNode | null;
40
+ export declare function toGraphEdge(rel: Neo4jRelationshipLike): GraphEdge | null;
41
+ /**
42
+ * Walk arbitrary Cypher result rows and collect every node and relationship,
43
+ * however deeply nested.
44
+ *
45
+ * A row is whatever the query returned: bare entities, a path object, a list
46
+ * from `collect()`, a map from a projection. Rather than enumerate those
47
+ * shapes, this recurses and picks out anything that looks like an entity —
48
+ * which is what makes `RETURN *` work as well as a careful projection.
49
+ *
50
+ * Duplicates collapse by id: the same node returned on ten rows is one node.
51
+ */
52
+ export declare function graphFromNeo4j(rows: unknown): GraphPayload;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Neo4j → {@link GraphPayload}.
3
+ *
4
+ * The ONLY module in this package that knows Neo4j exists. Everything under
5
+ * `src/` outside `adapters/` stays store-agnostic; `src/__tests__/no-store-leak.test.ts`
6
+ * enforces that, so the visualizer keeps working for callers who have never
7
+ * heard of Cypher.
8
+ *
9
+ * This mirrors the server-side mapping the `neo4j-aura` starter preset does in
10
+ * `agent/app/graph_payload.py`. Most apps will not need it — their backend
11
+ * already emits the payload. It exists for the ones that query Neo4j straight
12
+ * from the browser (an HTTP Query API call, a fixture, a test).
13
+ */
14
+ import { GRAPH_PAYLOAD_KIND, GRAPH_PAYLOAD_VERSION, } from "../model.js";
15
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
16
+ /**
17
+ * Normalize an id. The driver hands back `elementId` (a string) on modern
18
+ * versions and `identity` (an Integer object, or a bigint) on older ones;
19
+ * JSON round-trips turn the latter into `{low, high}`. All of them have to
20
+ * collapse to the same string, or edges stop matching their nodes.
21
+ */
22
+ function toId(value) {
23
+ if (typeof value === "string" && value.length > 0)
24
+ return value;
25
+ if (typeof value === "bigint")
26
+ return String(value);
27
+ // A `number` only when it can still BE the id. Past 2**53-1 a double has
28
+ // already lost the low bits — by the time this runs the rounding has
29
+ // happened, so `String(value)` faithfully stringifies the wrong number and
30
+ // two distinct ids collapse to one key. Same consequence as the `low`-only
31
+ // bug below: it does not fail, it points edges at the wrong nodes. A caller
32
+ // holding larger ids has exact forms available — a string, a bigint, an
33
+ // `elementId`, or `{low, high}`.
34
+ if (typeof value === "number")
35
+ return Number.isSafeInteger(value) ? String(value) : null;
36
+ if (isRecord(value)) {
37
+ // An elementId is exact; prefer it whenever the record carries one.
38
+ if (typeof value.elementId === "string" && value.elementId.length > 0) {
39
+ return value.elementId;
40
+ }
41
+ if (typeof value.low === "number") {
42
+ // A Neo4j Integer is a 64-bit value split across two 32-bit words, and
43
+ // `low` alone is only the bottom half. Dropping `high` makes every id
44
+ // past 2^32 collide with one below it — and a collision here does not
45
+ // fail, it silently points edges at the wrong nodes. BigInt, because
46
+ // the whole point is values a double cannot hold exactly.
47
+ const low = BigInt(value.low >>> 0);
48
+ const high = typeof value.high === "number" ? BigInt(value.high) : 0n;
49
+ return String((high << 32n) + low);
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ export const isNeo4jNode = (value) => isRecord(value) &&
55
+ Array.isArray(value.labels) &&
56
+ toId(value.elementId ?? value.identity) !== null;
57
+ export const isNeo4jRelationship = (value) => isRecord(value) &&
58
+ typeof value.type === "string" &&
59
+ toId(value.elementId ?? value.identity) !== null &&
60
+ toId(value.startNodeElementId ?? value.start) !== null &&
61
+ toId(value.endNodeElementId ?? value.end) !== null;
62
+ export function toGraphNode(node) {
63
+ const id = toId(node.elementId ?? node.identity);
64
+ if (id === null)
65
+ return null;
66
+ return {
67
+ id,
68
+ labels: node.labels ?? [],
69
+ properties: node.properties ?? {},
70
+ };
71
+ }
72
+ export function toGraphEdge(rel) {
73
+ const id = toId(rel.elementId ?? rel.identity);
74
+ const source = toId(rel.startNodeElementId ?? rel.start);
75
+ const target = toId(rel.endNodeElementId ?? rel.end);
76
+ if (id === null || source === null || target === null)
77
+ return null;
78
+ return {
79
+ id,
80
+ source,
81
+ target,
82
+ type: rel.type,
83
+ properties: rel.properties ?? {},
84
+ };
85
+ }
86
+ /**
87
+ * Walk arbitrary Cypher result rows and collect every node and relationship,
88
+ * however deeply nested.
89
+ *
90
+ * A row is whatever the query returned: bare entities, a path object, a list
91
+ * from `collect()`, a map from a projection. Rather than enumerate those
92
+ * shapes, this recurses and picks out anything that looks like an entity —
93
+ * which is what makes `RETURN *` work as well as a careful projection.
94
+ *
95
+ * Duplicates collapse by id: the same node returned on ten rows is one node.
96
+ */
97
+ export function graphFromNeo4j(rows) {
98
+ const nodes = new Map();
99
+ const edges = new Map();
100
+ const seen = new Set();
101
+ const visit = (value, depth) => {
102
+ if (value === null || value === undefined || depth > 12)
103
+ return;
104
+ if (Array.isArray(value)) {
105
+ for (const item of value)
106
+ visit(item, depth + 1);
107
+ return;
108
+ }
109
+ if (!isRecord(value))
110
+ return;
111
+ // Cycle guard: paths reference their own segments' nodes.
112
+ if (seen.has(value))
113
+ return;
114
+ seen.add(value);
115
+ if (isNeo4jNode(value)) {
116
+ const node = toGraphNode(value);
117
+ if (node)
118
+ nodes.set(node.id, node);
119
+ return;
120
+ }
121
+ if (isNeo4jRelationship(value)) {
122
+ const edge = toGraphEdge(value);
123
+ if (edge) {
124
+ edges.set(edge.id, edge);
125
+ // `MATCH ()-[r]->() RETURN r` is an ordinary query, and the driver
126
+ // gives back relationships with no node objects at all. Without a
127
+ // placeholder for each end, every edge dangles, `pruneDanglingEdges`
128
+ // drops the lot, and a perfectly good result renders as an empty
129
+ // graph. `setdefault` semantics: a real node visited later — in the
130
+ // same result or a merge — overwrites the stub and brings its labels
131
+ // and properties with it.
132
+ if (!nodes.has(edge.source))
133
+ nodes.set(edge.source, { id: edge.source });
134
+ if (!nodes.has(edge.target))
135
+ nodes.set(edge.target, { id: edge.target });
136
+ }
137
+ return;
138
+ }
139
+ for (const nested of Object.values(value))
140
+ visit(nested, depth + 1);
141
+ };
142
+ visit(rows, 0);
143
+ return {
144
+ kind: GRAPH_PAYLOAD_KIND,
145
+ version: GRAPH_PAYLOAD_VERSION,
146
+ nodes: [...nodes.values()],
147
+ edges: [...edges.values()],
148
+ };
149
+ }
@@ -0,0 +1,11 @@
1
+ import { type ClientToolEntry } from "@iloveagents/foundry-agent";
2
+ export declare const GRAPH_CLIENT_TOOLS: ClientToolEntry[];
3
+ /**
4
+ * Register the graph client tools globally.
5
+ *
6
+ * Global rather than page-scoped: the panel is available from every route, so
7
+ * scoping them to one page would make the agent's ability to drive the graph
8
+ * depend on where the user happens to be standing. Re-registering by the same
9
+ * name replaces the entry, so this is safe to call from a `useInit`.
10
+ */
11
+ export declare function registerGraphClientTools(): void;