perf: 优化智能体与工作流幕布渲染性能
- 分阶段加载智能体配置并按需缓存 MCP 工具 - 合并画布状态更新与节点尺寸监听,启用大图可视区域渲染和静态连线 - 隔离 Tinyflow Store 实例并补充数据同步与回归测试
This commit is contained in:
@@ -6,7 +6,7 @@ import type {
|
||||
AgentValidationIssue,
|
||||
} from './types';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onActivated, onDeactivated, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import {
|
||||
getAgentDetail,
|
||||
getAgentModels,
|
||||
getMcpPage,
|
||||
getMcpTools,
|
||||
getPublishedKnowledgeList,
|
||||
saveAgent,
|
||||
submitAgentOfflineApproval,
|
||||
@@ -35,6 +37,7 @@ import AgentStudioCanvas from './components/agent-studio/AgentStudioCanvas.vue';
|
||||
import AgentCommandBar from './components/AgentCommandBar.vue';
|
||||
import AgentInspectorPanel from './components/AgentInspectorPanel.vue';
|
||||
import { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||
import { createMcpToolLoader } from './mcpToolLoader';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -56,10 +59,13 @@ const {
|
||||
validate,
|
||||
} = useAgentDesignerState();
|
||||
|
||||
const pageLoading = ref(false);
|
||||
const pageLoading = ref(true);
|
||||
const contentReady = ref(false);
|
||||
const canvasActive = ref(true);
|
||||
const saveLoading = ref(false);
|
||||
const offlineLoading = ref(false);
|
||||
const publishLoading = ref(false);
|
||||
const mcpToolsLoading = ref<Record<string, boolean>>({});
|
||||
const issues = ref<AgentValidationIssue[]>([]);
|
||||
const categories = ref<AgentOption[]>([]);
|
||||
const models = ref<AgentOption[]>([]);
|
||||
@@ -67,6 +73,10 @@ const knowledges = ref<AgentOption[]>([]);
|
||||
const workflows = ref<AgentOption[]>([]);
|
||||
const pluginTools = ref<AgentOption[]>([]);
|
||||
const mcps = ref<AgentOption[]>([]);
|
||||
const fetchMcpToolResource = createMcpToolLoader(async (id) => {
|
||||
const res = await getMcpTools(id);
|
||||
return res.errorCode === 0 ? res.data : undefined;
|
||||
});
|
||||
|
||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||
const publishText = computed(() => {
|
||||
@@ -116,14 +126,23 @@ const offlineDisabled = computed(() => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
pageLoading.value = true;
|
||||
void loadDeferredOptions();
|
||||
try {
|
||||
await Promise.all([loadOptions(), loadAgent()]);
|
||||
await Promise.all([loadCriticalOptions(), loadAgent()]);
|
||||
} finally {
|
||||
contentReady.value = true;
|
||||
pageLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
canvasActive.value = true;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
canvasActive.value = false;
|
||||
});
|
||||
|
||||
async function loadAgent() {
|
||||
if (isNew.value) {
|
||||
reset();
|
||||
@@ -191,13 +210,33 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes, mcpRes] =
|
||||
await Promise.all([
|
||||
api.get('/api/v1/agentCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
}),
|
||||
getAgentModels(),
|
||||
async function loadCriticalOptions() {
|
||||
const [categoryResult, modelResult] = await Promise.allSettled([
|
||||
api.get('/api/v1/agentCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
}),
|
||||
getAgentModels(),
|
||||
]);
|
||||
|
||||
if (categoryResult.status === 'fulfilled') {
|
||||
categories.value = (categoryResult.value.data || []).map((item: any) => ({
|
||||
label: item.categoryName || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (modelResult.status === 'fulfilled') {
|
||||
models.value = (modelResult.value.data || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeferredOptions() {
|
||||
const [knowledgeResult, workflowResult, pluginResult, mcpResult] =
|
||||
await Promise.allSettled([
|
||||
getPublishedKnowledgeList(),
|
||||
api.get('/api/v1/workflow/page', {
|
||||
params: { pageNumber: 1, pageSize: 200 },
|
||||
@@ -205,37 +244,37 @@ async function loadOptions() {
|
||||
api.get('/api/v1/plugin/pageByCategory', {
|
||||
params: { pageNumber: 1, pageSize: 200, category: 0 },
|
||||
}),
|
||||
api.get('/api/v1/mcp/pageTools', {
|
||||
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
||||
}),
|
||||
getMcpPage(),
|
||||
]);
|
||||
|
||||
categories.value = (categoryRes.data || []).map((item: any) => ({
|
||||
label: item.categoryName || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
models.value = (modelRes.data || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
knowledges.value = (knowledgeRes.data || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
workflows.value = (
|
||||
(workflowRes.data?.records || workflowRes.data || []) as any[]
|
||||
).map((item) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
pluginTools.value = flattenPluginTools(
|
||||
pluginRes.data?.records || pluginRes.data || [],
|
||||
);
|
||||
mcps.value = mapMcpOptions(mcpRes.data?.records || mcpRes.data || []);
|
||||
if (knowledgeResult.status === 'fulfilled') {
|
||||
knowledges.value = (knowledgeResult.value.data || []).map((item: any) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (workflowResult.status === 'fulfilled') {
|
||||
workflows.value = (
|
||||
(workflowResult.value.data?.records ||
|
||||
workflowResult.value.data ||
|
||||
[]) as any[]
|
||||
).map((item) => ({
|
||||
label: item.title || item.name,
|
||||
value: String(item.id),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
if (pluginResult.status === 'fulfilled') {
|
||||
pluginTools.value = flattenPluginTools(
|
||||
pluginResult.value.data?.records || pluginResult.value.data || [],
|
||||
);
|
||||
}
|
||||
if (mcpResult.status === 'fulfilled') {
|
||||
mcps.value = mapMcpOptions(
|
||||
mcpResult.value.data?.records || mcpResult.value.data || [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenPluginTools(list: any[]): AgentOption[] {
|
||||
@@ -269,6 +308,56 @@ function mapMcpOptions(list: any[]): AgentOption[] {
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadMcpToolsForOption(id: number | string) {
|
||||
const key = String(id || '').trim();
|
||||
if (!key || mcpToolsLoading.value[key]) return;
|
||||
|
||||
mcpToolsLoading.value = {
|
||||
...mcpToolsLoading.value,
|
||||
[key]: true,
|
||||
};
|
||||
try {
|
||||
const resource = await fetchMcpToolResource(key);
|
||||
if (!resource) return;
|
||||
|
||||
const currentOption = mcps.value.find((item) => String(item.value) === key);
|
||||
const mergedResource = {
|
||||
...currentOption?.raw,
|
||||
...resource,
|
||||
tools: Array.isArray(resource.tools) ? resource.tools : [],
|
||||
};
|
||||
if (currentOption) {
|
||||
mcps.value = mcps.value.map((item) =>
|
||||
String(item.value) === key
|
||||
? {
|
||||
...item,
|
||||
label:
|
||||
resource.title || resource.name || currentOption.label || 'MCP',
|
||||
raw: mergedResource,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
state.toolBindings.forEach((binding) => {
|
||||
if (
|
||||
String(binding.toolType || '').toUpperCase() === 'MCP' &&
|
||||
String(binding.targetId || '') === key
|
||||
) {
|
||||
binding.resourceSummary = {
|
||||
...binding.resourceSummary,
|
||||
...mergedResource,
|
||||
};
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载 MCP 工具失败', error);
|
||||
} finally {
|
||||
const nextLoading = { ...mcpToolsLoading.value };
|
||||
delete nextLoading[key];
|
||||
mcpToolsLoading.value = nextLoading;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(kind: AgentCapabilityKind) {
|
||||
if (kind === 'knowledge') {
|
||||
addKnowledgeNode();
|
||||
@@ -417,43 +506,48 @@ function handleCloseTryout() {
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="agent-designer">
|
||||
<AgentStudioCanvas
|
||||
:state="state"
|
||||
:knowledge-options="knowledges"
|
||||
:mcp-options="mcps"
|
||||
:plugin-options="pluginTools"
|
||||
:selected-node-id="state.selectedNodeId"
|
||||
:workflow-options="workflows"
|
||||
@select="handleSelectNode"
|
||||
/>
|
||||
<AgentInspectorPanel
|
||||
:state="state"
|
||||
:models="models"
|
||||
:categories="categories"
|
||||
:knowledges="knowledges"
|
||||
:workflows="workflows"
|
||||
:plugin-tools="pluginTools"
|
||||
:mcps="mcps"
|
||||
:issues="issues"
|
||||
@change="markDirty"
|
||||
@remove-capability="removeSelectedCapability"
|
||||
@close-tryout="handleCloseTryout"
|
||||
@select-issue="handleSelectIssue"
|
||||
/>
|
||||
<AgentCommandBar
|
||||
:save-loading="saveLoading"
|
||||
:publish-loading="publishLoading"
|
||||
:publish-disabled="publishDisabled"
|
||||
:publish-text="publishText"
|
||||
:offline-disabled="offlineDisabled"
|
||||
:offline-loading="offlineLoading"
|
||||
:offline-visible="offlineVisible"
|
||||
@add="handleAdd"
|
||||
@save="handleSave()"
|
||||
@offline="handleOffline"
|
||||
@publish="handlePublish"
|
||||
@tryout="handleTryout"
|
||||
/>
|
||||
<template v-if="contentReady">
|
||||
<AgentStudioCanvas
|
||||
:active="canvasActive"
|
||||
:state="state"
|
||||
:knowledge-options="knowledges"
|
||||
:mcp-options="mcps"
|
||||
:plugin-options="pluginTools"
|
||||
:selected-node-id="state.selectedNodeId"
|
||||
:workflow-options="workflows"
|
||||
@select="handleSelectNode"
|
||||
/>
|
||||
<AgentInspectorPanel
|
||||
:state="state"
|
||||
:models="models"
|
||||
:categories="categories"
|
||||
:knowledges="knowledges"
|
||||
:workflows="workflows"
|
||||
:plugin-tools="pluginTools"
|
||||
:mcps="mcps"
|
||||
:mcp-tools-loading="mcpToolsLoading"
|
||||
:issues="issues"
|
||||
@change="markDirty"
|
||||
@remove-capability="removeSelectedCapability"
|
||||
@close-tryout="handleCloseTryout"
|
||||
@load-mcp-tools="loadMcpToolsForOption"
|
||||
@select-issue="handleSelectIssue"
|
||||
/>
|
||||
<AgentCommandBar
|
||||
:save-loading="saveLoading"
|
||||
:publish-loading="publishLoading"
|
||||
:publish-disabled="publishDisabled"
|
||||
:publish-text="publishText"
|
||||
:offline-disabled="offlineDisabled"
|
||||
:offline-loading="offlineLoading"
|
||||
:offline-visible="offlineVisible"
|
||||
@add="handleAdd"
|
||||
@save="handleSave()"
|
||||
@offline="handleOffline"
|
||||
@publish="handlePublish"
|
||||
@tryout="handleTryout"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -109,3 +109,13 @@ export function getPublishedKnowledgeList() {
|
||||
params: { publishedOnly: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMcpPage() {
|
||||
return api.get<RequestResult<any>>('/api/v1/mcp/page', {
|
||||
params: { pageNumber: 1, pageSize: 200, status: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
export function getMcpTools(id: number | string) {
|
||||
return api.post<RequestResult<any>>('/api/v1/mcp/getMcpTools', { id });
|
||||
}
|
||||
|
||||
@@ -5,22 +5,27 @@ import type {
|
||||
AgentValidationIssue,
|
||||
} from '../types';
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElTabPane, ElTabs } from 'element-plus';
|
||||
|
||||
import AgentBaseForm from './AgentBaseForm.vue';
|
||||
import AgentInteractionForm from './AgentInteractionForm.vue';
|
||||
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
|
||||
import AgentToolForm from './AgentToolForm.vue';
|
||||
import AgentTryoutPanel from './AgentTryoutPanel.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
categories: AgentOption[];
|
||||
issues: AgentValidationIssue[];
|
||||
knowledges: AgentOption[];
|
||||
mcps: AgentOption[];
|
||||
mcpToolsLoading: Record<string, boolean>;
|
||||
models: AgentOption[];
|
||||
pluginTools: AgentOption[];
|
||||
state: AgentDraftState;
|
||||
@@ -30,10 +35,24 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
change: [];
|
||||
closeTryout: [];
|
||||
loadMcpTools: [id: string];
|
||||
removeCapability: [];
|
||||
selectIssue: [nodeId: string];
|
||||
}>();
|
||||
|
||||
const loadAgentInteractionForm = () => import('./AgentInteractionForm.vue');
|
||||
const loadAgentKnowledgeForm = () => import('./AgentKnowledgeForm.vue');
|
||||
const loadAgentToolForm = () => import('./AgentToolForm.vue');
|
||||
const loadAgentTryoutPanel = () => import('./AgentTryoutPanel.vue');
|
||||
const AgentInteractionForm = defineAsyncComponent(loadAgentInteractionForm);
|
||||
const AgentKnowledgeForm = defineAsyncComponent(loadAgentKnowledgeForm);
|
||||
const AgentToolForm = defineAsyncComponent(loadAgentToolForm);
|
||||
const AgentTryoutPanel = defineAsyncComponent(loadAgentTryoutPanel);
|
||||
|
||||
interface AgentInteractionFormExpose {
|
||||
focusField: (field: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const selectedKnowledge = computed(() => {
|
||||
if (!props.state.selectedNodeId.startsWith('knowledge:')) return;
|
||||
const localId = props.state.selectedNodeId.slice('knowledge:'.length);
|
||||
@@ -41,7 +60,7 @@ const selectedKnowledge = computed(() => {
|
||||
});
|
||||
|
||||
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
||||
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
|
||||
const interactionForm = ref<AgentInteractionFormExpose>();
|
||||
const selectedModel = computed(() =>
|
||||
props.models.find((item) => item.value === String(props.state.agent.modelId)),
|
||||
);
|
||||
@@ -56,7 +75,7 @@ function isInteractionIssue(issue?: AgentValidationIssue) {
|
||||
async function focusIssue(issue: AgentValidationIssue) {
|
||||
if (issue.nodeId !== 'agent-base') return;
|
||||
activeBaseTab.value = isInteractionIssue(issue) ? 'interaction' : 'basic';
|
||||
if (isInteractionIssue(issue)) {
|
||||
if (isInteractionIssue(issue) && issue.field) {
|
||||
await nextTick();
|
||||
await interactionForm.value?.focusField(issue.field);
|
||||
}
|
||||
@@ -94,6 +113,49 @@ const selectedToolOptions = computed(() => {
|
||||
if (selectedToolKind.value === 'mcp') return props.mcps;
|
||||
return props.pluginTools;
|
||||
});
|
||||
|
||||
watch(
|
||||
() =>
|
||||
selectedToolKind.value === 'mcp'
|
||||
? String(selectedTool.value?.targetId || '')
|
||||
: '',
|
||||
(mcpId) => {
|
||||
if (mcpId) {
|
||||
emit('loadMcpTools', mcpId);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
let prefetchHandle: number | undefined;
|
||||
let prefetchTimer: number | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
const prefetchHiddenPanels = () => {
|
||||
void Promise.allSettled([
|
||||
loadAgentInteractionForm(),
|
||||
loadAgentKnowledgeForm(),
|
||||
loadAgentToolForm(),
|
||||
loadAgentTryoutPanel(),
|
||||
]);
|
||||
};
|
||||
if (typeof window.requestIdleCallback === 'function') {
|
||||
prefetchHandle = window.requestIdleCallback(prefetchHiddenPanels, {
|
||||
timeout: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
prefetchTimer = window.setTimeout(prefetchHiddenPanels, 1000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (prefetchHandle !== undefined) {
|
||||
window.cancelIdleCallback(prefetchHandle);
|
||||
}
|
||||
if (prefetchTimer !== undefined) {
|
||||
window.clearTimeout(prefetchTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,6 +224,9 @@ const selectedToolOptions = computed(() => {
|
||||
v-else-if="selectedTool"
|
||||
:binding="selectedTool"
|
||||
:kind="selectedToolKind"
|
||||
:mcp-tools-loading="
|
||||
Boolean(mcpToolsLoading[String(selectedTool.targetId || '')])
|
||||
"
|
||||
:options="selectedToolOptions"
|
||||
@change="emit('change')"
|
||||
@remove="emit('removeCapability')"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
const props = defineProps<{
|
||||
binding: AgentToolBinding;
|
||||
kind: 'mcp' | 'plugin' | 'workflow';
|
||||
mcpToolsLoading?: boolean;
|
||||
options: AgentOption[];
|
||||
}>();
|
||||
|
||||
@@ -129,7 +130,11 @@ function handleTargetChange(value: string) {
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<div v-if="kind === 'mcp'" class="agent-form__mcp-tools">
|
||||
<div
|
||||
v-if="kind === 'mcp'"
|
||||
v-loading="mcpToolsLoading"
|
||||
class="agent-form__mcp-tools"
|
||||
>
|
||||
<div class="agent-form__mcp-tools-header">
|
||||
<span>工具列表</span>
|
||||
<span>{{ selectedMcpToolCount }} 个</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type {AgentDraftState, AgentOption} from '../../types';
|
||||
import type { AgentDraftState, AgentOption } from '../../types';
|
||||
import type {
|
||||
AgentStudioCanvasSize,
|
||||
AgentStudioConnectionView,
|
||||
@@ -10,36 +10,46 @@ import type {
|
||||
|
||||
import {
|
||||
computed,
|
||||
createApp,
|
||||
h,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
render as renderVueVNode,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {usePreferences} from '@easyflow/preferences';
|
||||
import { usePreferences } from '@easyflow/preferences';
|
||||
|
||||
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||
import { Tinyflow } from '@tinyflow-ai/vue';
|
||||
|
||||
import {useAgentStudioLayout} from '../../composables/agent-studio/useAgentStudioLayout';
|
||||
import {useAgentStudioModel} from '../../composables/agent-studio/useAgentStudioModel';
|
||||
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 = defineProps<{
|
||||
knowledgeOptions?: AgentOption[];
|
||||
mcpOptions?: AgentOption[];
|
||||
pluginOptions?: AgentOption[];
|
||||
selectedNodeId: string;
|
||||
state: AgentDraftState;
|
||||
workflowOptions?: AgentOption[];
|
||||
}>();
|
||||
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];
|
||||
@@ -61,25 +71,33 @@ const canvasModel = useAgentStudioModel(
|
||||
workflow: props.workflowOptions || [],
|
||||
}),
|
||||
);
|
||||
const liveNodes = ref<AgentStudioNodeView[]>([]);
|
||||
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });
|
||||
const flowData = computed(() => {
|
||||
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.map((edge) => ({ ...edge })),
|
||||
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,
|
||||
data: { ...node.data },
|
||||
position: { ...(liveNode?.position || node.position) },
|
||||
position,
|
||||
};
|
||||
}),
|
||||
viewport: layout.viewport ? { ...layout.viewport } : undefined,
|
||||
};
|
||||
});
|
||||
const lastCaptureSignature = ref('');
|
||||
const lastFlowDataSignature = ref('');
|
||||
}
|
||||
let lastCapturedEdges: unknown;
|
||||
let lastCapturedNodes: unknown;
|
||||
let lastCapturedViewport: unknown;
|
||||
|
||||
const customNodes = computed(() => ({
|
||||
agentStudioBase: {
|
||||
@@ -90,6 +108,7 @@ const customNodes = computed(() => ({
|
||||
onUpdate: (parent: HTMLElement, node: any) => {
|
||||
renderVueNode(parent, AgentStudioBaseNode, node.data);
|
||||
},
|
||||
onDestroy: unmountVueNode,
|
||||
},
|
||||
agentStudioCapability: {
|
||||
presentation: 'plain',
|
||||
@@ -99,6 +118,7 @@ const customNodes = computed(() => ({
|
||||
onUpdate: (parent: HTMLElement, node: any) => {
|
||||
renderVueNode(parent, AgentStudioCapabilityNode, node.data);
|
||||
},
|
||||
onDestroy: unmountVueNode,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -116,7 +136,7 @@ const connections = computed<AgentStudioConnectionView[]>(() => {
|
||||
return {
|
||||
active: false,
|
||||
id: edge.id,
|
||||
path: buildConnectionPath(source, target, liveViewport.value),
|
||||
path: buildConnectionPath(source, target),
|
||||
sourceId: edge.source,
|
||||
targetId: edge.target,
|
||||
};
|
||||
@@ -132,37 +152,28 @@ function renderVueNode(
|
||||
const current = (parent as any).__agentStudioNodeView as
|
||||
| undefined
|
||||
| {
|
||||
app: ReturnType<typeof createApp>;
|
||||
component: any;
|
||||
data: AgentStudioNodeData;
|
||||
signature: string;
|
||||
source: AgentStudioNodeData;
|
||||
};
|
||||
const signature = JSON.stringify(data);
|
||||
if (current && current.component === component) {
|
||||
if (current.signature === signature) {
|
||||
if (current.source === data) {
|
||||
return;
|
||||
}
|
||||
Object.assign(current.data, data);
|
||||
current.signature = signature;
|
||||
return;
|
||||
current.source = data;
|
||||
}
|
||||
|
||||
current?.app.unmount();
|
||||
parent.textContent = '';
|
||||
|
||||
const reactiveData = reactive({ ...data }) as AgentStudioNodeData;
|
||||
const app = createApp({
|
||||
render: () => h(component, { data: reactiveData }),
|
||||
});
|
||||
app.mount(parent);
|
||||
renderVueVNode(h(component, { data }), parent);
|
||||
(parent as any).__agentStudioNodeView = {
|
||||
app,
|
||||
component,
|
||||
data: reactiveData,
|
||||
signature,
|
||||
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),
|
||||
@@ -170,20 +181,9 @@ function getNodeSize(node: AgentStudioNodeView) {
|
||||
};
|
||||
}
|
||||
|
||||
function toScreenPoint(
|
||||
point: { x: number; y: number },
|
||||
viewport: AgentStudioViewport,
|
||||
) {
|
||||
return {
|
||||
x: point.x * viewport.zoom + viewport.x,
|
||||
y: point.y * viewport.zoom + viewport.y,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConnectionPath(
|
||||
source: AgentStudioNodeView,
|
||||
target: AgentStudioNodeView,
|
||||
viewport: AgentStudioViewport,
|
||||
) {
|
||||
const sourceSize = getNodeSize(source);
|
||||
const targetSize = getNodeSize(target);
|
||||
@@ -204,80 +204,119 @@ function buildConnectionPath(
|
||||
x: target.position.x + (sourceOnLeft ? targetSize.width : 0),
|
||||
y: targetCenter.y,
|
||||
};
|
||||
const screenStart = toScreenPoint(start, viewport);
|
||||
const screenEnd = toScreenPoint(end, viewport);
|
||||
const midX = (screenStart.x + screenEnd.x) / 2;
|
||||
const midX = (start.x + end.x) / 2;
|
||||
return [
|
||||
`M ${screenStart.x} ${screenStart.y}`,
|
||||
`L ${midX} ${screenStart.y}`,
|
||||
`L ${midX} ${screenEnd.y}`,
|
||||
`L ${screenEnd.x} ${screenEnd.y}`,
|
||||
`M ${start.x} ${start.y}`,
|
||||
`L ${midX} ${start.y}`,
|
||||
`L ${midX} ${end.y}`,
|
||||
`L ${end.x} ${end.y}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function handleDataChange(data: any) {
|
||||
const signature = buildFlowDataSignature(data);
|
||||
if (signature === lastCaptureSignature.value) {
|
||||
if (!props.active) {
|
||||
return;
|
||||
}
|
||||
lastCaptureSignature.value = signature;
|
||||
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 (Array.isArray(data?.nodes)) {
|
||||
liveNodes.value = data.nodes as AgentStudioNodeView[];
|
||||
if (nodes) {
|
||||
liveNodes.value = nodes;
|
||||
}
|
||||
if (data?.viewport) {
|
||||
liveViewport.value = data.viewport as AgentStudioViewport;
|
||||
if (viewport) {
|
||||
liveViewport.value = viewport;
|
||||
}
|
||||
capture({
|
||||
nodes: data?.nodes as AgentStudioNodeView[] | undefined,
|
||||
viewport: data?.viewport as AgentStudioViewport | undefined,
|
||||
});
|
||||
const selected = data?.nodes?.find((node: any) => node.selected);
|
||||
const selected = nodes?.find((node: any) => node.selected);
|
||||
if (selected?.id && selected.id !== props.selectedNodeId) {
|
||||
emit('select', selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFlowDataSignature(data: any) {
|
||||
const nodes = Array.isArray(data?.nodes)
|
||||
? data.nodes.map((node: any) => ({
|
||||
data: node.data,
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
selected: node.selected,
|
||||
type: node.type,
|
||||
}))
|
||||
: [];
|
||||
const edges = Array.isArray(data?.edges)
|
||||
? data.edges.map((edge: any) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
}))
|
||||
: [];
|
||||
return JSON.stringify({
|
||||
edges,
|
||||
nodes,
|
||||
viewport: data?.viewport,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const stableFlowData = shallowRef(flowData.value);
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
function syncFlowData() {
|
||||
const next = flowData.value;
|
||||
const signature = buildFlowDataSignature(next);
|
||||
if (signature === lastFlowDataSignature.value) {
|
||||
return;
|
||||
function shallowEqual(left: object, right: object) {
|
||||
if (left === right) {
|
||||
return true;
|
||||
}
|
||||
lastFlowDataSignature.value = signature;
|
||||
stableFlowData.value = next;
|
||||
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]));
|
||||
}
|
||||
|
||||
syncFlowData();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
watch(flowData, syncFlowData);
|
||||
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();
|
||||
@@ -295,16 +334,41 @@ function updateCanvasSize() {
|
||||
canvasSize.value = next;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
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(() => {
|
||||
resizeObserver?.disconnect();
|
||||
stopCanvasObservation();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
async (active) => {
|
||||
if (!active) {
|
||||
stopCanvasObservation();
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
startCanvasObservation();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -329,8 +393,9 @@ onBeforeUnmount(() => {
|
||||
:elements-selectable="true"
|
||||
:drop-enabled="false"
|
||||
:on-data-change="handleDataChange"
|
||||
:on-data-commit="handleDataCommit"
|
||||
/>
|
||||
<AgentStudioEdgeLayer :connections="connections" />
|
||||
<AgentStudioEdgeLayer :connections="connections" :viewport="liveViewport" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type {AgentStudioConnectionView} from './types';
|
||||
import type { AgentStudioConnectionView, AgentStudioViewport } from './types';
|
||||
|
||||
defineProps<{
|
||||
connections: AgentStudioConnectionView[];
|
||||
viewport: AgentStudioViewport;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="agent-studio-edge-layer" aria-hidden="true">
|
||||
<path
|
||||
v-for="connection in connections"
|
||||
:key="connection.id"
|
||||
class="agent-studio-edge-layer__path"
|
||||
:class="{ 'is-active': connection.active }"
|
||||
:d="connection.path"
|
||||
:data-source-id="connection.sourceId"
|
||||
:data-target-id="connection.targetId"
|
||||
/>
|
||||
<g
|
||||
:transform="`translate(${viewport.x} ${viewport.y}) scale(${viewport.zoom})`"
|
||||
>
|
||||
<path
|
||||
v-for="connection in connections"
|
||||
:key="connection.id"
|
||||
class="agent-studio-edge-layer__path"
|
||||
:class="{ 'is-active': connection.active }"
|
||||
:d="connection.path"
|
||||
:data-source-id="connection.sourceId"
|
||||
:data-target-id="connection.targetId"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -93,11 +93,12 @@ export function useAgentStudioLayout(state: AgentDraftState) {
|
||||
}) {
|
||||
let changed = false;
|
||||
if (Array.isArray(data.nodes)) {
|
||||
let positionsChanged = false;
|
||||
const activeIds = new Set(data.nodes.map((node) => node.id));
|
||||
const nextPositions: AgentStudioLayoutSnapshot['nodePositions'] = {};
|
||||
for (const [key, position] of Object.entries(snapshot.nodePositions)) {
|
||||
if (activeIds.has(key)) nextPositions[key] = position;
|
||||
else changed = true;
|
||||
else positionsChanged = true;
|
||||
}
|
||||
data.nodes.forEach((node) => {
|
||||
if (isFinitePosition(node.position)) {
|
||||
@@ -108,11 +109,14 @@ export function useAgentStudioLayout(state: AgentDraftState) {
|
||||
current.y !== node.position.y
|
||||
) {
|
||||
nextPositions[node.id] = { ...node.position };
|
||||
changed = true;
|
||||
positionsChanged = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
snapshot.nodePositions = nextPositions;
|
||||
if (positionsChanged) {
|
||||
snapshot.nodePositions = nextPositions;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
isFiniteViewport(data.viewport) &&
|
||||
|
||||
@@ -100,4 +100,45 @@ describe('useAgentStudioModel', () => {
|
||||
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
|
||||
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
|
||||
});
|
||||
|
||||
it('单次模型计算只读取一次工具选项集合', () => {
|
||||
let optionReads = 0;
|
||||
const model = useAgentStudioModel(
|
||||
{
|
||||
agent: {
|
||||
name: '测试智能体',
|
||||
},
|
||||
dirty: false,
|
||||
knowledgeBindings: [],
|
||||
panelMode: 'capability',
|
||||
selectedNodeId: 'tool:plugin-1',
|
||||
toolBindings: [
|
||||
{
|
||||
localId: 'plugin-1',
|
||||
targetId: '1001',
|
||||
toolType: 'PLUGIN',
|
||||
},
|
||||
{
|
||||
localId: 'workflow-1',
|
||||
targetId: '2001',
|
||||
toolType: 'WORKFLOW',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => 'tool:plugin-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => {
|
||||
optionReads += 1;
|
||||
return {
|
||||
plugin: [{ label: '插件', value: '1001' }],
|
||||
workflow: [{ label: '工作流', value: '2001' }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(model.value.nodes).toHaveLength(3);
|
||||
expect(optionReads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,13 +31,36 @@ function firstText(...values: unknown[]) {
|
||||
return matched ? String(matched).trim() : '';
|
||||
}
|
||||
|
||||
function createKnowledgeOptionLookup(options: AgentOption[]) {
|
||||
const lookup = new Map<string, AgentOption>();
|
||||
options.forEach((option) => {
|
||||
const key = String(option.value);
|
||||
if (!lookup.has(key)) {
|
||||
lookup.set(key, option);
|
||||
}
|
||||
});
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function createToolOptionLookup(options: AgentOption[]) {
|
||||
const lookup = new Map<string, AgentOption>();
|
||||
options.forEach((option) => {
|
||||
const raw = option.raw || {};
|
||||
[option.value, raw.id, raw.mcpId].forEach((value) => {
|
||||
const key = String(value || '');
|
||||
if (key && !lookup.has(key)) {
|
||||
lookup.set(key, option);
|
||||
}
|
||||
});
|
||||
});
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function buildKnowledgeTitle(
|
||||
binding: AgentKnowledgeBinding,
|
||||
options: AgentOption[],
|
||||
optionLookup: Map<string, AgentOption>,
|
||||
) {
|
||||
const matchedOption = options.find(
|
||||
(item) => String(item.value) === String(binding.knowledgeId),
|
||||
);
|
||||
const matchedOption = optionLookup.get(String(binding.knowledgeId));
|
||||
return firstText(
|
||||
binding.resourceSummary?.title,
|
||||
binding.resourceSummary?.name,
|
||||
@@ -59,22 +82,18 @@ function buildKnowledgeTitle(
|
||||
|
||||
function findMatchedToolOption(
|
||||
binding: AgentToolBinding,
|
||||
options: AgentOption[],
|
||||
optionLookup: Map<string, AgentOption>,
|
||||
) {
|
||||
const targetId = String(binding.targetId || '');
|
||||
if (!targetId) return undefined;
|
||||
return options.find((item) => {
|
||||
const raw = item.raw || {};
|
||||
return (
|
||||
String(item.value) === targetId ||
|
||||
String(raw.id || '') === targetId ||
|
||||
String(raw.mcpId || '') === targetId
|
||||
);
|
||||
});
|
||||
return optionLookup.get(targetId);
|
||||
}
|
||||
|
||||
function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = []) {
|
||||
const matchedOption = findMatchedToolOption(binding, options);
|
||||
function buildToolTitle(
|
||||
binding: AgentToolBinding,
|
||||
optionLookup: Map<string, AgentOption>,
|
||||
matchedOption = findMatchedToolOption(binding, optionLookup),
|
||||
) {
|
||||
return firstText(
|
||||
binding.resourceSummary?.title,
|
||||
binding.resourceSummary?.name,
|
||||
@@ -99,11 +118,11 @@ function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = [])
|
||||
function buildToolDetail(
|
||||
binding: AgentToolBinding,
|
||||
fallback: string,
|
||||
options: AgentOption[] = [],
|
||||
optionLookup: Map<string, AgentOption>,
|
||||
) {
|
||||
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||
const matchedOption = findMatchedToolOption(binding, options);
|
||||
const resourceName = buildToolTitle(binding, options);
|
||||
const matchedOption = findMatchedToolOption(binding, optionLookup);
|
||||
const resourceName = buildToolTitle(binding, optionLookup, matchedOption);
|
||||
const tools =
|
||||
binding.resourceSummary?.tools ||
|
||||
binding.resourceSnapshot?.tools ||
|
||||
@@ -116,7 +135,7 @@ function buildToolDetail(
|
||||
return resourceName || fallback;
|
||||
}
|
||||
const toolName = firstText(binding.toolName);
|
||||
const resourceName = buildToolTitle(binding, options);
|
||||
const resourceName = buildToolTitle(binding, optionLookup);
|
||||
if (toolName && resourceName && toolName !== resourceName) {
|
||||
return `${resourceName} / ${toolName}`;
|
||||
}
|
||||
@@ -214,6 +233,16 @@ export function useAgentStudioModel(
|
||||
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
||||
layout?.nodePositions?.[nodeId] || fallback;
|
||||
const size = canvasSize?.();
|
||||
const resolvedKnowledgeOptions = knowledgeOptions?.() || [];
|
||||
const resolvedToolOptions = toolOptions?.() || {};
|
||||
const knowledgeOptionLookup = createKnowledgeOptionLookup(
|
||||
resolvedKnowledgeOptions,
|
||||
);
|
||||
const toolOptionLookups = {
|
||||
mcp: createToolOptionLookup(resolvedToolOptions.mcp || []),
|
||||
plugin: createToolOptionLookup(resolvedToolOptions.plugin || []),
|
||||
workflow: createToolOptionLookup(resolvedToolOptions.workflow || []),
|
||||
};
|
||||
const occupiedPositions: Array<{ x: number; y: number }> = Object.values(
|
||||
layout?.nodePositions || {},
|
||||
);
|
||||
@@ -238,7 +267,7 @@ export function useAgentStudioModel(
|
||||
|
||||
const knowledgeNodes = state.knowledgeBindings.map((binding, index) => {
|
||||
const nodeId = `knowledge:${binding.localId}`;
|
||||
const title = buildKnowledgeTitle(binding, knowledgeOptions?.() || []);
|
||||
const title = buildKnowledgeTitle(binding, knowledgeOptionLookup);
|
||||
const position = resolveCapabilityNodePosition({
|
||||
canvasSize: size,
|
||||
fallbackIndex: index,
|
||||
@@ -270,10 +299,10 @@ export function useAgentStudioModel(
|
||||
const isWorkflow = toolType === 'WORKFLOW';
|
||||
const isMcp = toolType === 'MCP';
|
||||
const matchedOptions = isWorkflow
|
||||
? toolOptions?.().workflow || []
|
||||
? toolOptionLookups.workflow
|
||||
: isMcp
|
||||
? toolOptions?.().mcp || []
|
||||
: toolOptions?.().plugin || [];
|
||||
? toolOptionLookups.mcp
|
||||
: toolOptionLookups.plugin;
|
||||
const fallback = isWorkflow
|
||||
? '待选择工作流'
|
||||
: isMcp
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createMcpToolLoader } from './mcpToolLoader';
|
||||
|
||||
describe('createMcpToolLoader', () => {
|
||||
it('复用同一 MCP 的并发请求和已完成结果', async () => {
|
||||
const request = vi.fn(async (id: string) => ({ id, tools: ['search'] }));
|
||||
const load = createMcpToolLoader(request);
|
||||
|
||||
const [first, second] = await Promise.all([load('mcp-1'), load('mcp-1')]);
|
||||
const third = await load('mcp-1');
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(first).toEqual({ id: 'mcp-1', tools: ['search'] });
|
||||
expect(second).toBe(first);
|
||||
expect(third).toBe(first);
|
||||
});
|
||||
|
||||
it('分别加载不同的 MCP', async () => {
|
||||
const request = vi.fn(async (id: string) => ({ id }));
|
||||
const load = createMcpToolLoader(request);
|
||||
|
||||
await Promise.all([load('mcp-1'), load('mcp-2')]);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('请求失败后允许重新加载', async () => {
|
||||
const request = vi
|
||||
.fn<(id: string) => Promise<undefined | { id: string }>>()
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce({ id: 'mcp-1' });
|
||||
const load = createMcpToolLoader(request);
|
||||
|
||||
await expect(load('mcp-1')).rejects.toThrow('network error');
|
||||
await expect(load('mcp-1')).resolves.toEqual({ id: 'mcp-1' });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
35
easyflow-ui-admin/app/src/views/ai/agents/mcpToolLoader.ts
Normal file
35
easyflow-ui-admin/app/src/views/ai/agents/mcpToolLoader.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
type McpToolRequester<T> = (id: string) => Promise<T | undefined>;
|
||||
|
||||
/**
|
||||
* 创建页面级 MCP 工具加载器,同一 MCP 在页面生命周期内只请求一次。
|
||||
*/
|
||||
export function createMcpToolLoader<T>(request: McpToolRequester<T>) {
|
||||
const requests = new Map<string, Promise<T | undefined>>();
|
||||
|
||||
return function loadMcpTools(id: number | string) {
|
||||
const key = String(id || '').trim();
|
||||
if (!key) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
const cached = requests.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = request(key).then(
|
||||
(result) => {
|
||||
if (result === undefined) {
|
||||
requests.delete(key);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
requests.delete(key);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
requests.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
onActivated,
|
||||
onBeforeUnmount,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
} from 'vue';
|
||||
import {useRoute} from 'vue-router';
|
||||
|
||||
@@ -52,6 +54,8 @@ import {
|
||||
|
||||
import '@tinyflow-ai/vue/dist/index.css';
|
||||
|
||||
const WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD = 40;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
shareMode?: boolean;
|
||||
@@ -67,6 +71,9 @@ onMounted(async () => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
await initializeWorkflow();
|
||||
});
|
||||
onActivated(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
captureCurrentWorkflowDraft();
|
||||
});
|
||||
@@ -77,6 +84,7 @@ onUnmounted(() => {
|
||||
}
|
||||
});
|
||||
onDeactivated(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
captureCurrentWorkflowDraft();
|
||||
});
|
||||
// variables
|
||||
@@ -85,7 +93,12 @@ const workflowId = ref(route.query.id);
|
||||
const workflowInfo = ref<any>({});
|
||||
const initializationError = ref(false);
|
||||
const runParams = ref<any>(null);
|
||||
const tinyFlowData = ref<any>(null);
|
||||
const tinyFlowData = shallowRef<any>(null);
|
||||
const onlyRenderVisibleWorkflowElements = computed(
|
||||
() =>
|
||||
(tinyFlowData.value?.nodes?.length || 0) >=
|
||||
WORKFLOW_VISIBLE_RENDER_NODE_THRESHOLD,
|
||||
);
|
||||
const llmList = ref<any>([]);
|
||||
const knowledgeList = ref<any>([]);
|
||||
const codeEngineList = ref<any[]>([
|
||||
@@ -476,7 +489,8 @@ function persistPendingWorkflowDraft() {
|
||||
draftWriteTimer = undefined;
|
||||
return;
|
||||
}
|
||||
const content = pendingDraftContent;
|
||||
const content = normalizeWorkflowStartNodes(pendingDraftContent);
|
||||
lastObservedWorkflowContent = content;
|
||||
pendingDraftContent = null;
|
||||
draftWriteTimer = undefined;
|
||||
const persisted = writeWorkflowDraft({
|
||||
@@ -491,8 +505,8 @@ function persistPendingWorkflowDraft() {
|
||||
}
|
||||
|
||||
function scheduleWorkflowDraft(content: any) {
|
||||
lastObservedWorkflowContent = normalizeWorkflowStartNodes(content);
|
||||
pendingDraftContent = lastObservedWorkflowContent;
|
||||
lastObservedWorkflowContent = content;
|
||||
pendingDraftContent = content;
|
||||
if (draftWriteTimer) {
|
||||
clearTimeout(draftWriteTimer);
|
||||
}
|
||||
@@ -942,11 +956,13 @@ function onAsyncExecute(info: any) {
|
||||
ref="tinyflowRef"
|
||||
v-else-if="showTinyFlow"
|
||||
class="tiny-flow-container"
|
||||
:data="JSON.parse(JSON.stringify(tinyFlowData))"
|
||||
:data="tinyFlowData"
|
||||
:edge-animated="false"
|
||||
:only-render-visible-elements="onlyRenderVisibleWorkflowElements"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:provider="provider"
|
||||
:custom-nodes="customNode"
|
||||
:on-data-change="scheduleWorkflowDraft"
|
||||
:on-data-commit="scheduleWorkflowDraft"
|
||||
:on-node-execute="runIndependently"
|
||||
:on-run-test="runWorkflow"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user