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
+26
View File
@@ -5,7 +5,9 @@
"": {
"name": "hwlab-cloud-web",
"dependencies": {
"@dagrejs/dagre": "3.0.0",
"@pinia/colada": "0.15.3",
"@vue-flow/core": "1.48.2",
"@vueuse/core": "^10.7.0",
"axios": "^1.16.0",
"dompurify": "^3.3.1",
@@ -57,6 +59,10 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dagrejs/dagre": ["@dagrejs/dagre@3.0.0", "", { "dependencies": { "@dagrejs/graphlib": "4.0.1" } }, "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q=="],
"@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.1", "", {}, "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@@ -217,6 +223,8 @@
"@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="],
"@vue-flow/core": ["@vue-flow/core@1.48.2", "", { "dependencies": { "@vueuse/core": "^10.5.0", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" }, "peerDependencies": { "vue": "^3.3.0" } }, "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="],
@@ -327,6 +335,24 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
+2
View File
@@ -16,7 +16,9 @@
"e2e:workbench:report": "bun run deps --quiet && bunx playwright show-report .state/workbench-e2e/html-report"
},
"dependencies": {
"@dagrejs/dagre": "3.0.0",
"@pinia/colada": "0.15.3",
"@vue-flow/core": "1.48.2",
"@vueuse/core": "^10.7.0",
"axios": "^1.16.0",
"dompurify": "^3.3.1",
@@ -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>
@@ -687,7 +687,7 @@ export function buildRelationWindow(
const append = (id: string, depth: number, position: number, setSize: number): void => {
const node = nodes.get(id);
if (!node) return;
const defaultExpanded = depth === 0 || selectedPath.has(id) || activePath.has(id);
const defaultExpanded = depth <= 2 || selectedPath.has(id) || activePath.has(id);
const expanded = node.children.length > 0 && !collapsedIds.has(id) && (expandedIds.has(id) || defaultExpanded);
rows.push({
id: node.id,
@@ -96,19 +96,33 @@
.agent-observer-card footer strong { color: var(--console-text); }
.agent-observer-card footer span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-tree { height: 100%; min-width: 0; min-height: 0; padding-block: 8px; }
.agent-observer-tree .console-virtual-list { overflow: auto; }
.agent-observer-tree-row { position: relative; height: calc(100% - 2px); min-width: 720px; margin-inline: 10px; padding-inline: calc(10px + (var(--tree-depth, 0) * 18px)) 10px; display: grid; grid-template-columns: 18px 110px minmax(220px, 1fr) minmax(180px, .8fr); align-items: center; gap: 9px; border-bottom: 1px solid var(--console-border); outline: 0; font-size: 12px; }
.agent-observer-tree-row:hover,
.agent-observer-tree-row[aria-selected="true"] { background: var(--console-accent-soft); }
.agent-observer-tree-row:focus-visible { box-shadow: inset 0 0 0 2px var(--console-accent); }
.agent-observer-tree-toggle { width: 18px; height: 18px; padding: 0; display: inline-grid; place-items: center; border: 0; background: transparent; color: var(--console-muted); }
.agent-observer-tree-toggle svg { transition: transform .14s ease; }
.agent-observer-tree-toggle svg[data-expanded="true"] { transform: rotate(90deg); }
.agent-observer-tree-row strong,
.agent-observer-tree-row small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-observer-tree-row small,
.agent-observer-relation-kind { color: var(--console-muted); }
.agent-observer-tree { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.agent-mind-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--console-surface-muted); }
.agent-mind-map-flow { width: 100%; height: 100%; }
.agent-mind-map-flow .vue-flow__pane { cursor: grab; }
.agent-mind-map-flow .vue-flow__pane.dragging { cursor: grabbing; }
.agent-mind-map-flow .vue-flow__edge-path { stroke: color-mix(in srgb, var(--console-muted) 55%, var(--console-border)); stroke-width: 1.4; }
.agent-mind-map-flow .vue-flow__arrowhead { fill: var(--console-muted); }
.agent-mind-map-node { width: 220px; height: 70px; min-width: 0; display: grid; grid-template-rows: 19px 1fr 16px; gap: 2px; padding: 7px 9px; border: 1px solid var(--console-border); border-left: 3px solid var(--console-muted); border-radius: 5px; background: var(--console-surface); color: var(--console-text); outline: 0; box-shadow: 0 2px 7px rgb(15 23 42 / 8%); cursor: pointer; }
.agent-mind-map-node[data-kind="scope"] { width: 260px; }
.agent-mind-map-node[data-kind="run"] { width: 244px; }
.agent-mind-map-node[data-status="running"] { border-left-color: var(--console-accent); }
.agent-mind-map-node[data-status="failed"] { border-left-color: var(--console-danger); }
.agent-mind-map-node[data-status="completed"] { border-left-color: var(--console-success); }
.agent-mind-map-node[data-selected="true"] { box-shadow: inset 0 0 0 2px var(--console-accent), 0 2px 7px rgb(15 23 42 / 8%); }
.agent-mind-map-node:focus-visible { outline: 2px solid var(--console-accent); outline-offset: 2px; }
.agent-mind-map-node header { min-width: 0; display: flex; align-items: center; gap: 6px; color: var(--console-muted); font-size: 10px; }
.agent-mind-map-node header > span:first-child { font-weight: 700; }
.agent-mind-map-node header .status-badge { margin-left: auto; }
.agent-mind-map-node strong,
.agent-mind-map-node small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-mind-map-node strong { font-size: 12px; }
.agent-mind-map-node small { color: var(--console-muted); font-size: 10px; }
.agent-mind-map-toggle { width: 18px; height: 18px; padding: 0; display: inline-grid; place-items: center; border: 1px solid var(--console-border); border-radius: 3px; background: var(--console-surface); color: var(--console-muted); font-size: 14px; line-height: 1; }
.agent-mind-map-controls { position: absolute; right: 10px; bottom: 10px; z-index: 5; display: inline-flex; border: 1px solid var(--console-border); border-radius: 5px; overflow: hidden; background: var(--console-surface); box-shadow: 0 2px 8px rgb(15 23 42 / 10%); }
.agent-mind-map-controls button { width: 34px; height: 34px; padding: 0; display: inline-grid; place-items: center; border: 0; border-right: 1px solid var(--console-border); background: transparent; color: var(--console-text); }
.agent-mind-map-controls button:last-child { border-right: 0; }
.agent-mind-map-controls button:hover { background: var(--console-accent-soft); color: var(--console-accent); }
.agent-observer-inspector { min-width: 0; min-height: 0; overflow: auto; border-left: 1px solid var(--console-border); background: var(--console-surface); }
.agent-observer-inspector-section { display: grid; gap: 14px; }
@@ -190,7 +204,7 @@
.agent-observer-table-row > :nth-child(7) { display: none; }
.agent-observer-card-grid { padding: 7px; }
.agent-observer-card dl { grid-template-columns: 1fr; gap: 6px; }
.agent-observer-tree-row { min-width: 560px; grid-template-columns: 18px 96px minmax(180px, 1fr) minmax(150px, .8fr); }
.agent-mind-map-controls { right: 7px; bottom: 7px; }
.agent-observer-inspector-grid { grid-template-columns: 1fr; }
.agent-observer-inspector-wide { grid-column: auto; }
.agent-observer-event-tail li { grid-template-columns: 1fr; }
@@ -199,5 +213,5 @@
@media (prefers-reduced-motion: reduce) {
.agent-observer-table-row[data-recent="true"] { animation: none; }
.agent-observer-tree-toggle svg { transition: none; }
.agent-mind-map-flow .vue-flow__viewport { transition: none !important; }
}
@@ -2,11 +2,10 @@
<!-- Implementation reference: MDTODO hwlab-web-product-experience/R3. -->
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
Activity,
ChevronRight,
Columns3,
HelpCircle,
LayoutGrid,
@@ -28,14 +27,12 @@ import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusS
import {
agentObserverStatusToken,
useAgentObserverStore,
type AgentObserverRelationRow,
type AgentObserverRun,
type AgentObserverView,
} from "@/stores/agent-observer";
type InspectorTab = "overview" | "relations" | "events" | "logs" | "raw";
type VirtualListHandle = { scrollToIndex: (index: number, align?: "nearest" | "start" | "end") => Promise<void> };
const AgentRunMindMap = defineAsyncComponent(() => import("@/components/agents/AgentRunMindMap.vue"));
const route = useRoute();
const router = useRouter();
const observer = useAgentObserverStore();
@@ -44,8 +41,6 @@ const urlApplying = ref(false);
const filtersOpen = ref(false);
const helpOpen = ref(false);
const compactInspector = ref(false);
const treeList = ref<VirtualListHandle | null>(null);
const activeRelationId = ref<string | null>(null);
const treeRows = computed(() => observer.relationRows);
const ageLabel = computed(() => formatAge(observer.dataAgeMs));
const streamLabel = computed(() => ({ idle: "未连接", replay: "回放", handoff: "交接", live: "Live", reconnecting: "重连中", stale: "陈旧", error: "传输错误" }[observer.phase]));
@@ -100,10 +95,6 @@ watch(
},
);
watch(() => observer.page, () => { if (!urlApplying.value) void observer.loadPage(); });
watch(treeRows, (rows) => {
if (rows.length === 0) activeRelationId.value = null;
else if (!activeRelationId.value || !rows.some((row) => row.id === activeRelationId.value)) activeRelationId.value = rows[0]?.id ?? null;
});
function updateInspectorMode(): void {
compactInspector.value = inspectorMedia?.matches ?? false;
@@ -162,55 +153,11 @@ function clearFilters(): void {
observer.timeWindow = "all";
}
function activateRelation(row: AgentObserverRelationRow): void {
activeRelationId.value = row.id;
if (row.childCount > 0 && !row.runId) observer.toggleRelation(row.id, !row.expanded);
if (row.runId) observer.selectRun(row.runId);
}
async function focusRelation(index: number, align: "nearest" | "start" | "end" = "nearest"): Promise<void> {
const target = Math.max(0, Math.min(treeRows.value.length - 1, index));
const row = treeRows.value[target];
if (!row) return;
activeRelationId.value = row.id;
await treeList.value?.scrollToIndex(target, align);
await nextTick();
document.querySelector<HTMLElement>(`[data-relation-id="${CSS.escape(row.id)}"]`)?.focus();
}
async function onRelationKeydown(event: KeyboardEvent, row: AgentObserverRelationRow, index: number): Promise<void> {
if (event.key === "ArrowRight" && row.childCount > 0) {
event.preventDefault();
if (!row.expanded) {
observer.toggleRelation(row.id, true);
await nextTick();
} else await focusRelation(index + 1);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
if (row.childCount > 0 && row.expanded) observer.toggleRelation(row.id, false);
else if (row.parentId) await focusRelation(treeRows.value.findIndex((candidate) => candidate.id === row.parentId));
return;
}
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const target = event.key === "Home" ? 0 : event.key === "End" ? treeRows.value.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1);
await focusRelation(target, event.key === "Home" ? "start" : event.key === "End" ? "end" : "nearest");
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activateRelation(row);
}
}
function status(value: string | null) {
return agentObserverStatusToken(value);
}
function rowKey(run: AgentObserverRun): string { return run.id; }
function relationKey(row: AgentObserverRelationRow): string { return row.id; }
function shortId(value: string | null, fallback = "未知"): string { return !value ? fallback : value.length > 28 ? `${value.slice(0, 13)}${value.slice(-10)}` : value; }
function formatTime(value: string | null): string { const time = value ? Date.parse(value) : Number.NaN; return Number.isFinite(time) ? new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" }).format(time) : "未知"; }
function duration(run: AgentObserverRun): string { const start = run.startedAt ? Date.parse(run.startedAt) : Number.NaN; const end = run.finishedAt ? Date.parse(run.finishedAt) : observer.nowMs; return Number.isFinite(start) && Number.isFinite(end) ? formatAge(Math.max(0, end - start)) : "未知"; }
@@ -289,11 +236,9 @@ function positivePage(value: unknown): number { const page = Number(value); retu
</VirtualGrid>
</section>
<section v-else class="agent-observer-tree" role="tree" aria-label="AgentRun 关系树">
<section v-else class="agent-observer-tree" aria-label="AgentRun 思维导图">
<p v-if="observer.relationWindowLimited" class="agent-observer-window-note">当前事件窗口关系树按 YAML 上限显示 {{ observer.relationNodeCount }} 个节点另有 {{ observer.relationOmittedRunCount }} 个运行未展开</p>
<VirtualList ref="treeList" :items="treeRows" :item-key="relationKey" :item-height="44" height="100%" :overscan="8" :window-limit="observer.config?.virtualListWindowLimit" root-role="presentation" item-role="presentation" aria-label="AgentRun 虚拟关系树">
<template #default="{ item: node, index }"><div class="agent-observer-tree-row" role="treeitem" :data-relation-id="node.id" :data-tree-index="index" :aria-level="node.depth + 1" :aria-posinset="node.position" :aria-setsize="node.setSize" :aria-expanded="node.childCount > 0 ? node.expanded : undefined" :aria-selected="observer.selectedRunId === node.runId" :tabindex="activeRelationId === node.id || (!activeRelationId && index === 0) ? 0 : -1" :style="{ '--tree-depth': node.depth }" @focus="activeRelationId = node.id" @click="activateRelation(node)" @keydown="onRelationKeydown($event, node, index)"><button v-if="node.childCount > 0" class="agent-observer-tree-toggle" type="button" :aria-label="node.expanded ? '折叠' : '展开'" tabindex="-1" @click.stop="observer.toggleRelation(node.id, !node.expanded)"><ChevronRight :size="15" :data-expanded="node.expanded" aria-hidden="true" /></button><span v-else class="agent-observer-tree-spacer" aria-hidden="true" /><StatusBadge v-if="node.status" :status="status(node.status).tone" :label="status(node.status).label" /><span v-else class="agent-observer-relation-kind">{{ node.kind }}</span><strong class="mono" :title="node.label">{{ shortId(node.label) }}</strong><small :title="node.detail || ''">{{ node.detail || `${node.childCount} 个子节点` }}</small></div></template>
</VirtualList>
<AgentRunMindMap :rows="treeRows" :runs="observer.visibleRuns" :selected-run-id="observer.selectedRunId" @select="observer.selectRun" @toggle="observer.toggleRelation" />
</section>
<Pagination v-if="observer.total > 0" v-model:page="observer.page" :page-size="observer.pageSize" :total="observer.total" />
</main>