feat: 将 AgentRun 树改为思维导图
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user