Files
EasyFlow/easyflow-ui-admin/app/src/views/ai/agents/components/agent-studio/AgentStudioCanvas.vue
陈子默 aedefe6b5e perf: 优化智能体与工作流幕布渲染性能
- 分阶段加载智能体配置并按需缓存 MCP 工具

- 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线

- 隔离 Tinyflow Store 实例并补充数据同步与回归测试
2026-07-27 18:27:01 +08:00

455 lines
12 KiB
Vue

<script setup lang="ts">
import type { AgentDraftState, AgentOption } from '../../types';
import type {
AgentStudioCanvasSize,
AgentStudioConnectionView,
AgentStudioNodeData,
AgentStudioNodeView,
AgentStudioViewport,
} from './types';
import {
computed,
h,
nextTick,
onBeforeUnmount,
onMounted,
ref,
render as renderVueVNode,
shallowRef,
watch,
} from 'vue';
import { usePreferences } from '@easyflow/preferences';
import { Tinyflow } from '@tinyflow-ai/vue';
import { useAgentStudioLayout } from '../../composables/agent-studio/useAgentStudioLayout';
import { useAgentStudioModel } from '../../composables/agent-studio/useAgentStudioModel';
import AgentStudioBaseNode from './AgentStudioBaseNode.vue';
import AgentStudioCapabilityNode from './AgentStudioCapabilityNode.vue';
import AgentStudioEdgeLayer from './AgentStudioEdgeLayer.vue';
import '@tinyflow-ai/vue/dist/index.css';
const props = withDefaults(
defineProps<{
active?: boolean;
knowledgeOptions?: AgentOption[];
mcpOptions?: AgentOption[];
pluginOptions?: AgentOption[];
selectedNodeId: string;
state: AgentDraftState;
workflowOptions?: AgentOption[];
}>(),
{
active: true,
knowledgeOptions: () => [],
mcpOptions: () => [],
pluginOptions: () => [],
workflowOptions: () => [],
},
);
const emit = defineEmits<{
select: [nodeId: string];
}>();
const { isDark } = usePreferences();
const { capture, layout } = useAgentStudioLayout(props.state);
const canvasRef = ref<HTMLElement>();
const canvasSize = ref<AgentStudioCanvasSize>();
const canvasModel = useAgentStudioModel(
props.state,
() => props.selectedNodeId,
layout,
() => canvasSize.value,
() => props.knowledgeOptions || [],
() => ({
mcp: props.mcpOptions || [],
plugin: props.pluginOptions || [],
workflow: props.workflowOptions || [],
}),
);
const liveNodes = shallowRef<AgentStudioNodeView[]>([]);
const liveViewport = shallowRef<AgentStudioViewport>({
x: 250,
y: 100,
zoom: 1,
});
function buildFlowData() {
const liveNodeMap = new Map(liveNodes.value.map((node) => [node.id, node]));
return {
edges: canvasModel.value.edges,
nodes: canvasModel.value.nodes.map((node) => {
const liveNode = liveNodeMap.get(node.id);
const position = liveNode?.position || node.position;
if (position === node.position) {
return node;
}
return {
...node,
position,
};
}),
viewport: layout.viewport ? { ...layout.viewport } : undefined,
};
}
let lastCapturedEdges: unknown;
let lastCapturedNodes: unknown;
let lastCapturedViewport: unknown;
const customNodes = computed(() => ({
agentStudioBase: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioBaseNode, node.data);
},
onDestroy: unmountVueNode,
},
agentStudioCapability: {
presentation: 'plain',
render: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
onUpdate: (parent: HTMLElement, node: any) => {
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
},
onDestroy: unmountVueNode,
},
}));
const connections = computed<AgentStudioConnectionView[]>(() => {
const nodes =
liveNodes.value.length > 0
? liveNodes.value
: (canvasModel.value.nodes as AgentStudioNodeView[]);
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
return canvasModel.value.edges
.map((edge) => {
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (!source || !target) return undefined;
return {
active: false,
id: edge.id,
path: buildConnectionPath(source, target),
sourceId: edge.source,
targetId: edge.target,
};
})
.filter(Boolean) as AgentStudioConnectionView[];
});
function renderVueNode(
parent: HTMLElement,
component: any,
data: AgentStudioNodeData,
) {
const current = (parent as any).__agentStudioNodeView as
| undefined
| {
component: any;
source: AgentStudioNodeData;
};
if (current && current.component === component) {
if (current.source === data) {
return;
}
current.source = data;
}
renderVueVNode(h(component, { data }), parent);
(parent as any).__agentStudioNodeView = {
component,
source: data,
};
}
function unmountVueNode(parent: HTMLElement) {
renderVueVNode(null, parent);
delete (parent as any).__agentStudioNodeView;
}
function getNodeSize(node: AgentStudioNodeView) {
return {
height: node.height || (node.data.kind === 'base' ? 104 : 78),
width: node.width || (node.data.kind === 'base' ? 268 : 212),
};
}
function buildConnectionPath(
source: AgentStudioNodeView,
target: AgentStudioNodeView,
) {
const sourceSize = getNodeSize(source);
const targetSize = getNodeSize(target);
const sourceCenter = {
x: source.position.x + sourceSize.width / 2,
y: source.position.y + sourceSize.height / 2,
};
const targetCenter = {
x: target.position.x + targetSize.width / 2,
y: target.position.y + targetSize.height / 2,
};
const sourceOnLeft = targetCenter.x < sourceCenter.x;
const start = {
x: source.position.x + (sourceOnLeft ? 0 : sourceSize.width),
y: sourceCenter.y,
};
const end = {
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
y: targetCenter.y,
};
const midX = (start.x + end.x) / 2;
return [
`M ${start.x} ${start.y}`,
`L ${midX} ${start.y}`,
`L ${midX} ${end.y}`,
`L ${end.x} ${end.y}`,
].join(' ');
}
function handleDataChange(data: any) {
if (!props.active) {
return;
}
const edges = Array.isArray(data?.edges) ? data.edges : undefined;
const nodes = Array.isArray(data?.nodes)
? (data.nodes as AgentStudioNodeView[])
: undefined;
const viewport = data?.viewport as AgentStudioViewport | undefined;
if (
edges === lastCapturedEdges &&
nodes === lastCapturedNodes &&
viewport === lastCapturedViewport
) {
return;
}
lastCapturedEdges = edges;
lastCapturedNodes = nodes;
lastCapturedViewport = viewport;
if (nodes) {
liveNodes.value = nodes;
}
if (viewport) {
liveViewport.value = viewport;
}
const selected = nodes?.find((node: any) => node.selected);
if (selected?.id && selected.id !== props.selectedNodeId) {
emit('select', selected.id);
}
}
function handleDataCommit(data: any) {
if (!props.active) {
return;
}
capture({
nodes: Array.isArray(data?.nodes)
? (data.nodes as AgentStudioNodeView[])
: undefined,
viewport: data?.viewport as AgentStudioViewport | undefined,
});
}
function shallowEqual(left: object, right: object) {
if (left === right) {
return true;
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
if (leftKeys.length !== Object.keys(rightRecord).length) {
return false;
}
return leftKeys.every((key) => Object.is(leftRecord[key], rightRecord[key]));
}
function canvasModelsEqual(
previous: typeof canvasModel.value,
next: typeof canvasModel.value,
comparePositions: boolean,
) {
if (
previous.nodes.length !== next.nodes.length ||
previous.edges.length !== next.edges.length
) {
return false;
}
const nodesEqual = previous.nodes.every((node, index) => {
const nextNode = next.nodes[index];
return (
node.id === nextNode?.id &&
node.type === nextNode.type &&
node.width === nextNode.width &&
node.height === nextNode.height &&
shallowEqual(node.data, nextNode.data) &&
(!comparePositions ||
(node.position.x === nextNode.position.x &&
node.position.y === nextNode.position.y))
);
});
if (!nodesEqual) {
return false;
}
return previous.edges.every((edge, index) => {
const nextEdge = next.edges[index];
return nextEdge ? shallowEqual(edge, nextEdge) : false;
});
}
const stableFlowData = shallowRef(buildFlowData());
let lastCanvasModel = canvasModel.value;
let resizeObserver: ResizeObserver | undefined;
function syncFlowData(nextModel: typeof canvasModel.value) {
const comparePositions = liveNodes.value.length === 0;
if (canvasModelsEqual(lastCanvasModel, nextModel, comparePositions)) {
return;
}
lastCanvasModel = nextModel;
stableFlowData.value = buildFlowData();
}
watch(canvasModel, syncFlowData);
function updateCanvasSize() {
const rect = canvasRef.value?.getBoundingClientRect();
if (!rect) return;
const next = {
height: rect.height,
width: rect.width,
};
if (
canvasSize.value?.height === next.height &&
canvasSize.value.width === next.width
) {
return;
}
canvasSize.value = next;
}
function stopCanvasObservation() {
resizeObserver?.disconnect();
resizeObserver = undefined;
}
function startCanvasObservation() {
stopCanvasObservation();
if (!props.active) {
return;
}
updateCanvasSize();
if (typeof ResizeObserver === 'undefined' || !canvasRef.value) return;
resizeObserver = new ResizeObserver(updateCanvasSize);
resizeObserver.observe(canvasRef.value);
}
onMounted(() => {
startCanvasObservation();
});
onBeforeUnmount(() => {
stopCanvasObservation();
});
watch(
() => props.active,
async (active) => {
if (!active) {
stopCanvasObservation();
return;
}
await nextTick();
startCanvasObservation();
},
);
</script>
<template>
<div ref="canvasRef" class="agent-studio-canvas">
<Tinyflow
class="agent-studio-canvas__flow"
:data="stableFlowData"
:theme="isDark ? 'dark' : 'light'"
:custom-nodes="customNodes"
:hide-bottom-dock="true"
:hide-edge-panel="true"
:hide-mini-map="true"
:hide-node-handles="true"
:hide-node-picker="true"
:hide-node-setting="true"
:hide-node-toolbar="true"
:hide-edge-markers="true"
:edge-animated="false"
:edge-interaction-width="0"
:nodes-draggable="true"
:nodes-connectable="false"
:elements-selectable="true"
:drop-enabled="false"
:on-data-change="handleDataChange"
:on-data-commit="handleDataCommit"
/>
<AgentStudioEdgeLayer :connections="connections" :viewport="liveViewport" />
</div>
</template>
<style scoped>
.agent-studio-canvas {
position: absolute;
inset: 0;
overflow: hidden;
}
.agent-studio-canvas__flow {
width: 100%;
height: 100%;
}
.agent-studio-canvas :deep(.tinyflow),
.agent-studio-canvas :deep(.agentsflow) {
width: 100%;
height: 100% !important;
}
.agent-studio-canvas :deep(.svelte-flow__node) {
z-index: 2 !important;
border: 0;
border-radius: 18px;
}
.agent-studio-canvas :deep(.svelte-flow__node::after) {
display: none;
}
.agent-studio-canvas :deep(.svelte-flow__node:hover),
.agent-studio-canvas :deep(.svelte-flow__node.selectable.selected) {
border: 0;
box-shadow: none;
}
.agent-studio-canvas :deep(.svelte-flow__handle),
.agent-studio-canvas :deep(.svelte-flow__minimap),
.agent-studio-canvas :deep(.tf-left-dock),
.agent-studio-canvas :deep(.tf-top-dock),
.agent-studio-canvas :deep(.tf-bottom-dock),
.agent-studio-canvas :deep(.tf-node-toolbar) {
display: none !important;
}
.agent-studio-canvas :deep(.tf-flow-line-path) {
stroke: var(--el-border-color);
stroke: color-mix(
in srgb,
var(--el-color-primary) 30%,
var(--el-border-color)
);
stroke-width: 1.5;
}
</style>