@seed-ship/mcp-ui-solid 6.8.0 → 6.8.1
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/CHANGELOG.md +32 -0
- package/dist/components/GraphRenderer.cjs +4 -2
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts +21 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +4 -2
- package/dist/components/GraphRenderer.js.map +1 -1
- package/docs/briefs/BRIEF-graph-component-g6.md +559 -0
- package/package.json +1 -1
- package/src/components/GraphRenderer.g6.test.tsx +77 -0
- package/src/components/GraphRenderer.tsx +224 -115
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
# Brief — `'graph'` ComponentType powered by `@antv/g6`
|
|
2
|
+
|
|
3
|
+
> **Status** : drafted 2026-05-02. NOT yet implemented. Intended to surface
|
|
4
|
+
> design choices + risks BEFORE coding so the API doesn't get baked in
|
|
5
|
+
> wrong.
|
|
6
|
+
>
|
|
7
|
+
> **Audience** : `@seed-ship/mcp-ui-solid` maintainer + deposium MCPs
|
|
8
|
+
> agent (will be the first emitter of `type: 'graph'` payloads).
|
|
9
|
+
>
|
|
10
|
+
> **PUBLIC PACKAGE — AGNOSTIC RULE** : `@seed-ship/mcp-ui-solid` and
|
|
11
|
+
> `@seed-ship/mcp-ui-spec` are public on npm. The lib code, CHANGELOG,
|
|
12
|
+
> README, and any committed doc must remain agnostic — no
|
|
13
|
+
> deposium-specific naming, no domain-specific naming. **This brief +
|
|
14
|
+
> its INSPIRATION companion stay LOCAL (untracked) ; they exist to
|
|
15
|
+
> stress-test the agnostic API against real consumer cases without
|
|
16
|
+
> baking those cases into shipped code.**
|
|
17
|
+
>
|
|
18
|
+
> **Effort** : ~1.5 days (spec schemas + renderer + lazy import + tests +
|
|
19
|
+
> CLI hookup). Backward compatible — new ComponentType, opt-in via peer
|
|
20
|
+
> dep installation.
|
|
21
|
+
>
|
|
22
|
+
> **Companion** : sibling pattern of `BRIEF-citations-in-table-cells.md`
|
|
23
|
+
> (data shape on the spec side, render logic on the solid side, peer
|
|
24
|
+
> optional for the heavy lib).
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. The user-facing problem
|
|
29
|
+
|
|
30
|
+
> Examples in this section name a specific consumer (deposium) for
|
|
31
|
+
> stress-testing only — the public lib must NOT reference any of them
|
|
32
|
+
> in code/CHANGELOG/README. The `'graph'` ComponentType ships as a
|
|
33
|
+
> generic node-link primitive ; the consumer wires meaning.
|
|
34
|
+
|
|
35
|
+
LLM-driven UI emitters increasingly want to show **relationships**, not
|
|
36
|
+
just rows or charts. Today mcp-ui has no primitive for this — consumers
|
|
37
|
+
fall back to (a) embedding an `iframe` to a third-party graph viewer
|
|
38
|
+
(heavy, sandboxed, can't pass LLM-generated data nicely) or (b) cobbling
|
|
39
|
+
something with `chart.js` (which doesn't do graph topology at all).
|
|
40
|
+
|
|
41
|
+
Three illustrative seed cases (LOCAL framing, not for the public README) :
|
|
42
|
+
|
|
43
|
+
1. **Entity / organization networks** — entities extracted from docs
|
|
44
|
+
connected by relationship types. Layout : `force`. Without this,
|
|
45
|
+
topology gets lost in flat tables.
|
|
46
|
+
2. **Tool / process DAGs** — facade-to-implementation dependencies, or
|
|
47
|
+
any hierarchical pipeline. Layout : `dagre`. Static DAG visualization
|
|
48
|
+
beats a tabular tool list for "why did this chain fire ?".
|
|
49
|
+
3. **Mindmaps inline in chat answers** — synthesizing LLM reasoning as
|
|
50
|
+
a radial mindmap instead of nested bullet lists. Layout : `mindmap`.
|
|
51
|
+
Visual structure makes the synthesis scannable.
|
|
52
|
+
|
|
53
|
+
§5.5 stress-tests this design against 7 additional concrete cases drawn
|
|
54
|
+
from the INSPIRATION companion brief (chat UX, compliance/SOC2/RGPD,
|
|
55
|
+
runtime observability) — the same agnostic API supports all of them via
|
|
56
|
+
weight + style passthrough.
|
|
57
|
+
|
|
58
|
+
## 2. What already works in MCP-UI v5.7.0
|
|
59
|
+
|
|
60
|
+
Adjacent renderers that informed the design choices below :
|
|
61
|
+
|
|
62
|
+
- **`<ChartRenderer>`** (`UIResourceRenderer.tsx`) — peer-optional
|
|
63
|
+
`chart.js` ; `isChartJSAvailable()` runtime check ; iframe fallback
|
|
64
|
+
to Quickchart.io. **Same lazy-load pattern** is the right precedent
|
|
65
|
+
for G6.
|
|
66
|
+
- **`<ImageGalleryRenderer>`**, **`<MapRenderer>`**, **DuckDB plugin** —
|
|
67
|
+
all peer-optional, all off the main bundle path.
|
|
68
|
+
- **`KNOWN_COMPONENT_TYPES`** + `SPEC_VALIDATORS` dispatch in
|
|
69
|
+
`services/validation.ts` — adding `'graph'` is a single-entry change
|
|
70
|
+
in each.
|
|
71
|
+
|
|
72
|
+
Also relevant : `MCPUITelemetryProvider` (v5.6.0) — graph mounts will
|
|
73
|
+
naturally emit `component:mounted` / `component:rendered` events with
|
|
74
|
+
`durationMs` from existing perf marks. Consumers get visibility for free.
|
|
75
|
+
|
|
76
|
+
## 3. `@antv/g6 v5` quick primer
|
|
77
|
+
|
|
78
|
+
- **Latest line** : v5.x (rewrote from v4 — different API surface, do
|
|
79
|
+
NOT skim v4 docs).
|
|
80
|
+
- **Layouts shipped** : `force` (default for networks), `dagre`,
|
|
81
|
+
`antv-dagre`, `d3-force`, `circular`, `grid`, `concentric`, `radial`,
|
|
82
|
+
`mds`, `random`, `combo-combined`, `dendrogram`, `compact-box`,
|
|
83
|
+
`mindmap` (verify exact name during impl — may be a `compact-box`
|
|
84
|
+
config rather than a dedicated layout).
|
|
85
|
+
- **Renderer** : Canvas (default, fast) or SVG (DOM-friendly,
|
|
86
|
+
print/export). User picks.
|
|
87
|
+
- **Bundle weight** (estimated from `bundlephobia` for v5.0.x) : ~150-200
|
|
88
|
+
KB gzipped for the core + a couple of layouts. Tree-shakable but most
|
|
89
|
+
apps will pull a few layouts.
|
|
90
|
+
- **SSR** : NO — needs canvas/SVG DOM, same constraint as Chart.js
|
|
91
|
+
native renderer. Lazy-loaded after `onMount` is the safe pattern.
|
|
92
|
+
- **Peer-optional declared** : `peerDependencies` `^5.0.0` +
|
|
93
|
+
`peerDependenciesMeta.optional: true`, mirror of `chart.js`.
|
|
94
|
+
|
|
95
|
+
## 4. Proposed API
|
|
96
|
+
|
|
97
|
+
### 4.1 New ComponentType `'graph'`
|
|
98
|
+
|
|
99
|
+
`mcp-ui-spec` adds `'graph'` to `ComponentTypeSchema` enum + new
|
|
100
|
+
`GraphComponentParamsSchema` :
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
GraphNodeSchema = z.object({
|
|
104
|
+
id: z.string().min(1),
|
|
105
|
+
label: z.string().optional(),
|
|
106
|
+
type: z.string().optional(), // 'circle' | 'rect' | 'image' | …
|
|
107
|
+
size: z.union([z.number(), z.tuple([z.number(), z.number()])]).optional(),
|
|
108
|
+
weight: z.number().optional(), // generic ranking signal (importance, frequency, score).
|
|
109
|
+
// Drives default node size if `size` omitted, and
|
|
110
|
+
// acts as the sort key for the `concentric` layout.
|
|
111
|
+
style: z.record(z.unknown()).optional(), // passthrough G6 NodeStyle
|
|
112
|
+
data: z.record(z.unknown()).optional(), // arbitrary metadata for tooltips/click handlers
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
GraphEdgeSchema = z.object({
|
|
116
|
+
source: z.string().min(1), // must match a node.id
|
|
117
|
+
target: z.string().min(1), // must match a node.id
|
|
118
|
+
label: z.string().optional(),
|
|
119
|
+
type: z.string().optional(), // 'line' | 'arc' | 'cubic' | 'polyline' | …
|
|
120
|
+
weight: z.number().optional(), // generic strength signal. Drives default stroke width
|
|
121
|
+
// on canvas, and acts as the attractive force in
|
|
122
|
+
// `force` layouts. Domain semantics opaque to the lib.
|
|
123
|
+
style: z.record(z.unknown()).optional(),
|
|
124
|
+
data: z.record(z.unknown()).optional(),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
GraphLayoutNameSchema = z.enum([
|
|
128
|
+
'force', // ← réseau d'organisations (default when edges.length > 0)
|
|
129
|
+
'dagre', // ← dépendances tools MCP (DAG top-down or left-right)
|
|
130
|
+
'mindmap', // ← mindmaps in chat answers
|
|
131
|
+
'tree', // ← simple parent-child tree
|
|
132
|
+
'circular',
|
|
133
|
+
'grid',
|
|
134
|
+
'concentric',
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
GraphComponentParamsSchema = z.object({
|
|
138
|
+
title: z.string().optional(),
|
|
139
|
+
nodes: z.array(GraphNodeSchema).min(1),
|
|
140
|
+
edges: z.array(GraphEdgeSchema).optional().default([]),
|
|
141
|
+
// Layout: shorthand string OR object with G6 passthrough options
|
|
142
|
+
layout: z.union([
|
|
143
|
+
GraphLayoutNameSchema,
|
|
144
|
+
z.object({
|
|
145
|
+
type: GraphLayoutNameSchema,
|
|
146
|
+
options: z.record(z.unknown()).optional(), // direction, nodeSep, rankSep, …
|
|
147
|
+
}),
|
|
148
|
+
]).optional(),
|
|
149
|
+
height: z.string().optional(), // default '400px'
|
|
150
|
+
width: z.string().optional(), // default '100%'
|
|
151
|
+
rendererPref: z.enum(['canvas', 'svg']).optional(), // default 'canvas'
|
|
152
|
+
fitView: z.boolean().optional(), // default true
|
|
153
|
+
enableZoom: z.boolean().optional(), // default true
|
|
154
|
+
enableDrag: z.boolean().optional(), // default true (drag nodes)
|
|
155
|
+
className: z.string().optional(),
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 4.2 Default layout heuristic
|
|
160
|
+
|
|
161
|
+
When `params.layout` is **omitted** :
|
|
162
|
+
- `edges.length === 0` → `'circular'` (nothing to lay out, just show nodes)
|
|
163
|
+
- `edges.length > 0` → `'force'` ✅ **confirmed 2026-05-02 by user** —
|
|
164
|
+
universal fallback, looks reasonable for networks, matches the most
|
|
165
|
+
common deposium use case (entity / org networks).
|
|
166
|
+
|
|
167
|
+
LLMs SHOULD pass `layout: 'dagre'` for hierarchies and `layout: 'mindmap'`
|
|
168
|
+
for radial structures. This is documented in the spec README + a recipe.
|
|
169
|
+
|
|
170
|
+
### 4.3 Optional `params.onNodeClick` callback (consumer-side)
|
|
171
|
+
|
|
172
|
+
Consumers wire interactivity at the renderer level (NOT in spec —
|
|
173
|
+
function, not JSON-serializable) :
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
type GraphComponentParams = z.infer<...> & {
|
|
177
|
+
onNodeClick?: (nodeId: string, node: GraphNode) => void
|
|
178
|
+
onEdgeClick?: (edgeId: string, edge: GraphEdge) => void
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Same pattern as `<TableRenderer>`'s `citationRender` from v5.7.0 :
|
|
183
|
+
spec-side carries data, consumer-side wires functions.
|
|
184
|
+
|
|
185
|
+
## 5. Implementation sketch
|
|
186
|
+
|
|
187
|
+
### 5.1 `<GraphRenderer>` skeleton
|
|
188
|
+
|
|
189
|
+
New file `src/components/GraphRenderer.tsx`. Mirrors `ChartJSRenderer.tsx`
|
|
190
|
+
+ `MapRenderer.tsx` patterns :
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import { Component, createSignal, onCleanup, onMount, Show } from 'solid-js'
|
|
194
|
+
import type { UIComponent } from '../types'
|
|
195
|
+
|
|
196
|
+
let g6Modulep: Promise<typeof import('@antv/g6')> | undefined
|
|
197
|
+
|
|
198
|
+
export async function isG6Available(): Promise<boolean> {
|
|
199
|
+
try {
|
|
200
|
+
if (!g6Modulep) g6Modulep = import('@antv/g6')
|
|
201
|
+
await g6Modulep
|
|
202
|
+
return true
|
|
203
|
+
} catch {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const GraphRenderer: Component<{ component: UIComponent }> = (props) => {
|
|
209
|
+
const params = () => props.component.params as any
|
|
210
|
+
const [available, setAvailable] = createSignal<boolean | null>(null)
|
|
211
|
+
let containerRef: HTMLDivElement | undefined
|
|
212
|
+
let graphInstance: any | undefined
|
|
213
|
+
|
|
214
|
+
onMount(async () => {
|
|
215
|
+
if (!(await isG6Available())) {
|
|
216
|
+
setAvailable(false)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
setAvailable(true)
|
|
220
|
+
const { Graph } = await g6Modulep!
|
|
221
|
+
const layoutCfg = resolveLayout(params()) // helper handles shorthand + heuristic
|
|
222
|
+
graphInstance = new Graph({
|
|
223
|
+
container: containerRef!,
|
|
224
|
+
data: { nodes: params().nodes, edges: params().edges ?? [] },
|
|
225
|
+
layout: layoutCfg,
|
|
226
|
+
autoFit: params().fitView !== false ? 'view' : false,
|
|
227
|
+
behaviors: collectBehaviors(params()), // drag-canvas, zoom-canvas, drag-element
|
|
228
|
+
renderer: params().rendererPref === 'svg' ? 'svg' : 'canvas',
|
|
229
|
+
})
|
|
230
|
+
await graphInstance.render()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
onCleanup(() => {
|
|
234
|
+
graphInstance?.destroy()
|
|
235
|
+
graphInstance = undefined
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<Show
|
|
240
|
+
when={available() !== false}
|
|
241
|
+
fallback={
|
|
242
|
+
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
243
|
+
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-100">Graph rendering unavailable</p>
|
|
244
|
+
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
|
245
|
+
Install <code>@antv/g6</code> peer dependency to render graph components.
|
|
246
|
+
</p>
|
|
247
|
+
</div>
|
|
248
|
+
}
|
|
249
|
+
>
|
|
250
|
+
<div
|
|
251
|
+
ref={containerRef}
|
|
252
|
+
class={`w-full ${params().className || ''}`}
|
|
253
|
+
style={`height: ${params().height || '400px'}; width: ${params().width || '100%'};`}
|
|
254
|
+
/>
|
|
255
|
+
</Show>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### 5.2 Wire into `UIResourceRenderer` switch
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
<Show when={props.component.type === 'graph'}>
|
|
264
|
+
<GraphRenderer component={props.component} />
|
|
265
|
+
</Show>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 5.3 `validation.ts` dispatch
|
|
269
|
+
|
|
270
|
+
Add to `SPEC_VALIDATORS` :
|
|
271
|
+
```ts
|
|
272
|
+
graph: { schema: GraphComponentParamsSchema, legacyCode: 'INVALID_GRAPH' },
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Add to `KNOWN_COMPONENT_TYPES` set. Adds graph to the count : **15/17
|
|
276
|
+
ComponentTypes Zod-driven** after this (chart, table, modal still
|
|
277
|
+
imperative as documented).
|
|
278
|
+
|
|
279
|
+
### 5.4 Layout heuristic helper
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
function resolveLayout(params: GraphComponentParams) {
|
|
283
|
+
if (typeof params.layout === 'object') {
|
|
284
|
+
return { type: params.layout.type, ...params.layout.options }
|
|
285
|
+
}
|
|
286
|
+
if (typeof params.layout === 'string') {
|
|
287
|
+
return { type: params.layout }
|
|
288
|
+
}
|
|
289
|
+
// Heuristic when layout omitted
|
|
290
|
+
return { type: (params.edges?.length ?? 0) > 0 ? 'force' : 'circular' }
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## 5.5 Design stress-test against the 7 prioritized inspiration cases
|
|
295
|
+
|
|
296
|
+
> Source : `BRIEF-graph-component-g6-INSPIRATION.md` — both files stay
|
|
297
|
+
> LOCAL (untracked) since the package is public and these cases leak
|
|
298
|
+
> deposium internals. Listed here to prove the agnostic API covers them
|
|
299
|
+
> all without case-specific knobs.
|
|
300
|
+
|
|
301
|
+
| Case | Layout | Critical fields used | API gap ? |
|
|
302
|
+
|---|---|---|---|
|
|
303
|
+
| 1.1 Citation provenance graph | `concentric` | `node.weight` (rerank score → ring order), `edge.weight` (rerank → thickness), `node.style` (confidence color) | none |
|
|
304
|
+
| 1.2 Conversation thread map | `mindmap` (horizontal) OR `dagre` LR | `node.data` (parent_id, terminal_state), tooltip default | collapse-expand opt-in deferred (G6 behavior, can opt in later via `enableCollapse: true` flag) |
|
|
305
|
+
| 1.3 Space membership topology | `force` (or `circular` if <20 nodes) | `node.data` (role badges), `edge.data` (relationship type) | none |
|
|
306
|
+
| 2.1 Tenant Access Trust Path | `dagre` top-down | `edge.style` (color encoding HTTP status), `node.data` (resource kind) | none |
|
|
307
|
+
| 2.3 Rate Limit Pressure Map | `force` with grouping | `node.weight` (% consumption), `edge.weight` (correlation), passthrough `layout.options.cluster` | none |
|
|
308
|
+
| 3.2 Dream sense-making lineage | `tree` bottom-up | `edge.weight` (token contribution → branch width), `node.data` (run id) | none |
|
|
309
|
+
| **3.3 BM25 doc-similarity (PRIORITY)** | `concentric` (frequency at center) OR `force` (community detection) | `node.weight` (retrieval count → ring + size), `edge.weight` (co-retrieval count → thickness + force strength), `node.style.fill` (consumer-supplied community color) | community detection itself is consumer responsibility (compute on backend, supply colors via `node.style`) — out of v1 lib scope |
|
|
310
|
+
|
|
311
|
+
All 7 cases reduce to **`{ nodes, edges, layout }` + `weight` first-class
|
|
312
|
+
on both** + style passthrough for visual fine-tuning. The 7-layout enum
|
|
313
|
+
hits 5 distinct values (`force`, `dagre`, `concentric`, `mindmap`,
|
|
314
|
+
`tree`) ; `circular` + `grid` aren't strictly needed for these cases
|
|
315
|
+
but stay in the enum for completeness.
|
|
316
|
+
|
|
317
|
+
### Implications for v1 implementation order
|
|
318
|
+
|
|
319
|
+
3.3 BM25 is flagged **priority** in the focus directive. Its hard
|
|
320
|
+
requirement is `node.weight` + `edge.weight` driving (a) concentric ring
|
|
321
|
+
order, (b) default node size, (c) default edge thickness, (d) force
|
|
322
|
+
strength when `layout: 'force'`. **If the v1 implementation has to cut
|
|
323
|
+
scope, cut `mindmap` layout polish FIRST and keep `force` + `concentric`
|
|
324
|
+
+ `weight` end-to-end working.** All other cases benefit from
|
|
325
|
+
`weight` too, so it's a high-leverage minimum viable.
|
|
326
|
+
|
|
327
|
+
## 6. Mapping use cases → defaults
|
|
328
|
+
|
|
329
|
+
| User case | Recommended `layout` | Notes |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| Réseau d'organisations | `'force'` | Auto if edges present + layout omitted. Force-directed with collision = readable cluster topology. |
|
|
332
|
+
| Dépendances tools MCP | `'dagre'` | LLM passes explicitly. Default direction TB ; LLM can override via `layout: { type: 'dagre', options: { rankdir: 'LR' } }`. |
|
|
333
|
+
| Mindmap dans chat | `'mindmap'` | LLM passes explicitly. Root-centered radial. May need to verify exact G6 v5 layout name (could be `'compact-box'` or `'mindmap'`). |
|
|
334
|
+
| Knowledge graph dense | `'force-atlas2'` | Power user override via `{ type: 'force', options: {...} }` ; we don't expose all 13+ G6 layouts in the enum to keep the spec narrow. |
|
|
335
|
+
| Tree / dependency hierarchy simple | `'tree'` or `'dagre'` | LLM picks. |
|
|
336
|
+
|
|
337
|
+
The 7-layout enum in the spec is **deliberately narrow** — it covers
|
|
338
|
+
80% of needs without bloating the type surface. Power users opt in
|
|
339
|
+
via the `{ type, options }` object form which passes through to G6.
|
|
340
|
+
|
|
341
|
+
## 7. Test plan
|
|
342
|
+
|
|
343
|
+
### 7.1 Spec (`mcp-ui-spec` — ~3 tests)
|
|
344
|
+
|
|
345
|
+
- `GraphComponentParamsSchema` accepts minimal `{ nodes: [{id:'a'}] }`
|
|
346
|
+
- Rejects empty `nodes` array (`min(1)`)
|
|
347
|
+
- Accepts both shorthand `layout: 'force'` and object `layout: { type: 'force', options: {...} }`
|
|
348
|
+
|
|
349
|
+
### 7.2 Renderer (`mcp-ui-solid` — ~6-8 tests)
|
|
350
|
+
|
|
351
|
+
`@antv/g6` mocked in vitest setup (G6 needs canvas, jsdom doesn't have
|
|
352
|
+
one natively — same approach as chart.js mock). Test :
|
|
353
|
+
|
|
354
|
+
- `<GraphRenderer>` renders fallback UI when `isG6Available()` returns
|
|
355
|
+
false
|
|
356
|
+
- With G6 available + `nodes` only → `Graph` constructor called with
|
|
357
|
+
empty `edges` array
|
|
358
|
+
- With G6 available + nodes + edges → `Graph.render()` called
|
|
359
|
+
- `layout` shorthand `'dagre'` → resolved layout object `{ type: 'dagre' }`
|
|
360
|
+
- `layout` object form → options passed through
|
|
361
|
+
- `onCleanup` calls `graph.destroy()` (memory leak guard)
|
|
362
|
+
- canvas default (rendererPref absent/'canvas') → constructor config has NO `renderer` field (G6 v5 default)
|
|
363
|
+
- `rendererPref: 'svg'` → constructor still receives NO string `renderer` (svg degrades to canvas until the g-svg factory is wired)
|
|
364
|
+
- Telemetry integration (B.5) : `component:mounted` + `component:rendered`
|
|
365
|
+
fire (durationMs from perf marks)
|
|
366
|
+
|
|
367
|
+
### 7.3 Integration
|
|
368
|
+
|
|
369
|
+
- `<UIResourceRenderer>` with a `type: 'graph'` component routes to
|
|
370
|
+
`<GraphRenderer>` (no UNKNOWN_COMPONENT_TYPE)
|
|
371
|
+
- `validation.ts` rejects empty `nodes` array with code `INVALID_GRAPH`
|
|
372
|
+
- DOMPurify whitelist : N/A (G6 renders to canvas/svg directly, no
|
|
373
|
+
HTML sanitization path)
|
|
374
|
+
|
|
375
|
+
## 8. Bundle + perf impact
|
|
376
|
+
|
|
377
|
+
- **Apps that don't install `@antv/g6`** : 0 byte added to mcp-ui-solid
|
|
378
|
+
bundle. The dynamic `import('@antv/g6')` resolves to a bundler-handled
|
|
379
|
+
chunk that's never fetched.
|
|
380
|
+
- **Apps that DO install** : ~150-200 KB gzipped fetched on first graph
|
|
381
|
+
mount (asynchronously). Subsequent graphs reuse the loaded module.
|
|
382
|
+
- **Render perf** : G6 v5 canvas renderer handles ~1k nodes / 5k edges
|
|
383
|
+
smoothly. We should add a `RESOURCE_LIMIT_EXCEEDED` check in
|
|
384
|
+
`validateGraphComponent` (max 500 nodes default, configurable via
|
|
385
|
+
`ResourceLimits.maxGraphNodes`) similar to chart's `maxDataPoints`.
|
|
386
|
+
- **Telemetry** : `component:rendered` will report durationMs that
|
|
387
|
+
includes the async G6 init on first mount. May want a separate
|
|
388
|
+
`graph:layout-computed` event later if deposium asks for it (out of
|
|
389
|
+
scope v1).
|
|
390
|
+
|
|
391
|
+
## 9. Open questions
|
|
392
|
+
|
|
393
|
+
1. **G6 v5 mindmap layout name** — needs verification during impl.
|
|
394
|
+
Either `'mindmap'` exists as a dedicated layout, or we map it to
|
|
395
|
+
`'compact-box'` with `{ direction: 'RL' }` style options. If the
|
|
396
|
+
latter, the spec enum still says `'mindmap'` and the resolver
|
|
397
|
+
translates internally.
|
|
398
|
+
|
|
399
|
+
2. **Edge id requirement** — G6 v5 may require an `id` field on edges
|
|
400
|
+
for hover/click handlers. The spec currently doesn't require it
|
|
401
|
+
(uniqueness via `source + target`). If G6 needs unique edge ids,
|
|
402
|
+
we either auto-generate `${source}-${target}-${i}` in the renderer
|
|
403
|
+
OR add `id` to `GraphEdgeSchema` as required. Recommendation : auto-
|
|
404
|
+
generate, keep spec lean.
|
|
405
|
+
|
|
406
|
+
3. **Default `behaviors` set** — `'drag-canvas'`, `'zoom-canvas'`,
|
|
407
|
+
`'drag-element'` are sane defaults. But `'click-select'` (highlights
|
|
408
|
+
nodes on click) might be better default for MCP-rendered graphs that
|
|
409
|
+
typically expect interaction. Recommendation : enable click-select
|
|
410
|
+
by default, expose `enableSelect: false` opt-out.
|
|
411
|
+
|
|
412
|
+
4. **`onNodeClick` / `onEdgeClick` payload shape** — what do consumers
|
|
413
|
+
actually want ? G6 click events return `{ itemType, itemId, item }`.
|
|
414
|
+
Should we surface that raw, or normalize to `{ id, label, data }`
|
|
415
|
+
matching our spec node shape ? Recommendation : normalize, hide G6
|
|
416
|
+
internals.
|
|
417
|
+
|
|
418
|
+
5. **Resource limits** — proposed `maxGraphNodes: 500`,
|
|
419
|
+
`maxGraphEdges: 2000`. Tight enough to keep canvas perf, loose
|
|
420
|
+
enough for real org networks. Configurable via the existing
|
|
421
|
+
`ValidationOptions.limits` extension. **Confirm with deposium** :
|
|
422
|
+
are there cases where they'd want 1000+ nodes ?
|
|
423
|
+
|
|
424
|
+
6. **Tooltip / hover behavior** — G6 v5 has `tooltip` plugin. Do we
|
|
425
|
+
wire it by default reading `node.label` + `node.data` ? Or leave
|
|
426
|
+
off and let consumers wire via callbacks ? Recommendation : wire
|
|
427
|
+
default tooltip showing label + data summary, opt-out via prop.
|
|
428
|
+
|
|
429
|
+
7. **Combos (groupings)** — G6 supports node grouping. Real org
|
|
430
|
+
networks could benefit (group by department). Out of scope v1 ?
|
|
431
|
+
Recommendation : ship without combo support, add in v5.9.0 if
|
|
432
|
+
requested.
|
|
433
|
+
|
|
434
|
+
8. **Server-side rendering of graph snapshots** — completely out of
|
|
435
|
+
scope. G6 needs DOM. If deposium wants graph thumbnails in static
|
|
436
|
+
contexts (PDF export, email summary), they'd render server-side
|
|
437
|
+
via headless Chrome → PNG, not via mcp-ui-solid.
|
|
438
|
+
|
|
439
|
+
## 10. Cross-stack — what deposium needs to do
|
|
440
|
+
|
|
441
|
+
**Phase 1 (one-shot)** — install peer dep + nothing else :
|
|
442
|
+
```bash
|
|
443
|
+
pnpm --filter deposium-solid add @antv/g6@^5
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**Phase 2 (per use case)** — emit `type: 'graph'` payloads from MCPs :
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
// Org network example
|
|
450
|
+
{
|
|
451
|
+
type: 'graph',
|
|
452
|
+
position: { colStart: 1, colSpan: 12 },
|
|
453
|
+
params: {
|
|
454
|
+
title: 'Réseau d\'acteurs Mathilde',
|
|
455
|
+
nodes: extractedEntities.map(e => ({ id: e.id, label: e.name, type: 'circle' })),
|
|
456
|
+
edges: relationships.map(r => ({ source: r.from, target: r.to, label: r.type })),
|
|
457
|
+
// layout omitted → auto 'force'
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Tool dependency DAG example
|
|
462
|
+
{
|
|
463
|
+
type: 'graph',
|
|
464
|
+
position: { colStart: 1, colSpan: 12 },
|
|
465
|
+
params: {
|
|
466
|
+
title: 'Tool execution chain',
|
|
467
|
+
nodes: chain.map(t => ({ id: t.name, label: t.name })),
|
|
468
|
+
edges: chain.flatMap(t => t.delegates.map(d => ({ source: t.name, target: d }))),
|
|
469
|
+
layout: { type: 'dagre', options: { rankdir: 'TB' } },
|
|
470
|
+
height: '500px',
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Mindmap example
|
|
475
|
+
{
|
|
476
|
+
type: 'graph',
|
|
477
|
+
position: { colStart: 1, colSpan: 12 },
|
|
478
|
+
params: {
|
|
479
|
+
title: 'Synthèse Mathilde',
|
|
480
|
+
nodes: mindmapNodes,
|
|
481
|
+
edges: mindmapEdges,
|
|
482
|
+
layout: 'mindmap',
|
|
483
|
+
height: '600px',
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
**Phase 3 (optional)** — wire `onNodeClick` to open the source-doc
|
|
489
|
+
panel for entity nodes (hosts have this hook for citations already).
|
|
490
|
+
|
|
491
|
+
## 11. Migration / risk
|
|
492
|
+
|
|
493
|
+
- **Backward compatible** : new ComponentType, no existing API changed.
|
|
494
|
+
- **Apps without peer dep** : graph components render the fallback UI
|
|
495
|
+
("Install @antv/g6 to render graphs") — non-blocking, informative.
|
|
496
|
+
- **Apps with peer dep** : graphs render normally. No behavior change
|
|
497
|
+
on other ComponentTypes.
|
|
498
|
+
- **Spec bump** : `mcp-ui-spec@5.0.4` (additive enum value + new
|
|
499
|
+
schemas).
|
|
500
|
+
- **Solid bump** : `mcp-ui-solid@5.8.0` (minor — new public component
|
|
501
|
+
+ ComponentType).
|
|
502
|
+
- **CLI bump** : `mcp-ui-cli@5.0.1` (registers `'graph'` as known
|
|
503
|
+
type for `validate` + `generate-types`). ✅ **confirmed 2026-05-02
|
|
504
|
+
by user** — bundled in the v5.8.0 release wave.
|
|
505
|
+
|
|
506
|
+
## 12. Decision needed before implementation
|
|
507
|
+
|
|
508
|
+
### Already resolved (2026-05-02)
|
|
509
|
+
|
|
510
|
+
- ✅ Default layout when edges present + layout omitted = `'force'`
|
|
511
|
+
- ✅ `mcp-ui-cli` registers `'graph'` as known type (bundled in v5.8.0
|
|
512
|
+
release wave)
|
|
513
|
+
- ✅ Brief itself approved (this section)
|
|
514
|
+
|
|
515
|
+
### Still open — pick defaults to unblock implementation
|
|
516
|
+
|
|
517
|
+
1. **GO / NO-GO** on the overall design. Can be partial (e.g. ship
|
|
518
|
+
`force + dagre` only in v1, defer `mindmap` until G6 mindmap
|
|
519
|
+
layout name is verified during impl).
|
|
520
|
+
2. **§9 #3 default behaviors** : default `behaviors` set in G6 ctor.
|
|
521
|
+
Proposed : `['drag-canvas', 'zoom-canvas', 'drag-element',
|
|
522
|
+
'click-select']`. Click-select gives nodes a "selected" highlight
|
|
523
|
+
on click — useful for chat contexts where users want to focus on
|
|
524
|
+
one entity. Opt-out via `enableSelect: false` prop. **OK to ship
|
|
525
|
+
this default ?**
|
|
526
|
+
3. **§9 #5 resource limits** : proposed `maxGraphNodes: 500`,
|
|
527
|
+
`maxGraphEdges: 2000`. **Are there real deposium use cases where
|
|
528
|
+
you'd emit > 500 nodes ?** (e.g. SIRENE org dump for a whole
|
|
529
|
+
département). If yes, raise the cap to 1000 or 2000.
|
|
530
|
+
4. **§9 #6 default tooltip** : G6 has a `tooltip` plugin that shows
|
|
531
|
+
`node.label` + `node.data` summary on hover. Ship enabled by
|
|
532
|
+
default with a `tooltip: false` opt-out, OR ship disabled with
|
|
533
|
+
`tooltip: true` opt-in ? Recommendation : enabled by default
|
|
534
|
+
(typical chat UX expects hover info).
|
|
535
|
+
5. **§9 #4 onNodeClick payload** : normalize to `{ id, label, data }`
|
|
536
|
+
matching our spec node shape (recommended) OR pass G6 raw event ?
|
|
537
|
+
6. **Deposium emission examples §10** : are the 3 example payloads in
|
|
538
|
+
§10 realistic ? Specifically, for the **mindmap** case, do you
|
|
539
|
+
already have a way to extract a tree structure from Mathilde's
|
|
540
|
+
reasoning, or is that itself a separate PR ?
|
|
541
|
+
|
|
542
|
+
## 13. References
|
|
543
|
+
|
|
544
|
+
- `@antv/g6` v5 docs : https://g6.antv.antgroup.com/en/manual
|
|
545
|
+
- `mcp-ui-solid/src/components/ChartJSRenderer.tsx` — the lazy-load
|
|
546
|
+
pattern to mirror
|
|
547
|
+
- `mcp-ui-solid/src/services/validation.ts` — `SPEC_VALIDATORS` +
|
|
548
|
+
`KNOWN_COMPONENT_TYPES` to extend
|
|
549
|
+
- `mcp-ui-solid/CHANGELOG.md` v5.7.0 — last stable shipped
|
|
550
|
+
- Companion brief : `docs/briefs/BRIEF-citations-in-table-cells.md` —
|
|
551
|
+
same data-on-spec / behavior-on-solid split, same peer-optional
|
|
552
|
+
pattern (citations is content not heavy, but the api shape pattern
|
|
553
|
+
matches).
|
|
554
|
+
- **Inspiration** : `docs/briefs/BRIEF-graph-component-g6-INSPIRATION.md`
|
|
555
|
+
— 9 additional use cases gathered 2026-05-02 from a multi-repo
|
|
556
|
+
brainstorm (chat UX / compliance / observability angles). Validates
|
|
557
|
+
the 7-layout enum against a wider intent space and surfaces 3
|
|
558
|
+
commercial angles (RGPD audit, SOC2 incident response, anti-hallu
|
|
559
|
+
forensics) not covered by the seed use cases above.
|
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphRenderer — G6 v5 config contract test (v6.8.1).
|
|
3
|
+
*
|
|
4
|
+
* This is the gap the 2026-05-30 audit (P0.2) identified: the existing
|
|
5
|
+
* `GraphRenderer.test.tsx` only covers the pure transforms (Mermaid/JSON) and
|
|
6
|
+
* `GraphRenderer.fallback.test.tsx` only covers the peer-missing path. NO test
|
|
7
|
+
* exercised the config actually handed to the G6 `Graph` constructor — which
|
|
8
|
+
* is exactly how the `renderer: 'canvas' | 'svg'` string regression (G6 v5
|
|
9
|
+
* expects a renderer *factory*, not a string → "renderer is not a function")
|
|
10
|
+
* shipped unnoticed, crashing every `type:'graph'`.
|
|
11
|
+
*
|
|
12
|
+
* Rather than drive the full component through jsdom (whose container ref is
|
|
13
|
+
* gated behind an async availability `<Show>`, so `new Graph(...)` is never
|
|
14
|
+
* reached in a test renderer), we assert the contract on the **pure**
|
|
15
|
+
* `buildGraphConfig()` helper the component delegates to. Same guarantee,
|
|
16
|
+
* deterministic, no DOM/canvas needed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { buildGraphConfig } from './GraphRenderer';
|
|
21
|
+
import type { GraphComponentParams } from '@seed-ship/mcp-ui-spec';
|
|
22
|
+
|
|
23
|
+
const fakeContainer = {} as HTMLElement;
|
|
24
|
+
|
|
25
|
+
function params(overrides: Partial<GraphComponentParams> = {}): GraphComponentParams {
|
|
26
|
+
return { nodes: [{ id: 'a' }], ...overrides } as GraphComponentParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('buildGraphConfig — G6 v5 constructor contract', () => {
|
|
30
|
+
it('passes the container and node/edge data through', () => {
|
|
31
|
+
const config = buildGraphConfig(
|
|
32
|
+
params({
|
|
33
|
+
nodes: [{ id: 'a', label: 'A' }, { id: 'b' }],
|
|
34
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
35
|
+
}),
|
|
36
|
+
fakeContainer
|
|
37
|
+
);
|
|
38
|
+
expect(config.container).toBe(fakeContainer);
|
|
39
|
+
expect((config.data as { nodes: unknown[] }).nodes).toHaveLength(2);
|
|
40
|
+
expect((config.data as { edges: unknown[] }).edges).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does NOT pass a `renderer` field on the default (canvas) path', () => {
|
|
44
|
+
// The bug: `renderer: 'canvas'` (a string) → G6 v5 throws
|
|
45
|
+
// "renderer is not a function". The fix omits it entirely.
|
|
46
|
+
const config = buildGraphConfig(params(), fakeContainer);
|
|
47
|
+
expect('renderer' in config).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('never passes a string `renderer` for rendererPref: "canvas" (regression guard)', () => {
|
|
51
|
+
const config = buildGraphConfig(params({ rendererPref: 'canvas' }), fakeContainer);
|
|
52
|
+
expect(typeof config.renderer).not.toBe('string');
|
|
53
|
+
expect('renderer' in config).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('never passes a string `renderer` for rendererPref: "svg" (degrades to canvas)', () => {
|
|
57
|
+
// svg is not wired yet — it must NOT inject the string 'svg' (the bug).
|
|
58
|
+
// It degrades to the canvas default, i.e. no `renderer` field at all.
|
|
59
|
+
const config = buildGraphConfig(params({ rendererPref: 'svg' }), fakeContainer);
|
|
60
|
+
expect(typeof config.renderer).not.toBe('string');
|
|
61
|
+
expect('renderer' in config).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('resolves a layout and behaviors array', () => {
|
|
65
|
+
const config = buildGraphConfig(
|
|
66
|
+
params({ edges: [{ source: 'a', target: 'a' }] }),
|
|
67
|
+
fakeContainer
|
|
68
|
+
);
|
|
69
|
+
expect((config.layout as { type: string }).type).toBeTruthy();
|
|
70
|
+
expect(Array.isArray(config.behaviors)).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('omits autoFit when fitView is false', () => {
|
|
74
|
+
expect('autoFit' in buildGraphConfig(params(), fakeContainer)).toBe(true);
|
|
75
|
+
expect('autoFit' in buildGraphConfig(params({ fitView: false }), fakeContainer)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|