feat: add render_mindmap a2ui tool with d3-hierarchy layout
This commit is contained in:
@@ -26,6 +26,7 @@ const CATALOG_ENTRIES: A2UICatalogEntry[] = [
|
||||
{ type: 'metric', description: 'Key-value metric display', custom: true },
|
||||
{ type: 'list', description: 'Ordered or unordered item list' },
|
||||
{ type: 'form', description: 'Form container with fields and submit button', custom: true },
|
||||
{ type: 'mindmap', description: 'Mind map tree diagram with a central topic and branching nodes', custom: true },
|
||||
{ type: 'row', description: 'Horizontal layout container' },
|
||||
{ type: 'column', description: 'Vertical layout container' },
|
||||
{ type: 'divider', description: 'Visual separator' },
|
||||
|
||||
@@ -113,6 +113,17 @@ export interface RenderTabsArgs {
|
||||
tabs: RenderTabArgs[];
|
||||
}
|
||||
|
||||
export interface RenderMindmapNodeArgs {
|
||||
id: string;
|
||||
label: string;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export interface RenderMindmapArgs {
|
||||
title?: string;
|
||||
nodes: RenderMindmapNodeArgs[];
|
||||
}
|
||||
|
||||
// ---- Generators ----
|
||||
|
||||
export function generateChart(
|
||||
@@ -359,6 +370,28 @@ export function generateTabs(
|
||||
|
||||
// ---- Tool name to generator dispatch ----
|
||||
|
||||
export function generateMindmap(
|
||||
conversationId: string,
|
||||
args: RenderMindmapArgs,
|
||||
): A2UIServerMessage[] {
|
||||
const mindmapId = makeId('mindmap');
|
||||
const component: A2UIComponent = {
|
||||
id: mindmapId,
|
||||
type: 'mindmap',
|
||||
properties: {
|
||||
title: args.title,
|
||||
},
|
||||
dataBinding: '/mindmapNodes',
|
||||
};
|
||||
|
||||
return createSurfaceMessages(
|
||||
conversationId,
|
||||
[component],
|
||||
[mindmapId],
|
||||
[{ path: '/mindmapNodes', value: args.nodes }],
|
||||
);
|
||||
}
|
||||
|
||||
const GENERATORS: Record<string, (conversationId: string, args: Record<string, unknown>) => A2UIServerMessage[]> = {
|
||||
render_chart: (cid, args) => generateChart(cid, args as unknown as RenderChartArgs),
|
||||
render_table: (cid, args) => generateTable(cid, args as unknown as RenderTableArgs),
|
||||
@@ -367,6 +400,7 @@ const GENERATORS: Record<string, (conversationId: string, args: Record<string, u
|
||||
render_metric: (cid, args) => generateMetric(cid, args as unknown as RenderMetricArgs),
|
||||
render_list: (cid, args) => generateList(cid, args as unknown as RenderListArgs),
|
||||
render_tabs: (cid, args) => generateTabs(cid, args as unknown as RenderTabsArgs),
|
||||
render_mindmap: (cid, args) => generateMindmap(cid, args as unknown as RenderMindmapArgs),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,8 @@ export type A2UIComponentType =
|
||||
| 'list'
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'divider';
|
||||
| 'divider'
|
||||
| 'mindmap';
|
||||
|
||||
export interface A2UIComponent {
|
||||
id: string;
|
||||
|
||||
@@ -138,6 +138,19 @@ export function createA2UITools() {
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
}),
|
||||
|
||||
render_mindmap: tool({
|
||||
description: 'Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.',
|
||||
inputSchema: z.object({
|
||||
title: z.string().optional().describe('Optional mind map title'),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string().describe('Unique node identifier'),
|
||||
label: z.string().describe('Node label text'),
|
||||
children: z.array(z.string()).optional().describe('IDs of child nodes'),
|
||||
})).describe('Flat array of nodes. The first node is the root. Each node references children by ID.'),
|
||||
}),
|
||||
execute: async (_input) => ({ success: true }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { A2UIList } from './components/A2UIList';
|
||||
import { A2UIRow } from './components/A2UIRow';
|
||||
import { A2UIColumn } from './components/A2UIColumn';
|
||||
import { A2UIDivider } from './components/A2UIDivider';
|
||||
import { A2UIMindmap } from './components/A2UIMindmap';
|
||||
|
||||
export interface A2UIComponentProps {
|
||||
component: A2UIResolvedComponent;
|
||||
@@ -53,6 +54,7 @@ const COMPONENT_REGISTRY: Record<string, ComponentRenderer> = {
|
||||
row: A2UIRow,
|
||||
column: A2UIColumn,
|
||||
divider: A2UIDivider,
|
||||
mindmap: A2UIMindmap,
|
||||
};
|
||||
|
||||
interface A2UIRendererProps {
|
||||
|
||||
252
src/renderer/a2ui/components/A2UIMindmap.tsx
Normal file
252
src/renderer/a2ui/components/A2UIMindmap.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* A2UI Mindmap Component
|
||||
*
|
||||
* Renders a mind map diagram using d3-hierarchy for layout
|
||||
* and d3-shape for link paths, with inline SVG output.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { hierarchy, tree as d3tree } from 'd3-hierarchy';
|
||||
import { linkHorizontal } from 'd3-shape';
|
||||
import type { A2UIResolvedComponent, A2UIClientAction } from '../../../main/a2ui/types';
|
||||
|
||||
interface A2UIComponentProps {
|
||||
component: A2UIResolvedComponent;
|
||||
surfaceId: string;
|
||||
onAction: (action: A2UIClientAction) => void;
|
||||
onDataChange?: (surfaceId: string, path: string, value: unknown) => void;
|
||||
renderChildren?: (children: A2UIResolvedComponent[]) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface MindmapNode {
|
||||
id: string;
|
||||
label: string;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
const NODE_COLORS = [
|
||||
'var(--vscode-charts-blue, #75beff)',
|
||||
'var(--vscode-charts-green, #89d185)',
|
||||
'var(--vscode-charts-orange, #d18616)',
|
||||
'var(--vscode-charts-purple, #b180d7)',
|
||||
'var(--vscode-charts-red, #f14c4c)',
|
||||
'var(--vscode-charts-yellow, #e2e210)',
|
||||
];
|
||||
|
||||
function getNodeColor(depth: number): string {
|
||||
if (depth === 0) return 'var(--vscode-focusBorder, #007fd4)';
|
||||
return NODE_COLORS[(depth - 1) % NODE_COLORS.length];
|
||||
}
|
||||
|
||||
/** Build a nested tree from a flat node array. First node is the root. */
|
||||
export function buildTree(nodes: MindmapNode[]): TreeNode | null {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
const nodeMap = new Map<string, MindmapNode>();
|
||||
for (const node of nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
}
|
||||
|
||||
function toTreeNode(id: string): TreeNode | null {
|
||||
const node = nodeMap.get(id);
|
||||
if (!node) return null;
|
||||
const children: TreeNode[] = [];
|
||||
if (node.children) {
|
||||
for (const childId of node.children) {
|
||||
const child = toTreeNode(childId);
|
||||
if (child) children.push(child);
|
||||
}
|
||||
}
|
||||
return { id: node.id, label: node.label, children };
|
||||
}
|
||||
|
||||
return toTreeNode(nodes[0].id);
|
||||
}
|
||||
|
||||
/** Estimate text width in pixels (rough: 7px per char at 12px font). */
|
||||
function estimateTextWidth(text: string): number {
|
||||
return Math.max(text.length * 7, 40);
|
||||
}
|
||||
|
||||
const NODE_HEIGHT = 28;
|
||||
const NODE_PADDING_X = 12;
|
||||
const NODE_PADDING_Y = 6;
|
||||
const NODE_RADIUS = 6;
|
||||
const VERTICAL_GAP = 8;
|
||||
|
||||
interface LayoutResult {
|
||||
svgWidth: number;
|
||||
svgHeight: number;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
depth: number;
|
||||
}>;
|
||||
links: Array<{
|
||||
source: { x: number; y: number };
|
||||
target: { x: number; y: number };
|
||||
}>;
|
||||
}
|
||||
|
||||
export function computeLayout(root: TreeNode): LayoutResult {
|
||||
const h = hierarchy(root, (d) => (d.children.length > 0 ? d.children : null));
|
||||
|
||||
// Node sizes: [vertical spacing, horizontal spacing]
|
||||
const nodeVSpacing = NODE_HEIGHT + VERTICAL_GAP;
|
||||
const nodeHSpacing = 180;
|
||||
|
||||
const treeLayout = d3tree<TreeNode>().nodeSize([nodeVSpacing, nodeHSpacing]);
|
||||
const treeRoot = treeLayout(h);
|
||||
|
||||
// Collect all positioned nodes
|
||||
const allNodes = treeRoot.descendants();
|
||||
const allLinks = treeRoot.links();
|
||||
|
||||
// Compute node widths based on label text
|
||||
const positioned = allNodes.map((n) => {
|
||||
const textW = estimateTextWidth(n.data.label);
|
||||
const width = textW + NODE_PADDING_X * 2;
|
||||
return {
|
||||
id: n.data.id,
|
||||
label: n.data.label,
|
||||
// d3.tree uses x for vertical, y for horizontal (depth axis)
|
||||
x: n.y, // horizontal position (depth)
|
||||
y: n.x, // vertical position
|
||||
width,
|
||||
depth: n.depth,
|
||||
};
|
||||
});
|
||||
|
||||
// Compute links — connect right edge of source to left edge of target
|
||||
const nodeById = new Map(positioned.map((n) => [n.id, n]));
|
||||
const links = allLinks.map((link) => {
|
||||
const source = nodeById.get(link.source.data.id)!;
|
||||
const target = nodeById.get(link.target.data.id)!;
|
||||
return {
|
||||
source: { x: source.x + source.width / 2, y: source.y },
|
||||
target: { x: target.x - target.width / 2, y: target.y },
|
||||
};
|
||||
});
|
||||
|
||||
// Compute bounding box
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
for (const n of positioned) {
|
||||
const left = n.x - n.width / 2;
|
||||
const right = n.x + n.width / 2;
|
||||
const top = n.y - NODE_HEIGHT / 2;
|
||||
const bottom = n.y + NODE_HEIGHT / 2;
|
||||
if (left < minX) minX = left;
|
||||
if (right > maxX) maxX = right;
|
||||
if (top < minY) minY = top;
|
||||
if (bottom > maxY) maxY = bottom;
|
||||
}
|
||||
|
||||
// Add padding
|
||||
const pad = 16;
|
||||
minX -= pad;
|
||||
minY -= pad;
|
||||
maxX += pad;
|
||||
maxY += pad;
|
||||
|
||||
// Translate all coordinates so they start at (0,0)
|
||||
const offsetX = -minX;
|
||||
const offsetY = -minY;
|
||||
|
||||
return {
|
||||
svgWidth: maxX - minX,
|
||||
svgHeight: maxY - minY,
|
||||
nodes: positioned.map((n) => ({
|
||||
...n,
|
||||
x: n.x + offsetX,
|
||||
y: n.y + offsetY,
|
||||
})),
|
||||
links: links.map((l) => ({
|
||||
source: { x: l.source.x + offsetX, y: l.source.y + offsetY },
|
||||
target: { x: l.target.x + offsetX, y: l.target.y + offsetY },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const linkGenerator = linkHorizontal<
|
||||
{ source: { x: number; y: number }; target: { x: number; y: number } },
|
||||
{ x: number; y: number }
|
||||
>()
|
||||
.x((d) => d.x)
|
||||
.y((d) => d.y);
|
||||
|
||||
export const A2UIMindmap: React.FC<A2UIComponentProps> = ({ component }) => {
|
||||
const title = component.properties.title as string | undefined;
|
||||
const nodes = (component.boundValue as MindmapNode[]) ??
|
||||
(component.properties.nodes as MindmapNode[]) ?? [];
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const root = buildTree(nodes);
|
||||
if (!root) return null;
|
||||
return computeLayout(root);
|
||||
}, [nodes]);
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return (
|
||||
<div className="assistant-panel-mindmap">
|
||||
{title && <p className="assistant-panel-mindmap-title">{title}</p>}
|
||||
<svg
|
||||
className="assistant-panel-mindmap-svg"
|
||||
viewBox={`0 0 ${layout.svgWidth} ${layout.svgHeight}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* Links */}
|
||||
{layout.links.map((link, i) => (
|
||||
<path
|
||||
key={`${component.id}-link-${i}`}
|
||||
className="assistant-panel-mindmap-link"
|
||||
d={linkGenerator(link) ?? ''}
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
{/* Nodes */}
|
||||
{layout.nodes.map((node) => {
|
||||
const color = getNodeColor(node.depth);
|
||||
return (
|
||||
<g key={`${component.id}-node-${node.id}`}>
|
||||
<rect
|
||||
className="assistant-panel-mindmap-node"
|
||||
x={node.x - node.width / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={node.width}
|
||||
height={NODE_HEIGHT}
|
||||
rx={NODE_RADIUS}
|
||||
ry={NODE_RADIUS}
|
||||
fill={color}
|
||||
fillOpacity={node.depth === 0 ? 0.25 : 0.15}
|
||||
stroke={color}
|
||||
strokeWidth={node.depth === 0 ? 2 : 1}
|
||||
>
|
||||
<title>{node.label}</title>
|
||||
</rect>
|
||||
<text
|
||||
className="assistant-panel-mindmap-label"
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -430,3 +430,32 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---- Mindmap ---- */
|
||||
|
||||
.assistant-panel-mindmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.assistant-panel-mindmap-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-panel-mindmap-svg {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.assistant-panel-mindmap-link {
|
||||
stroke: var(--vscode-panel-border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.assistant-panel-mindmap-label {
|
||||
font-size: 12px;
|
||||
fill: var(--vscode-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user