feat: 完成 Agent MCP 对接
- 增加 MCP 连接类型、环境检测接口和容器运行环境支持 - 将 Agent 编排改为绑定整体 MCP 并编译为 runtime McpSpec - 优化 MCP 工具展示、审批、草稿试运行和画布回显稳定性
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"configJson": "ConfigJson",
|
||||
"approvalRequired": "Approval Required",
|
||||
"deptId": "DeptId",
|
||||
"tenantId": "TenantId",
|
||||
"created": "Created",
|
||||
@@ -27,5 +28,23 @@
|
||||
"labels": {
|
||||
"clientOnline": "ClientOnline",
|
||||
"clientOffline": "ClientOffline"
|
||||
},
|
||||
"jsonEditor": {
|
||||
"format": "Format",
|
||||
"invalid": "Invalid JSON"
|
||||
},
|
||||
"check": {
|
||||
"action": "Check",
|
||||
"overall": "Overall",
|
||||
"resultTitle": "MCP Environment Check",
|
||||
"toolCount": "Tools",
|
||||
"message": {
|
||||
"configRequired": "Please enter MCP config JSON first"
|
||||
},
|
||||
"status": {
|
||||
"success": "Passed",
|
||||
"warning": "Warning",
|
||||
"failed": "Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"title": "名称",
|
||||
"description": "描述",
|
||||
"configJson": "MCP配置JSON",
|
||||
"approvalRequired": "执行前审批",
|
||||
"deptId": "部门ID",
|
||||
"tenantId": "租户ID",
|
||||
"created": "创建时间",
|
||||
@@ -27,5 +28,23 @@
|
||||
"labels": {
|
||||
"clientOnline": "客户端在线",
|
||||
"clientOffline": "客户端离线"
|
||||
},
|
||||
"jsonEditor": {
|
||||
"format": "格式化",
|
||||
"invalid": "JSON 格式错误"
|
||||
},
|
||||
"check": {
|
||||
"action": "检测",
|
||||
"overall": "整体状态",
|
||||
"resultTitle": "MCP 环境检测",
|
||||
"toolCount": "工具数",
|
||||
"message": {
|
||||
"configRequired": "请先填写 MCP 配置 JSON"
|
||||
},
|
||||
"status": {
|
||||
"success": "通过",
|
||||
"warning": "警告",
|
||||
"failed": "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,49 @@ describe('agentTimelineAdapter', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses tool display name when restoring aliased MCP tools from history', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
id: 'mcp-history',
|
||||
senderRole: 'assistant',
|
||||
contentText: '已完成',
|
||||
roundId: 'round-mcp',
|
||||
contentPayload: {
|
||||
chains: [
|
||||
{
|
||||
id: 'tool-mcp-1',
|
||||
name: 'mcp_123_search',
|
||||
toolDisplayName: 'Context MCP - search',
|
||||
status: 'TOOL_RESULT',
|
||||
result: 'ok',
|
||||
},
|
||||
],
|
||||
messageChain: [
|
||||
{
|
||||
role: 'assistant',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-mcp-1',
|
||||
name: 'mcp_123_search',
|
||||
toolDisplayName: 'Context MCP - search',
|
||||
arguments: '{}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
toolCallId: 'tool-mcp-1',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = items.find((item) => item.type === 'tool');
|
||||
expect(tool?.toolName).toBe('Context MCP - search');
|
||||
});
|
||||
|
||||
it('hides knowledge retrieval cards when restoring agent chat history', () => {
|
||||
const items = recordsToTimelineItems([
|
||||
{
|
||||
|
||||
@@ -68,7 +68,9 @@ function shouldSkipToolProjection(value: unknown) {
|
||||
|
||||
function normalizeToolCallName(payload: Record<string, any>) {
|
||||
const fn = asRecord(payload.function);
|
||||
return normalizeToolName(payload.name ?? payload.toolName ?? fn.name);
|
||||
return normalizeToolName(
|
||||
payload.toolDisplayName ?? payload.name ?? payload.toolName ?? fn.name,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeToolCallInput(payload: Record<string, any>) {
|
||||
@@ -211,7 +213,9 @@ function projectHistoryChain(
|
||||
hasAssistantThinking = true;
|
||||
continue;
|
||||
}
|
||||
const toolName = normalizeToolName(item.name ?? item.toolName);
|
||||
const toolName = normalizeToolName(
|
||||
item.toolDisplayName ?? item.name ?? item.toolName,
|
||||
);
|
||||
const toolCallId = normalizeToolCallId(item);
|
||||
if (toolCallId && toolName) {
|
||||
toolNameByCallId.set(toolCallId, toolName);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/* cspell:ignore tryit */
|
||||
import type {AgentCapabilityKind, AgentOption, AgentValidationIssue,} from './types';
|
||||
import type {
|
||||
AgentCapabilityKind,
|
||||
AgentOption,
|
||||
AgentValidationIssue,
|
||||
} from './types';
|
||||
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {ElMessage, ElMessageBox} from 'element-plus';
|
||||
import {tryit} from 'radash';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
import { api } from '#/api/request';
|
||||
import {
|
||||
canAiResourceOffline,
|
||||
canAiResourcePublish,
|
||||
@@ -30,7 +34,7 @@ import {
|
||||
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 { useAgentDesignerState } from './composables/useAgentDesignerState';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -62,6 +66,7 @@ const models = ref<AgentOption[]>([]);
|
||||
const knowledges = ref<AgentOption[]>([]);
|
||||
const workflows = ref<AgentOption[]>([]);
|
||||
const pluginTools = ref<AgentOption[]>([]);
|
||||
const mcps = ref<AgentOption[]>([]);
|
||||
|
||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||
const publishText = computed(() => {
|
||||
@@ -132,6 +137,21 @@ async function loadAgent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgentLifecycleState() {
|
||||
if (!state.agent.id) return;
|
||||
const [, res] = await tryit(getAgentDetail)(String(state.agent.id));
|
||||
if (res?.errorCode !== 0 || !res.data) {
|
||||
return;
|
||||
}
|
||||
const agentState = { ...res.data };
|
||||
delete agentState.knowledgeBindings;
|
||||
delete agentState.toolBindings;
|
||||
state.agent = {
|
||||
...state.agent,
|
||||
...agentState,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNavTitle() {
|
||||
const navTitle = Array.isArray(route.query.navTitle)
|
||||
? route.query.navTitle[0]
|
||||
@@ -172,7 +192,7 @@ function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
|
||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes, mcpRes] =
|
||||
await Promise.all([
|
||||
api.get('/api/v1/agentCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
@@ -185,6 +205,9 @@ 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 },
|
||||
}),
|
||||
]);
|
||||
|
||||
categories.value = (categoryRes.data || []).map((item: any) => ({
|
||||
@@ -212,6 +235,7 @@ async function loadOptions() {
|
||||
pluginTools.value = flattenPluginTools(
|
||||
pluginRes.data?.records || pluginRes.data || [],
|
||||
);
|
||||
mcps.value = mapMcpOptions(mcpRes.data?.records || mcpRes.data || []);
|
||||
}
|
||||
|
||||
function flattenPluginTools(list: any[]): AgentOption[] {
|
||||
@@ -229,6 +253,22 @@ function flattenPluginTools(list: any[]): AgentOption[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapMcpOptions(list: any[]): AgentOption[] {
|
||||
return list.map((mcp) => ({
|
||||
label: mcp.title || mcp.name || 'MCP',
|
||||
value: String(mcp.id),
|
||||
raw: {
|
||||
...mcp,
|
||||
id: mcp.id,
|
||||
mcpId: mcp.id,
|
||||
mcpTitle: mcp.title || mcp.name,
|
||||
title: mcp.title || mcp.name,
|
||||
tools: Array.isArray(mcp.tools) ? mcp.tools : [],
|
||||
approvalRequired: Boolean(mcp.approvalRequired),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleAdd(kind: AgentCapabilityKind) {
|
||||
if (kind === 'knowledge') {
|
||||
addKnowledgeNode();
|
||||
@@ -331,7 +371,7 @@ async function handlePublish() {
|
||||
const res = await submitAgentPublishApproval(String(state.agent.id));
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '已提交');
|
||||
await loadAgent();
|
||||
await refreshAgentLifecycleState();
|
||||
}
|
||||
} finally {
|
||||
publishLoading.value = false;
|
||||
@@ -358,7 +398,7 @@ async function handleOffline() {
|
||||
const res = await submitAgentOfflineApproval(String(state.agent.id));
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success(res.message || '已提交');
|
||||
await loadAgent();
|
||||
await refreshAgentLifecycleState();
|
||||
}
|
||||
} finally {
|
||||
offlineLoading.value = false;
|
||||
@@ -380,7 +420,10 @@ function handleCloseTryout() {
|
||||
<AgentStudioCanvas
|
||||
:state="state"
|
||||
:knowledge-options="knowledges"
|
||||
:mcp-options="mcps"
|
||||
:plugin-options="pluginTools"
|
||||
:selected-node-id="state.selectedNodeId"
|
||||
:workflow-options="workflows"
|
||||
@select="handleSelectNode"
|
||||
/>
|
||||
<AgentInspectorPanel
|
||||
@@ -390,6 +433,7 @@ function handleCloseTryout() {
|
||||
:knowledges="knowledges"
|
||||
:workflows="workflows"
|
||||
:plugin-tools="pluginTools"
|
||||
:mcps="mcps"
|
||||
:issues="issues"
|
||||
@change="markDirty"
|
||||
@remove-capability="removeSelectedCapability"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {resolveCapabilityNodePosition} from './useAgentStudioModel';
|
||||
import {useAgentStudioModel, resolveCapabilityNodePosition} from './useAgentStudioModel';
|
||||
|
||||
describe('resolveCapabilityNodePosition', () => {
|
||||
it('无视口信息时沿用默认左侧列位置', () => {
|
||||
@@ -57,3 +57,47 @@ describe('resolveCapabilityNodePosition', () => {
|
||||
).toEqual({ x: 128, y: 256 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAgentStudioModel', () => {
|
||||
it('MCP 绑定缺少资源快照时从选项中回显节点信息', () => {
|
||||
const model = useAgentStudioModel(
|
||||
{
|
||||
agent: {
|
||||
name: '测试智能体',
|
||||
},
|
||||
dirty: false,
|
||||
knowledgeBindings: [],
|
||||
panelMode: 'capability',
|
||||
selectedNodeId: 'tool:mcp-1',
|
||||
toolBindings: [
|
||||
{
|
||||
localId: 'mcp-1',
|
||||
targetId: '1001',
|
||||
toolType: 'MCP',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => 'tool:mcp-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => ({
|
||||
mcp: [
|
||||
{
|
||||
label: 'context7',
|
||||
value: '1001',
|
||||
raw: {
|
||||
id: '1001',
|
||||
title: 'context7',
|
||||
tools: [{ name: 'resolve-library-id' }, { name: 'query-docs' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const mcpNode = model.value.nodes.find((node) => node.id === 'tool:mcp-1');
|
||||
expect(mcpNode?.data.title).toBe('context7 · 2 个工具');
|
||||
expect(mcpNode?.data.detail).toBe('context7 · 2 个工具');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
AgentToolBinding,
|
||||
} from '../../types';
|
||||
|
||||
import {computed} from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const BASE_NODE_ID = 'agent-base';
|
||||
const BASE_POSITION = { x: 430, y: 260 };
|
||||
@@ -57,19 +57,66 @@ function buildKnowledgeTitle(
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolTitle(binding: AgentToolBinding) {
|
||||
function findMatchedToolOption(
|
||||
binding: AgentToolBinding,
|
||||
options: 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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildToolTitle(binding: AgentToolBinding, options: AgentOption[] = []) {
|
||||
const matchedOption = findMatchedToolOption(binding, options);
|
||||
return firstText(
|
||||
binding.resourceSummary?.title,
|
||||
binding.resourceSummary?.name,
|
||||
binding.resourceSummary?.label,
|
||||
binding.resourceSummary?.displayName,
|
||||
binding.resourceSummary?.mcpTitle,
|
||||
binding.resourceSnapshot?.title,
|
||||
binding.resourceSnapshot?.name,
|
||||
binding.resourceSnapshot?.label,
|
||||
binding.resourceSnapshot?.displayName,
|
||||
binding.resourceSnapshot?.mcpTitle,
|
||||
matchedOption?.label,
|
||||
matchedOption?.raw?.title,
|
||||
matchedOption?.raw?.name,
|
||||
matchedOption?.raw?.label,
|
||||
matchedOption?.raw?.displayName,
|
||||
matchedOption?.raw?.mcpTitle,
|
||||
binding.toolName,
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolDetail(binding: AgentToolBinding, fallback: string) {
|
||||
function buildToolDetail(
|
||||
binding: AgentToolBinding,
|
||||
fallback: string,
|
||||
options: AgentOption[] = [],
|
||||
) {
|
||||
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||
const matchedOption = findMatchedToolOption(binding, options);
|
||||
const resourceName = buildToolTitle(binding, options);
|
||||
const tools =
|
||||
binding.resourceSummary?.tools ||
|
||||
binding.resourceSnapshot?.tools ||
|
||||
matchedOption?.raw?.tools ||
|
||||
[];
|
||||
const toolCount = Array.isArray(tools) ? tools.length : 0;
|
||||
if (resourceName && toolCount > 0) {
|
||||
return `${resourceName} · ${toolCount} 个工具`;
|
||||
}
|
||||
return resourceName || fallback;
|
||||
}
|
||||
const toolName = firstText(binding.toolName);
|
||||
const resourceName = buildToolTitle(binding);
|
||||
const resourceName = buildToolTitle(binding, options);
|
||||
if (toolName && resourceName && toolName !== resourceName) {
|
||||
return `${resourceName} / ${toolName}`;
|
||||
}
|
||||
@@ -157,6 +204,11 @@ export function useAgentStudioModel(
|
||||
layout?: AgentStudioLayoutSnapshot,
|
||||
canvasSize?: () => AgentStudioCanvasSize | undefined,
|
||||
knowledgeOptions?: () => AgentOption[],
|
||||
toolOptions?: () => {
|
||||
mcp?: AgentOption[];
|
||||
plugin?: AgentOption[];
|
||||
workflow?: AgentOption[];
|
||||
},
|
||||
) {
|
||||
return computed(() => {
|
||||
const positionOf = (nodeId: string, fallback: { x: number; y: number }) =>
|
||||
@@ -214,10 +266,20 @@ export function useAgentStudioModel(
|
||||
});
|
||||
const toolNodes = state.toolBindings.map((binding, index) => {
|
||||
const nodeId = `tool:${binding.localId}`;
|
||||
const isWorkflow =
|
||||
String(binding.toolType || '').toUpperCase() === 'WORKFLOW';
|
||||
const fallback = isWorkflow ? '待选择工作流' : '待选择插件工具';
|
||||
const detail = buildToolDetail(binding, fallback);
|
||||
const toolType = String(binding.toolType || '').toUpperCase();
|
||||
const isWorkflow = toolType === 'WORKFLOW';
|
||||
const isMcp = toolType === 'MCP';
|
||||
const matchedOptions = isWorkflow
|
||||
? toolOptions?.().workflow || []
|
||||
: isMcp
|
||||
? toolOptions?.().mcp || []
|
||||
: toolOptions?.().plugin || [];
|
||||
const fallback = isWorkflow
|
||||
? '待选择工作流'
|
||||
: isMcp
|
||||
? '待选择 MCP'
|
||||
: '待选择插件工具';
|
||||
const detail = buildToolDetail(binding, fallback, matchedOptions);
|
||||
const position = resolveCapabilityNodePosition({
|
||||
canvasSize: size,
|
||||
fallbackIndex: state.knowledgeBindings.length + index,
|
||||
@@ -233,14 +295,20 @@ export function useAgentStudioModel(
|
||||
width: CAPABILITY_NODE_WIDTH,
|
||||
height: CAPABILITY_NODE_HEIGHT,
|
||||
data: {
|
||||
badge: isWorkflow ? '工作流' : '插件',
|
||||
badge: isWorkflow ? '工作流' : isMcp ? 'MCP' : '插件',
|
||||
detail,
|
||||
iconKey: isWorkflow ? 'workflow' : 'plugin',
|
||||
iconKey: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||
id: nodeId,
|
||||
kind: isWorkflow ? 'workflow' : 'plugin',
|
||||
kind: isWorkflow ? 'workflow' : isMcp ? 'mcp' : 'plugin',
|
||||
selected: selectedNodeId() === nodeId,
|
||||
title:
|
||||
detail === fallback ? (isWorkflow ? '工作流' : '插件') : detail,
|
||||
detail === fallback
|
||||
? isWorkflow
|
||||
? '工作流'
|
||||
: isMcp
|
||||
? 'MCP'
|
||||
: '插件'
|
||||
: detail,
|
||||
} satisfies AgentStudioNodeData,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,10 +7,10 @@ import type {
|
||||
AgentValidationIssue,
|
||||
} from '../types';
|
||||
|
||||
import {computed, reactive} from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
const BASE_NODE_ID = 'agent-base';
|
||||
const SAFE_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const SAFE_TOOL_NAME_PATTERN = /^[\w-]+$/;
|
||||
|
||||
function createLocalId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
@@ -25,6 +25,15 @@ function buildFallbackToolName(prefix: string, resource?: Record<string, any>) {
|
||||
return `${prefix}_${id}`;
|
||||
}
|
||||
|
||||
function toolKindFromType(
|
||||
toolType?: string,
|
||||
): Exclude<AgentCapabilityKind, 'knowledge'> {
|
||||
const normalized = String(toolType || '').toUpperCase();
|
||||
if (normalized === 'WORKFLOW') return 'workflow';
|
||||
if (normalized === 'MCP') return 'mcp';
|
||||
return 'plugin';
|
||||
}
|
||||
|
||||
function resolveToolName(
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||
resource?: Record<string, any>,
|
||||
@@ -36,19 +45,19 @@ function resolveToolName(
|
||||
return String(resource?.name);
|
||||
}
|
||||
return buildFallbackToolName(
|
||||
kind === 'workflow' ? 'workflow' : 'plugin',
|
||||
kind === 'workflow' ? 'workflow' : kind === 'mcp' ? 'mcp' : 'plugin',
|
||||
resource,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBindingToolName(binding: AgentToolBinding) {
|
||||
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||
return '';
|
||||
}
|
||||
if (isSafeToolName(binding.toolName)) {
|
||||
return String(binding.toolName);
|
||||
}
|
||||
const kind =
|
||||
String(binding.toolType || '').toUpperCase() === 'WORKFLOW'
|
||||
? 'workflow'
|
||||
: 'plugin';
|
||||
const kind = toolKindFromType(binding.toolType);
|
||||
const resource = {
|
||||
...(binding.resourceSnapshot || {}),
|
||||
...(binding.resourceSummary || {}),
|
||||
@@ -178,10 +187,7 @@ export function useAgentDesignerState() {
|
||||
(item) => item.localId === localId,
|
||||
);
|
||||
return {
|
||||
kind:
|
||||
String(binding?.toolType || '').toUpperCase() === 'WORKFLOW'
|
||||
? ('workflow' as AgentCapabilityKind)
|
||||
: ('plugin' as AgentCapabilityKind),
|
||||
kind: toolKindFromType(binding?.toolType) as AgentCapabilityKind,
|
||||
binding,
|
||||
};
|
||||
}
|
||||
@@ -237,12 +243,17 @@ export function useAgentDesignerState() {
|
||||
kind: Exclude<AgentCapabilityKind, 'knowledge'>,
|
||||
resource?: Record<string, any>,
|
||||
) {
|
||||
const toolType = kind === 'workflow' ? 'WORKFLOW' : 'PLUGIN';
|
||||
const toolType =
|
||||
kind === 'workflow' ? 'WORKFLOW' : kind === 'mcp' ? 'MCP' : 'PLUGIN';
|
||||
const binding = normalizeToolBinding(
|
||||
{
|
||||
toolType,
|
||||
targetId: resource?.id ? String(resource.id) : '',
|
||||
toolName: resolveToolName(kind, resource),
|
||||
targetId: resource?.mcpId
|
||||
? String(resource.mcpId)
|
||||
: resource?.id
|
||||
? String(resource.id)
|
||||
: '',
|
||||
toolName: kind === 'mcp' ? '' : resolveToolName(kind, resource),
|
||||
resourceSummary: resource || {},
|
||||
},
|
||||
state.toolBindings.length,
|
||||
@@ -299,6 +310,9 @@ export function useAgentDesignerState() {
|
||||
if (!binding.targetId) {
|
||||
issues.push({ nodeId, field: 'targetId', message: '请选择能力资源' });
|
||||
}
|
||||
if (String(binding.toolType || '').toUpperCase() === 'MCP') {
|
||||
return;
|
||||
}
|
||||
if (!String(binding.toolName || '').trim()) {
|
||||
issues.push({ nodeId, field: 'toolName', message: '请填写工具名称' });
|
||||
} else if (!isSafeToolName(binding.toolName)) {
|
||||
@@ -340,13 +354,17 @@ export function useAgentDesignerState() {
|
||||
}
|
||||
|
||||
function buildToolPayload(agentId?: number | string) {
|
||||
return state.toolBindings.map((binding, index) => ({
|
||||
...binding,
|
||||
agentId,
|
||||
enabled: binding.enabled !== false,
|
||||
hitlEnabled: Boolean(binding.hitlEnabled),
|
||||
sortNo: index + 1,
|
||||
}));
|
||||
return state.toolBindings.map((binding, index) => {
|
||||
const isMcp = String(binding.toolType || '').toUpperCase() === 'MCP';
|
||||
return {
|
||||
...binding,
|
||||
agentId,
|
||||
enabled: binding.enabled !== false,
|
||||
hitlEnabled: Boolean(binding.hitlEnabled),
|
||||
toolName: isMcp ? '' : binding.toolName,
|
||||
sortNo: index + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* cspell:ignore hitl */
|
||||
|
||||
export type AgentPanelMode = 'base' | 'capability' | 'tryout';
|
||||
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow';
|
||||
export type AgentCapabilityKind = 'knowledge' | 'plugin' | 'workflow' | 'mcp';
|
||||
|
||||
export interface AgentInfo {
|
||||
id?: number | string;
|
||||
@@ -33,7 +33,7 @@ export interface AgentInfo {
|
||||
export interface AgentToolBinding {
|
||||
id?: number | string;
|
||||
agentId?: number | string;
|
||||
toolType: 'PLUGIN' | 'WORKFLOW' | string;
|
||||
toolType: 'MCP' | 'PLUGIN' | 'WORKFLOW' | string;
|
||||
targetId?: number | string;
|
||||
toolName?: string;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { markRaw, ref } from 'vue';
|
||||
|
||||
import { Delete, MoreFilled, Plus, Refresh } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
ElSwitch,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
@@ -28,6 +31,26 @@ import McpModal from './McpModal.vue';
|
||||
const formRef = ref<FormInstance>();
|
||||
const pageDataRef = ref();
|
||||
const saveDialog = ref();
|
||||
interface McpCheckItem {
|
||||
name: string;
|
||||
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface McpServerCheckResult {
|
||||
serverName: string;
|
||||
transport: string;
|
||||
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
toolCount: number;
|
||||
checks: McpCheckItem[];
|
||||
}
|
||||
|
||||
interface McpEnvironmentCheckResult {
|
||||
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
servers: McpServerCheckResult[];
|
||||
}
|
||||
|
||||
function reset(formEl: FormInstance | undefined) {
|
||||
formEl?.resetFields();
|
||||
pageDataRef.value.setQuery({});
|
||||
@@ -103,11 +126,110 @@ const handleHeaderButtonClick = (button: any) => {
|
||||
};
|
||||
const loadingMap = ref<Record<number | string, boolean>>({});
|
||||
const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
||||
const checkLoadingMap = ref<Record<number | string, boolean>>({});
|
||||
const checkDialogVisible = ref(false);
|
||||
const checkResult = ref<McpEnvironmentCheckResult>();
|
||||
|
||||
const checkTagType = (status?: string) => {
|
||||
if (status === 'SUCCESS') {
|
||||
return 'success';
|
||||
}
|
||||
if (status === 'WARNING') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const checkStatusLabel = (status?: string) => {
|
||||
if (status === 'SUCCESS') {
|
||||
return $t('mcp.check.status.success');
|
||||
}
|
||||
if (status === 'WARNING') {
|
||||
return $t('mcp.check.status.warning');
|
||||
}
|
||||
return $t('mcp.check.status.failed');
|
||||
};
|
||||
|
||||
const handleCheck = (row: any) => {
|
||||
checkLoadingMap.value[row.id] = true;
|
||||
api
|
||||
.post('/api/v1/mcp/check', { configJson: row.configJson })
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
checkResult.value = res.data;
|
||||
checkDialogVisible.value = true;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkLoadingMap.value[row.id] = false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 p-6">
|
||||
<McpModal ref="saveDialog" @reload="reset" />
|
||||
<ElDialog
|
||||
v-model="checkDialogVisible"
|
||||
:title="$t('mcp.check.resultTitle')"
|
||||
width="720px"
|
||||
>
|
||||
<div v-if="checkResult" class="mcp-check-result">
|
||||
<ElAlert
|
||||
:closable="false"
|
||||
:type="
|
||||
checkResult.overallStatus === 'SUCCESS'
|
||||
? 'success'
|
||||
: checkResult.overallStatus === 'WARNING'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
|
||||
checkResult.overallStatus,
|
||||
)}`"
|
||||
show-icon
|
||||
/>
|
||||
<div
|
||||
v-for="server in checkResult.servers"
|
||||
:key="server.serverName"
|
||||
class="mcp-check-server"
|
||||
>
|
||||
<div class="mcp-check-server__head">
|
||||
<div>
|
||||
<div class="mcp-check-server__title">
|
||||
{{ server.serverName }}
|
||||
</div>
|
||||
<div class="mcp-check-server__meta">
|
||||
{{ server.transport || '-' }} · {{ $t('mcp.check.toolCount') }}
|
||||
{{ server.toolCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<ElTag :type="checkTagType(server.status)" size="small">
|
||||
{{ checkStatusLabel(server.status) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="mcp-check-list">
|
||||
<div
|
||||
v-for="(item, index) in server.checks"
|
||||
:key="`${item.name}-${index}`"
|
||||
class="mcp-check-item"
|
||||
>
|
||||
<ElTag :type="checkTagType(item.status)" size="small">
|
||||
{{ checkStatusLabel(item.status) }}
|
||||
</ElTag>
|
||||
<div class="mcp-check-item__content">
|
||||
<div class="mcp-check-item__message">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
<div v-if="item.detail" class="mcp-check-item__detail">
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDialog>
|
||||
<ListPageShell>
|
||||
<template #filters>
|
||||
<HeaderSearch
|
||||
@@ -185,6 +307,17 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
||||
<ElButton link :icon="MoreFilled" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-access:code="'/api/v1/mcp/check'">
|
||||
<ElDropdownItem @click="handleCheck(row)">
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
:loading="checkLoadingMap[row.id]"
|
||||
>
|
||||
{{ $t('mcp.check.action') }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
<div v-access:code="'/api/v1/mcp/remove'">
|
||||
<ElDropdownItem @click="remove(row)">
|
||||
<ElButton type="danger" :icon="Delete" link>
|
||||
@@ -205,4 +338,59 @@ const refreshLoadingMap = ref<Record<number | string, boolean>>({});
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.mcp-check-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mcp-check-server {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mcp-check-server__head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mcp-check-server__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.mcp-check-server__meta,
|
||||
.mcp-check-item__detail {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mcp-check-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mcp-check-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mcp-check-item__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcp-check-item__message {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||
import { MagicStick } from '@element-plus/icons-vue';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import jsonLanguage from 'highlight.js/lib/languages/json';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
@@ -15,11 +20,14 @@ import {
|
||||
ElTableColumn,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
hljs.registerLanguage('json', jsonLanguage);
|
||||
|
||||
interface PropValue {
|
||||
type?: string;
|
||||
description?: string;
|
||||
@@ -42,9 +50,30 @@ interface McpEntity {
|
||||
configJson: string;
|
||||
deptId: string;
|
||||
status: boolean;
|
||||
approvalRequired: boolean;
|
||||
tools: McpTool[];
|
||||
}
|
||||
|
||||
interface McpCheckItem {
|
||||
name: string;
|
||||
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface McpServerCheckResult {
|
||||
serverName: string;
|
||||
transport: string;
|
||||
status: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
toolCount: number;
|
||||
checks: McpCheckItem[];
|
||||
}
|
||||
|
||||
interface McpEnvironmentCheckResult {
|
||||
overallStatus: 'FAILED' | 'SUCCESS' | 'WARNING';
|
||||
servers: McpServerCheckResult[];
|
||||
}
|
||||
|
||||
const emit = defineEmits(['reload']);
|
||||
|
||||
onMounted(() => {});
|
||||
@@ -56,6 +85,11 @@ const saveForm = ref<FormInstance>();
|
||||
const dialogVisible = ref(false);
|
||||
const isAdd = ref(true);
|
||||
const btnLoading = ref(false);
|
||||
const checkLoading = ref(false);
|
||||
const checkResult = ref<McpEnvironmentCheckResult>();
|
||||
const jsonEditorTextarea = ref<HTMLTextAreaElement>();
|
||||
const jsonEditorHighlight = ref<HTMLElement>();
|
||||
const jsonError = ref('');
|
||||
|
||||
const defaultEntity: McpEntity = {
|
||||
title: '',
|
||||
@@ -63,6 +97,7 @@ const defaultEntity: McpEntity = {
|
||||
configJson: '',
|
||||
deptId: '',
|
||||
status: false,
|
||||
approvalRequired: false,
|
||||
tools: [],
|
||||
};
|
||||
const entity = ref<McpEntity>({ ...defaultEntity });
|
||||
@@ -84,13 +119,30 @@ const rules = ref({
|
||||
],
|
||||
});
|
||||
|
||||
const highlightedConfigJson = computed(() => {
|
||||
const value = entity.value.configJson || '';
|
||||
return hljs.highlight(value || ' ', { language: 'json' }).value;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => entity.value.configJson,
|
||||
() => {
|
||||
validateConfigJson(false);
|
||||
},
|
||||
);
|
||||
|
||||
function openDialog(row: Partial<McpEntity> = {}) {
|
||||
isAdd.value = !row.id;
|
||||
entity.value = { ...defaultEntity, ...row };
|
||||
checkResult.value = undefined;
|
||||
if (!isAdd.value) {
|
||||
getMcpTools(row);
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => {
|
||||
syncJsonEditorScroll();
|
||||
validateConfigJson(false);
|
||||
});
|
||||
}
|
||||
|
||||
function getMcpTools(row: Partial<McpEntity>) {
|
||||
@@ -101,6 +153,9 @@ function getMcpTools(row: Partial<McpEntity>) {
|
||||
});
|
||||
}
|
||||
function save() {
|
||||
if (!validateConfigJson(true)) {
|
||||
return;
|
||||
}
|
||||
saveForm.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
btnLoading.value = true;
|
||||
@@ -128,12 +183,96 @@ function save() {
|
||||
});
|
||||
}
|
||||
|
||||
function checkMcpConfig() {
|
||||
if (!entity.value.configJson) {
|
||||
ElMessage.warning($t('mcp.check.message.configRequired'));
|
||||
return;
|
||||
}
|
||||
if (!validateConfigJson(true)) {
|
||||
return;
|
||||
}
|
||||
checkLoading.value = true;
|
||||
api
|
||||
.post('api/v1/mcp/check', { configJson: entity.value.configJson })
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
checkResult.value = res.data;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
saveForm.value?.resetFields();
|
||||
isAdd.value = true;
|
||||
entity.value = { ...defaultEntity };
|
||||
checkResult.value = undefined;
|
||||
jsonError.value = '';
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
function formatConfigJson() {
|
||||
if (!validateConfigJson(true)) {
|
||||
return;
|
||||
}
|
||||
entity.value.configJson = JSON.stringify(
|
||||
JSON.parse(entity.value.configJson),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
nextTick(() => {
|
||||
syncJsonEditorScroll();
|
||||
});
|
||||
}
|
||||
|
||||
function validateConfigJson(showMessage: boolean) {
|
||||
if (!entity.value.configJson) {
|
||||
jsonError.value = '';
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSON.parse(entity.value.configJson);
|
||||
jsonError.value = '';
|
||||
return true;
|
||||
} catch (error) {
|
||||
jsonError.value =
|
||||
error instanceof Error ? error.message : $t('mcp.jsonEditor.invalid');
|
||||
if (showMessage) {
|
||||
ElMessage.warning($t('mcp.jsonEditor.invalid'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncJsonEditorScroll() {
|
||||
if (!jsonEditorTextarea.value || !jsonEditorHighlight.value) {
|
||||
return;
|
||||
}
|
||||
jsonEditorHighlight.value.scrollTop = jsonEditorTextarea.value.scrollTop;
|
||||
jsonEditorHighlight.value.scrollLeft = jsonEditorTextarea.value.scrollLeft;
|
||||
}
|
||||
|
||||
const checkTagType = (status?: string) => {
|
||||
if (status === 'SUCCESS') {
|
||||
return 'success';
|
||||
}
|
||||
if (status === 'WARNING') {
|
||||
return 'warning';
|
||||
}
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const checkStatusLabel = (status?: string) => {
|
||||
if (status === 'SUCCESS') {
|
||||
return $t('mcp.check.status.success');
|
||||
}
|
||||
if (status === 'WARNING') {
|
||||
return $t('mcp.check.status.warning');
|
||||
}
|
||||
return $t('mcp.check.status.failed');
|
||||
};
|
||||
const jsonPlaceholder = ref(`{
|
||||
"mcpServers": {
|
||||
"12306-mcp": {
|
||||
@@ -176,13 +315,113 @@ const activeName = ref('config');
|
||||
<ElInput v-model.trim="entity.description" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="configJson" :label="$t('mcp.configJson')">
|
||||
<ElInput
|
||||
type="textarea"
|
||||
:rows="15"
|
||||
v-model.trim="entity.configJson"
|
||||
:placeholder="$t('mcp.example') + jsonPlaceholder"
|
||||
/>
|
||||
<div
|
||||
class="mcp-json-editor"
|
||||
:class="{ 'mcp-json-editor--error': jsonError }"
|
||||
>
|
||||
<div class="mcp-json-editor__toolbar">
|
||||
<ElButton
|
||||
:aria-label="$t('mcp.jsonEditor.format')"
|
||||
:icon="MagicStick"
|
||||
circle
|
||||
size="small"
|
||||
text
|
||||
:title="$t('mcp.jsonEditor.format')"
|
||||
@click="formatConfigJson"
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
ref="jsonEditorHighlight"
|
||||
class="mcp-json-editor__highlight"
|
||||
aria-hidden="true"
|
||||
><code v-html="highlightedConfigJson"></code></pre>
|
||||
<textarea
|
||||
ref="jsonEditorTextarea"
|
||||
v-model="entity.configJson"
|
||||
class="mcp-json-editor__textarea"
|
||||
spellcheck="false"
|
||||
:placeholder="$t('mcp.example') + jsonPlaceholder"
|
||||
:aria-invalid="Boolean(jsonError)"
|
||||
:aria-label="$t('mcp.configJson')"
|
||||
@scroll="syncJsonEditorScroll"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-if="jsonError" class="mcp-json-editor__error">
|
||||
{{ jsonError }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<div class="mcp-check-actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:loading="checkLoading"
|
||||
:disabled="checkLoading"
|
||||
@click="checkMcpConfig"
|
||||
>
|
||||
{{ $t('mcp.check.action') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElFormItem
|
||||
prop="approvalRequired"
|
||||
:label="$t('mcp.approvalRequired')"
|
||||
>
|
||||
<ElSwitch v-model="entity.approvalRequired" />
|
||||
</ElFormItem>
|
||||
<div v-if="checkResult" class="mcp-check-result">
|
||||
<ElAlert
|
||||
:closable="false"
|
||||
:type="
|
||||
checkResult.overallStatus === 'SUCCESS'
|
||||
? 'success'
|
||||
: checkResult.overallStatus === 'WARNING'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
"
|
||||
:title="`${$t('mcp.check.overall')}: ${checkStatusLabel(
|
||||
checkResult.overallStatus,
|
||||
)}`"
|
||||
show-icon
|
||||
/>
|
||||
<div
|
||||
v-for="server in checkResult.servers"
|
||||
:key="server.serverName"
|
||||
class="mcp-check-server"
|
||||
>
|
||||
<div class="mcp-check-server__head">
|
||||
<div>
|
||||
<div class="mcp-check-server__title">
|
||||
{{ server.serverName }}
|
||||
</div>
|
||||
<div class="mcp-check-server__meta">
|
||||
{{ server.transport || '-' }} ·
|
||||
{{ $t('mcp.check.toolCount') }} {{ server.toolCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<ElTag :type="checkTagType(server.status)" size="small">
|
||||
{{ checkStatusLabel(server.status) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="mcp-check-list">
|
||||
<div
|
||||
v-for="(item, index) in server.checks"
|
||||
:key="`${item.name}-${index}`"
|
||||
class="mcp-check-item"
|
||||
>
|
||||
<ElTag :type="checkTagType(item.status)" size="small">
|
||||
{{ checkStatusLabel(item.status) }}
|
||||
</ElTag>
|
||||
<div class="mcp-check-item__content">
|
||||
<div class="mcp-check-item__message">
|
||||
{{ item.message }}
|
||||
</div>
|
||||
<div v-if="item.detail" class="mcp-check-item__detail">
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElFormItem prop="status" :label="$t('mcp.status')">
|
||||
<ElSwitch v-model="entity.status" />
|
||||
</ElFormItem>
|
||||
@@ -319,14 +558,195 @@ const activeName = ref('config');
|
||||
|
||||
.params-left-title-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #e6e9ee;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mcp-json-editor {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color var(--el-transition-duration),
|
||||
box-shadow var(--el-transition-duration);
|
||||
}
|
||||
|
||||
.mcp-json-editor:focus-within {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.mcp-json-editor--error {
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.mcp-json-editor--error:focus-within {
|
||||
border-color: var(--el-color-danger);
|
||||
box-shadow: 0 0 0 1px var(--el-color-danger-light-7);
|
||||
}
|
||||
|
||||
.mcp-json-editor__toolbar {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mcp-json-editor__toolbar :deep(.el-button) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mcp-json-editor__toolbar :deep(.el-button:hover),
|
||||
.mcp-json-editor__toolbar :deep(.el-button:focus-visible) {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.mcp-json-editor__highlight,
|
||||
.mcp-json-editor__textarea {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 14px 48px 12px 12px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mcp-json-editor__highlight {
|
||||
z-index: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mcp-json-editor__textarea {
|
||||
z-index: 2;
|
||||
color: transparent;
|
||||
caret-color: var(--el-text-color-primary);
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mcp-json-editor__textarea::placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.mcp-json-editor__textarea::selection {
|
||||
color: transparent;
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.mcp-json-editor__error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.mcp-json-editor :deep(.hljs-attr) {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.mcp-json-editor :deep(.hljs-string) {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.mcp-json-editor :deep(.hljs-number),
|
||||
.mcp-json-editor :deep(.hljs-literal) {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.mcp-json-editor :deep(.hljs-punctuation) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mcp-check-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mcp-check-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mcp-check-server {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mcp-check-server__head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mcp-check-server__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.mcp-check-server__meta,
|
||||
.mcp-check-item__detail {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.mcp-check-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mcp-check-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mcp-check-item__content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcp-check-item__message {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
|
||||
Reference in New Issue
Block a user