All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m38s
Co-authored-by: Cursor <cursoragent@cursor.com>
532 lines
14 KiB
Vue
532 lines
14 KiB
Vue
<template>
|
||
<div class="mcp-tools-container">
|
||
<div class="page-head">
|
||
<div>
|
||
<h2 class="page-title">{{ t('menu.system.mcpTools') }}</h2>
|
||
<p class="page-desc">{{ t('system.mcpTools.pageDesc') }}</p>
|
||
</div>
|
||
<a-button :loading="loading" @click="loadTools">{{ t('system.mcpTools.refresh') }}</a-button>
|
||
</div>
|
||
|
||
<div class="layout">
|
||
<div class="tool-list liquid-card">
|
||
<div
|
||
v-for="tool in tools"
|
||
:key="tool.toolKey"
|
||
class="tool-card"
|
||
:class="{ active: selectedKey === tool.toolKey, disabled: !tool.enabled }"
|
||
@click="selectTool(tool)"
|
||
>
|
||
<div class="tool-card-top">
|
||
<span class="tool-name">{{ tool.displayName }}</span>
|
||
<a-switch
|
||
:model-value="tool.enabled"
|
||
size="small"
|
||
@click.stop
|
||
@change="(v: boolean | string | number) => toggleEnabled(tool, Boolean(v))"
|
||
/>
|
||
</div>
|
||
<div class="tool-key">{{ tool.toolKey }}</div>
|
||
<p class="tool-desc">{{ tool.description }}</p>
|
||
</div>
|
||
<a-empty v-if="!loading && !tools.length" />
|
||
</div>
|
||
|
||
<div class="tool-detail liquid-card">
|
||
<template v-if="selected">
|
||
<div class="detail-title">{{ selected.displayName }}</div>
|
||
<a-form :model="editForm" layout="vertical" class="detail-form">
|
||
<a-form-item :label="t('system.mcpTools.displayName')">
|
||
<a-input v-model="editForm.displayName" />
|
||
</a-form-item>
|
||
<a-form-item :label="t('system.mcpTools.description')">
|
||
<a-textarea v-model="editForm.description" :auto-size="{ minRows: 3, maxRows: 6 }" />
|
||
</a-form-item>
|
||
<a-form-item :label="t('system.mcpTools.sortOrder')">
|
||
<a-input-number v-model="editForm.sortOrder" :min="0" style="width: 160px" />
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-button type="primary" :loading="saving" @click="saveSelected">
|
||
{{ t('system.mcpTools.save') }}
|
||
</a-button>
|
||
</a-form-item>
|
||
</a-form>
|
||
|
||
<a-divider />
|
||
|
||
<div class="section-title">{{ t('system.mcpTools.testTitle') }}</div>
|
||
<p class="section-hint">{{ t('system.mcpTools.testHint') }}</p>
|
||
<a-textarea
|
||
v-model="testArgs"
|
||
:placeholder="t('system.mcpTools.testArgsPlaceholder')"
|
||
:auto-size="{ minRows: 4, maxRows: 10 }"
|
||
/>
|
||
<div class="row-actions">
|
||
<a-button type="primary" :loading="testing" :disabled="!selected.enabled" @click="runTest">
|
||
{{ t('system.mcpTools.runTest') }}
|
||
</a-button>
|
||
</div>
|
||
<pre v-if="testResult" class="result-pre" :class="{ ok: testOk, bad: testOk === false }">{{
|
||
testResult
|
||
}}</pre>
|
||
|
||
<a-divider />
|
||
|
||
<div class="section-title">{{ t('system.mcpTools.agentTitle') }}</div>
|
||
<p class="section-hint">{{ t('system.mcpTools.agentHint') }}</p>
|
||
<div class="agent-toolbar">
|
||
<a-select
|
||
v-model="providerKey"
|
||
:placeholder="t('system.mcpTools.provider')"
|
||
style="width: 200px"
|
||
allow-search
|
||
>
|
||
<a-option
|
||
v-for="p in providers"
|
||
:key="p.providerKey"
|
||
:value="p.providerKey"
|
||
:label="p.displayName || p.providerKey"
|
||
/>
|
||
</a-select>
|
||
<a-input
|
||
v-model="modelId"
|
||
:placeholder="t('system.mcpTools.model')"
|
||
style="width: 200px"
|
||
allow-clear
|
||
/>
|
||
</div>
|
||
<a-textarea
|
||
v-model="agentPrompt"
|
||
:placeholder="t('system.mcpTools.agentPlaceholder')"
|
||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||
/>
|
||
<div class="row-actions">
|
||
<a-button type="primary" :loading="agentLoading" @click="runAgent">
|
||
{{ t('system.mcpTools.runAgent') }}
|
||
</a-button>
|
||
</div>
|
||
<div v-if="agentReply" class="agent-reply">
|
||
<div class="agent-reply-label">{{ t('system.mcpTools.agentReply') }}</div>
|
||
<pre class="result-pre ok">{{ agentReply }}</pre>
|
||
</div>
|
||
<div v-if="agentTraces.length" class="agent-traces">
|
||
<div class="agent-reply-label">{{ t('system.mcpTools.toolTraces') }}</div>
|
||
<div v-for="(tr, idx) in agentTraces" :key="idx" class="trace-item">
|
||
<div class="trace-head">
|
||
<span>{{ tr.toolKey }}</span>
|
||
<a-tag :color="tr.success ? 'green' : 'red'" size="small">
|
||
{{ tr.success ? 'OK' : 'FAIL' }}
|
||
</a-tag>
|
||
</div>
|
||
<pre class="trace-args">{{ tr.argumentsJson }}</pre>
|
||
<pre class="trace-result">{{ tr.resultPreview }}</pre>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<a-empty v-else :description="t('system.mcpTools.selectHint')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Message } from '@arco-design/web-vue';
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
import {
|
||
callMcpTool,
|
||
chatAiAgent,
|
||
listMcpTools,
|
||
saveMcpTool,
|
||
type AiAgentToolTrace,
|
||
type McpToolItem,
|
||
} from '@/api/mcp';
|
||
import { listAiProviders } from '@/api/ai';
|
||
import {
|
||
resolveTextModelId,
|
||
type AiProviderItem,
|
||
} from '@/views/system/aiAssistant/providerCatalog';
|
||
|
||
defineOptions({
|
||
// 与路由 name 对齐;index.vue 默认推断为 index,会导致切标签无法缓存
|
||
name: 'SystemMcpTools',
|
||
});
|
||
|
||
const { t } = useI18n();
|
||
|
||
const loading = ref(false);
|
||
const saving = ref(false);
|
||
const testing = ref(false);
|
||
const agentLoading = ref(false);
|
||
const tools = ref<McpToolItem[]>([]);
|
||
const selectedKey = ref('');
|
||
const testArgs = ref('{}');
|
||
const testResult = ref('');
|
||
const testOk = ref<boolean | null>(null);
|
||
const providers = ref<AiProviderItem[]>([]);
|
||
const providerKey = ref('');
|
||
const modelId = ref('');
|
||
const agentPrompt = ref('');
|
||
const agentReply = ref('');
|
||
const agentTraces = ref<AiAgentToolTrace[]>([]);
|
||
|
||
const editForm = reactive({
|
||
displayName: '',
|
||
description: '',
|
||
sortOrder: 0,
|
||
enabled: true,
|
||
});
|
||
|
||
const selected = computed(() => tools.value.find((x) => x.toolKey === selectedKey.value) || null);
|
||
|
||
const SAMPLE_ARGS: Record<string, string> = {
|
||
'oms.price_check': JSON.stringify(
|
||
{ destinationCountry: 'US', cargoWeight: 0.5, postalCode: '10001' },
|
||
null,
|
||
2,
|
||
),
|
||
'oms.channel_guide': JSON.stringify(
|
||
{ productCode: '', productName: '', cargoName: 'lipstick' },
|
||
null,
|
||
2,
|
||
),
|
||
'oms.track_query': JSON.stringify({ orderNumber: '' }, null, 2),
|
||
};
|
||
|
||
function selectTool(tool: McpToolItem) {
|
||
selectedKey.value = tool.toolKey;
|
||
editForm.displayName = tool.displayName;
|
||
editForm.description = tool.description;
|
||
editForm.sortOrder = tool.sortOrder;
|
||
editForm.enabled = tool.enabled;
|
||
testArgs.value = SAMPLE_ARGS[tool.toolKey] || '{\n \n}';
|
||
testResult.value = '';
|
||
testOk.value = null;
|
||
}
|
||
|
||
async function loadTools() {
|
||
loading.value = true;
|
||
try {
|
||
tools.value = await listMcpTools();
|
||
if (!selectedKey.value && tools.value[0]) {
|
||
selectTool(tools.value[0]);
|
||
} else if (selectedKey.value) {
|
||
const cur = tools.value.find((x) => x.toolKey === selectedKey.value);
|
||
if (cur) selectTool(cur);
|
||
}
|
||
} catch (e: any) {
|
||
Message.error(e?.message || t('system.mcpTools.loadFail'));
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function toggleEnabled(tool: McpToolItem, enabled: boolean) {
|
||
try {
|
||
await saveMcpTool({
|
||
toolKey: tool.toolKey,
|
||
displayName: tool.displayName,
|
||
description: tool.description,
|
||
enabled,
|
||
sortOrder: tool.sortOrder,
|
||
});
|
||
tool.enabled = enabled;
|
||
if (selected.value?.toolKey === tool.toolKey) editForm.enabled = enabled;
|
||
Message.success(enabled ? t('system.mcpTools.enabled') : t('system.mcpTools.disabled'));
|
||
} catch (e: any) {
|
||
Message.error(e?.message || t('system.mcpTools.saveFail'));
|
||
}
|
||
}
|
||
|
||
async function saveSelected() {
|
||
if (!selected.value) return;
|
||
saving.value = true;
|
||
try {
|
||
const saved = await saveMcpTool({
|
||
toolKey: selected.value.toolKey,
|
||
displayName: editForm.displayName.trim(),
|
||
description: editForm.description.trim(),
|
||
enabled: editForm.enabled,
|
||
sortOrder: Number(editForm.sortOrder) || 0,
|
||
});
|
||
const idx = tools.value.findIndex((x) => x.toolKey === saved.toolKey);
|
||
if (idx >= 0) {
|
||
tools.value[idx] = { ...tools.value[idx], ...saved };
|
||
}
|
||
Message.success(t('system.mcpTools.saveOk'));
|
||
} catch (e: any) {
|
||
Message.error(e?.message || t('system.mcpTools.saveFail'));
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function runTest() {
|
||
if (!selected.value) return;
|
||
let args = testArgs.value.trim() || '{}';
|
||
try {
|
||
JSON.parse(args);
|
||
} catch {
|
||
Message.warning(t('system.mcpTools.argsInvalid'));
|
||
return;
|
||
}
|
||
testing.value = true;
|
||
testResult.value = '';
|
||
testOk.value = null;
|
||
try {
|
||
const res = await callMcpTool(selected.value.toolKey, args);
|
||
testOk.value = res.success;
|
||
testResult.value = `${res.success ? '✓' : '✗'} ${res.elapsedMs}ms\n\n${res.content}`;
|
||
} catch (e: any) {
|
||
testOk.value = false;
|
||
testResult.value = e?.message || t('system.mcpTools.testFail');
|
||
} finally {
|
||
testing.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadProviders() {
|
||
try {
|
||
const items = await listAiProviders();
|
||
providers.value = ((items ?? []) as AiProviderItem[]).filter((p) => p.enabled && p.apiKey);
|
||
const def = providers.value.find((p) => p.isDefault) ?? providers.value[0];
|
||
if (def) {
|
||
providerKey.value = def.providerKey;
|
||
modelId.value = resolveTextModelId(def);
|
||
}
|
||
} catch {
|
||
providers.value = [];
|
||
}
|
||
}
|
||
|
||
async function runAgent() {
|
||
const text = agentPrompt.value.trim();
|
||
if (!text) {
|
||
Message.warning(t('system.mcpTools.agentNeedPrompt'));
|
||
return;
|
||
}
|
||
if (!providerKey.value) {
|
||
Message.warning(t('system.mcpTools.agentNeedProvider'));
|
||
return;
|
||
}
|
||
agentLoading.value = true;
|
||
agentReply.value = '';
|
||
agentTraces.value = [];
|
||
try {
|
||
const res = await chatAiAgent({
|
||
providerKey: providerKey.value,
|
||
model: modelId.value || undefined,
|
||
messages: [{ role: 'user', content: text }],
|
||
maxToolRounds: 2,
|
||
});
|
||
agentReply.value = res.content;
|
||
agentTraces.value = res.toolTraces;
|
||
} catch (e: any) {
|
||
Message.error(e?.message || t('system.mcpTools.agentFail'));
|
||
} finally {
|
||
agentLoading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadTools(), loadProviders()]);
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.mcp-tools-container {
|
||
padding: 16px;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
background: var(--app-page-bg);
|
||
min-height: 0;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.page-title {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--color-text-1);
|
||
}
|
||
|
||
.page-desc {
|
||
margin: 4px 0 0;
|
||
font-size: 13px;
|
||
color: var(--color-text-3);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.layout {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
gap: 12px;
|
||
}
|
||
|
||
.liquid-card {
|
||
background: var(--app-card-bg);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--color-border-2);
|
||
padding: 14px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.tool-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.tool-card {
|
||
padding: 12px;
|
||
border: 1px solid var(--color-border-2);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
|
||
&:hover {
|
||
border-color: var(--app-primary-color, #2d8c8c);
|
||
}
|
||
|
||
&.active {
|
||
border-color: var(--app-primary-color, #2d8c8c);
|
||
background: rgba(45, 140, 140, 0.06);
|
||
}
|
||
|
||
&.disabled {
|
||
opacity: 0.65;
|
||
}
|
||
}
|
||
|
||
.tool-card-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tool-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--color-text-1);
|
||
}
|
||
|
||
.tool-key {
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: var(--color-text-3);
|
||
font-family: Consolas, monospace;
|
||
}
|
||
|
||
.tool-desc {
|
||
margin: 8px 0 0;
|
||
font-size: 12px;
|
||
color: var(--color-text-2);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.detail-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--color-text-1);
|
||
}
|
||
|
||
.section-hint {
|
||
margin: 4px 0 10px;
|
||
font-size: 12px;
|
||
color: var(--color-text-3);
|
||
}
|
||
|
||
.row-actions {
|
||
margin-top: 10px;
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.result-pre {
|
||
margin-top: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
background: var(--color-fill-2);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: 280px;
|
||
overflow: auto;
|
||
|
||
&.ok {
|
||
background: #e8ffea;
|
||
}
|
||
|
||
&.bad {
|
||
background: #ffece8;
|
||
}
|
||
}
|
||
|
||
.agent-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.agent-reply,
|
||
.agent-traces {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.agent-reply-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--color-text-2);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.trace-item {
|
||
border: 1px solid var(--color-border-2);
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.trace-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.trace-args,
|
||
.trace-result {
|
||
margin: 6px 0 0;
|
||
font-size: 11px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: var(--color-text-3);
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|