feat: 完成 Agent MCP 对接

- 增加 MCP 连接类型、环境检测接口和容器运行环境支持

- 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec

- 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
This commit is contained in:
2026-05-29 11:09:21 +08:00
parent e39f7521e2
commit cc3bb9cff0
33 changed files with 2405 additions and 127 deletions

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import type {AgentCapabilityKind} from '../types';
import type { AgentCapabilityKind } from '../types';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import {Connection, Files, Loading, Plus, Share, VideoPlay,} from '@element-plus/icons-vue';
import {
Connection,
Files,
Link,
Loading,
Plus,
Share,
VideoPlay,
} from '@element-plus/icons-vue';
const props = defineProps<{
offlineDisabled?: boolean;
@@ -47,6 +55,12 @@ const capabilityItems = [
desc: '执行工具能力',
icon: Connection,
},
{
kind: 'mcp' as const,
title: 'MCP',
desc: '连接外部工具',
icon: Link,
},
];
function handleAdd(kind: AgentCapabilityKind) {

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import type {AgentDraftState, AgentOption, AgentValidationIssue,} from '../types';
import type {
AgentDraftState,
AgentOption,
AgentValidationIssue,
} from '../types';
import {computed} from 'vue';
import { computed } from 'vue';
import {Close} from '@element-plus/icons-vue';
import {ElButton} from 'element-plus';
import { Close } from '@element-plus/icons-vue';
import { ElButton } from 'element-plus';
import AgentBaseForm from './AgentBaseForm.vue';
import AgentKnowledgeForm from './AgentKnowledgeForm.vue';
@@ -15,6 +19,7 @@ const props = defineProps<{
categories: AgentOption[];
issues: AgentValidationIssue[];
knowledges: AgentOption[];
mcps: AgentOption[];
models: AgentOption[];
pluginTools: AgentOption[];
state: AgentDraftState;
@@ -40,11 +45,18 @@ const selectedTool = computed(() => {
return props.state.toolBindings.find((item) => item.localId === localId);
});
const selectedToolKind = computed(() =>
String(selectedTool.value?.toolType || '').toUpperCase() === 'WORKFLOW'
? 'workflow'
: 'plugin',
);
const selectedToolKind = computed(() => {
const toolType = String(selectedTool.value?.toolType || '').toUpperCase();
if (toolType === 'WORKFLOW') return 'workflow';
if (toolType === 'MCP') return 'mcp';
return 'plugin';
});
const selectedToolOptions = computed(() => {
if (selectedToolKind.value === 'workflow') return props.workflows;
if (selectedToolKind.value === 'mcp') return props.mcps;
return props.pluginTools;
});
</script>
<template>
@@ -100,7 +112,7 @@ const selectedToolKind = computed(() =>
v-else-if="selectedTool"
:binding="selectedTool"
:kind="selectedToolKind"
:options="selectedToolKind === 'workflow' ? workflows : pluginTools"
:options="selectedToolOptions"
@change="emit('change')"
@remove="emit('removeCapability')"
/>

View File

@@ -1,12 +1,23 @@
<script setup lang="ts">
/* eslint-disable vue/no-mutating-props */
import type {AgentOption, AgentToolBinding} from '../types';
import type { AgentOption, AgentToolBinding } from '../types';
import {ElButton, ElForm, ElFormItem, ElInput, ElOption, ElSelect, ElSwitch,} from 'element-plus';
import { computed } from 'vue';
import {
ElButton,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
const props = defineProps<{
binding: AgentToolBinding;
kind: 'plugin' | 'workflow';
kind: 'mcp' | 'plugin' | 'workflow';
options: AgentOption[];
}>();
@@ -15,7 +26,7 @@ const emit = defineEmits<{
remove: [];
}>();
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
function isSafeToolName(name?: string) {
return SAFE_TOOL_NAME_PATTERN.test(String(name || ''));
@@ -40,11 +51,47 @@ function shouldSyncToolName() {
return name.startsWith(prefix);
}
const targetValue = computed({
get() {
return props.binding.targetId ? String(props.binding.targetId) : '';
},
set(value: string) {
props.binding.targetId = value;
},
});
const resourceLabel = computed(() => {
if (props.kind === 'workflow') return '工作流';
if (props.kind === 'mcp') return 'MCP';
return '插件工具';
});
const selectedMcpTools = computed(() => {
if (props.kind !== 'mcp') {
return [];
}
const option = props.options.find(
(item) => String(item.value) === String(props.binding.targetId),
);
const tools = option?.raw?.tools || props.binding.resourceSummary?.tools;
return Array.isArray(tools) ? tools : [];
});
const selectedMcpToolCount = computed(() => selectedMcpTools.value.length);
function handleTargetChange(value: string) {
const option = props.options.find(
(item) => String(item.value) === String(value),
);
props.binding.resourceSummary = option?.raw || {};
if (props.kind === 'mcp') {
props.binding.targetId = option?.raw?.mcpId
? String(option.raw.mcpId)
: value;
props.binding.toolName = '';
emit('change');
return;
}
if (shouldSyncToolName()) {
props.binding.toolName = resolveToolName(option);
}
@@ -54,9 +101,9 @@ function handleTargetChange(value: string) {
<template>
<ElForm label-position="top" class="agent-form">
<ElFormItem :label="kind === 'workflow' ? '工作流' : '插件工具'" required>
<ElFormItem :label="resourceLabel" required>
<ElSelect
v-model="binding.targetId"
v-model="targetValue"
filterable
placeholder="选择资源"
@change="handleTargetChange"
@@ -69,7 +116,28 @@ function handleTargetChange(value: string) {
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="工具名称" required>
<div v-if="kind === 'mcp'" class="agent-form__mcp-tools">
<div class="agent-form__mcp-tools-header">
<span>工具列表</span>
<span>{{ selectedMcpToolCount }} </span>
</div>
<div v-if="selectedMcpTools.length > 0" class="agent-form__mcp-tool-list">
<div
v-for="tool in selectedMcpTools"
:key="tool.name || tool.title"
class="agent-form__mcp-tool"
>
<div class="agent-form__mcp-tool-name">
{{ tool.name || tool.title || '未命名工具' }}
</div>
<div v-if="tool.description" class="agent-form__mcp-tool-desc">
{{ tool.description }}
</div>
</div>
</div>
<ElEmpty v-else description="暂无工具" :image-size="64" />
</div>
<ElFormItem v-if="kind !== 'mcp'" label="工具名称" required>
<ElInput
v-model="binding.toolName"
placeholder="仅支持英文、数字、下划线或中划线"
@@ -99,6 +167,59 @@ function handleTargetChange(value: string) {
width: 100%;
}
.agent-form__mcp-tools {
margin-bottom: 16px;
}
.agent-form__mcp-tools-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.agent-form__mcp-tools-header span:last-child {
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-secondary);
}
.agent-form__mcp-tool-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-form__mcp-tool {
padding: 10px 12px;
background: var(--el-fill-color-lighter);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.agent-form__mcp-tool-name {
overflow: hidden;
font-size: 13px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-form__mcp-tool-desc {
display: -webkit-box;
margin-top: 4px;
overflow: hidden;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.agent-form__danger {
width: 100%;
margin-top: 8px;

View File

@@ -34,8 +34,11 @@ import '@tinyflow-ai/vue/dist/index.css';
const props = defineProps<{
knowledgeOptions?: AgentOption[];
mcpOptions?: AgentOption[];
pluginOptions?: AgentOption[];
selectedNodeId: string;
state: AgentDraftState;
workflowOptions?: AgentOption[];
}>();
const emit = defineEmits<{
@@ -52,6 +55,11 @@ const canvasModel = useAgentStudioModel(
layout,
() => canvasSize.value,
() => props.knowledgeOptions || [],
() => ({
mcp: props.mcpOptions || [],
plugin: props.pluginOptions || [],
workflow: props.workflowOptions || [],
}),
);
const liveNodes = ref<AgentStudioNodeView[]>([]);
const liveViewport = ref<AgentStudioViewport>({ x: 250, y: 100, zoom: 1 });

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import type {AgentStudioNodeData} from './types';
import type { AgentStudioNodeData } from './types';
import {computed} from 'vue';
import { computed } from 'vue';
import {Connection, Cpu, Files, Share} from '@element-plus/icons-vue';
import {ElIcon} from 'element-plus';
import { Connection, Cpu, Files, Link, Share } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
const props = defineProps<{
data: AgentStudioNodeData;
@@ -14,6 +14,7 @@ const iconComponent = computed(() => {
const icons = {
base: Cpu,
knowledge: Files,
mcp: Link,
plugin: Connection,
workflow: Share,
};
@@ -108,6 +109,7 @@ const iconComponent = computed(() => {
}
.agent-studio-node--knowledge,
.agent-studio-node--mcp,
.agent-studio-node--workflow,
.agent-studio-node--plugin {
min-height: 78px;