Compare commits
2 Commits
5624175ddd
...
5efc5c351c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5efc5c351c | |||
|
|
209e38d8a6 |
@@ -16,3 +16,4 @@
|
|||||||
| #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) |
|
| #12 | Session history sidebar | COMPLETED | [issue-012.md](issue-012.md) |
|
||||||
| #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) |
|
| #13 | Session config sidebar component | COMPLETED | [issue-013.md](issue-013.md) |
|
||||||
| #14 | Preset configurations | COMPLETED | [issue-014.md](issue-014.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>
|
<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>
|
<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 })
|
create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE })
|
||||||
);
|
);
|
||||||
let showConfig = $state(false);
|
let showConfig = $state(false);
|
||||||
|
const lineageHref = resolveRoute('/lineage');
|
||||||
|
|
||||||
const isNonDefaultConfig = $derived(
|
const isNonDefaultConfig = $derived(
|
||||||
sessionConfig.overrideLevel !== OverrideLevel.NONE ||
|
sessionConfig.overrideLevel !== OverrideLevel.NONE ||
|
||||||
@@ -136,6 +137,15 @@
|
|||||||
<div class="flex flex-1 flex-col">
|
<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">
|
<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>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (showConfig = !showConfig)}
|
onclick={() => (showConfig = !showConfig)}
|
||||||
@@ -147,6 +157,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
Config
|
Config
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MessageList {messages} />
|
<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