@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.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/neo4j.d.ts +52 -0
- package/dist/adapters/neo4j.js +149 -0
- package/dist/assistant-ui/graph-client-tools.d.ts +11 -0
- package/dist/assistant-ui/graph-client-tools.js +261 -0
- package/dist/assistant-ui/graph-context.d.ts +38 -0
- package/dist/assistant-ui/graph-context.js +113 -0
- package/dist/assistant-ui/graph-panel-content.d.ts +45 -0
- package/dist/assistant-ui/graph-panel-content.js +28 -0
- package/dist/assistant-ui/graph-panel.d.ts +11 -0
- package/dist/assistant-ui/graph-panel.js +121 -0
- package/dist/assistant-ui/graph-provenance.d.ts +17 -0
- package/dist/assistant-ui/graph-provenance.js +31 -0
- package/dist/assistant-ui/graph-result-seeder.d.ts +73 -0
- package/dist/assistant-ui/graph-result-seeder.js +157 -0
- package/dist/assistant-ui/graph-tool-registry.d.ts +25 -0
- package/dist/assistant-ui/graph-tool-registry.js +15 -0
- package/dist/assistant-ui/graph-tool-ui.d.ts +54 -0
- package/dist/assistant-ui/graph-tool-ui.js +77 -0
- package/dist/assistant-ui/index.d.ts +17 -0
- package/dist/assistant-ui/index.js +17 -0
- package/dist/assistant-ui/merge-into-panel.d.ts +21 -0
- package/dist/assistant-ui/merge-into-panel.js +72 -0
- package/dist/assistant-ui/register-graph-panel.d.ts +8 -0
- package/dist/assistant-ui/register-graph-panel.js +18 -0
- package/dist/caption-placement.d.ts +119 -0
- package/dist/caption-placement.js +146 -0
- package/dist/graph-canvas-paint.d.ts +90 -0
- package/dist/graph-canvas-paint.js +186 -0
- package/dist/graph-canvas.d.ts +49 -0
- package/dist/graph-canvas.js +533 -0
- package/dist/graph-inspector.d.ts +42 -0
- package/dist/graph-inspector.js +106 -0
- package/dist/graph-legend.d.ts +19 -0
- package/dist/graph-legend.js +20 -0
- package/dist/graph-notice.d.ts +19 -0
- package/dist/graph-notice.js +30 -0
- package/dist/graph-table.d.ts +28 -0
- package/dist/graph-table.js +57 -0
- package/dist/graph-toolbar.d.ts +22 -0
- package/dist/graph-toolbar.js +8 -0
- package/dist/graph-tooltip.d.ts +4 -0
- package/dist/graph-tooltip.js +55 -0
- package/dist/graph-view.d.ts +94 -0
- package/dist/graph-view.js +338 -0
- package/dist/graph-workspace.d.ts +55 -0
- package/dist/graph-workspace.js +100 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +21 -0
- package/dist/model.d.ts +206 -0
- package/dist/model.js +369 -0
- package/dist/styles.css +97 -0
- package/dist/theme.d.ts +36 -0
- package/dist/theme.js +83 -0
- package/dist/use-element-size.d.ts +13 -0
- package/dist/use-element-size.js +32 -0
- package/dist/use-graph-model.d.ts +101 -0
- package/dist/use-graph-model.js +164 -0
- package/dist/use-graph-styling.d.ts +55 -0
- package/dist/use-graph-styling.js +156 -0
- package/dist/use-graph-theme.d.ts +11 -0
- package/dist/use-graph-theme.js +54 -0
- package/dist/use-graph-view-state.d.ts +42 -0
- package/dist/use-graph-view-state.js +145 -0
- package/package.json +83 -0
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph payload contract.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately source-agnostic: nothing here knows about Neo4j, Cypher, or any
|
|
5
|
+
* other store. A backend tool, a REST response, or a hand-built fixture all
|
|
6
|
+
* produce the same shape, and {@link GraphView} renders it. Store-specific
|
|
7
|
+
* mapping lives in `adapters/` (see `adapters/neo4j.ts`) and is never imported
|
|
8
|
+
* from this module — a unit test enforces that.
|
|
9
|
+
*/
|
|
10
|
+
/** Discriminator carried by every payload, so a tool card can sniff an
|
|
11
|
+
* arbitrary JSON result and decide whether it is renderable as a graph. */
|
|
12
|
+
export declare const GRAPH_PAYLOAD_KIND: "foundry.graph";
|
|
13
|
+
/** Payload format version. Bump only for a breaking shape change. */
|
|
14
|
+
export declare const GRAPH_PAYLOAD_VERSION: 1;
|
|
15
|
+
export interface GraphNode {
|
|
16
|
+
/** Stable identity. Edges reference nodes by this value. */
|
|
17
|
+
id: string;
|
|
18
|
+
/**
|
|
19
|
+
* Type tags. Neo4j labels map straight onto this, but so does a single
|
|
20
|
+
* `["Person"]` from any other source. The FIRST label drives colour and
|
|
21
|
+
* legend grouping.
|
|
22
|
+
*/
|
|
23
|
+
labels?: string[];
|
|
24
|
+
/** Text drawn on the node. When absent, {@link resolveCaption} picks one. */
|
|
25
|
+
caption?: string;
|
|
26
|
+
properties?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export interface GraphEdge {
|
|
29
|
+
id: string;
|
|
30
|
+
/** {@link GraphNode.id} of the edge's origin. */
|
|
31
|
+
source: string;
|
|
32
|
+
/** {@link GraphNode.id} of the edge's destination. */
|
|
33
|
+
target: string;
|
|
34
|
+
/** Relationship type — drawn along the edge and used for legend grouping. */
|
|
35
|
+
type?: string;
|
|
36
|
+
properties?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
export interface GraphPayload {
|
|
39
|
+
kind: typeof GRAPH_PAYLOAD_KIND;
|
|
40
|
+
version: typeof GRAPH_PAYLOAD_VERSION;
|
|
41
|
+
nodes: GraphNode[];
|
|
42
|
+
edges: GraphEdge[];
|
|
43
|
+
/** The producer hit a cap and dropped elements. Surfaced in the UI. */
|
|
44
|
+
truncated?: boolean;
|
|
45
|
+
/** Totals BEFORE truncation, when the producer knows them. */
|
|
46
|
+
stats?: {
|
|
47
|
+
nodeCount?: number;
|
|
48
|
+
edgeCount?: number;
|
|
49
|
+
};
|
|
50
|
+
/** Per-label styling hints from the producer. Overridden by host styling. */
|
|
51
|
+
legend?: Record<string, {
|
|
52
|
+
color?: string;
|
|
53
|
+
captionKey?: string;
|
|
54
|
+
}>;
|
|
55
|
+
/**
|
|
56
|
+
* Producer-supplied context about how this graph was obtained.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately open: every source has something different worth carrying, and
|
|
59
|
+
* a closed type would force each of them to fork the contract. Conventional
|
|
60
|
+
* keys, which the starter presets emit and the UI reads when present:
|
|
61
|
+
*
|
|
62
|
+
* - `query` — the statement that produced this graph, for a panel title
|
|
63
|
+
* - `elapsedMs`, `database` — provenance
|
|
64
|
+
* - `scores` — `{ id, score }[]` from a similarity search
|
|
65
|
+
* - `rows` — scalar results, when the query returned no graph at all
|
|
66
|
+
*
|
|
67
|
+
* Never put user-supplied query PARAMETERS here: this travels back into the
|
|
68
|
+
* conversation and into any transcript store.
|
|
69
|
+
*/
|
|
70
|
+
meta?: Record<string, unknown>;
|
|
71
|
+
/**
|
|
72
|
+
* Never present on a successful payload — declared so TypeScript rejects it
|
|
73
|
+
* at the construction site.
|
|
74
|
+
*
|
|
75
|
+
* The AG-UI runner parses every tool result and marks the whole tool call
|
|
76
|
+
* failed if the parsed object has a top-level `error` key
|
|
77
|
+
* (`onToolCallResultEvent` in `@iloveagents/foundry-agent`). A payload that
|
|
78
|
+
* reported, say, a partial failure under `error` would render as a red failed
|
|
79
|
+
* tool call no matter how much good data it carried. Producers signal trouble
|
|
80
|
+
* with `truncated` and `meta` instead; a genuine failure returns a plain
|
|
81
|
+
* `{ error }` object and no payload at all.
|
|
82
|
+
*/
|
|
83
|
+
error?: never;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Structural check. Strict on the discriminator — an arbitrary object that
|
|
87
|
+
* happens to carry `nodes` and `edges` is NOT a graph payload, because a tool
|
|
88
|
+
* card uses this to decide whether to take over the render.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isGraphPayload(value: unknown): value is GraphPayload;
|
|
91
|
+
/**
|
|
92
|
+
* Best-effort extraction of a payload from a tool result.
|
|
93
|
+
*
|
|
94
|
+
* Tool results reach the UI in several shapes depending on how the agent
|
|
95
|
+
* framework serialized them: the payload itself, a JSON string of it, a
|
|
96
|
+
* double-encoded JSON string (a backend that returned a string, which the
|
|
97
|
+
* AG-UI runner then parsed once), or an envelope with the payload one level
|
|
98
|
+
* down (`{ graph: … }`, `{ result: … }`). All of them are common enough that
|
|
99
|
+
* making each caller handle them would just spread the same bug around.
|
|
100
|
+
*
|
|
101
|
+
* Returns `null` rather than throwing — a non-graph result is an ordinary
|
|
102
|
+
* outcome, not an error.
|
|
103
|
+
*/
|
|
104
|
+
export declare function coerceGraphPayload(value: unknown): GraphPayload | null;
|
|
105
|
+
/** An empty, valid payload — the render path's "nothing yet" value. */
|
|
106
|
+
export declare function emptyGraphPayload(): GraphPayload;
|
|
107
|
+
/** Property keys tried, in order, when a node carries no explicit caption. */
|
|
108
|
+
export declare const DEFAULT_CAPTION_KEYS: readonly ["name", "title", "label", "caption", "displayName", "id"];
|
|
109
|
+
/**
|
|
110
|
+
* Pick the text to draw on a node.
|
|
111
|
+
*
|
|
112
|
+
* `captionKey` (from the payload legend or host styling) wins; then the
|
|
113
|
+
* conventional keys above; then the first short string property; then the id.
|
|
114
|
+
* Never returns an empty string — an unlabelled circle is unreadable.
|
|
115
|
+
*/
|
|
116
|
+
export declare function resolveCaption(node: GraphNode, captionKey?: string): string;
|
|
117
|
+
/** First label, or a stable fallback so untyped nodes still group together. */
|
|
118
|
+
export declare const primaryLabel: (node: GraphNode) => string;
|
|
119
|
+
/**
|
|
120
|
+
* Drop edges whose endpoints are missing, and report how many went.
|
|
121
|
+
*
|
|
122
|
+
* A Cypher result routinely returns relationships whose far end was not in the
|
|
123
|
+
* projection. force-graph throws on an unresolvable link id, so this has to
|
|
124
|
+
* happen before the data reaches the canvas — and silently dropping them
|
|
125
|
+
* without a count makes "why is my graph missing edges?" unanswerable.
|
|
126
|
+
*/
|
|
127
|
+
export declare function pruneDanglingEdges(payload: GraphPayload): {
|
|
128
|
+
nodes: GraphNode[];
|
|
129
|
+
edges: GraphEdge[];
|
|
130
|
+
dropped: number;
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Union two payloads, right-hand side winning on id collisions.
|
|
134
|
+
*
|
|
135
|
+
* What "expand" and "the agent just added a node" both need: the new part has
|
|
136
|
+
* to join the graph on screen, not replace it. Replacing is what makes an
|
|
137
|
+
* explorer feel like a search box — every step throws away the context of the
|
|
138
|
+
* step before, and the position of every node the user had already placed.
|
|
139
|
+
*
|
|
140
|
+
* The right-hand side wins because it is the fresher read of the same thing:
|
|
141
|
+
* a node returned again after a property was set carries the new value.
|
|
142
|
+
*/
|
|
143
|
+
export declare function mergeGraphPayloads(base: GraphPayload, addition: GraphPayload): GraphPayload;
|
|
144
|
+
/**
|
|
145
|
+
* `JSON.stringify` that survives what a graph actually carries.
|
|
146
|
+
*
|
|
147
|
+
* This is the DISPLAY and TRANSPORT form: the panel's `content` string behind
|
|
148
|
+
* the Copy button, the raw-result view on the card, and the tool results the
|
|
149
|
+
* model reads. It is deliberately NOT the form used to decide whether a value
|
|
150
|
+
* changed — see {@link deepEqual}, and the note below for why the two must
|
|
151
|
+
* stay apart.
|
|
152
|
+
*
|
|
153
|
+
* `properties` is `unknown` by design — it is whatever the database had — and
|
|
154
|
+
* the Neo4j driver hands back native `bigint` when configured with
|
|
155
|
+
* `useBigInt`, which `JSON.stringify` THROWS on. So opening the panel for an
|
|
156
|
+
* otherwise perfect payload could fail before anything rendered. Bigints
|
|
157
|
+
* become their decimal string (which is what the inspector already displays),
|
|
158
|
+
* and a cycle becomes `"[circular]"` rather than an exception.
|
|
159
|
+
*
|
|
160
|
+
* That string tag is LOSSY — `10n` and `"10"` come out the same — and that is
|
|
161
|
+
* correct here. A reader wants `10`, and so does the model: a stretch of this
|
|
162
|
+
* function's history tagged the bigint to keep an equality check honest, and
|
|
163
|
+
* the cost was a node property reaching the agent as `{"__bigint":"10"}`
|
|
164
|
+
* instead of a number. Equality is a different job; give it {@link deepEqual}.
|
|
165
|
+
*/
|
|
166
|
+
export declare function safeStringify(value: unknown, space?: number): string;
|
|
167
|
+
/**
|
|
168
|
+
* A structurally identical value that `JSON.stringify` cannot choke on.
|
|
169
|
+
*
|
|
170
|
+
* For anything that will be TRANSPORTED by someone else\'s serializer — the
|
|
171
|
+
* chat context rides along on every turn and is serialized by the HTTP agent,
|
|
172
|
+
* far from here — a raw `properties` object is a hazard: one native `bigint`
|
|
173
|
+
* and the request body throws, which does not degrade, it stops the user
|
|
174
|
+
* sending messages at all while that node is selected.
|
|
175
|
+
*/
|
|
176
|
+
export declare function jsonSafe<T>(value: T): T;
|
|
177
|
+
/**
|
|
178
|
+
* Did this value actually change?
|
|
179
|
+
*
|
|
180
|
+
* Compared structurally, WITHOUT serialising — which is the whole point. This
|
|
181
|
+
* question was answered three times by stringifying with a tag for `bigint`,
|
|
182
|
+
* and each tag collided with the data it competed against: `10n` as `"10"`
|
|
183
|
+
* collides with the string `"10"`, as `"10n"` with the string `"10n"`, and as
|
|
184
|
+
* `{__bigint:"10"}` with a property that happens to be that object. There is
|
|
185
|
+
* no fourth tag that works, because `properties` is `unknown` — any encoding
|
|
186
|
+
* can be imitated by data shaped like the encoding.
|
|
187
|
+
*
|
|
188
|
+
* Comparing types directly has no encoding to imitate: `typeof 10n` is not
|
|
189
|
+
* `typeof "10"`, and the question is settled in one step.
|
|
190
|
+
*
|
|
191
|
+
* It also drops a subtler bug the string form carried. Those replacers marked
|
|
192
|
+
* every visited object in one `WeakSet`, so the SECOND appearance of a shared
|
|
193
|
+
* but perfectly acyclic sub-object (the same `properties` on two nodes)
|
|
194
|
+
* serialised as `"[circular]"` — making values that differ there compare
|
|
195
|
+
* equal. Cycles are tracked as PAIRS here, so a repeat is only a cycle when
|
|
196
|
+
* both sides repeat together.
|
|
197
|
+
*/
|
|
198
|
+
/**
|
|
199
|
+
* `Object.hasOwn`, which this repo\'s ES2020 target does not have.
|
|
200
|
+
*
|
|
201
|
+
* Needed wherever a key comes from the payload: labels and relationship types
|
|
202
|
+
* are whatever the producer called them, and `record["constructor"]` finds a
|
|
203
|
+
* function on the prototype rather than reporting the key as absent.
|
|
204
|
+
*/
|
|
205
|
+
export declare function hasOwn(target: object, key: string): boolean;
|
|
206
|
+
export declare function deepEqual(a: unknown, b: unknown, seen?: Map<object, Set<object>>): boolean;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph payload contract.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately source-agnostic: nothing here knows about Neo4j, Cypher, or any
|
|
5
|
+
* other store. A backend tool, a REST response, or a hand-built fixture all
|
|
6
|
+
* produce the same shape, and {@link GraphView} renders it. Store-specific
|
|
7
|
+
* mapping lives in `adapters/` (see `adapters/neo4j.ts`) and is never imported
|
|
8
|
+
* from this module — a unit test enforces that.
|
|
9
|
+
*/
|
|
10
|
+
/** Discriminator carried by every payload, so a tool card can sniff an
|
|
11
|
+
* arbitrary JSON result and decide whether it is renderable as a graph. */
|
|
12
|
+
export const GRAPH_PAYLOAD_KIND = "foundry.graph";
|
|
13
|
+
/** Payload format version. Bump only for a breaking shape change. */
|
|
14
|
+
export const GRAPH_PAYLOAD_VERSION = 1;
|
|
15
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
const isStringArray = (value) => value === undefined || (Array.isArray(value) && value.every((item) => typeof item === "string"));
|
|
17
|
+
/**
|
|
18
|
+
* A node, checked past its id.
|
|
19
|
+
*
|
|
20
|
+
* The optional fields are checked because this predicate decides whether the
|
|
21
|
+
* graph card TAKES OVER a tool result, and everything downstream then trusts
|
|
22
|
+
* the shape: `labels: [42]` was accepted as a graph and crashed the first
|
|
23
|
+
* search, in `matchNodes`, on `toLowerCase()`. Rejecting it renders the result
|
|
24
|
+
* as an ordinary tool card, which is the right outcome for something that is
|
|
25
|
+
* not a graph.
|
|
26
|
+
*/
|
|
27
|
+
const isNode = (value) => isRecord(value) &&
|
|
28
|
+
typeof value.id === "string" &&
|
|
29
|
+
value.id.length > 0 &&
|
|
30
|
+
isStringArray(value.labels) &&
|
|
31
|
+
(value.caption === undefined || typeof value.caption === "string") &&
|
|
32
|
+
(value.properties === undefined || isRecord(value.properties));
|
|
33
|
+
const isEdge = (value) => isRecord(value) &&
|
|
34
|
+
typeof value.id === "string" &&
|
|
35
|
+
typeof value.source === "string" &&
|
|
36
|
+
typeof value.target === "string" &&
|
|
37
|
+
(value.type === undefined || typeof value.type === "string") &&
|
|
38
|
+
(value.properties === undefined || isRecord(value.properties));
|
|
39
|
+
/**
|
|
40
|
+
* Structural check. Strict on the discriminator — an arbitrary object that
|
|
41
|
+
* happens to carry `nodes` and `edges` is NOT a graph payload, because a tool
|
|
42
|
+
* card uses this to decide whether to take over the render.
|
|
43
|
+
*/
|
|
44
|
+
export function isGraphPayload(value) {
|
|
45
|
+
if (!isRecord(value))
|
|
46
|
+
return false;
|
|
47
|
+
if (value.kind !== GRAPH_PAYLOAD_KIND)
|
|
48
|
+
return false;
|
|
49
|
+
if (value.version !== GRAPH_PAYLOAD_VERSION)
|
|
50
|
+
return false;
|
|
51
|
+
return (Array.isArray(value.nodes) &&
|
|
52
|
+
Array.isArray(value.edges) &&
|
|
53
|
+
value.nodes.every(isNode) &&
|
|
54
|
+
value.edges.every(isEdge) &&
|
|
55
|
+
isLegend(value.legend));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Producer styling hints, checked for the same reason the nodes are: whatever
|
|
59
|
+
* passes here is trusted from then on. `legend: { Person: null }` used to be
|
|
60
|
+
* accepted and then crash `useGraphStyling` on `hint.color`.
|
|
61
|
+
*/
|
|
62
|
+
const isLegend = (value) => value === undefined ||
|
|
63
|
+
(isRecord(value) &&
|
|
64
|
+
Object.values(value).every((hint) => isRecord(hint) &&
|
|
65
|
+
(hint.color === undefined || typeof hint.color === "string") &&
|
|
66
|
+
(hint.captionKey === undefined || typeof hint.captionKey === "string")));
|
|
67
|
+
/**
|
|
68
|
+
* Best-effort extraction of a payload from a tool result.
|
|
69
|
+
*
|
|
70
|
+
* Tool results reach the UI in several shapes depending on how the agent
|
|
71
|
+
* framework serialized them: the payload itself, a JSON string of it, a
|
|
72
|
+
* double-encoded JSON string (a backend that returned a string, which the
|
|
73
|
+
* AG-UI runner then parsed once), or an envelope with the payload one level
|
|
74
|
+
* down (`{ graph: … }`, `{ result: … }`). All of them are common enough that
|
|
75
|
+
* making each caller handle them would just spread the same bug around.
|
|
76
|
+
*
|
|
77
|
+
* Returns `null` rather than throwing — a non-graph result is an ordinary
|
|
78
|
+
* outcome, not an error.
|
|
79
|
+
*/
|
|
80
|
+
export function coerceGraphPayload(value) {
|
|
81
|
+
return coerceAtDepth(value, 0);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bound on how far to chase a payload through strings and envelopes.
|
|
85
|
+
*
|
|
86
|
+
* Tool results are attacker-influenced in any app whose graph holds
|
|
87
|
+
* user-supplied text, so the search has to terminate on adversarial input:
|
|
88
|
+
* a deeply nested `{result:{result:{…}}}`, or a string encoded a thousand
|
|
89
|
+
* times. Six is far more than any real serializer produces.
|
|
90
|
+
*/
|
|
91
|
+
const MAX_COERCE_DEPTH = 6;
|
|
92
|
+
const ENVELOPE_KEYS = ["graph", "result", "data", "payload"];
|
|
93
|
+
function coerceAtDepth(value, depth) {
|
|
94
|
+
if (depth > MAX_COERCE_DEPTH)
|
|
95
|
+
return null;
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
const trimmed = value.trim();
|
|
98
|
+
// Cheap reject before paying for a parse of a long prose result. `{` is a
|
|
99
|
+
// payload; `"` is a payload that was encoded one extra time.
|
|
100
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith('"'))
|
|
101
|
+
return null;
|
|
102
|
+
try {
|
|
103
|
+
return coerceAtDepth(JSON.parse(trimmed), depth + 1);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (isGraphPayload(value))
|
|
110
|
+
return value;
|
|
111
|
+
if (!isRecord(value))
|
|
112
|
+
return null;
|
|
113
|
+
for (const key of ENVELOPE_KEYS) {
|
|
114
|
+
const nested = value[key];
|
|
115
|
+
if (nested === undefined || nested === value)
|
|
116
|
+
continue;
|
|
117
|
+
const found = coerceAtDepth(nested, depth + 1);
|
|
118
|
+
if (found)
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/** An empty, valid payload — the render path's "nothing yet" value. */
|
|
124
|
+
export function emptyGraphPayload() {
|
|
125
|
+
return { kind: GRAPH_PAYLOAD_KIND, version: GRAPH_PAYLOAD_VERSION, nodes: [], edges: [] };
|
|
126
|
+
}
|
|
127
|
+
/** Property keys tried, in order, when a node carries no explicit caption. */
|
|
128
|
+
export const DEFAULT_CAPTION_KEYS = [
|
|
129
|
+
"name",
|
|
130
|
+
"title",
|
|
131
|
+
"label",
|
|
132
|
+
"caption",
|
|
133
|
+
"displayName",
|
|
134
|
+
"id",
|
|
135
|
+
];
|
|
136
|
+
/**
|
|
137
|
+
* Pick the text to draw on a node.
|
|
138
|
+
*
|
|
139
|
+
* `captionKey` (from the payload legend or host styling) wins; then the
|
|
140
|
+
* conventional keys above; then the first short string property; then the id.
|
|
141
|
+
* Never returns an empty string — an unlabelled circle is unreadable.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveCaption(node, captionKey) {
|
|
144
|
+
if (node.caption)
|
|
145
|
+
return node.caption;
|
|
146
|
+
const props = node.properties ?? {};
|
|
147
|
+
if (captionKey) {
|
|
148
|
+
const explicit = props[captionKey];
|
|
149
|
+
if (typeof explicit === "string" && explicit.length > 0)
|
|
150
|
+
return explicit;
|
|
151
|
+
if (typeof explicit === "number")
|
|
152
|
+
return String(explicit);
|
|
153
|
+
}
|
|
154
|
+
for (const key of DEFAULT_CAPTION_KEYS) {
|
|
155
|
+
const candidate = props[key];
|
|
156
|
+
if (typeof candidate === "string" && candidate.length > 0)
|
|
157
|
+
return candidate;
|
|
158
|
+
}
|
|
159
|
+
for (const candidate of Object.values(props)) {
|
|
160
|
+
if (typeof candidate === "string" && candidate.length > 0 && candidate.length <= 60) {
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return node.id;
|
|
165
|
+
}
|
|
166
|
+
/** First label, or a stable fallback so untyped nodes still group together. */
|
|
167
|
+
export const primaryLabel = (node) => node.labels?.[0] ?? "Node";
|
|
168
|
+
/**
|
|
169
|
+
* Drop edges whose endpoints are missing, and report how many went.
|
|
170
|
+
*
|
|
171
|
+
* A Cypher result routinely returns relationships whose far end was not in the
|
|
172
|
+
* projection. force-graph throws on an unresolvable link id, so this has to
|
|
173
|
+
* happen before the data reaches the canvas — and silently dropping them
|
|
174
|
+
* without a count makes "why is my graph missing edges?" unanswerable.
|
|
175
|
+
*/
|
|
176
|
+
export function pruneDanglingEdges(payload) {
|
|
177
|
+
const ids = new Set(payload.nodes.map((n) => n.id));
|
|
178
|
+
const edges = payload.edges.filter((e) => ids.has(e.source) && ids.has(e.target));
|
|
179
|
+
return { nodes: payload.nodes, edges, dropped: payload.edges.length - edges.length };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Union two payloads, right-hand side winning on id collisions.
|
|
183
|
+
*
|
|
184
|
+
* What "expand" and "the agent just added a node" both need: the new part has
|
|
185
|
+
* to join the graph on screen, not replace it. Replacing is what makes an
|
|
186
|
+
* explorer feel like a search box — every step throws away the context of the
|
|
187
|
+
* step before, and the position of every node the user had already placed.
|
|
188
|
+
*
|
|
189
|
+
* The right-hand side wins because it is the fresher read of the same thing:
|
|
190
|
+
* a node returned again after a property was set carries the new value.
|
|
191
|
+
*/
|
|
192
|
+
export function mergeGraphPayloads(base, addition) {
|
|
193
|
+
const nodes = new Map(base.nodes.map((node) => [node.id, node]));
|
|
194
|
+
for (const node of addition.nodes)
|
|
195
|
+
nodes.set(node.id, node);
|
|
196
|
+
const edges = new Map(base.edges.map((edge) => [edge.id, edge]));
|
|
197
|
+
for (const edge of addition.edges)
|
|
198
|
+
edges.set(edge.id, edge);
|
|
199
|
+
return {
|
|
200
|
+
kind: GRAPH_PAYLOAD_KIND,
|
|
201
|
+
version: GRAPH_PAYLOAD_VERSION,
|
|
202
|
+
nodes: [...nodes.values()],
|
|
203
|
+
edges: [...edges.values()],
|
|
204
|
+
// Either side hitting a cap means the union is incomplete.
|
|
205
|
+
truncated: base.truncated || addition.truncated ? true : undefined,
|
|
206
|
+
// `stats` means totals BEFORE truncation, so it can only be stated when
|
|
207
|
+
// nothing was truncated — there the retained counts ARE those totals.
|
|
208
|
+
// Once either producer capped, the union's true total is not derivable
|
|
209
|
+
// from anything here: each side knows its own pre-cap count, and how much
|
|
210
|
+
// those two sets OVERLAP is exactly what the capped rows would have said.
|
|
211
|
+
// Publishing the retained counts under this field understated a merge of
|
|
212
|
+
// a 500-node truncated read as ~300 and called it a total; the field is
|
|
213
|
+
// optional, so the honest answer is to omit it and let `truncated` carry
|
|
214
|
+
// the "incomplete" signal on its own.
|
|
215
|
+
stats: base.truncated || addition.truncated
|
|
216
|
+
? undefined
|
|
217
|
+
: { nodeCount: nodes.size, edgeCount: edges.size },
|
|
218
|
+
// The legend describes labels, which accumulate; meta describes the most
|
|
219
|
+
// recent query, which does not.
|
|
220
|
+
legend: base.legend || addition.legend ? { ...base.legend, ...addition.legend } : undefined,
|
|
221
|
+
meta: addition.meta ?? base.meta,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* `JSON.stringify` that survives what a graph actually carries.
|
|
226
|
+
*
|
|
227
|
+
* This is the DISPLAY and TRANSPORT form: the panel's `content` string behind
|
|
228
|
+
* the Copy button, the raw-result view on the card, and the tool results the
|
|
229
|
+
* model reads. It is deliberately NOT the form used to decide whether a value
|
|
230
|
+
* changed — see {@link deepEqual}, and the note below for why the two must
|
|
231
|
+
* stay apart.
|
|
232
|
+
*
|
|
233
|
+
* `properties` is `unknown` by design — it is whatever the database had — and
|
|
234
|
+
* the Neo4j driver hands back native `bigint` when configured with
|
|
235
|
+
* `useBigInt`, which `JSON.stringify` THROWS on. So opening the panel for an
|
|
236
|
+
* otherwise perfect payload could fail before anything rendered. Bigints
|
|
237
|
+
* become their decimal string (which is what the inspector already displays),
|
|
238
|
+
* and a cycle becomes `"[circular]"` rather than an exception.
|
|
239
|
+
*
|
|
240
|
+
* That string tag is LOSSY — `10n` and `"10"` come out the same — and that is
|
|
241
|
+
* correct here. A reader wants `10`, and so does the model: a stretch of this
|
|
242
|
+
* function's history tagged the bigint to keep an equality check honest, and
|
|
243
|
+
* the cost was a node property reaching the agent as `{"__bigint":"10"}`
|
|
244
|
+
* instead of a number. Equality is a different job; give it {@link deepEqual}.
|
|
245
|
+
*/
|
|
246
|
+
export function safeStringify(value, space) {
|
|
247
|
+
// ANCESTORS of the value being written, not everything ever seen. A single
|
|
248
|
+
// visited-set calls the second sighting of a shared object a cycle, and two
|
|
249
|
+
// nodes sharing one `properties` object is an ordinary thing for a hand-built
|
|
250
|
+
// payload or a browser-side adapter to produce — so the panel's copied
|
|
251
|
+
// content, and the tool results the agent reads, carried a literal
|
|
252
|
+
// "[circular]" where real data belonged. A repeat is only a cycle if the
|
|
253
|
+
// object is on the path back to the root.
|
|
254
|
+
//
|
|
255
|
+
// `JSON.stringify` walks depth-first and calls the replacer with `this` bound
|
|
256
|
+
// to the container it is writing into, which is what makes the path
|
|
257
|
+
// reconstructible: pop until the top of the stack is this item's parent.
|
|
258
|
+
const path = [];
|
|
259
|
+
return JSON.stringify(value, function (_key, item) {
|
|
260
|
+
if (typeof item === "bigint")
|
|
261
|
+
return item.toString();
|
|
262
|
+
if (typeof item !== "object" || item === null)
|
|
263
|
+
return item;
|
|
264
|
+
while (path.length > 0 && path[path.length - 1] !== this)
|
|
265
|
+
path.pop();
|
|
266
|
+
if (path.includes(item))
|
|
267
|
+
return "[circular]";
|
|
268
|
+
path.push(item);
|
|
269
|
+
return item;
|
|
270
|
+
}, space);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A structurally identical value that `JSON.stringify` cannot choke on.
|
|
274
|
+
*
|
|
275
|
+
* For anything that will be TRANSPORTED by someone else\'s serializer — the
|
|
276
|
+
* chat context rides along on every turn and is serialized by the HTTP agent,
|
|
277
|
+
* far from here — a raw `properties` object is a hazard: one native `bigint`
|
|
278
|
+
* and the request body throws, which does not degrade, it stops the user
|
|
279
|
+
* sending messages at all while that node is selected.
|
|
280
|
+
*/
|
|
281
|
+
export function jsonSafe(value) {
|
|
282
|
+
return JSON.parse(safeStringify(value));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Did this value actually change?
|
|
286
|
+
*
|
|
287
|
+
* Compared structurally, WITHOUT serialising — which is the whole point. This
|
|
288
|
+
* question was answered three times by stringifying with a tag for `bigint`,
|
|
289
|
+
* and each tag collided with the data it competed against: `10n` as `"10"`
|
|
290
|
+
* collides with the string `"10"`, as `"10n"` with the string `"10n"`, and as
|
|
291
|
+
* `{__bigint:"10"}` with a property that happens to be that object. There is
|
|
292
|
+
* no fourth tag that works, because `properties` is `unknown` — any encoding
|
|
293
|
+
* can be imitated by data shaped like the encoding.
|
|
294
|
+
*
|
|
295
|
+
* Comparing types directly has no encoding to imitate: `typeof 10n` is not
|
|
296
|
+
* `typeof "10"`, and the question is settled in one step.
|
|
297
|
+
*
|
|
298
|
+
* It also drops a subtler bug the string form carried. Those replacers marked
|
|
299
|
+
* every visited object in one `WeakSet`, so the SECOND appearance of a shared
|
|
300
|
+
* but perfectly acyclic sub-object (the same `properties` on two nodes)
|
|
301
|
+
* serialised as `"[circular]"` — making values that differ there compare
|
|
302
|
+
* equal. Cycles are tracked as PAIRS here, so a repeat is only a cycle when
|
|
303
|
+
* both sides repeat together.
|
|
304
|
+
*/
|
|
305
|
+
/**
|
|
306
|
+
* `Object.hasOwn`, which this repo\'s ES2020 target does not have.
|
|
307
|
+
*
|
|
308
|
+
* Needed wherever a key comes from the payload: labels and relationship types
|
|
309
|
+
* are whatever the producer called them, and `record["constructor"]` finds a
|
|
310
|
+
* function on the prototype rather than reporting the key as absent.
|
|
311
|
+
*/
|
|
312
|
+
export function hasOwn(target, key) {
|
|
313
|
+
return Object.prototype.hasOwnProperty.call(target, key);
|
|
314
|
+
}
|
|
315
|
+
function isDate(value) {
|
|
316
|
+
return Object.prototype.toString.call(value) === "[object Date]";
|
|
317
|
+
}
|
|
318
|
+
/** Own enumerable keys describe this object completely. */
|
|
319
|
+
function isPlainObject(value) {
|
|
320
|
+
const proto = Object.getPrototypeOf(value);
|
|
321
|
+
return proto === Object.prototype || proto === null;
|
|
322
|
+
}
|
|
323
|
+
export function deepEqual(a, b, seen) {
|
|
324
|
+
if (Object.is(a, b))
|
|
325
|
+
return true;
|
|
326
|
+
// Different types can never be equal, which is exactly the bigint case.
|
|
327
|
+
if (typeof a !== typeof b)
|
|
328
|
+
return false;
|
|
329
|
+
if (typeof a !== "object" || a === null || b === null)
|
|
330
|
+
return false;
|
|
331
|
+
const left = a;
|
|
332
|
+
const right = b;
|
|
333
|
+
const pairs = seen ?? new Map();
|
|
334
|
+
const already = pairs.get(left);
|
|
335
|
+
// Assumed equal while the comparison is in flight: two structures that
|
|
336
|
+
// recurse identically are equal, and re-entering would not terminate.
|
|
337
|
+
if (already?.has(right))
|
|
338
|
+
return true;
|
|
339
|
+
if (already)
|
|
340
|
+
already.add(right);
|
|
341
|
+
else
|
|
342
|
+
pairs.set(left, new Set([right]));
|
|
343
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
344
|
+
if (!Array.isArray(left) || !Array.isArray(right))
|
|
345
|
+
return false;
|
|
346
|
+
if (left.length !== right.length)
|
|
347
|
+
return false;
|
|
348
|
+
return left.every((item, index) => deepEqual(item, right[index], pairs));
|
|
349
|
+
}
|
|
350
|
+
// Enumerable keys only describe a PLAIN object. A `Date`, `Map`, `Set` or
|
|
351
|
+
// `RegExp` keeps its state internally and has none, so comparing key sets
|
|
352
|
+
// said two different dates were the same value — and this guard's entire job
|
|
353
|
+
// is deciding whether something changed. `Date` is worth spelling out
|
|
354
|
+
// because it is what a host or an adapter actually puts in a property; every
|
|
355
|
+
// other exotic object falls through to "not equal unless identical", which
|
|
356
|
+
// is the safe direction: a spurious change costs one redundant update, a
|
|
357
|
+
// missed one loses the user's data silently.
|
|
358
|
+
if (isDate(left) || isDate(right)) {
|
|
359
|
+
return isDate(left) && isDate(right) && Object.is(left.getTime(), right.getTime());
|
|
360
|
+
}
|
|
361
|
+
if (!isPlainObject(left) || !isPlainObject(right))
|
|
362
|
+
return false;
|
|
363
|
+
const leftKeys = Object.keys(left);
|
|
364
|
+
const rightKeys = new Set(Object.keys(right));
|
|
365
|
+
if (leftKeys.length !== rightKeys.size)
|
|
366
|
+
return false;
|
|
367
|
+
return leftKeys.every((key) => rightKeys.has(key) &&
|
|
368
|
+
deepEqual(left[key], right[key], pairs));
|
|
369
|
+
}
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* Graph palette.
|
|
2
|
+
*
|
|
3
|
+
* Eight categorical hues, evenly spaced around the wheel and held at a
|
|
4
|
+
* constant lightness and chroma so no single label shouts louder than the
|
|
5
|
+
* others — a palette where one colour is darker reads as a hierarchy that
|
|
6
|
+
* isn't there. Spacing is even rather than "pleasing" on purpose: the job is
|
|
7
|
+
* telling `Person` from `Movie` at a glance, including for the common
|
|
8
|
+
* red/green and blue/purple confusions.
|
|
9
|
+
*
|
|
10
|
+
* The violet at `--graph-1` is the same family as the shell's `--primary`, so
|
|
11
|
+
* the first (and in most graphs, largest) label sits inside the app's identity
|
|
12
|
+
* instead of fighting it.
|
|
13
|
+
*
|
|
14
|
+
* These are plain custom properties, not `@theme` tokens: they are read from
|
|
15
|
+
* the DOM by `resolveGraphTheme()` and painted onto a canvas, never turned
|
|
16
|
+
* into Tailwind utilities. Importing this file is optional — without it the
|
|
17
|
+
* graph falls back to the host's `--chart-1..5`, and then to a built-in
|
|
18
|
+
* palette.
|
|
19
|
+
*
|
|
20
|
+
* Dark values lift lightness and drop chroma: the same colour at the same
|
|
21
|
+
* chroma glows against a dark ground and bleeds into its own caption.
|
|
22
|
+
*/
|
|
23
|
+
:root {
|
|
24
|
+
--graph-1: oklch(0.61 0.212 285); /* violet — pairs with --primary */
|
|
25
|
+
--graph-2: oklch(0.65 0.155 200); /* cyan */
|
|
26
|
+
--graph-3: oklch(0.62 0.208 15); /* rose */
|
|
27
|
+
--graph-4: oklch(0.7 0.165 75); /* amber */
|
|
28
|
+
--graph-5: oklch(0.63 0.152 158); /* emerald */
|
|
29
|
+
--graph-6: oklch(0.61 0.223 328); /* fuchsia */
|
|
30
|
+
--graph-7: oklch(0.6 0.19 255); /* blue */
|
|
31
|
+
--graph-8: oklch(0.68 0.17 122); /* lime */
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.dark {
|
|
35
|
+
--graph-1: oklch(0.72 0.175 285);
|
|
36
|
+
--graph-2: oklch(0.76 0.13 200);
|
|
37
|
+
--graph-3: oklch(0.72 0.17 15);
|
|
38
|
+
--graph-4: oklch(0.79 0.14 75);
|
|
39
|
+
--graph-5: oklch(0.74 0.128 158);
|
|
40
|
+
--graph-6: oklch(0.72 0.18 328);
|
|
41
|
+
--graph-7: oklch(0.71 0.155 255);
|
|
42
|
+
--graph-8: oklch(0.78 0.145 122);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* The hover tooltip.
|
|
46
|
+
*
|
|
47
|
+
* `force-graph` renders it through `float-tooltip`, which injects its own
|
|
48
|
+
* stylesheet: a hardcoded `rgba(0,0,0,0.6)` box with `#eee` text and a 12px
|
|
49
|
+
* sans stack. That is a black box floating over a themed app, and it looks
|
|
50
|
+
* exactly like the third-party widget it is.
|
|
51
|
+
*
|
|
52
|
+
* These rules re-dress it from the host's tokens. They live here rather than
|
|
53
|
+
* in a component because the element is created outside React — there is no
|
|
54
|
+
* render pass to attach a class in.
|
|
55
|
+
*/
|
|
56
|
+
.float-tooltip-kap {
|
|
57
|
+
padding: 0.5rem 0.625rem;
|
|
58
|
+
border: 1px solid var(--border);
|
|
59
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
60
|
+
background: var(--popover);
|
|
61
|
+
color: var(--popover-foreground);
|
|
62
|
+
font:
|
|
63
|
+
12px/1.45 ui-sans-serif,
|
|
64
|
+
system-ui,
|
|
65
|
+
sans-serif;
|
|
66
|
+
box-shadow:
|
|
67
|
+
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
|
68
|
+
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
69
|
+
max-width: 22rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* The details drawer's slide-in.
|
|
73
|
+
*
|
|
74
|
+
* A keyframe, not React state. The obvious implementation — mount at
|
|
75
|
+
* `translate-x-full`, flip to `translate-x-0` in an effect — needs a state
|
|
76
|
+
* write on every selection change, and a selection change is also what
|
|
77
|
+
* re-renders the graph around it. Keyframes run off the render path entirely,
|
|
78
|
+
* so re-selecting cannot feed back into another render.
|
|
79
|
+
*/
|
|
80
|
+
@keyframes foundry-graph-drawer-in {
|
|
81
|
+
from {
|
|
82
|
+
transform: translateX(100%);
|
|
83
|
+
}
|
|
84
|
+
to {
|
|
85
|
+
transform: translateX(0);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.foundry-graph-drawer {
|
|
90
|
+
animation: foundry-graph-drawer-in 200ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@media (prefers-reduced-motion: reduce) {
|
|
94
|
+
.foundry-graph-drawer {
|
|
95
|
+
animation: none;
|
|
96
|
+
}
|
|
97
|
+
}
|