@seed-ship/mcp-ui-solid 6.8.0 → 6.8.2

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.
@@ -18,15 +18,20 @@
18
18
  * data). All three are computed lazily on click.
19
19
  */
20
20
 
21
- import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
22
- import type { UIComponent } from '../types'
23
- import type { GraphComponentParams, GraphLayout, GraphNode, GraphEdge } from '@seed-ship/mcp-ui-spec'
24
- import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
25
- import { PortalDropdownMenu } from './PortalDropdownMenu'
21
+ import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js';
22
+ import type { UIComponent } from '../types';
23
+ import type {
24
+ GraphComponentParams,
25
+ GraphLayout,
26
+ GraphNode,
27
+ GraphEdge,
28
+ } from '@seed-ship/mcp-ui-spec';
29
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
30
+ import { PortalDropdownMenu } from './PortalDropdownMenu';
26
31
 
27
32
  // Module-scoped lazy import promise — first call triggers the dynamic
28
33
  // import, subsequent calls reuse the resolved module.
29
- let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined
34
+ let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined;
30
35
 
31
36
  /**
32
37
  * Whether the `@antv/g6` peer dependency is installed and importable.
@@ -37,12 +42,12 @@ let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined
37
42
  export async function isG6Available(): Promise<boolean> {
38
43
  try {
39
44
  if (!g6ModulePromise) {
40
- g6ModulePromise = import('@antv/g6')
45
+ g6ModulePromise = import('@antv/g6');
41
46
  }
42
- await g6ModulePromise
43
- return true
47
+ await g6ModulePromise;
48
+ return true;
44
49
  } catch {
45
- return false
50
+ return false;
46
51
  }
47
52
  }
48
53
 
@@ -52,16 +57,77 @@ export async function isG6Available(): Promise<boolean> {
52
57
  * present (universal default) or `'circular'` otherwise.
53
58
  */
54
59
  function resolveLayout(params: GraphComponentParams): { type: string; [key: string]: unknown } {
55
- const layout: GraphLayout | undefined = params.layout
60
+ const layout: GraphLayout | undefined = params.layout;
56
61
  if (layout === undefined) {
57
- const hasEdges = (params.edges?.length ?? 0) > 0
58
- return { type: hasEdges ? 'force' : 'circular' }
62
+ const hasEdges = (params.edges?.length ?? 0) > 0;
63
+ return { type: hasEdges ? 'force' : 'circular' };
59
64
  }
60
65
  if (typeof layout === 'string') {
61
- return { type: layout }
66
+ return { type: layout };
62
67
  }
63
68
  // Object form: spread the passthrough options alongside `type`.
64
- return { type: layout.type, ...(layout.options ?? {}) }
69
+ return { type: layout.type, ...(layout.options ?? {}) };
70
+ }
71
+
72
+ /**
73
+ * Build the G6 v5 `Graph` constructor config from the component params.
74
+ *
75
+ * Pure (no DOM/lib side effects beyond reading `container`), so it can be
76
+ * unit-tested directly without a jsdom render — this is the contract the
77
+ * 2026-05-30 audit (P0.2) wants locked.
78
+ *
79
+ * ⚠️ G6 v5's `renderer` is a factory `(layer) => IRenderer`, NOT a string
80
+ * (that was the v4 contract). Passing the string `'canvas'` / `'svg'` makes
81
+ * G6 throw `renderer is not a function` — and because the default path used
82
+ * to pass the string `'canvas'`, EVERY graph crashed, not just svg. So we
83
+ * **omit `renderer` entirely**: G6 then uses its built-in canvas renderer
84
+ * (documented default `() => new CanvasRenderer()`).
85
+ *
86
+ * `rendererPref: 'svg'` is reserved but NOT wired yet — a real G6 v5 SVG
87
+ * renderer needs the `@antv/g-svg` factory (a transitive dep of `@antv/g6`,
88
+ * not statically resolvable at build time). Until that's wired behind proper
89
+ * optional-peer resolution, svg degrades to the canvas default with a
90
+ * one-time warning. It must NEVER inject a string `renderer`.
91
+ */
92
+ export function buildGraphConfig(
93
+ p: GraphComponentParams,
94
+ container?: HTMLElement
95
+ ): Record<string, unknown> {
96
+ const config: Record<string, unknown> = {
97
+ container,
98
+ data: { nodes: p.nodes, edges: p.edges ?? [] },
99
+ layout: resolveLayout(p),
100
+ behaviors: resolveBehaviors(p),
101
+ };
102
+
103
+ if (p.rendererPref === 'svg') {
104
+ console.warn(
105
+ '[MCP-UI] GraphRenderer: rendererPref "svg" is not yet supported; using the default canvas renderer.'
106
+ );
107
+ }
108
+
109
+ if (p.fitView !== false) {
110
+ config.autoFit = 'view';
111
+ }
112
+
113
+ if (p.tooltip !== false) {
114
+ config.plugins = [
115
+ {
116
+ type: 'tooltip',
117
+ getContent: (_evt: unknown, items: any[]) => {
118
+ const item = items?.[0];
119
+ if (!item) return '';
120
+ const label = item.label ?? item.id ?? '';
121
+ const data = item.data ? JSON.stringify(item.data) : '';
122
+ return `<div style="padding:4px 8px"><strong>${escapeHtml(String(label))}</strong>${
123
+ data ? `<br><span style="font-size:11px;opacity:0.7">${escapeHtml(data)}</span>` : ''
124
+ }</div>`;
125
+ },
126
+ },
127
+ ];
128
+ }
129
+
130
+ return config;
65
131
  }
66
132
 
67
133
  /**
@@ -70,13 +136,13 @@ function resolveLayout(params: GraphComponentParams): { type: string; [key: stri
70
136
  * Any flag set to `false` opts out.
71
137
  */
72
138
  function resolveBehaviors(params: GraphComponentParams): string[] {
73
- const behaviors: string[] = []
74
- if (params.enableDrag !== false) behaviors.push('drag-element')
139
+ const behaviors: string[] = [];
140
+ if (params.enableDrag !== false) behaviors.push('drag-element');
75
141
  if (params.enableZoom !== false) {
76
- behaviors.push('zoom-canvas', 'drag-canvas')
142
+ behaviors.push('zoom-canvas', 'drag-canvas');
77
143
  }
78
- if (params.enableSelect !== false) behaviors.push('click-select')
79
- return behaviors
144
+ if (params.enableSelect !== false) behaviors.push('click-select');
145
+ return behaviors;
80
146
  }
81
147
 
82
148
  /**
@@ -85,7 +151,7 @@ function resolveBehaviors(params: GraphComponentParams): string[] {
85
151
  * else (force, concentric, circular, grid) → LR (default mermaid).
86
152
  */
87
153
  function mermaidDirection(layoutType: string): 'TD' | 'LR' {
88
- return layoutType === 'dagre' || layoutType === 'tree' || layoutType === 'mindmap' ? 'TD' : 'LR'
154
+ return layoutType === 'dagre' || layoutType === 'tree' || layoutType === 'mindmap' ? 'TD' : 'LR';
89
155
  }
90
156
 
91
157
  /**
@@ -93,7 +159,10 @@ function mermaidDirection(layoutType: string): 'TD' | 'LR' {
93
159
  * on raw quotes / brackets / pipes ; we strip the worst offenders.
94
160
  */
95
161
  function mermaidLabel(s: string): string {
96
- return s.replace(/["[\]|]/g, '').replace(/\s+/g, ' ').trim()
162
+ return s
163
+ .replace(/["[\]|]/g, '')
164
+ .replace(/\s+/g, ' ')
165
+ .trim();
97
166
  }
98
167
 
99
168
  /**
@@ -101,82 +170,105 @@ function mermaidLabel(s: string): string {
101
170
  * carries the optional `weight` prefix when present (e.g. `|3| label`).
102
171
  */
103
172
  function toMermaid(params: GraphComponentParams): string {
104
- const layoutType = resolveLayout(params).type
105
- const dir = mermaidDirection(layoutType)
106
- const lines: string[] = [`flowchart ${dir}`]
173
+ const layoutType = resolveLayout(params).type;
174
+ const dir = mermaidDirection(layoutType);
175
+ const lines: string[] = [`flowchart ${dir}`];
107
176
  for (const n of params.nodes) {
108
- const label = mermaidLabel(n.label ?? n.id)
109
- lines.push(` ${n.id}["${label}"]`)
177
+ const label = mermaidLabel(n.label ?? n.id);
178
+ lines.push(` ${n.id}["${label}"]`);
110
179
  }
111
180
  for (const e of params.edges ?? []) {
112
- const labelParts: string[] = []
113
- if (e.weight !== undefined) labelParts.push(String(e.weight))
114
- if (e.label) labelParts.push(mermaidLabel(e.label))
115
- const labelText = labelParts.join(' · ')
181
+ const labelParts: string[] = [];
182
+ if (e.weight !== undefined) labelParts.push(String(e.weight));
183
+ if (e.label) labelParts.push(mermaidLabel(e.label));
184
+ const labelText = labelParts.join(' · ');
116
185
  if (labelText) {
117
- lines.push(` ${e.source} -->|${labelText}| ${e.target}`)
186
+ lines.push(` ${e.source} -->|${labelText}| ${e.target}`);
118
187
  } else {
119
- lines.push(` ${e.source} --> ${e.target}`)
188
+ lines.push(` ${e.source} --> ${e.target}`);
120
189
  }
121
190
  }
122
- return lines.join('\n')
191
+ return lines.join('\n');
123
192
  }
124
193
 
125
194
  function toJSON(params: GraphComponentParams): string {
126
- return JSON.stringify({ nodes: params.nodes, edges: params.edges ?? [] }, null, 2)
195
+ return JSON.stringify({ nodes: params.nodes, edges: params.edges ?? [] }, null, 2);
127
196
  }
128
197
 
129
198
  function downloadBlob(content: string | Blob, filename: string, mimeType?: string): void {
130
- const blob = typeof content === 'string' ? new Blob([content], { type: mimeType ?? 'text/plain' }) : content
131
- const url = URL.createObjectURL(blob)
132
- const a = document.createElement('a')
133
- a.href = url
134
- a.download = filename
135
- document.body.appendChild(a)
136
- a.click()
137
- document.body.removeChild(a)
138
- URL.revokeObjectURL(url)
199
+ const blob =
200
+ typeof content === 'string' ? new Blob([content], { type: mimeType ?? 'text/plain' }) : content;
201
+ const url = URL.createObjectURL(blob);
202
+ const a = document.createElement('a');
203
+ a.href = url;
204
+ a.download = filename;
205
+ document.body.appendChild(a);
206
+ a.click();
207
+ document.body.removeChild(a);
208
+ URL.revokeObjectURL(url);
139
209
  }
140
210
 
141
211
  export interface GraphRendererProps {
142
- component: UIComponent
212
+ component: UIComponent;
143
213
  /**
144
214
  * Forwarded to the underlying `<ExpandableWrapper>` (v6.3.1).
145
215
  * @see ExpandableWrapperProps.toolbarVariant
146
216
  */
147
- toolbarVariant?: 'hover' | 'always-visible'
217
+ toolbarVariant?: 'hover' | 'always-visible';
148
218
  }
149
219
 
150
220
  export const GraphRenderer: Component<GraphRendererProps> = (props) => {
151
- const params = () => props.component.params as GraphComponentParams
152
- const isExpanded = useExpanded()
153
- const [available, setAvailable] = createSignal<boolean | null>(null)
154
- const [error, setError] = createSignal<string | undefined>()
155
- const [exportMenuOpen, setExportMenuOpen] = createSignal(false)
156
- let containerRef: HTMLDivElement | undefined
221
+ const params = () => props.component.params as GraphComponentParams;
222
+ const isExpanded = useExpanded();
223
+ const [available, setAvailable] = createSignal<boolean | null>(null);
224
+ const [error, setError] = createSignal<string | undefined>();
225
+ const [exportMenuOpen, setExportMenuOpen] = createSignal(false);
226
+ let containerRef: HTMLDivElement | undefined;
157
227
  // v6.4.0 — trigger ref consumed by <PortalDropdownMenu> for positioning
158
- let exportTriggerRef: HTMLButtonElement | undefined
228
+ let exportTriggerRef: HTMLButtonElement | undefined;
159
229
  // Loosely typed because G6 is a peer-optional — we don't pull its
160
230
  // types into the bundle just to type a transient local handle.
161
- let graphInstance: any | undefined
231
+ let graphInstance: any | undefined;
162
232
 
163
233
  onMount(async () => {
164
- const g6Available = await isG6Available()
165
- setAvailable(g6Available)
166
- if (!g6Available || !containerRef) return
234
+ const g6Available = await isG6Available();
235
+ setAvailable(g6Available);
236
+ if (!g6Available || !containerRef) return;
167
237
 
168
238
  try {
169
- const { Graph } = await g6ModulePromise!
170
- const p = params()
239
+ const { Graph } = await g6ModulePromise!;
240
+ const p = params();
171
241
  const config: Record<string, unknown> = {
172
242
  container: containerRef,
173
243
  data: { nodes: p.nodes, edges: p.edges ?? [] },
174
244
  layout: resolveLayout(p),
175
245
  behaviors: resolveBehaviors(p),
176
- renderer: p.rendererPref === 'svg' ? 'svg' : 'canvas',
246
+ };
247
+
248
+ // G6 v5's `renderer` is a factory `(layer) => IRenderer`, NOT a string
249
+ // (that was the v4 contract). Passing the string `'canvas'` / `'svg'`
250
+ // makes G6 throw `renderer is not a function` — and because the default
251
+ // path also passed the string `'canvas'`, EVERY graph crashed, not just
252
+ // the svg one.
253
+ //
254
+ // Fix: omit `renderer` entirely. G6 then uses its built-in canvas
255
+ // renderer (documented default `() => new CanvasRenderer()`), which is
256
+ // what we want for every graph.
257
+ //
258
+ // `rendererPref: 'svg'` is NOT wired yet: a real G6 v5 SVG renderer
259
+ // needs the `@antv/g-svg` factory, which is a *transitive* dep of
260
+ // `@antv/g6` (not a declared peer) and isn't statically resolvable at
261
+ // build time. Rather than ship a fragile/unresolvable import, we treat
262
+ // svg as "not yet supported" and fall back to the canvas default with a
263
+ // one-time warning. Tracked for a follow-up that wires the factory
264
+ // behind a proper optional-peer resolution. (Never pass a string here.)
265
+ if (p.rendererPref === 'svg') {
266
+ console.warn(
267
+ '[MCP-UI] GraphRenderer: rendererPref "svg" is not yet supported; using the default canvas renderer.'
268
+ );
177
269
  }
178
270
  if (p.fitView !== false) {
179
- config.autoFit = 'view'
271
+ config.autoFit = 'view';
180
272
  }
181
273
  if (p.tooltip !== false) {
182
274
  // Built-in tooltip plugin — shows label + a compact dump of
@@ -185,67 +277,69 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
185
277
  {
186
278
  type: 'tooltip',
187
279
  getContent: (_evt: unknown, items: any[]) => {
188
- const item = items?.[0]
189
- if (!item) return ''
190
- const label = item.label ?? item.id ?? ''
191
- const data = item.data ? JSON.stringify(item.data) : ''
280
+ const item = items?.[0];
281
+ if (!item) return '';
282
+ const label = item.label ?? item.id ?? '';
283
+ const data = item.data ? JSON.stringify(item.data) : '';
192
284
  return `<div style="padding:4px 8px"><strong>${escapeHtml(String(label))}</strong>${
193
- data ? `<br><span style="font-size:11px;opacity:0.7">${escapeHtml(data)}</span>` : ''
194
- }</div>`
285
+ data
286
+ ? `<br><span style="font-size:11px;opacity:0.7">${escapeHtml(data)}</span>`
287
+ : ''
288
+ }</div>`;
195
289
  },
196
290
  },
197
- ]
291
+ ];
198
292
  }
199
- graphInstance = new (Graph as any)(config)
200
- await graphInstance.render()
293
+ graphInstance = new (Graph as any)(config);
294
+ await graphInstance.render();
201
295
  } catch (err) {
202
- setError(err instanceof Error ? err.message : 'Failed to render graph')
296
+ setError(err instanceof Error ? err.message : 'Failed to render graph');
203
297
  }
204
- })
298
+ });
205
299
 
206
300
  onCleanup(() => {
207
301
  try {
208
- graphInstance?.destroy()
302
+ graphInstance?.destroy();
209
303
  } catch {
210
304
  // G6 destroy can throw on already-destroyed instances or partial
211
305
  // init failures — silent because the component is unmounting anyway.
212
306
  }
213
- graphInstance = undefined
214
- })
307
+ graphInstance = undefined;
308
+ });
215
309
 
216
310
  // ─── Export handlers ────────────────────────────────────────────────
217
311
  const handleExportJSON = () => {
218
- downloadBlob(toJSON(params()), `${graphFilenameStem(params())}.json`, 'application/json')
219
- setExportMenuOpen(false)
220
- }
312
+ downloadBlob(toJSON(params()), `${graphFilenameStem(params())}.json`, 'application/json');
313
+ setExportMenuOpen(false);
314
+ };
221
315
 
222
316
  const handleExportMermaid = () => {
223
- downloadBlob(toMermaid(params()), `${graphFilenameStem(params())}.mmd`, 'text/plain')
224
- setExportMenuOpen(false)
225
- }
317
+ downloadBlob(toMermaid(params()), `${graphFilenameStem(params())}.mmd`, 'text/plain');
318
+ setExportMenuOpen(false);
319
+ };
226
320
 
227
321
  const handleExportPNG = async () => {
228
- if (!graphInstance) return
322
+ if (!graphInstance) return;
229
323
  try {
230
324
  // G6 v5 exposes `toDataURL()` on the graph instance.
231
- const dataUrl: string = await graphInstance.toDataURL?.('image/png')
325
+ const dataUrl: string = await graphInstance.toDataURL?.('image/png');
232
326
  if (!dataUrl) {
233
327
  // Fallback: try to grab the underlying canvas directly.
234
- const canvas = containerRef?.querySelector('canvas')
328
+ const canvas = containerRef?.querySelector('canvas');
235
329
  if (canvas) {
236
- const url = (canvas as HTMLCanvasElement).toDataURL('image/png')
237
- await downloadDataUrl(url, `${graphFilenameStem(params())}.png`)
330
+ const url = (canvas as HTMLCanvasElement).toDataURL('image/png');
331
+ await downloadDataUrl(url, `${graphFilenameStem(params())}.png`);
238
332
  } else {
239
- setError('PNG export not supported in current renderer mode')
333
+ setError('PNG export not supported in current renderer mode');
240
334
  }
241
335
  } else {
242
- await downloadDataUrl(dataUrl, `${graphFilenameStem(params())}.png`)
336
+ await downloadDataUrl(dataUrl, `${graphFilenameStem(params())}.png`);
243
337
  }
244
338
  } catch (err) {
245
- setError(err instanceof Error ? err.message : 'PNG export failed')
339
+ setError(err instanceof Error ? err.message : 'PNG export failed');
246
340
  }
247
- setExportMenuOpen(false)
248
- }
341
+ setExportMenuOpen(false);
342
+ };
249
343
 
250
344
  return (
251
345
  <Show
@@ -266,7 +360,8 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
266
360
  Graph rendering unavailable
267
361
  </p>
268
362
  <p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
269
- Install <code>@antv/g6</code> peer dependency to render <code>type: "graph"</code> components.
363
+ Install <code>@antv/g6</code> peer dependency to render <code>type: "graph"</code>{' '}
364
+ components.
270
365
  </p>
271
366
  </div>
272
367
  </Show>
@@ -278,9 +373,11 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
278
373
  copyLabel="Copy graph (JSON)"
279
374
  toolbarVariant={props.toolbarVariant}
280
375
  >
281
- <div class={`relative w-full ${params().className ?? ''} ${
282
- isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
283
- }`}>
376
+ <div
377
+ class={`relative w-full ${params().className ?? ''} ${
378
+ isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
379
+ }`}
380
+ >
284
381
  {/* Export menu — top-right, mirrors TableRenderer's pattern */}
285
382
  <div class="absolute right-2 top-2 z-10">
286
383
  <button
@@ -302,11 +399,17 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
302
399
  width={176}
303
400
  class="text-xs"
304
401
  >
305
- <For each={[
306
- { label: 'Download PNG', onClick: handleExportPNG, hint: 'visual snapshot' },
307
- { label: 'Download Mermaid', onClick: handleExportMermaid, hint: 'markdown / GitHub' },
308
- { label: 'Download JSON', onClick: handleExportJSON, hint: 'raw data' },
309
- ]}>
402
+ <For
403
+ each={[
404
+ { label: 'Download PNG', onClick: handleExportPNG, hint: 'visual snapshot' },
405
+ {
406
+ label: 'Download Mermaid',
407
+ onClick: handleExportMermaid,
408
+ hint: 'markdown / GitHub',
409
+ },
410
+ { label: 'Download JSON', onClick: handleExportJSON, hint: 'raw data' },
411
+ ]}
412
+ >
310
413
  {(item) => (
311
414
  <button
312
415
  type="button"
@@ -338,34 +441,40 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
338
441
  </div>
339
442
  </ExpandableWrapper>
340
443
  </Show>
341
- )
342
- }
444
+ );
445
+ };
343
446
 
344
447
  // ─── Helpers ──────────────────────────────────────────────────────────
345
448
 
346
449
  function graphFilenameStem(params: GraphComponentParams): string {
347
- const base = (params.title ?? 'graph').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-+|-+$/g, '')
348
- return base || 'graph'
450
+ const base = (params.title ?? 'graph').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-+|-+$/g, '');
451
+ return base || 'graph';
349
452
  }
350
453
 
351
454
  async function downloadDataUrl(dataUrl: string, filename: string): Promise<void> {
352
- const res = await fetch(dataUrl)
353
- const blob = await res.blob()
354
- downloadBlob(blob, filename)
455
+ const res = await fetch(dataUrl);
456
+ const blob = await res.blob();
457
+ downloadBlob(blob, filename);
355
458
  }
356
459
 
357
460
  function escapeHtml(s: string): string {
358
461
  return s.replace(/[&<>"']/g, (c) => {
359
462
  switch (c) {
360
- case '&': return '&amp;'
361
- case '<': return '&lt;'
362
- case '>': return '&gt;'
363
- case '"': return '&quot;'
364
- case "'": return '&#39;'
365
- default: return c
463
+ case '&':
464
+ return '&amp;';
465
+ case '<':
466
+ return '&lt;';
467
+ case '>':
468
+ return '&gt;';
469
+ case '"':
470
+ return '&quot;';
471
+ case "'":
472
+ return '&#39;';
473
+ default:
474
+ return c;
366
475
  }
367
- })
476
+ });
368
477
  }
369
478
 
370
479
  // Re-export for tests + consumers that want to compose their own export menu
371
- export { toMermaid as graphToMermaid, toJSON as graphToJSON }
480
+ export { toMermaid as graphToMermaid, toJSON as graphToJSON };