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:
@@ -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) |
|
||||
|
||||
39
implementation-plans/issue-015.md
Normal file
39
implementation-plans/issue-015.md
Normal 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
|
||||
281
src/lib/components/LineageTree.svelte
Normal file
281
src/lib/components/LineageTree.svelte
Normal 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
200
src/lib/types/lineage.ts
Normal 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'
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
145
src/routes/lineage/+page.svelte
Normal file
145
src/routes/lineage/+page.svelte
Normal 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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
2
src/routes/lineage/+page.ts
Normal file
2
src/routes/lineage/+page.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
Reference in New Issue
Block a user