@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,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
|
+
}
|