feat: 将 AgentRun 树改为思维导图

This commit is contained in:
root
2026-07-16 10:52:41 +02:00
parent 2c93273145
commit 62091c858e
6 changed files with 208 additions and 75 deletions
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, nextTick } from "vue";
import dagre from "@dagrejs/dagre";
import { MarkerType, Position, VueFlow, useVueFlow, type Edge, type Node } from "@vue-flow/core";
import { Focus, Minus, Plus } from "lucide-vue-next";
import StatusBadge from "@/components/common/StatusBadge.vue";
import {
agentObserverStatusToken,
type AgentObserverRelationRow,
type AgentObserverRun,
} from "@/stores/agent-observer";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
interface MindMapNodeData {
row: AgentObserverRelationRow;
title: string;
identity: string;
}
const props = defineProps<{
rows: AgentObserverRelationRow[];
runs: AgentObserverRun[];
selectedRunId: string | null;
}>();
const emit = defineEmits<{
select: [runId: string];
toggle: [relationId: string, expanded: boolean];
}>();
const flowId = "agentrun-mind-map";
const { fitView, zoomIn, zoomOut } = useVueFlow({ id: flowId });
const runById = computed(() => new Map(props.runs.map((run) => [run.id, run])));
const graph = computed(() => {
const layout = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
layout.setGraph({ rankdir: "LR", ranksep: 74, nodesep: 24, edgesep: 12, marginx: 32, marginy: 32 });
for (const row of props.rows) layout.setNode(row.id, { width: nodeWidth(row), height: 70 });
for (const row of props.rows) if (row.parentId && layout.hasNode(row.parentId)) layout.setEdge(row.parentId, row.id);
dagre.layout(layout);
const nodes: Node<MindMapNodeData>[] = props.rows.map((row) => {
const position = layout.node(row.id);
const run = row.runId ? runById.value.get(row.runId) : undefined;
const title = row.kind === "run" && run?.title ? run.title : row.label;
return {
id: row.id,
type: "observer",
position: { x: position.x - position.width / 2, y: position.y - position.height / 2 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
selectable: false,
draggable: false,
data: { row, title, identity: row.kind === "run" && run ? shortId(run.id) : shortId(row.label) },
};
});
const edges: Edge[] = props.rows
.filter((row) => row.parentId && layout.hasNode(row.parentId))
.map((row) => ({
id: `${row.parentId}->${row.id}`,
source: row.parentId!,
target: row.id,
type: "smoothstep",
markerEnd: MarkerType.ArrowClosed,
}));
return { nodes, edges };
});
function nodeWidth(row: AgentObserverRelationRow): number {
return row.kind === "scope" ? 260 : row.kind === "run" ? 244 : 220;
}
function shortId(value: string): string {
return value.length > 28 ? `${value.slice(0, 13)}${value.slice(-10)}` : value;
}
function nodeLabel(kind: AgentObserverRelationRow["kind"]): string {
return ({ scope: "范围", session: "会话", task: "任务", run: "运行", attempt: "尝试", command: "命令" }[kind]);
}
function activate(row: AgentObserverRelationRow): void {
if (row.runId) emit("select", row.runId);
else if (row.childCount > 0) emit("toggle", row.id, !row.expanded);
}
async function fit(): Promise<void> {
await nextTick();
await fitView({ padding: 0.14, duration: 220, maxZoom: 1 });
}
</script>
<template>
<div class="agent-mind-map">
<VueFlow
:id="flowId"
:nodes="graph.nodes"
:edges="graph.edges"
:min-zoom="0.18"
:max-zoom="1.8"
:zoom-on-double-click="false"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
fit-view-on-init
class="agent-mind-map-flow"
aria-label="AgentRun 思维导图"
>
<template #node-observer="{ data }">
<article
class="agent-mind-map-node"
:data-kind="data.row.kind"
:data-status="data.row.status ? agentObserverStatusToken(data.row.status).tone : 'unknown'"
:data-selected="selectedRunId === data.row.runId"
:title="data.row.detail || data.title"
tabindex="0"
@click="activate(data.row)"
@keydown.enter.prevent="activate(data.row)"
@keydown.space.prevent="activate(data.row)"
>
<header>
<span>{{ nodeLabel(data.row.kind) }}</span>
<StatusBadge v-if="data.row.status" :status="agentObserverStatusToken(data.row.status).tone" :label="agentObserverStatusToken(data.row.status).label" />
<button
v-if="data.row.childCount > 0"
class="agent-mind-map-toggle nodrag"
type="button"
:title="data.row.expanded ? '折叠分支' : '展开分支'"
:aria-label="data.row.expanded ? '折叠分支' : '展开分支'"
@click.stop="emit('toggle', data.row.id, !data.row.expanded)"
>{{ data.row.expanded ? '' : '+' }}</button>
</header>
<strong>{{ data.title }}</strong>
<small class="mono">{{ data.row.detail || data.identity }}</small>
</article>
</template>
</VueFlow>
<div class="agent-mind-map-controls" aria-label="关系图缩放控制">
<button type="button" title="放大" aria-label="放大" @click="zoomIn({ duration: 120 })"><Plus :size="16" aria-hidden="true" /></button>
<button type="button" title="缩小" aria-label="缩小" @click="zoomOut({ duration: 120 })"><Minus :size="16" aria-hidden="true" /></button>
<button type="button" title="适应视图" aria-label="适应视图" @click="fit"><Focus :size="16" aria-hidden="true" /></button>
</div>
</div>
</template>