- 分阶段加载智能体配置并按需缓存 MCP 工具 - 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线 - 隔离 Tinyflow Store 实例并补充数据同步与回归测试
455 lines
12 KiB
Vue
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>
|