@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
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { clientToolRegistry } from "@iloveagents/foundry-agent";
|
|
2
|
+
import { useToolPanelStore } from "@iloveagents/foundry-web-ui";
|
|
3
|
+
import { coerceGraphPayload, hasOwn, mergeGraphPayloads, primaryLabel, resolveCaption, safeStringify, } from "../model.js";
|
|
4
|
+
import { findWorkspaceNode, graphWorkspaceStore, isAmbiguous } from "../graph-workspace.js";
|
|
5
|
+
import { GRAPH_PANEL_TYPE, graphPanelContent } from "./graph-panel-content.js";
|
|
6
|
+
/**
|
|
7
|
+
* Client tools that let the agent work IN the open graph, not just hand one
|
|
8
|
+
* over.
|
|
9
|
+
*
|
|
10
|
+
* These execute in the browser, so they act on what the user is actually
|
|
11
|
+
* looking at — the live panel, its current selection, its current search.
|
|
12
|
+
* That is the difference between an agent that can produce a picture and one
|
|
13
|
+
* you can have a conversation with about the picture.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A node as the AGENT should see it — captioned the way the canvas paints it.
|
|
17
|
+
*
|
|
18
|
+
* The payload\'s `legend.captionKey` is a supported part of the contract, and
|
|
19
|
+
* where a producer sets one, resolving without it names the node something
|
|
20
|
+
* that appears nowhere on screen. `findWorkspaceNode` already matches with the
|
|
21
|
+
* legend key, so without this the tool could find a node by the caption the
|
|
22
|
+
* user can see and then report a different name back.
|
|
23
|
+
*/
|
|
24
|
+
const describe = (node, legend) => {
|
|
25
|
+
const label = primaryLabel(node);
|
|
26
|
+
const captionKey = legend && hasOwn(legend, label) ? legend[label]?.captionKey : undefined;
|
|
27
|
+
return {
|
|
28
|
+
id: node.id,
|
|
29
|
+
labels: node.labels ?? [],
|
|
30
|
+
caption: resolveCaption(node, captionKey),
|
|
31
|
+
properties: node.properties ?? {},
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
// Not `JSON.stringify`: every one of these results carries node properties
|
|
35
|
+
// straight out of the database, and a `bigint` there would throw inside the
|
|
36
|
+
// tool rather than answering it.
|
|
37
|
+
const ok = (value) => safeStringify(value);
|
|
38
|
+
const fail = (message) => JSON.stringify({ error: message });
|
|
39
|
+
function parseArgs(argsJson) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(argsJson || "{}");
|
|
42
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const showGraph = {
|
|
49
|
+
name: "graph_show",
|
|
50
|
+
description: "Show a graph in the side panel. Updates the graph already open rather than opening a " +
|
|
51
|
+
"second one, and keeps the positions of nodes that were already on screen, so a new " +
|
|
52
|
+
"result reads as a change rather than a new picture. Set merge: true to ADD to what is " +
|
|
53
|
+
"on screen — always do that for an expansion or a write, so the new part appears beside " +
|
|
54
|
+
"what it connects to. Pass the payload a graph tool returned.",
|
|
55
|
+
parameters: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
title: { type: "string", description: "Panel heading, e.g. the question this answers." },
|
|
59
|
+
graph: { type: "object", description: "A graph payload (kind: 'foundry.graph')." },
|
|
60
|
+
merge: {
|
|
61
|
+
type: "boolean",
|
|
62
|
+
description: "Add to the graph on screen instead of replacing it. Use for expansions and writes.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ["graph"],
|
|
66
|
+
},
|
|
67
|
+
execute: async (argsJson) => {
|
|
68
|
+
const args = parseArgs(argsJson);
|
|
69
|
+
const payload = coerceGraphPayload(args.graph);
|
|
70
|
+
if (!payload)
|
|
71
|
+
return fail("That is not a graph payload (expected kind: 'foundry.graph').");
|
|
72
|
+
const workspace = graphWorkspaceStore.getState();
|
|
73
|
+
const merging = args.merge === true && workspace.payload !== null;
|
|
74
|
+
const next = merging ? mergeGraphPayloads(workspace.payload, payload) : payload;
|
|
75
|
+
const title = typeof args.title === "string" && args.title
|
|
76
|
+
? args.title
|
|
77
|
+
: ((merging ? workspace.title : null) ?? "Graph");
|
|
78
|
+
// Update in place when a graph is already up. `openPanel` would do the
|
|
79
|
+
// same to the store, but going through `setContent` says what is meant:
|
|
80
|
+
// this is the same workspace changing, not a new one — which is also how
|
|
81
|
+
// it behaves on screen, since the renderer instance is reused and the
|
|
82
|
+
// layout reconciles by node id.
|
|
83
|
+
const panel = useToolPanelStore.getState();
|
|
84
|
+
const content = graphPanelContent(title, next);
|
|
85
|
+
if (panel.isOpen && panel.content?.type === GRAPH_PANEL_TYPE)
|
|
86
|
+
panel.setContent(content);
|
|
87
|
+
else
|
|
88
|
+
panel.openPanel(content);
|
|
89
|
+
return ok({
|
|
90
|
+
shown: true,
|
|
91
|
+
merged: merging,
|
|
92
|
+
nodes: next.nodes.length,
|
|
93
|
+
edges: next.edges.length,
|
|
94
|
+
added: merging ? next.nodes.length - workspace.payload.nodes.length : next.nodes.length,
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const selectNode = {
|
|
99
|
+
name: "graph_select_node",
|
|
100
|
+
description: "Select a node in the open graph and centre the view on it, as if the user had clicked it. " +
|
|
101
|
+
"Accepts a node id or a caption such as a name or title. Returns the node's properties.",
|
|
102
|
+
parameters: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
node: { type: "string", description: "Node id, or its caption (name/title)." },
|
|
106
|
+
},
|
|
107
|
+
required: ["node"],
|
|
108
|
+
},
|
|
109
|
+
execute: async (argsJson) => {
|
|
110
|
+
const args = parseArgs(argsJson);
|
|
111
|
+
const needle = typeof args.node === "string" ? args.node : "";
|
|
112
|
+
const { handle, payload: openPayload } = graphWorkspaceStore.getState();
|
|
113
|
+
if (!handle)
|
|
114
|
+
return fail("No graph is open. Call graph_show first.");
|
|
115
|
+
const legend = openPayload?.legend;
|
|
116
|
+
const captionOf = (node) => describe(node, legend).caption;
|
|
117
|
+
const match = findWorkspaceNode(needle);
|
|
118
|
+
if (!match)
|
|
119
|
+
return fail(`No node matching "${needle}" is in the open graph.`);
|
|
120
|
+
// Several nodes answer to that text. Picking one would be a confident
|
|
121
|
+
// wrong answer the user cannot see — duplicate captions are ordinary — so
|
|
122
|
+
// hand back the candidates WITH their ids, which is what the agent needs
|
|
123
|
+
// to ask a real question or select precisely.
|
|
124
|
+
if (isAmbiguous(match)) {
|
|
125
|
+
const options = match.ambiguous
|
|
126
|
+
.slice(0, 10)
|
|
127
|
+
.map((n) => `${describe(n, openPayload?.legend).caption} (id ${n.id})`)
|
|
128
|
+
.join(", ");
|
|
129
|
+
return fail(`"${needle}" matches ${match.ambiguous.length} nodes in the open graph: ${options}. ` +
|
|
130
|
+
`Select by id, or ask the user which one they mean.`);
|
|
131
|
+
}
|
|
132
|
+
const node = match;
|
|
133
|
+
// A node can be in the workspace payload and still not be RENDERED — the
|
|
134
|
+
// view caps a large graph to its connected core — and reporting a
|
|
135
|
+
// selection that did not happen sends the agent on to describe something
|
|
136
|
+
// the user cannot see.
|
|
137
|
+
if (!handle.select(node.id)) {
|
|
138
|
+
// Two different reasons a node is not drawn, and they need opposite
|
|
139
|
+
// advice. A legend filter is undone by turning the label back on;
|
|
140
|
+
// narrowing the query cannot bring back something the USER switched off,
|
|
141
|
+
// so telling the agent to narrow sends it somewhere that cannot work.
|
|
142
|
+
const hidden = handle.getViewState().hiddenLabels;
|
|
143
|
+
const label = primaryLabel(node);
|
|
144
|
+
if (hidden.includes(label)) {
|
|
145
|
+
return fail(`"${captionOf(node)}" is in this result but hidden: the "${label}" filter is ` +
|
|
146
|
+
`switched off in the legend. Ask the user to turn it back on to see this node.`);
|
|
147
|
+
}
|
|
148
|
+
return fail(`"${captionOf(node)}" is in this result but not drawn — the view is showing the ` +
|
|
149
|
+
`most connected part of a graph too large for one screen. Narrow the query to bring ` +
|
|
150
|
+
`it in.`);
|
|
151
|
+
}
|
|
152
|
+
// Reveal, not centre: the user framed this graph, and a tool that
|
|
153
|
+
// re-centres on every mention throws that away. Move only if the node is
|
|
154
|
+
// off-screen or behind the details drawer.
|
|
155
|
+
handle.reveal(node.id);
|
|
156
|
+
return ok({ selected: describe(node, legend) });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const searchGraph = {
|
|
160
|
+
name: "graph_search",
|
|
161
|
+
description: "Highlight nodes in the open graph matching a term, dimming the rest. Searches captions, " +
|
|
162
|
+
"labels and property values. Pass an empty string to clear.",
|
|
163
|
+
parameters: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: { query: { type: "string", description: "Text to highlight. Empty clears." } },
|
|
166
|
+
required: ["query"],
|
|
167
|
+
},
|
|
168
|
+
execute: async (argsJson) => {
|
|
169
|
+
const args = parseArgs(argsJson);
|
|
170
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
171
|
+
const { handle, payload } = graphWorkspaceStore.getState();
|
|
172
|
+
if (!handle || !payload)
|
|
173
|
+
return fail("No graph is open. Call graph_show first.");
|
|
174
|
+
handle.search(query);
|
|
175
|
+
return ok({ query, of: payload.nodes.length });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const getView = {
|
|
179
|
+
name: "graph_get_view",
|
|
180
|
+
description: "Read what the user is currently looking at: the open graph's title, its node and " +
|
|
181
|
+
"relationship counts, the types present, the selected node, and any active search. Call " +
|
|
182
|
+
"this before answering a question about 'this' node or 'the graph' on screen.",
|
|
183
|
+
parameters: { type: "object", properties: {} },
|
|
184
|
+
execute: async () => {
|
|
185
|
+
const { payload, title, handle } = graphWorkspaceStore.getState();
|
|
186
|
+
if (!payload || !handle)
|
|
187
|
+
return ok({ open: false });
|
|
188
|
+
// Read the VIEW, not the payload. Two different things were being lost by
|
|
189
|
+
// counting the payload: the render cap keeps only the connected core of a
|
|
190
|
+
// graph too big for one screen, and a label switched off in the legend
|
|
191
|
+
// takes its nodes and their relationships off the canvas. Either way the
|
|
192
|
+
// agent was describing a graph the user was not looking at — and could
|
|
193
|
+
// name a node that is not drawn.
|
|
194
|
+
const view = handle.getViewState();
|
|
195
|
+
// Counted into a `Map`, not `{}`. Labels and relationship types come from
|
|
196
|
+
// the payload, which is source-neutral and untrusted — a graph is free to
|
|
197
|
+
// have a label called `constructor` or `toString`, and reading those off
|
|
198
|
+
// an object literal finds an inherited function instead of "not counted
|
|
199
|
+
// yet". The count then either throws or lands in the result as something
|
|
200
|
+
// that is not a number.
|
|
201
|
+
const labels = new Map();
|
|
202
|
+
for (const node of view.nodes) {
|
|
203
|
+
const label = primaryLabel(node);
|
|
204
|
+
labels.set(label, (labels.get(label) ?? 0) + 1);
|
|
205
|
+
}
|
|
206
|
+
const types = new Map();
|
|
207
|
+
for (const edge of view.edges) {
|
|
208
|
+
const type = edge.type ?? "RELATED";
|
|
209
|
+
types.set(type, (types.get(type) ?? 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
return ok({
|
|
212
|
+
open: true,
|
|
213
|
+
title,
|
|
214
|
+
nodeCount: view.nodes.length,
|
|
215
|
+
edgeCount: view.edges.length,
|
|
216
|
+
// The producer's own truncation OR the render cap. Reporting only the
|
|
217
|
+
// former said `false` while the UI was showing a loss notice.
|
|
218
|
+
truncated: (payload.truncated ?? false) || view.lossy,
|
|
219
|
+
labels: Object.fromEntries(labels),
|
|
220
|
+
relationshipTypes: Object.fromEntries(types),
|
|
221
|
+
// Named, so the agent can say "Person is hidden" rather than silently
|
|
222
|
+
// reporting a smaller graph than the query returned.
|
|
223
|
+
hiddenLabels: view.hiddenLabels,
|
|
224
|
+
hiddenNodeCount: payload.nodes.length - view.nodes.length,
|
|
225
|
+
selectedNode: view.selection ? describe(view.selection, payload.legend) : null,
|
|
226
|
+
// A selected node whose label the legend hides stays selected — the
|
|
227
|
+
// drawer is still open and its properties are still what the user is
|
|
228
|
+
// reading — but it is NOT in `nodeCount` or among the drawn nodes. Said
|
|
229
|
+
// plainly here, so the agent can answer "selected, though its label is
|
|
230
|
+
// currently hidden" instead of describing a node this same result says
|
|
231
|
+
// is not on screen.
|
|
232
|
+
selectedNodeHidden: view.selection ? !view.selectionDrawn : false,
|
|
233
|
+
// A relationship can be selected too, and the drawer shows it. Reporting
|
|
234
|
+
// only nodes said "nothing is selected" with an edge plainly selected.
|
|
235
|
+
selectedRelationship: view.selectedEdge
|
|
236
|
+
? {
|
|
237
|
+
id: view.selectedEdge.id,
|
|
238
|
+
type: view.selectedEdge.type ?? "RELATED",
|
|
239
|
+
source: view.selectedEdge.source,
|
|
240
|
+
target: view.selectedEdge.target,
|
|
241
|
+
properties: view.selectedEdge.properties ?? {},
|
|
242
|
+
}
|
|
243
|
+
: null,
|
|
244
|
+
search: view.query || null,
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
export const GRAPH_CLIENT_TOOLS = [showGraph, selectNode, searchGraph, getView];
|
|
249
|
+
/**
|
|
250
|
+
* Register the graph client tools globally.
|
|
251
|
+
*
|
|
252
|
+
* Global rather than page-scoped: the panel is available from every route, so
|
|
253
|
+
* scoping them to one page would make the agent's ability to drive the graph
|
|
254
|
+
* depend on where the user happens to be standing. Re-registering by the same
|
|
255
|
+
* name replaces the entry, so this is safe to call from a `useInit`.
|
|
256
|
+
*/
|
|
257
|
+
export function registerGraphClientTools() {
|
|
258
|
+
const { registerGlobal } = clientToolRegistry.getState();
|
|
259
|
+
for (const tool of GRAPH_CLIENT_TOOLS)
|
|
260
|
+
registerGlobal(tool);
|
|
261
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type GraphNode } from "../model.js";
|
|
2
|
+
/**
|
|
3
|
+
* The context key the graph's selection occupies.
|
|
4
|
+
*
|
|
5
|
+
* One key means one item: selecting a different node replaces the previous
|
|
6
|
+
* one rather than stacking, and deselecting clears it. That is exactly what
|
|
7
|
+
* `setContextItem` is for — "context that tracks a piece of UI rather than a
|
|
8
|
+
* user's pin".
|
|
9
|
+
*/
|
|
10
|
+
export declare const GRAPH_SELECTION_CONTEXT_KEY = "graph.selection";
|
|
11
|
+
/**
|
|
12
|
+
* Keep the graph's selected node in the chat's context.
|
|
13
|
+
*
|
|
14
|
+
* Without this, "tell me about this node" means the agent has to guess, or
|
|
15
|
+
* the user has to retype what they are already looking at. With it, whatever
|
|
16
|
+
* is selected is simply part of the conversation — and because it is keyed,
|
|
17
|
+
* selecting something else swaps it instead of piling up.
|
|
18
|
+
*/
|
|
19
|
+
export declare function syncGraphSelectionContext(node: GraphNode | null, sourcePage: string, captionKey?: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Pin the selected node so it outlives the selection.
|
|
22
|
+
*
|
|
23
|
+
* Uses the shell's own pin vocabulary — same icons, same `text-primary` when
|
|
24
|
+
* active — because it is the same idea: this stays in the conversation until
|
|
25
|
+
* I remove it. `promoteContextItem` is what turns the keyed, selection-tracking
|
|
26
|
+
* item into a persistent pin (it drops the key), so pinning and then selecting
|
|
27
|
+
* elsewhere leaves the pin standing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function GraphContextPinButton({ node, captionKey, }: {
|
|
30
|
+
node: GraphNode;
|
|
31
|
+
/**
|
|
32
|
+
* The payload's caption key for this node's label — the same one the canvas
|
|
33
|
+
* paints with. Unpinning rebuilds the tracking item, and without it the chip
|
|
34
|
+
* the user reads (and the `preview` the agent reads) reverts to a default
|
|
35
|
+
* caption that may appear nowhere on screen.
|
|
36
|
+
*/
|
|
37
|
+
captionKey?: string;
|
|
38
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { Pin, PinOff } from "lucide-react";
|
|
4
|
+
import { cn } from "@iloveagents/foundry-web-primitives";
|
|
5
|
+
import { useAppStore } from "@iloveagents/foundry-web-ui";
|
|
6
|
+
import { jsonSafe, primaryLabel, resolveCaption } from "../model.js";
|
|
7
|
+
/**
|
|
8
|
+
* The context key the graph's selection occupies.
|
|
9
|
+
*
|
|
10
|
+
* One key means one item: selecting a different node replaces the previous
|
|
11
|
+
* one rather than stacking, and deselecting clears it. That is exactly what
|
|
12
|
+
* `setContextItem` is for — "context that tracks a piece of UI rather than a
|
|
13
|
+
* user's pin".
|
|
14
|
+
*/
|
|
15
|
+
export const GRAPH_SELECTION_CONTEXT_KEY = "graph.selection";
|
|
16
|
+
function contextItemFor(node, sourcePage, captionKey) {
|
|
17
|
+
// Captioned with the payload\'s own `legend.captionKey`, like the canvas,
|
|
18
|
+
// the expansion prompt and the client tools. Without it the chip the user
|
|
19
|
+
// reads — and the `preview` the agent reads — can name the selected node
|
|
20
|
+
// something that appears nowhere on screen.
|
|
21
|
+
const caption = resolveCaption(node, captionKey);
|
|
22
|
+
return {
|
|
23
|
+
key: GRAPH_SELECTION_CONTEXT_KEY,
|
|
24
|
+
type: "ref",
|
|
25
|
+
label: `${primaryLabel(node)} · ${caption}`,
|
|
26
|
+
payload: {
|
|
27
|
+
kind: "ref",
|
|
28
|
+
refType: "graph-node",
|
|
29
|
+
refId: node.id,
|
|
30
|
+
preview: caption,
|
|
31
|
+
// The agent gets the node's own data, so it can answer about "this
|
|
32
|
+
// node" without a round trip back through a tool.
|
|
33
|
+
//
|
|
34
|
+
// A JSON-SAFE copy, not the live object. This item is attached to every
|
|
35
|
+
// subsequent turn and serialized by the HTTP agent, so a single native
|
|
36
|
+
// `bigint` in `properties` — which is what the Neo4j driver returns
|
|
37
|
+
// under `useBigInt` — throws inside someone else's `JSON.stringify`.
|
|
38
|
+
// That does not lose a property, it stops the user sending any message
|
|
39
|
+
// at all for as long as the node stays selected. Copying also detaches
|
|
40
|
+
// it from a node object the simulation keeps mutating.
|
|
41
|
+
meta: jsonSafe({ labels: node.labels ?? [], properties: node.properties ?? {} }),
|
|
42
|
+
},
|
|
43
|
+
sourcePage,
|
|
44
|
+
// Ephemeral: it follows the selection. Pinning promotes it, which drops
|
|
45
|
+
// the key and makes it persist.
|
|
46
|
+
persistence: "ephemeral",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Keep the graph's selected node in the chat's context.
|
|
51
|
+
*
|
|
52
|
+
* Without this, "tell me about this node" means the agent has to guess, or
|
|
53
|
+
* the user has to retype what they are already looking at. With it, whatever
|
|
54
|
+
* is selected is simply part of the conversation — and because it is keyed,
|
|
55
|
+
* selecting something else swaps it instead of piling up.
|
|
56
|
+
*/
|
|
57
|
+
export function syncGraphSelectionContext(node, sourcePage, captionKey) {
|
|
58
|
+
const { setContextItem, contextItems } = useAppStore.getState();
|
|
59
|
+
if (!node) {
|
|
60
|
+
setContextItem(GRAPH_SELECTION_CONTEXT_KEY, null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Already pinned? Then the node is in the conversation once, deliberately,
|
|
64
|
+
// and the tracking item would make it twice. `promoteContextItem` drops the
|
|
65
|
+
// key to pin, so nothing stops this from re-adding a keyed copy on the next
|
|
66
|
+
// republish — a route change or a merge — and the agent would be handed the
|
|
67
|
+
// same node under two entries whose properties can disagree after a merge.
|
|
68
|
+
if (contextItems.some((item) => isPinnedGraphNode(item, node.id))) {
|
|
69
|
+
setContextItem(GRAPH_SELECTION_CONTEXT_KEY, null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setContextItem(GRAPH_SELECTION_CONTEXT_KEY, contextItemFor(node, sourcePage, captionKey));
|
|
73
|
+
}
|
|
74
|
+
/** A user\'s pin of this exact node: promoted, so it carries no key. */
|
|
75
|
+
function isPinnedGraphNode(item, nodeId) {
|
|
76
|
+
const payload = item.payload;
|
|
77
|
+
return (item.key === undefined &&
|
|
78
|
+
payload?.kind === "ref" &&
|
|
79
|
+
payload.refType === "graph-node" &&
|
|
80
|
+
payload.refId === nodeId);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Pin the selected node so it outlives the selection.
|
|
84
|
+
*
|
|
85
|
+
* Uses the shell's own pin vocabulary — same icons, same `text-primary` when
|
|
86
|
+
* active — because it is the same idea: this stays in the conversation until
|
|
87
|
+
* I remove it. `promoteContextItem` is what turns the keyed, selection-tracking
|
|
88
|
+
* item into a persistent pin (it drops the key), so pinning and then selecting
|
|
89
|
+
* elsewhere leaves the pin standing.
|
|
90
|
+
*/
|
|
91
|
+
export function GraphContextPinButton({ node, captionKey, }) {
|
|
92
|
+
const items = useAppStore((s) => s.contextItems);
|
|
93
|
+
const promoteContextItem = useAppStore((s) => s.promoteContextItem);
|
|
94
|
+
const removeContextItem = useAppStore((s) => s.removeContextItem);
|
|
95
|
+
const tracked = items.find((item) => item.key === GRAPH_SELECTION_CONTEXT_KEY);
|
|
96
|
+
const pinned = items.find((item) => isPinnedGraphNode(item, node.id));
|
|
97
|
+
const toggle = useCallback(() => {
|
|
98
|
+
if (pinned) {
|
|
99
|
+
removeContextItem(pinned.id);
|
|
100
|
+
// Pinning PROMOTED the keyed item, which is how it survives selecting
|
|
101
|
+
// elsewhere — so removing the pin removes the only copy, and the node
|
|
102
|
+
// the user is still looking at drops out of the conversation entirely
|
|
103
|
+
// until they select something else. Unpinning means "stop keeping this
|
|
104
|
+
// one", not "forget what I have selected", so the tracking item comes
|
|
105
|
+
// back.
|
|
106
|
+
syncGraphSelectionContext(node, pinned.sourcePage ?? "", captionKey);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (tracked)
|
|
110
|
+
promoteContextItem(tracked.id);
|
|
111
|
+
}, [node, captionKey, pinned, tracked, promoteContextItem, removeContextItem]);
|
|
112
|
+
return (_jsxs("button", { type: "button", onClick: toggle, title: pinned ? "Unpin from chat" : "Pin to chat", "aria-pressed": !!pinned, className: cn("border-border hover:bg-muted focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-1", pinned ? "text-primary" : "text-muted-foreground"), children: [pinned ? _jsx(PinOff, { className: "size-3.5" }) : _jsx(Pin, { className: "size-3.5" }), pinned ? "Pinned" : "Pin"] }));
|
|
113
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type GraphPayload } from "../model.js";
|
|
2
|
+
/** The `ToolPanelContent.type` a graph panel matches on. */
|
|
3
|
+
export declare const GRAPH_PANEL_TYPE = "graph";
|
|
4
|
+
/**
|
|
5
|
+
* ANY panel content, for the renderer that has to decide whether it is a graph.
|
|
6
|
+
*
|
|
7
|
+
* Declared structurally so this module does not import the store's type — it
|
|
8
|
+
* only ever reads these fields, and a structural shape keeps the dependency
|
|
9
|
+
* one-way. `type` is deliberately wide here: the whole job of
|
|
10
|
+
* `graphPanelPayload` is to be handed something that might not be a graph.
|
|
11
|
+
*/
|
|
12
|
+
export interface GraphPanelContentLike {
|
|
13
|
+
title: string;
|
|
14
|
+
content: string;
|
|
15
|
+
type: string;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Content this module BUILT, which is a graph by construction.
|
|
20
|
+
*
|
|
21
|
+
* Separate from the wide shape above so `type` can be the literal, which is
|
|
22
|
+
* what makes it assignable to the host's `ToolPanelContent` union. One type
|
|
23
|
+
* serving both directions forced an `as never` at every `openPanel` and
|
|
24
|
+
* `setContent` call — casts that switch checking off exactly where a shape
|
|
25
|
+
* mismatch with the host would otherwise be caught.
|
|
26
|
+
*/
|
|
27
|
+
export interface GraphPanelContent extends GraphPanelContentLike {
|
|
28
|
+
type: typeof GRAPH_PANEL_TYPE;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the panel payload for a graph.
|
|
32
|
+
*
|
|
33
|
+
* `data` carries the parsed payload so the renderer never re-parses, and
|
|
34
|
+
* `content` carries the serialization so the panel header's Copy button and the
|
|
35
|
+
* built-in text fallback still do something useful if no renderer matched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function graphPanelContent(title: string, payload: GraphPayload): GraphPanelContent;
|
|
38
|
+
/**
|
|
39
|
+
* Recover the payload from panel content.
|
|
40
|
+
*
|
|
41
|
+
* Prefers `data`; falls back to parsing `content` so a panel opened by a
|
|
42
|
+
* generic client tool (`ui_open_panel`, which only knows how to set strings)
|
|
43
|
+
* still renders as a graph.
|
|
44
|
+
*/
|
|
45
|
+
export declare function graphPanelPayload(content: GraphPanelContentLike): GraphPayload | null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { coerceGraphPayload, safeStringify } from "../model.js";
|
|
2
|
+
/** The `ToolPanelContent.type` a graph panel matches on. */
|
|
3
|
+
export const GRAPH_PANEL_TYPE = "graph";
|
|
4
|
+
/**
|
|
5
|
+
* Build the panel payload for a graph.
|
|
6
|
+
*
|
|
7
|
+
* `data` carries the parsed payload so the renderer never re-parses, and
|
|
8
|
+
* `content` carries the serialization so the panel header's Copy button and the
|
|
9
|
+
* built-in text fallback still do something useful if no renderer matched.
|
|
10
|
+
*/
|
|
11
|
+
export function graphPanelContent(title, payload) {
|
|
12
|
+
return {
|
|
13
|
+
title,
|
|
14
|
+
type: GRAPH_PANEL_TYPE,
|
|
15
|
+
content: safeStringify(payload, 2),
|
|
16
|
+
data: payload,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Recover the payload from panel content.
|
|
21
|
+
*
|
|
22
|
+
* Prefers `data`; falls back to parsing `content` so a panel opened by a
|
|
23
|
+
* generic client tool (`ui_open_panel`, which only knows how to set strings)
|
|
24
|
+
* still renders as a graph.
|
|
25
|
+
*/
|
|
26
|
+
export function graphPanelPayload(content) {
|
|
27
|
+
return coerceGraphPayload(content.data) ?? coerceGraphPayload(content.content);
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type GraphPanelContentLike } from "./graph-panel-content.js";
|
|
2
|
+
/**
|
|
3
|
+
* Full-size graph for the tool panel, connected to the shared workspace.
|
|
4
|
+
*
|
|
5
|
+
* Default-exported and loaded through `React.lazy` from
|
|
6
|
+
* `registerGraphPanelRenderer`, so neither this nor the force engine is in the
|
|
7
|
+
* bundle until a graph panel is actually opened.
|
|
8
|
+
*/
|
|
9
|
+
export default function GraphPanel({ content }: {
|
|
10
|
+
content: GraphPanelContentLike;
|
|
11
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { GraphView } from "../graph-view.js";
|
|
4
|
+
import { hasOwn, primaryLabel, resolveCaption } from "../model.js";
|
|
5
|
+
import { GraphEmptyState } from "../graph-notice.js";
|
|
6
|
+
import { graphWorkspaceStore } from "../graph-workspace.js";
|
|
7
|
+
import { submitComposerText, useAppStore, useToolPanelStore } from "@iloveagents/foundry-web-ui";
|
|
8
|
+
import { graphPanelPayload } from "./graph-panel-content.js";
|
|
9
|
+
import { GraphContextPinButton, syncGraphSelectionContext } from "./graph-context.js";
|
|
10
|
+
/**
|
|
11
|
+
* Full-size graph for the tool panel, connected to the shared workspace.
|
|
12
|
+
*
|
|
13
|
+
* Default-exported and loaded through `React.lazy` from
|
|
14
|
+
* `registerGraphPanelRenderer`, so neither this nor the force engine is in the
|
|
15
|
+
* bundle until a graph panel is actually opened.
|
|
16
|
+
*/
|
|
17
|
+
export default function GraphPanel({ content }) {
|
|
18
|
+
const handleRef = useRef(null);
|
|
19
|
+
// Memoised on the panel's own content, so an unrelated re-render does not
|
|
20
|
+
// hand `GraphView` a new payload object and re-run the workspace effect.
|
|
21
|
+
const payload = useMemo(() => graphPanelPayload(content), [content]);
|
|
22
|
+
const isOpen = useToolPanelStore((state) => state.isOpen);
|
|
23
|
+
const currentPage = useAppStore((state) => state.currentPage);
|
|
24
|
+
/**
|
|
25
|
+
* Expand through the conversation, not behind it.
|
|
26
|
+
*
|
|
27
|
+
* The panel cannot query the graph — the Cypher tools live on the server, as
|
|
28
|
+
* they must. So a double-click asks the agent, in the chat, the way the user
|
|
29
|
+
* would: the turn is visible, the agent picks the traversal, and the answer
|
|
30
|
+
* merges into the graph on screen. The expansion becomes part of the
|
|
31
|
+
* conversation rather than a side effect nobody can refer back to.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The payload's caption key for this node's label.
|
|
35
|
+
*
|
|
36
|
+
* Every path that NAMES a node — the expansion prompt, the chat-context
|
|
37
|
+
* chip, the client tools — resolves through the same rule the canvas paints
|
|
38
|
+
* by, or the same node ends up with two names and the agent discusses one
|
|
39
|
+
* the user cannot see.
|
|
40
|
+
*/
|
|
41
|
+
const captionKeyFor = useCallback((node) => {
|
|
42
|
+
const legend = payload?.legend;
|
|
43
|
+
const label = primaryLabel(node);
|
|
44
|
+
return legend && hasOwn(legend, label) ? legend[label]?.captionKey : undefined;
|
|
45
|
+
}, [payload]);
|
|
46
|
+
const expandNode = useCallback((node) => {
|
|
47
|
+
submitComposerText(`Expand "${resolveCaption(node, captionKeyFor(node))}" (${primaryLabel(node)}) in the graph — node id ${node.id}.`);
|
|
48
|
+
}, [captionKeyFor]);
|
|
49
|
+
// Publish this panel as THE live graph, so the agent's client tools can read
|
|
50
|
+
// what is on screen and drive it. Registering the handle is what lets
|
|
51
|
+
// `graph_select_node` and `graph_search` reach into a mounted view.
|
|
52
|
+
//
|
|
53
|
+
// Gated on `isOpen`, because closing the panel does not unmount it — the
|
|
54
|
+
// tool panel slides off-screen with `translate-x-full` and stays in the
|
|
55
|
+
// tree. Without this the workspace stayed connected to a graph nobody could
|
|
56
|
+
// see, and `graph_get_view` went on describing it.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const handle = handleRef.current;
|
|
59
|
+
if (!handle || !payload || !isOpen)
|
|
60
|
+
return;
|
|
61
|
+
const workspace = graphWorkspaceStore.getState();
|
|
62
|
+
workspace.connect(handle, content.title, payload);
|
|
63
|
+
// Republish what the view is already showing. `disconnect` cleared the
|
|
64
|
+
// workspace when the panel closed, but the view was never unmounted — so
|
|
65
|
+
// reopening it leaves the drawer and the search box exactly as they were
|
|
66
|
+
// while `graph_get_view` reports neither, and the selected node is missing
|
|
67
|
+
// from the chat context. The callbacks cannot help: they fire on CHANGE,
|
|
68
|
+
// and from the view's side nothing changed.
|
|
69
|
+
const view = handle.getViewState();
|
|
70
|
+
workspace.setSelection(view.selection);
|
|
71
|
+
workspace.setQuery(view.query);
|
|
72
|
+
workspace.setHiddenLabels(view.hiddenLabels);
|
|
73
|
+
syncGraphSelectionContext(view.selection, `graph:${content.title}`, view.selection ? captionKeyFor(view.selection) : undefined);
|
|
74
|
+
return () => graphWorkspaceStore.getState().disconnect(handle);
|
|
75
|
+
// `currentPage` is a dependency for the same reason `isOpen` is, and it is
|
|
76
|
+
// easy to miss because the panel does not move: `setCurrentPage` clears
|
|
77
|
+
// every keyed and ephemeral context item on navigation, on the reasonable
|
|
78
|
+
// assumption that a keyed item tracks UI the navigation just unmounted.
|
|
79
|
+
// The tool panel is the exception — it belongs to the shell, so the graph
|
|
80
|
+
// and its open drawer survive a route change while the selected node
|
|
81
|
+
// silently drops out of the conversation. Nothing fires to put it back:
|
|
82
|
+
// the selection did not change. Re-running this republishes it, which is
|
|
83
|
+
// the same job it already does for reopening a closed panel.
|
|
84
|
+
}, [content.title, payload, isOpen, currentPage]);
|
|
85
|
+
/**
|
|
86
|
+
* The selected node leaves the conversation when the graph does — and not
|
|
87
|
+
* before.
|
|
88
|
+
*
|
|
89
|
+
* This used to ride in the cleanup above, which made it fire on every
|
|
90
|
+
* payload change: an in-place merge, the thing this panel is built around,
|
|
91
|
+
* dropped the user's selected node out of the chat context even though it
|
|
92
|
+
* was still on screen and still selected. Visibility is its own lifetime.
|
|
93
|
+
*/
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (isOpen)
|
|
96
|
+
return;
|
|
97
|
+
syncGraphSelectionContext(null, "");
|
|
98
|
+
}, [isOpen]);
|
|
99
|
+
useEffect(() => () => syncGraphSelectionContext(null, ""), []);
|
|
100
|
+
if (!payload) {
|
|
101
|
+
return _jsx(GraphEmptyState, { message: "This panel does not contain a readable graph." });
|
|
102
|
+
}
|
|
103
|
+
// `absolute inset-0`, not `h-full`.
|
|
104
|
+
//
|
|
105
|
+
// The tool panel's content wrapper is `relative flex-1 overflow-y-auto` — a
|
|
106
|
+
// scroll container. A percentage height inside one resolves against the
|
|
107
|
+
// CONTENT box, so `h-full` lets the canvas grow past the visible area: the
|
|
108
|
+
// graph gets correctly fitted into a canvas taller than the panel, and the
|
|
109
|
+
// bottom half is simply below the fold. Pinning to the positioned ancestor
|
|
110
|
+
// makes the canvas exactly the size of what the user can see, which is also
|
|
111
|
+
// what the ResizeObserver needs to re-fit on drag-resize and fullscreen.
|
|
112
|
+
return (_jsx("div", { className: "absolute inset-0 p-3", children: _jsx(GraphView, { data: payload, variant: "full", height: "100%", handleRef: handleRef, onExpandNode: expandNode,
|
|
113
|
+
// The user's clicks and the agent's reads share one piece of state, so
|
|
114
|
+
// "tell me about this node" needs no restating on either side.
|
|
115
|
+
onSelectionChange: (node) => {
|
|
116
|
+
graphWorkspaceStore.getState().setSelection(node);
|
|
117
|
+
// The selected node joins the conversation automatically, so
|
|
118
|
+
// "tell me about this" needs no restating on either side.
|
|
119
|
+
syncGraphSelectionContext(node, `graph:${content.title}`, node ? captionKeyFor(node) : undefined);
|
|
120
|
+
}, inspectorActions: (node) => (_jsx(GraphContextPinButton, { node: node, captionKey: captionKeyFor(node) })), onQueryChange: (query) => graphWorkspaceStore.getState().setQuery(query), onFilterChange: (labels) => graphWorkspaceStore.getState().setHiddenLabels(labels) }) }));
|
|
121
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type GraphPayload } from "../model.js";
|
|
2
|
+
interface GraphProvenanceProps {
|
|
3
|
+
payload: GraphPayload;
|
|
4
|
+
/** Arguments the agent called the tool with — usually where the query lives. */
|
|
5
|
+
args?: unknown;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* What the card shows when expanded: the query that ran, and the result.
|
|
10
|
+
*
|
|
11
|
+
* The card is provenance, not a viewport — the graph belongs in the panel,
|
|
12
|
+
* where it has room. What someone opens a tool card for is "what did it
|
|
13
|
+
* actually do, and do I believe it": the statement, the shape of what came
|
|
14
|
+
* back, and a way to see the data itself.
|
|
15
|
+
*/
|
|
16
|
+
export declare function GraphProvenance({ payload, args, className }: GraphProvenanceProps): import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export {};
|