front/src/views/system/mcpTools/index.vue
zzh 0791ea968a
All checks were successful
Build and Deploy Front / deploy (push) Successful in 7m38s
fix: 页签缓存名对齐、状态标签去色条,并修复订单详情嵌套字段不显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 14:15:57 +08:00

532 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>