@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
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { cn } from "@iloveagents/foundry-web-primitives";
4
+ import { primaryLabel, safeStringify } from "../model.js";
5
+ function readString(source, key) {
6
+ if (typeof source !== "object" || source === null)
7
+ return null;
8
+ const value = source[key];
9
+ return typeof value === "string" && value.length > 0 ? value : null;
10
+ }
11
+ /**
12
+ * What the card shows when expanded: the query that ran, and the result.
13
+ *
14
+ * The card is provenance, not a viewport — the graph belongs in the panel,
15
+ * where it has room. What someone opens a tool card for is "what did it
16
+ * actually do, and do I believe it": the statement, the shape of what came
17
+ * back, and a way to see the data itself.
18
+ */
19
+ export function GraphProvenance({ payload, args, className }) {
20
+ const [showRaw, setShowRaw] = useState(false);
21
+ const query = readString(payload.meta, "query") ?? readString(args, "cypher") ?? readString(args, "query");
22
+ const elapsed = typeof payload.meta?.elapsedMs === "number" ? payload.meta.elapsedMs : null;
23
+ const database = readString(payload.meta, "database");
24
+ const rows = Array.isArray(payload.meta?.rows) ? payload.meta.rows : null;
25
+ const labelCounts = new Map();
26
+ for (const node of payload.nodes) {
27
+ const label = primaryLabel(node);
28
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
29
+ }
30
+ return (_jsxs("div", { className: cn("space-y-2 text-xs", className), children: [query && (_jsxs("section", { children: [_jsx("h4", { className: "text-muted-foreground mb-1 font-medium", children: "Query" }), _jsx("pre", { className: "border-border bg-muted/60 text-foreground max-h-40 overflow-auto rounded border p-2 font-mono text-[11px] leading-relaxed whitespace-pre-wrap", children: query })] })), _jsxs("section", { children: [_jsx("h4", { className: "text-muted-foreground mb-1 font-medium", children: "Result" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1", children: [_jsxs("span", { className: "text-foreground", children: [payload.nodes.length, " nodes \u00B7 ", payload.edges.length, " relationships"] }), [...labelCounts].map(([label, count]) => (_jsxs("span", { className: "text-muted-foreground", children: [label, " ", count] }, label))), payload.truncated && (_jsx("span", { className: "text-muted-foreground", children: "truncated at the server's cap" })), elapsed !== null && _jsxs("span", { className: "text-muted-foreground", children: [elapsed, " ms"] }), database && _jsxs("span", { className: "text-muted-foreground", children: ["db: ", database] })] })] }), rows && rows.length > 0 && (_jsxs("section", { children: [_jsx("h4", { className: "text-muted-foreground mb-1 font-medium", children: "Rows" }), _jsx("pre", { className: "border-border bg-muted/60 text-foreground max-h-40 overflow-auto rounded border p-2 font-mono text-[11px]", children: safeStringify(rows, 2) })] })), _jsx("button", { type: "button", onClick: () => setShowRaw((open) => !open), className: "text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-1", children: showRaw ? "Hide raw result" : "Show raw result" }), showRaw && (_jsx("pre", { className: "border-border bg-muted/60 text-foreground max-h-64 overflow-auto rounded border p-2 font-mono text-[11px]", children: safeStringify(payload, 2) }))] }));
31
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A graph result must reach the panel without anyone expanding anything.
3
+ *
4
+ * Folding a result into the open graph used to be a side effect of RENDERING
5
+ * the tool card — and that card lives inside the turn's collapsed
6
+ * "Worked · 3 tools" strip, which does not mount until the reader expands it.
7
+ * So the merge never ran: the agent narrated an expansion, described four new
8
+ * relationships by name, and the panel sat at three nodes with nothing
9
+ * reporting a failure anywhere.
10
+ *
11
+ * This is the same failure `@ltwlf/spaces-web-ui`'s `CitationSeeder` and
12
+ * `TaskSeeder` were written for, in the same strip, and it takes the same
13
+ * shape: a headless watcher over the thread's own parts, mounted once with the
14
+ * tool UIs, independent of what is expanded. The alternative — force-mounting
15
+ * the strip's contents — makes a disclosure's contents cost something in every
16
+ * transcript and replays every historical result on a reopened chat.
17
+ */
18
+ import type { GraphPayload } from "../model.js";
19
+ interface ToolishPart {
20
+ type?: string;
21
+ toolName?: string;
22
+ toolCallId?: string;
23
+ args?: unknown;
24
+ result?: unknown;
25
+ }
26
+ export interface SeedableGraphResult {
27
+ toolCallId: string;
28
+ toolName: string;
29
+ payload: GraphPayload;
30
+ args: unknown;
31
+ title: string;
32
+ autoOpenPanel: boolean;
33
+ }
34
+ /**
35
+ * Every graph-shaped tool result in the thread, oldest first.
36
+ *
37
+ * Keyed by `toolCallId` rather than by position, so a re-render that rebuilds
38
+ * the parts array does not look like new results.
39
+ */
40
+ export declare function seedableGraphResults(messages: readonly {
41
+ parts?: readonly ToolishPart[];
42
+ }[] | undefined,
43
+ /**
44
+ * Calls already dealt with. Checked BEFORE `extract` runs, which is the
45
+ * whole point of the parameter: `thread.messages` is a new array on every
46
+ * streamed token, so this walk happens per token, and extracting means
47
+ * validating every node and edge of every graph the conversation has ever
48
+ * produced — work that grows with the transcript and is thrown away every
49
+ * time.
50
+ */
51
+ skip?: ReadonlySet<string>): SeedableGraphResult[];
52
+ /**
53
+ * Headless. Acts on graph results that arrive while this thread is open.
54
+ *
55
+ * The baseline is what keeps a reopened conversation from replaying its whole
56
+ * history — opening a saved chat rehydrates every turn it ever had, and
57
+ * without this the last `show_graph` in it would throw a panel over whatever
58
+ * the user was doing. Two details, both learned the hard way downstream
59
+ * (ltwlf/lastspace#650):
60
+ *
61
+ * 1. **History arrives asynchronously.** A resumed thread mounts with NO
62
+ * messages and fills them in later, so a baseline taken on the first effect
63
+ * records an empty set and the entire history then looks new. It is taken
64
+ * on the first NON-EMPTY view instead.
65
+ * 2. **The runtime can outlive the thread.** A host that switches threads
66
+ * without remounting would carry thread A's ids into thread B, so the
67
+ * baseline is re-taken whenever the thread identity changes.
68
+ *
69
+ * The card is untouched and still has its "Open in panel" action, so a result
70
+ * in a reopened thread stays reachable that way.
71
+ */
72
+ export declare function GraphResultSeeder(): null;
73
+ export {};
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A graph result must reach the panel without anyone expanding anything.
3
+ *
4
+ * Folding a result into the open graph used to be a side effect of RENDERING
5
+ * the tool card — and that card lives inside the turn's collapsed
6
+ * "Worked · 3 tools" strip, which does not mount until the reader expands it.
7
+ * So the merge never ran: the agent narrated an expansion, described four new
8
+ * relationships by name, and the panel sat at three nodes with nothing
9
+ * reporting a failure anywhere.
10
+ *
11
+ * This is the same failure `@ltwlf/spaces-web-ui`'s `CitationSeeder` and
12
+ * `TaskSeeder` were written for, in the same strip, and it takes the same
13
+ * shape: a headless watcher over the thread's own parts, mounted once with the
14
+ * tool UIs, independent of what is expanded. The alternative — force-mounting
15
+ * the strip's contents — makes a disclosure's contents cost something in every
16
+ * transcript and replays every historical result on a reopened chat.
17
+ */
18
+ import { useEffect, useRef } from "react";
19
+ import { useAuiState } from "@assistant-ui/react";
20
+ import { useToolPanelStore } from "@iloveagents/foundry-web-ui";
21
+ import { graphToolBindings } from "./graph-tool-registry.js";
22
+ import { GRAPH_PANEL_TYPE, graphPanelContent } from "./graph-panel-content.js";
23
+ import { mergeIntoOpenGraph } from "./merge-into-panel.js";
24
+ /**
25
+ * Every graph-shaped tool result in the thread, oldest first.
26
+ *
27
+ * Keyed by `toolCallId` rather than by position, so a re-render that rebuilds
28
+ * the parts array does not look like new results.
29
+ */
30
+ export function seedableGraphResults(messages,
31
+ /**
32
+ * Calls already dealt with. Checked BEFORE `extract` runs, which is the
33
+ * whole point of the parameter: `thread.messages` is a new array on every
34
+ * streamed token, so this walk happens per token, and extracting means
35
+ * validating every node and edge of every graph the conversation has ever
36
+ * produced — work that grows with the transcript and is thrown away every
37
+ * time.
38
+ */
39
+ skip = new Set()) {
40
+ const bindings = graphToolBindings();
41
+ const found = [];
42
+ const seen = new Set();
43
+ for (const message of messages ?? []) {
44
+ for (const part of message?.parts ?? []) {
45
+ const binding = part?.toolName ? bindings.get(part.toolName) : undefined;
46
+ if (!binding)
47
+ continue;
48
+ // A call still streaming has no result yet, and `undefined` is not a
49
+ // failure — it is simply not ready.
50
+ if (part.result === undefined || part.result === null)
51
+ continue;
52
+ const id = part.toolCallId;
53
+ if (!id || seen.has(id) || skip.has(id))
54
+ continue;
55
+ const payload = binding.extract(part.result, part.args);
56
+ if (!payload)
57
+ continue;
58
+ seen.add(id);
59
+ found.push({
60
+ toolCallId: id,
61
+ toolName: binding.toolName,
62
+ payload,
63
+ args: part.args,
64
+ title: binding.panelTitle?.(payload, part.args) ?? binding.title,
65
+ autoOpenPanel: binding.autoOpenPanel,
66
+ });
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+ /**
72
+ * Headless. Acts on graph results that arrive while this thread is open.
73
+ *
74
+ * The baseline is what keeps a reopened conversation from replaying its whole
75
+ * history — opening a saved chat rehydrates every turn it ever had, and
76
+ * without this the last `show_graph` in it would throw a panel over whatever
77
+ * the user was doing. Two details, both learned the hard way downstream
78
+ * (ltwlf/lastspace#650):
79
+ *
80
+ * 1. **History arrives asynchronously.** A resumed thread mounts with NO
81
+ * messages and fills them in later, so a baseline taken on the first effect
82
+ * records an empty set and the entire history then looks new. It is taken
83
+ * on the first NON-EMPTY view instead.
84
+ * 2. **The runtime can outlive the thread.** A host that switches threads
85
+ * without remounting would carry thread A's ids into thread B, so the
86
+ * baseline is re-taken whenever the thread identity changes.
87
+ *
88
+ * The card is untouched and still has its "Open in panel" action, so a result
89
+ * in a reopened thread stays reachable that way.
90
+ */
91
+ export function GraphResultSeeder() {
92
+ const messages = useAuiState((s) => s.thread.messages);
93
+ const threadId = useAuiState((s) => s.thread.threadId);
94
+ // A GRAPH panel, not any panel. `isOpen` alone let a result be marked
95
+ // handled while a PDF was up: the merge rejected the non-graph content and
96
+ // the call was consumed anyway, so opening the graph later could never
97
+ // apply it — the same loss the closed-panel deferral was added to prevent.
98
+ const graphPanelOpen = useToolPanelStore((state) => state.isOpen && state.content?.type === GRAPH_PANEL_TYPE);
99
+ const baseline = useRef(null);
100
+ /** Seen, extracted, and waiting for a graph panel. Cleared with the baseline. */
101
+ const deferred = useRef(new Set());
102
+ useEffect(() => {
103
+ const current = baseline.current;
104
+ if (current === null || current.threadId !== threadId) {
105
+ // Nothing to anchor to yet — an empty thread may simply not have loaded.
106
+ if (!messages || messages.length === 0)
107
+ return;
108
+ deferred.current.clear();
109
+ baseline.current = {
110
+ threadId,
111
+ ids: new Set(seedableGraphResults(messages).map((result) => result.toolCallId)),
112
+ };
113
+ return;
114
+ }
115
+ // Everything already acted on is skipped before its payload is extracted,
116
+ // so a streaming turn re-walks the parts array without re-parsing the
117
+ // whole conversation's graphs on every token.
118
+ // Handled OR deferred: both are "do not extract this again". They stay
119
+ // separate sets because only `ids` means finished — a deferred call has to
120
+ // become extractable again the moment a graph panel is there for it.
121
+ const skip = graphPanelOpen ? current.ids : new Set([...current.ids, ...deferred.current]);
122
+ for (const result of seedableGraphResults(messages, skip)) {
123
+ if (result.autoOpenPanel) {
124
+ current.ids.add(result.toolCallId);
125
+ useToolPanelStore.getState().openPanel(graphPanelContent(result.title, result.payload));
126
+ continue;
127
+ }
128
+ // A result that arrives while the panel is CLOSED is not handled, it is
129
+ // early. Ask for an expansion, close the panel before the tool returns,
130
+ // reopen it: the merge had nothing to merge into, and marking the call
131
+ // done anyway meant the answer the user asked for was gone for good.
132
+ // Held back instead, and applied when a graph panel comes back — which
133
+ // is why `graphPanelOpen` is a dependency of this effect.
134
+ if (!graphPanelOpen) {
135
+ // Deferred, and REMEMBERED as deferred. Leaving it out of `deferred`
136
+ // as well as out of `ids` meant `seedableGraphResults` re-extracted it
137
+ // on every pass — and a pass happens per streamed token — so a graph
138
+ // held back while the panel was closed re-validated every node and
139
+ // edge of itself for the rest of the turn. That is the same cost the
140
+ // `skip` parameter exists to avoid; this set is the half of it the
141
+ // deferral had quietly opted out of.
142
+ deferred.current.add(result.toolCallId);
143
+ continue;
144
+ }
145
+ deferred.current.delete(result.toolCallId);
146
+ // Fold into the graph already open when it is about the same part of
147
+ // the graph. This is what makes expanding a node — and watching the
148
+ // agent write one — read as the workspace growing rather than as a
149
+ // series of unrelated pictures. A `false` here with the panel OPEN is a
150
+ // real decision — an unrelated result, or nothing new — so it counts as
151
+ // handled either way.
152
+ current.ids.add(result.toolCallId);
153
+ mergeIntoOpenGraph(result.payload);
154
+ }
155
+ }, [messages, threadId, graphPanelOpen]);
156
+ return null;
157
+ }
@@ -0,0 +1,25 @@
1
+ import type { GraphPayload } from "../model.js";
2
+ /**
3
+ * What every graph-shaped tool in the app is called, and how to read it.
4
+ *
5
+ * `makeGraphToolUI` fills this in as a side effect of binding a card, so the
6
+ * headless `GraphResultSeeder` can act on a result without the card having
7
+ * rendered — which it may never do, since it lives inside a collapsed
8
+ * disclosure. Binding a card is therefore what makes a tool's result reach the
9
+ * panel; there is no second registration to forget.
10
+ */
11
+ export interface GraphToolBinding {
12
+ toolName: string;
13
+ title: string;
14
+ extract: (result: unknown, args: unknown) => GraphPayload | null;
15
+ panelTitle?: (payload: GraphPayload, args: unknown) => string;
16
+ autoOpenPanel: boolean;
17
+ }
18
+ /**
19
+ * Idempotent by tool name: a module re-registering on a hot reload replaces
20
+ * its binding rather than stacking a second one.
21
+ */
22
+ export declare function registerGraphTool(binding: GraphToolBinding): void;
23
+ export declare function graphToolBindings(): ReadonlyMap<string, GraphToolBinding>;
24
+ /** Test seam. Not exported from the package. */
25
+ export declare function _resetGraphToolBindings(): void;
@@ -0,0 +1,15 @@
1
+ const bindings = new Map();
2
+ /**
3
+ * Idempotent by tool name: a module re-registering on a hot reload replaces
4
+ * its binding rather than stacking a second one.
5
+ */
6
+ export function registerGraphTool(binding) {
7
+ bindings.set(binding.toolName, binding);
8
+ }
9
+ export function graphToolBindings() {
10
+ return bindings;
11
+ }
12
+ /** Test seam. Not exported from the package. */
13
+ export function _resetGraphToolBindings() {
14
+ bindings.clear();
15
+ }
@@ -0,0 +1,54 @@
1
+ import { type ReactNode } from "react";
2
+ import { type GraphNode, type GraphPayload } from "../model.js";
3
+ export interface MakeGraphToolUIOptions {
4
+ /** The agent tool whose result this card renders. */
5
+ toolName: string;
6
+ title?: string;
7
+ icon?: ReactNode;
8
+ /**
9
+ * Pull a payload out of the tool result. Defaults to {@link coerceGraphPayload},
10
+ * which accepts the payload itself, a JSON string, or one nested under
11
+ * `graph` / `result` / `data` / `payload`.
12
+ */
13
+ extract?: (result: unknown, args: unknown) => GraphPayload | null;
14
+ /** Panel heading. Defaults to the card title. */
15
+ panelTitle?: (payload: GraphPayload, args: unknown) => string;
16
+ /** Open the side panel as soon as a graph arrives. Off by default. */
17
+ autoOpenPanel?: boolean;
18
+ /**
19
+ * Expand the inline graph inside the card. Off by default.
20
+ *
21
+ * The card stays a compact summary and the full graph goes to the side
22
+ * panel, where it has the room to be readable — a 280px graph wedged into a
23
+ * chat column is a thumbnail, not an explorer. Turn this on for a surface
24
+ * with no panel.
25
+ */
26
+ defaultExpanded?: boolean;
27
+ inlineHeight?: number;
28
+ onExpandNode?: (node: GraphNode) => void;
29
+ }
30
+ /**
31
+ * Build a tool card that renders a graph-shaped result.
32
+ *
33
+ * A factory rather than a component because `makeAssistantToolUI` binds a
34
+ * literal tool name at construction time — and hard-coding one would tie this
35
+ * package to whatever the Neo4j starter happens to call its tools. Any agent
36
+ * that emits the payload contract gets the same card:
37
+ *
38
+ * ```ts
39
+ * const CypherToolUI = makeGraphToolUI({ toolName: "graph_read_cypher" });
40
+ * ```
41
+ *
42
+ * A result that is not graph-shaped is not an error — the card renders the
43
+ * usual summary and leaves the body empty rather than throwing inside a chat
44
+ * message.
45
+ */
46
+ export declare function makeGraphToolUI(options: MakeGraphToolUIOptions): import("@assistant-ui/react").AssistantToolUI;
47
+ /**
48
+ * The generic "show me this as a graph" card, bound to a `show_graph` tool.
49
+ *
50
+ * Mirrors `ShowDocumentToolUI` in `@iloveagents/foundry-web-ui`: an agent that
51
+ * wants to put a graph in front of the user calls `show_graph` with the payload
52
+ * and the panel opens itself.
53
+ */
54
+ export declare const GraphToolUI: import("@assistant-ui/react").AssistantToolUI;
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useState } from "react";
3
+ import { makeAssistantToolUI } from "@assistant-ui/react";
4
+ import { Share2 } from "lucide-react";
5
+ import { ToolCallCard, useToolPanelStore } from "@iloveagents/foundry-web-ui";
6
+ import { coerceGraphPayload } from "../model.js";
7
+ import { GraphView } from "../graph-view.js";
8
+ import { GraphProvenance } from "./graph-provenance.js";
9
+ import { graphPanelContent } from "./graph-panel-content.js";
10
+ import { registerGraphTool } from "./graph-tool-registry.js";
11
+ const summarize = (payload) => {
12
+ const nodes = payload.nodes.length;
13
+ const edges = payload.edges.length;
14
+ return `${nodes} ${nodes === 1 ? "node" : "nodes"} · ${edges} ${edges === 1 ? "relationship" : "relationships"}`;
15
+ };
16
+ /**
17
+ * Build a tool card that renders a graph-shaped result.
18
+ *
19
+ * A factory rather than a component because `makeAssistantToolUI` binds a
20
+ * literal tool name at construction time — and hard-coding one would tie this
21
+ * package to whatever the Neo4j starter happens to call its tools. Any agent
22
+ * that emits the payload contract gets the same card:
23
+ *
24
+ * ```ts
25
+ * const CypherToolUI = makeGraphToolUI({ toolName: "graph_read_cypher" });
26
+ * ```
27
+ *
28
+ * A result that is not graph-shaped is not an error — the card renders the
29
+ * usual summary and leaves the body empty rather than throwing inside a chat
30
+ * message.
31
+ */
32
+ export function makeGraphToolUI(options) {
33
+ const { toolName, title = "Graph", icon = _jsx(Share2, { className: "size-3.5" }), extract = (result) => coerceGraphPayload(result), panelTitle, autoOpenPanel = false, defaultExpanded = false, inlineHeight = 280, onExpandNode, } = options;
34
+ // Registering the BINDING, not just the card. `GraphResultSeeder` reads this
35
+ // to fold a result into the open graph, because the card itself may never
36
+ // mount — it lives inside the turn's collapsed activity strip. Binding a
37
+ // card is therefore all a new graph tool needs; there is no second
38
+ // registration to forget.
39
+ registerGraphTool({ toolName, title, extract, panelTitle, autoOpenPanel });
40
+ return makeAssistantToolUI({
41
+ toolName,
42
+ render: ({ args, result, status }) => {
43
+ const openPanel = useToolPanelStore((state) => state.openPanel);
44
+ const [expanded, setExpanded] = useState(defaultExpanded);
45
+ const payload = status.type === "complete" ? extract(result, args) : null;
46
+ const open = useCallback(() => {
47
+ if (!payload)
48
+ return;
49
+ openPanel(graphPanelContent(panelTitle?.(payload, args) ?? title, payload));
50
+ }, [payload, openPanel, args]);
51
+ let description;
52
+ if (status.type === "running")
53
+ description = "Querying the graph…";
54
+ else if (status.type === "requires-action")
55
+ description = "Waiting for approval";
56
+ else if (payload)
57
+ description = summarize(payload);
58
+ else if (status.type === "complete")
59
+ description = "No graph in this result";
60
+ else
61
+ description = "Graph query did not complete";
62
+ return (_jsx(ToolCallCard, { icon: icon, title: title, description: description, status: status, className: "w-full", open: expanded, onOpenChange: setExpanded, action: payload ? { label: "Open in panel", onClick: open } : undefined, children: payload &&
63
+ (defaultExpanded ? (_jsx(GraphView, { data: payload, variant: "inline", height: inlineHeight, onExpandNode: onExpandNode })) : (_jsx(GraphProvenance, { payload: payload, args: args }))) }));
64
+ },
65
+ });
66
+ }
67
+ /**
68
+ * The generic "show me this as a graph" card, bound to a `show_graph` tool.
69
+ *
70
+ * Mirrors `ShowDocumentToolUI` in `@iloveagents/foundry-web-ui`: an agent that
71
+ * wants to put a graph in front of the user calls `show_graph` with the payload
72
+ * and the panel opens itself.
73
+ */
74
+ export const GraphToolUI = makeGraphToolUI({
75
+ toolName: "show_graph",
76
+ autoOpenPanel: true,
77
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Chat glue: the assistant-ui tool card and the tool-panel renderer.
3
+ *
4
+ * Split into its own entry point so the root export stays free of
5
+ * `@assistant-ui/react` — a host that only wants to draw a graph (a dashboard,
6
+ * a report page) imports `@iloveagents/foundry-web-graph` and never loads the
7
+ * chat runtime.
8
+ */
9
+ export { GraphToolUI, makeGraphToolUI, type MakeGraphToolUIOptions } from "./graph-tool-ui.js";
10
+ export { GraphProvenance } from "./graph-provenance.js";
11
+ export { GraphResultSeeder, seedableGraphResults } from "./graph-result-seeder.js";
12
+ export { graphToolBindings, type GraphToolBinding } from "./graph-tool-registry.js";
13
+ export { registerGraphPanelRenderer } from "./register-graph-panel.js";
14
+ export { mergeIntoOpenGraph } from "./merge-into-panel.js";
15
+ export { GRAPH_SELECTION_CONTEXT_KEY, GraphContextPinButton, syncGraphSelectionContext, } from "./graph-context.js";
16
+ export { GRAPH_CLIENT_TOOLS, registerGraphClientTools } from "./graph-client-tools.js";
17
+ export { GRAPH_PANEL_TYPE, graphPanelContent, graphPanelPayload, type GraphPanelContent, type GraphPanelContentLike, } from "./graph-panel-content.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Chat glue: the assistant-ui tool card and the tool-panel renderer.
3
+ *
4
+ * Split into its own entry point so the root export stays free of
5
+ * `@assistant-ui/react` — a host that only wants to draw a graph (a dashboard,
6
+ * a report page) imports `@iloveagents/foundry-web-graph` and never loads the
7
+ * chat runtime.
8
+ */
9
+ export { GraphToolUI, makeGraphToolUI } from "./graph-tool-ui.js";
10
+ export { GraphProvenance } from "./graph-provenance.js";
11
+ export { GraphResultSeeder, seedableGraphResults } from "./graph-result-seeder.js";
12
+ export { graphToolBindings } from "./graph-tool-registry.js";
13
+ export { registerGraphPanelRenderer } from "./register-graph-panel.js";
14
+ export { mergeIntoOpenGraph } from "./merge-into-panel.js";
15
+ export { GRAPH_SELECTION_CONTEXT_KEY, GraphContextPinButton, syncGraphSelectionContext, } from "./graph-context.js";
16
+ export { GRAPH_CLIENT_TOOLS, registerGraphClientTools } from "./graph-client-tools.js";
17
+ export { GRAPH_PANEL_TYPE, graphPanelContent, graphPanelPayload, } from "./graph-panel-content.js";
@@ -0,0 +1,21 @@
1
+ import { type GraphPayload } from "../model.js";
2
+ /**
3
+ * Fold a fresh result into the graph the user already has open.
4
+ *
5
+ * The panel is a workspace, not a slideshow. When the agent expands a node or
6
+ * writes one, the answer is about the graph on screen — so it belongs in that
7
+ * graph, appearing beside what it connects to, rather than arriving as a
8
+ * separate picture the user has to open and mentally join up.
9
+ *
10
+ * Doing it here rather than asking the model to call `graph_show` afterwards
11
+ * also keeps the turn to a single tool call. Two calls in one turn is where
12
+ * the AG-UI client-tool replay gets into trouble, and "the agent remembered to
13
+ * call the second tool" is not a foundation to build the main interaction on.
14
+ *
15
+ * Merges only when the new payload SHARES a node with what is on screen. That
16
+ * overlap is the evidence the two results are about the same thing; without
17
+ * it, an unrelated query would quietly graft itself onto someone's workspace.
18
+ *
19
+ * @returns whether the open graph was updated.
20
+ */
21
+ export declare function mergeIntoOpenGraph(payload: GraphPayload): boolean;
@@ -0,0 +1,72 @@
1
+ import { useToolPanelStore } from "@iloveagents/foundry-web-ui";
2
+ import { deepEqual, mergeGraphPayloads, } from "../model.js";
3
+ import { graphWorkspaceStore } from "../graph-workspace.js";
4
+ import { GRAPH_PANEL_TYPE, graphPanelContent, graphPanelPayload, } from "./graph-panel-content.js";
5
+ /**
6
+ * Fold a fresh result into the graph the user already has open.
7
+ *
8
+ * The panel is a workspace, not a slideshow. When the agent expands a node or
9
+ * writes one, the answer is about the graph on screen — so it belongs in that
10
+ * graph, appearing beside what it connects to, rather than arriving as a
11
+ * separate picture the user has to open and mentally join up.
12
+ *
13
+ * Doing it here rather than asking the model to call `graph_show` afterwards
14
+ * also keeps the turn to a single tool call. Two calls in one turn is where
15
+ * the AG-UI client-tool replay gets into trouble, and "the agent remembered to
16
+ * call the second tool" is not a foundation to build the main interaction on.
17
+ *
18
+ * Merges only when the new payload SHARES a node with what is on screen. That
19
+ * overlap is the evidence the two results are about the same thing; without
20
+ * it, an unrelated query would quietly graft itself onto someone's workspace.
21
+ *
22
+ * @returns whether the open graph was updated.
23
+ */
24
+ export function mergeIntoOpenGraph(payload) {
25
+ const panel = useToolPanelStore.getState();
26
+ const content = panel.content;
27
+ if (!panel.isOpen || content?.type !== GRAPH_PANEL_TYPE)
28
+ return false;
29
+ // The base is what the PANEL holds, not what the workspace published.
30
+ // `setContent` below updates the panel synchronously; the workspace only
31
+ // catches up when `GraphPanel`'s effect re-runs. Two results handled in one
32
+ // pass — an expansion and a write in the same turn, which is the ordinary
33
+ // case here — would otherwise both read the same stale base, and the second
34
+ // would either reject an addition overlapping only the first or overwrite
35
+ // it with `old + second`, silently losing a merge already on screen. The
36
+ // workspace is the fallback for a panel opened by something that only knows
37
+ // how to set strings.
38
+ const current = graphPanelPayload(content) ?? graphWorkspaceStore.getState().payload;
39
+ // An empty payload is not a merge. It is usually a write that DELETED
40
+ // something, and merging cannot express a removal — there is no row for a
41
+ // node that no longer exists, and nothing here can know which ids went. The
42
+ // write tool therefore marks its own result `view_stale` and tells the agent
43
+ // to re-read; silently treating this as "no change" is what would leave the
44
+ // panel drawing a node the database no longer has.
45
+ if (!current || payload.nodes.length === 0)
46
+ return false;
47
+ const onScreen = new Set(current.nodes.map((node) => node.id));
48
+ if (!payload.nodes.some((node) => onScreen.has(node.id)))
49
+ return false;
50
+ // Nothing new AND nothing changed: re-setting identical content would churn
51
+ // the store and re-run the panel's effects for no visible difference.
52
+ //
53
+ // "New" is not enough on its own. `MATCH (n) SET n.status = $status RETURN n`
54
+ // is an ordinary write whose result carries only ids the graph already has,
55
+ // and an id-only test threw it away — leaving the details drawer showing the
56
+ // property the user just changed, at its old value. `mergeGraphPayloads`
57
+ // already lets the fresher entity win; this test only decides whether to ask
58
+ // it to.
59
+ const byId = new Map();
60
+ for (const node of current.nodes)
61
+ byId.set(node.id, node);
62
+ for (const edge of current.edges)
63
+ byId.set(edge.id, edge);
64
+ const differs = (entity) => {
65
+ const existing = byId.get(entity.id);
66
+ return existing === undefined || !deepEqual(existing, entity);
67
+ };
68
+ if (!payload.nodes.some(differs) && !payload.edges.some(differs))
69
+ return false;
70
+ panel.setContent(graphPanelContent(content.title, mergeGraphPayloads(current, payload)));
71
+ return true;
72
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Teach the tool panel to render graphs.
3
+ *
4
+ * Call once at app start, or from a `ChatModule`'s `useInit`. Idempotent: the
5
+ * entry carries a stable id, and the store replaces a same-id entry rather than
6
+ * appending, so a StrictMode double-render or a hot reload cannot stack copies.
7
+ */
8
+ export declare function registerGraphPanelRenderer(): void;
@@ -0,0 +1,18 @@
1
+ import { lazy } from "react";
2
+ import { useToolPanelStore } from "@iloveagents/foundry-web-ui";
3
+ import { GRAPH_PANEL_TYPE, graphPanelPayload } from "./graph-panel-content.js";
4
+ const GraphPanelRenderer = lazy(() => import("./graph-panel.js"));
5
+ /**
6
+ * Teach the tool panel to render graphs.
7
+ *
8
+ * Call once at app start, or from a `ChatModule`'s `useInit`. Idempotent: the
9
+ * entry carries a stable id, and the store replaces a same-id entry rather than
10
+ * appending, so a StrictMode double-render or a hot reload cannot stack copies.
11
+ */
12
+ export function registerGraphPanelRenderer() {
13
+ useToolPanelStore.getState().registerRenderer({
14
+ id: "foundry.graph",
15
+ match: (content) => content.type === GRAPH_PANEL_TYPE && graphPanelPayload(content) !== null,
16
+ component: GraphPanelRenderer,
17
+ });
18
+ }