Merge pull request 'feat: agent lineage visualization (#15)' (#35) from feature/issue-15-agent-lineage into main

This commit was merged in pull request #35.
This commit is contained in:
2026-03-12 12:21:23 +01:00
8 changed files with 692 additions and 0 deletions

View File

@@ -16,3 +16,4 @@
| #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) |
| #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) |
| #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.md) |
| #15 | Agent lineage visualization | COMPLETED | [issue-015.md](issue-015.md) |

View File

@@ -0,0 +1,39 @@
---
---
# Issue #15: Agent lineage visualization
**Status:** COMPLETED
**Issue:** https://git.shahondin1624.de/llm-multiverse/llm-multiverse-ui/issues/15
**Branch:** `feature/issue-15-agent-lineage`
## Acceptance Criteria
- [x] Dedicated `/lineage` route
- [x] Tree/graph visualization of agent spawn chain
- [x] Each node displays: agent_id, agent_type, spawn_depth
- [x] Visual hierarchy reflecting parent-child agent relationships
- [x] Custom SVG-based rendering (no external graph library needed)
- [x] Updates as new agents appear in the stream (reactive via `$derived`)
- [x] Clickable nodes to show agent details
## Implementation
### New Files
- `src/lib/types/lineage.ts` — lineage types (`LineageNode`, `SimpleAgentIdentifier`), tree builder, color/label helpers, sample data
- `src/lib/components/LineageTree.svelte` — custom SVG tree visualization with horizontal layout, bezier edges, colored nodes by agent type, click selection
- `src/routes/lineage/+page.svelte` — dedicated lineage route with tree, legend, and detail panel
- `src/routes/lineage/+page.ts` — SSR disabled for this route
### Modified Files
- `src/routes/+page.svelte` — added navigation links to Chat and Agent Lineage
- `src/routes/chat/+page.svelte` — added Lineage link in header alongside Config button
### Key Decisions
- Uses sample/demo data since the API does not yet expose lineage information (AuditService is write-only, ProcessRequestResponse does not include lineage)
- `SimpleAgentIdentifier` type decouples the UI from protobuf Message dependency, making it easy to adapt when real data arrives
- Horizontal tree layout: root on left, children branching right, computed via recursive leaf-counting algorithm
- SVG bezier curves for edges, rounded rectangles for nodes
- Agent type colors: Orchestrator=blue, Researcher=green, Coder=purple, SysAdmin=orange, Assistant=teal, Unspecified=gray
- Detail panel slides in from right when a node is clicked, showing full agent info and child list
- Tree layout is fully reactive via `$derived` runes — updating the `agents` array recomputes the tree automatically

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import type { LineageNode } from '$lib/types/lineage';
import { agentTypeLabel, agentTypeColor } from '$lib/types/lineage';
let {
nodes,
onSelectNode
}: {
nodes: LineageNode[];
onSelectNode?: (node: LineageNode) => void;
} = $props();
let selectedNodeId: string | null = $state(null);
// Layout constants
const NODE_WIDTH = 180;
const NODE_HEIGHT = 64;
const HORIZONTAL_GAP = 60;
const VERTICAL_GAP = 24;
/**
* Positioned node with computed x/y coordinates for SVG rendering.
*/
interface PositionedNode {
node: LineageNode;
x: number;
y: number;
children: PositionedNode[];
}
/**
* Edge between two positioned nodes.
*/
interface Edge {
fromX: number;
fromY: number;
toX: number;
toY: number;
}
/**
* Recursively counts the total number of leaf nodes in a subtree.
* A leaf is a node with no children; it counts as 1 vertical slot.
*/
function countLeaves(node: LineageNode): number {
if (node.children.length === 0) return 1;
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
/**
* Computes positioned nodes using a horizontal tree layout.
* Root is on the left; children branch to the right.
*/
function layoutTree(roots: LineageNode[]): {
positioned: PositionedNode[];
totalWidth: number;
totalHeight: number;
} {
const totalLeaves = roots.reduce((sum, r) => sum + countLeaves(r), 0);
const totalHeight = totalLeaves * (NODE_HEIGHT + VERTICAL_GAP) - VERTICAL_GAP;
function positionNode(
node: LineageNode,
depth: number,
startY: number,
availableHeight: number
): PositionedNode {
const x = depth * (NODE_WIDTH + HORIZONTAL_GAP);
if (node.children.length === 0) {
const y = startY + availableHeight / 2 - NODE_HEIGHT / 2;
return { node, x, y, children: [] };
}
const childLeafCounts = node.children.map(countLeaves);
const totalChildLeaves = childLeafCounts.reduce((a, b) => a + b, 0);
let currentY = startY;
const positionedChildren: PositionedNode[] = [];
for (let i = 0; i < node.children.length; i++) {
const childHeight = (childLeafCounts[i] / totalChildLeaves) * availableHeight;
const positioned = positionNode(node.children[i], depth + 1, currentY, childHeight);
positionedChildren.push(positioned);
currentY += childHeight;
}
// Center parent vertically relative to its children
const firstChildCenter = positionedChildren[0].y + NODE_HEIGHT / 2;
const lastChildCenter =
positionedChildren[positionedChildren.length - 1].y + NODE_HEIGHT / 2;
const y = (firstChildCenter + lastChildCenter) / 2 - NODE_HEIGHT / 2;
return { node, x, y, children: positionedChildren };
}
let currentY = 0;
const positioned: PositionedNode[] = [];
for (const root of roots) {
const rootLeaves = countLeaves(root);
const rootHeight = (rootLeaves / totalLeaves) * totalHeight;
positioned.push(positionNode(root, 0, currentY, rootHeight));
currentY += rootHeight;
}
// Calculate max depth for width
function maxDepth(pn: PositionedNode): number {
if (pn.children.length === 0) return 0;
return 1 + Math.max(...pn.children.map(maxDepth));
}
const depth = positioned.length > 0 ? Math.max(...positioned.map(maxDepth)) : 0;
const totalWidth = (depth + 1) * (NODE_WIDTH + HORIZONTAL_GAP) - HORIZONTAL_GAP;
return { positioned, totalWidth, totalHeight };
}
/**
* Collects all edges from the positioned tree for SVG rendering.
*/
function collectEdges(positioned: PositionedNode[]): Edge[] {
const edges: Edge[] = [];
function walk(pn: PositionedNode) {
for (const child of pn.children) {
edges.push({
fromX: pn.x + NODE_WIDTH,
fromY: pn.y + NODE_HEIGHT / 2,
toX: child.x,
toY: child.y + NODE_HEIGHT / 2
});
walk(child);
}
}
for (const pn of positioned) {
walk(pn);
}
return edges;
}
/**
* Collects all positioned nodes into a flat list for rendering.
*/
function collectNodes(positioned: PositionedNode[]): PositionedNode[] {
const result: PositionedNode[] = [];
function walk(pn: PositionedNode) {
result.push(pn);
for (const child of pn.children) {
walk(child);
}
}
for (const pn of positioned) {
walk(pn);
}
return result;
}
const layout = $derived(layoutTree(nodes));
const edges = $derived(collectEdges(layout.positioned));
const allNodes = $derived(collectNodes(layout.positioned));
const svgWidth = $derived(layout.totalWidth + 40);
const svgHeight = $derived(layout.totalHeight + 40);
function handleNodeClick(node: LineageNode) {
selectedNodeId = selectedNodeId === node.id ? null : node.id;
onSelectNode?.(node);
}
/**
* Generates an SVG path for a smooth bezier curve between two points.
*/
function edgePath(edge: Edge): string {
const midX = (edge.fromX + edge.toX) / 2;
return `M ${edge.fromX} ${edge.fromY} C ${midX} ${edge.fromY}, ${midX} ${edge.toY}, ${edge.toX} ${edge.toY}`;
}
/**
* Truncates an ID string for display in the node.
*/
function truncateId(id: string): string {
return id.length > 16 ? id.slice(0, 14) + '..' : id;
}
</script>
<div class="overflow-auto rounded-lg border border-gray-200 bg-white">
{#if nodes.length === 0}
<div class="flex items-center justify-center p-12 text-sm text-gray-400">
No lineage data available
</div>
{:else}
<svg
width={svgWidth}
height={svgHeight}
viewBox="0 0 {svgWidth} {svgHeight}"
class="min-w-full"
>
<g transform="translate(20, 20)">
<!-- Edges -->
{#each edges as edge, i (i)}
<path
d={edgePath(edge)}
fill="none"
stroke="#d1d5db"
stroke-width="2"
stroke-linecap="round"
/>
{/each}
<!-- Nodes -->
{#each allNodes as pn (pn.node.id)}
{@const colors = agentTypeColor(pn.node.agentType)}
{@const isSelected = selectedNodeId === pn.node.id}
<g
class="cursor-pointer"
transform="translate({pn.x}, {pn.y})"
onclick={() => handleNodeClick(pn.node)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleNodeClick(pn.node);
}}
role="button"
tabindex="0"
aria-label="Agent {pn.node.id}, type {agentTypeLabel(pn.node.agentType)}, depth {pn.node.spawnDepth}"
>
<!-- Node background -->
<rect
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx="8"
ry="8"
fill={colors.fill}
stroke={isSelected ? colors.stroke : '#e5e7eb'}
stroke-width={isSelected ? 2.5 : 1.5}
/>
<!-- Agent type label -->
<text
x={NODE_WIDTH / 2}
y="22"
text-anchor="middle"
fill={colors.text}
font-size="12"
font-weight="600"
>
{agentTypeLabel(pn.node.agentType)}
</text>
<!-- Agent ID -->
<text
x={NODE_WIDTH / 2}
y="38"
text-anchor="middle"
fill="#6b7280"
font-size="10"
font-family="monospace"
>
{truncateId(pn.node.id)}
</text>
<!-- Spawn depth badge -->
<text
x={NODE_WIDTH / 2}
y="54"
text-anchor="middle"
fill="#9ca3af"
font-size="10"
>
depth: {pn.node.spawnDepth}
</text>
</g>
{/each}
</g>
</svg>
{/if}
</div>

200
src/lib/types/lineage.ts Normal file
View File

@@ -0,0 +1,200 @@
import { AgentType } from '$lib/proto/llm_multiverse/v1/common_pb';
/**
* A node in the agent lineage tree, enriched with children references
* for tree rendering.
*/
export interface LineageNode {
id: string;
agentType: AgentType;
spawnDepth: number;
children: LineageNode[];
}
/**
* Flat agent identifier matching the proto shape but without protobuf Message dependency.
* Used for sample data and as input to tree building.
*/
export interface SimpleAgentIdentifier {
agentId: string;
agentType: AgentType;
spawnDepth: number;
parentId?: string;
}
/**
* Maps AgentType enum values to human-readable labels.
*/
export function agentTypeLabel(type: AgentType): string {
switch (type) {
case AgentType.ORCHESTRATOR:
return 'Orchestrator';
case AgentType.RESEARCHER:
return 'Researcher';
case AgentType.CODER:
return 'Coder';
case AgentType.SYSADMIN:
return 'SysAdmin';
case AgentType.ASSISTANT:
return 'Assistant';
default:
return 'Unspecified';
}
}
/**
* Maps AgentType enum values to Tailwind-friendly color tokens.
*/
export function agentTypeColor(type: AgentType): {
fill: string;
stroke: string;
text: string;
badge: string;
} {
switch (type) {
case AgentType.ORCHESTRATOR:
return {
fill: '#dbeafe',
stroke: '#3b82f6',
text: '#1e40af',
badge: 'bg-blue-100 text-blue-700'
};
case AgentType.RESEARCHER:
return {
fill: '#dcfce7',
stroke: '#22c55e',
text: '#166534',
badge: 'bg-green-100 text-green-700'
};
case AgentType.CODER:
return {
fill: '#f3e8ff',
stroke: '#a855f7',
text: '#6b21a8',
badge: 'bg-purple-100 text-purple-700'
};
case AgentType.SYSADMIN:
return {
fill: '#ffedd5',
stroke: '#f97316',
text: '#9a3412',
badge: 'bg-orange-100 text-orange-700'
};
case AgentType.ASSISTANT:
return {
fill: '#ccfbf1',
stroke: '#14b8a6',
text: '#115e59',
badge: 'bg-teal-100 text-teal-700'
};
default:
return {
fill: '#f3f4f6',
stroke: '#9ca3af',
text: '#374151',
badge: 'bg-gray-100 text-gray-700'
};
}
}
/**
* Builds a tree of LineageNode from a flat list of SimpleAgentIdentifier.
*
* The algorithm groups agents by spawnDepth. Depth-0 agents become root nodes.
* Each agent at depth N is attached as a child of the last agent at depth N-1
* (based on insertion order), which models a sequential spawn chain.
*
* If parentId is provided, it is used for explicit parent-child linking.
*/
export function buildLineageTree(agents: SimpleAgentIdentifier[]): LineageNode[] {
if (agents.length === 0) return [];
const nodeMap = new Map<string, LineageNode>();
// Create all nodes first
for (const agent of agents) {
nodeMap.set(agent.agentId, {
id: agent.agentId,
agentType: agent.agentType,
spawnDepth: agent.spawnDepth,
children: []
});
}
const roots: LineageNode[] = [];
const depthLastNode = new Map<number, LineageNode>();
for (const agent of agents) {
const node = nodeMap.get(agent.agentId)!;
if (agent.parentId && nodeMap.has(agent.parentId)) {
// Explicit parent link
nodeMap.get(agent.parentId)!.children.push(node);
} else if (agent.spawnDepth === 0) {
roots.push(node);
} else {
// Attach to the last node at depth - 1
const parent = depthLastNode.get(agent.spawnDepth - 1);
if (parent) {
parent.children.push(node);
} else {
// Fallback: treat as root if no parent found
roots.push(node);
}
}
depthLastNode.set(agent.spawnDepth, node);
}
return roots;
}
/**
* Returns sample/demo lineage data for visualization development.
* This will be replaced with real API data when available.
*/
export function getSampleLineageData(): SimpleAgentIdentifier[] {
return [
{
agentId: 'orch-001',
agentType: AgentType.ORCHESTRATOR,
spawnDepth: 0
},
{
agentId: 'research-001',
agentType: AgentType.RESEARCHER,
spawnDepth: 1,
parentId: 'orch-001'
},
{
agentId: 'coder-001',
agentType: AgentType.CODER,
spawnDepth: 1,
parentId: 'orch-001'
},
{
agentId: 'sysadmin-001',
agentType: AgentType.SYSADMIN,
spawnDepth: 1,
parentId: 'orch-001'
},
{
agentId: 'assist-001',
agentType: AgentType.ASSISTANT,
spawnDepth: 2,
parentId: 'research-001'
},
{
agentId: 'coder-002',
agentType: AgentType.CODER,
spawnDepth: 2,
parentId: 'coder-001'
},
{
agentId: 'research-002',
agentType: AgentType.RESEARCHER,
spawnDepth: 3,
parentId: 'coder-002'
}
];
}

View File

@@ -1,2 +1,15 @@
<script lang="ts">
import { resolveRoute } from '$app/paths';
const chatHref = resolveRoute('/chat');
const lineageHref = resolveRoute('/lineage');
</script>
<h1 class="text-3xl font-bold text-center mt-8">LLM Multiverse UI</h1>
<p class="text-center text-gray-600 mt-2">Orchestration interface</p>
<nav class="flex justify-center gap-4 mt-6">
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={chatHref} class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Chat</a>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a href={lineageHref} class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200">Agent Lineage</a>
</nav>

View File

@@ -29,6 +29,7 @@
create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE })
);
let showConfig = $state(false);
const lineageHref = resolveRoute('/lineage');
const isNonDefaultConfig = $derived(
sessionConfig.overrideLevel !== OverrideLevel.NONE ||
@@ -136,6 +137,15 @@
<div class="flex flex-1 flex-col">
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3">
<h1 class="text-lg font-semibold text-gray-900">Chat</h1>
<div class="flex items-center gap-2">
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a
href={lineageHref}
class="rounded-lg px-2.5 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Lineage
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
<button
type="button"
onclick={() => (showConfig = !showConfig)}
@@ -147,6 +157,7 @@
{/if}
Config
</button>
</div>
</header>
<MessageList {messages} />

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { resolveRoute } from '$app/paths';
import { AgentType } from '$lib/proto/llm_multiverse/v1/common_pb';
import LineageTree from '$lib/components/LineageTree.svelte';
import type { LineageNode, SimpleAgentIdentifier } from '$lib/types/lineage';
import {
buildLineageTree,
getSampleLineageData,
agentTypeLabel,
agentTypeColor
} from '$lib/types/lineage';
const chatHref = resolveRoute('/chat');
let agents: SimpleAgentIdentifier[] = $state(getSampleLineageData());
let treeNodes: LineageNode[] = $derived(buildLineageTree(agents));
let selectedNode: LineageNode | null = $state(null);
function handleSelectNode(node: LineageNode) {
selectedNode = selectedNode?.id === node.id ? null : node;
}
const agentTypeLegend = [
AgentType.ORCHESTRATOR,
AgentType.RESEARCHER,
AgentType.CODER,
AgentType.SYSADMIN,
AgentType.ASSISTANT,
AgentType.UNSPECIFIED
];
</script>
<div class="flex h-screen flex-col bg-gray-50">
<!-- Header -->
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<div class="flex items-center gap-4">
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
<a
href={chatHref}
class="rounded-lg px-2.5 py-1.5 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
&larr; Chat
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
<h1 class="text-lg font-semibold text-gray-900">Agent Lineage</h1>
</div>
<span class="rounded-md bg-amber-50 px-2 py-1 text-xs text-amber-700">
Sample Data
</span>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Main tree area -->
<main class="flex-1 overflow-auto p-6">
<LineageTree nodes={treeNodes} onSelectNode={handleSelectNode} />
<!-- Legend -->
<div class="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-gray-200 bg-white px-4 py-3">
<span class="text-xs font-medium text-gray-500">Agent Types:</span>
{#each agentTypeLegend as type (type)}
{@const colors = agentTypeColor(type)}
<span
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium {colors.badge}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {colors.stroke}"
></span>
{agentTypeLabel(type)}
</span>
{/each}
</div>
</main>
<!-- Detail panel -->
{#if selectedNode}
{@const colors = agentTypeColor(selectedNode.agentType)}
<aside
class="w-72 shrink-0 border-l border-gray-200 bg-white p-4 overflow-y-auto"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-900">Agent Details</h2>
<button
type="button"
onclick={() => (selectedNode = null)}
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close detail panel"
>
&#10005;
</button>
</div>
<div class="space-y-3">
<div>
<p class="text-xs font-medium text-gray-500">Agent ID</p>
<p class="mt-0.5 break-all font-mono text-sm text-gray-900">{selectedNode.id}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500">Agent Type</p>
<span
class="mt-0.5 inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium {colors.badge}"
>
<span
class="h-2 w-2 rounded-full"
style="background-color: {colors.stroke}"
></span>
{agentTypeLabel(selectedNode.agentType)}
</span>
</div>
<div>
<p class="text-xs font-medium text-gray-500">Spawn Depth</p>
<p class="mt-0.5 text-sm text-gray-900">{selectedNode.spawnDepth}</p>
</div>
<div>
<p class="text-xs font-medium text-gray-500">Children</p>
<p class="mt-0.5 text-sm text-gray-900">{selectedNode.children.length}</p>
</div>
{#if selectedNode.children.length > 0}
<div>
<p class="text-xs font-medium text-gray-500">Child Agents</p>
<ul class="mt-1 space-y-1">
{#each selectedNode.children as child (child.id)}
{@const childColors = agentTypeColor(child.agentType)}
<li
class="flex items-center gap-2 rounded-md border border-gray-100 px-2 py-1.5"
>
<span
class="h-2 w-2 shrink-0 rounded-full"
style="background-color: {childColors.stroke}"
></span>
<span class="truncate font-mono text-xs text-gray-700">{child.id}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</aside>
{/if}
</div>
</div>

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;