feat: update MCP servers management UI and add descriptions for better clarity

This commit is contained in:
Soulter
2026-04-30 11:45:27 +08:00
parent 798ee44620
commit 8d985eba61
10 changed files with 550 additions and 173 deletions

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 258 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -364,6 +364,10 @@
content: "\F01DA";
}
.mdi-download-outline::before {
content: "\F0B8F";
}
.mdi-emoticon::before {
content: "\F0C68";
}
@@ -404,8 +408,8 @@
content: "\F0215";
}
.mdi-file-code::before {
content: "\F022E";
.mdi-file-code-outline::before {
content: "\F102B";
}
.mdi-file-delimited-outline::before {
@@ -940,6 +944,10 @@
content: "\F060D";
}
.mdi-sync::before {
content: "\F04E6";
}
.mdi-text::before {
content: "\F09A8";
}

View File

@@ -1,85 +1,175 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0" elevation="0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
>
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
</div>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card style="background-color: rgb(var(--v-theme-mcpCardBg));" :item="server" title-field="name"
enabled-field="active" @toggle-enabled="updateServerStatus" @delete="deleteServer" @edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div v-else class="mcp-server-list">
<OutlinedActionListItem
v-for="server in mcpServers || []"
:key="server.name"
:title="server.name"
>
<div
class="mcp-server-config text-body-2 text-medium-emphasis"
:title="getServerConfigSummary(server)"
>
<v-icon
:icon="getServerConfigIcon(server)"
size="small"
class="me-1"
/>
<span>{{ getServerConfigSummary(server) }}</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
</span>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" color="primary" @click="isActive.value = false">
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
<div v-else class="text-caption text-medium-emphasis">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</div>
<div v-if="mcpServerUpdateLoaders[item.name]" class="text-caption text-medium-emphasis">
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
<div class="mcp-server-tools text-caption text-medium-emphasis">
<template v-if="server.tools && server.tools.length > 0">
<v-dialog max-width="600px">
<template v-slot:activator="{ props: listToolsProps }">
<button
v-bind="listToolsProps"
class="mcp-server-tools__button"
type="button"
>
<v-icon size="small" class="me-1">mdi-tools</v-icon>
{{
tm('mcpServers.status.availableTools', {
count: server.tools.length,
})
}}
({{ server.tools.length }})
</button>
</template>
<template v-slot:default="{ isActive }">
<v-card style="padding: 16px;">
<v-card-title class="d-flex align-center">
<span>{{ tm('mcpServers.status.availableTools') }}</span>
</v-card-title>
<v-card-text>
<ul>
<li
v-for="(tool, idx) in server.tools"
:key="idx"
style="margin: 8px 0px;"
>
{{ tool }}
</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn
variant="text"
color="primary"
@click="isActive.value = false"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</template>
</item-card>
</v-col>
</v-row>
<template v-else>
<v-icon size="small" color="warning" class="me-1">
mdi-alert-circle
</v-icon>
{{ tm('mcpServers.status.noTools') }}
</template>
</div>
<template #actions>
<v-tooltip :text="t('core.common.itemCard.edit')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-pencil-outline"
variant="text"
size="small"
class="list-action-icon-btn"
@click="editServer(server)"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
@click="deleteServer(server)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-progress-circular
v-if="mcpServerUpdateLoaders[server.name]"
indeterminate
color="primary"
size="18"
/>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="server.active"
:loading="mcpServerUpdateLoaders[server.name] || false"
:disabled="mcpServerUpdateLoaders[server.name] || false"
@update:model-value="updateServerStatus(server)"
/>
</template>
<span>{{
server.active
? t('core.common.itemCard.enabled')
: t('core.common.itemCard.disabled')
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</v-container>
<div class="mcp-fab-stack">
<v-tooltip :text="tm('mcpServers.buttons.sync')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-sync"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showSyncMcpServerDialog = true"
/>
</template>
</v-tooltip>
<v-tooltip :text="tm('mcpServers.buttons.add')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-plus"
size="x-large"
variant="elevated"
class="mcp-fab"
@click="showMcpServerDialog = true"
/>
</template>
</v-tooltip>
</div>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px">
<v-card>
@@ -220,8 +310,8 @@
<script>
import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import OutlinedActionListItem from '@/components/shared/OutlinedActionListItem.vue';
import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
@@ -231,7 +321,7 @@ export default {
name: 'McpServersSection',
components: {
VueMonacoEditor,
ItemCard
OutlinedActionListItem
},
setup() {
const { t } = useI18n();
@@ -272,6 +362,9 @@ export default {
},
getServerConfigSummary() {
return (server) => {
if (server.transport) {
return String(server.transport).trim();
}
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
@@ -283,6 +376,21 @@ export default {
}
return this.tm('mcpServers.status.noConfig');
};
},
getServerConfigIcon() {
return (server) => {
const transport = String(server.transport || '').toLowerCase();
if (transport === 'streamable_http') {
return 'mdi-web';
}
if (transport === 'sse') {
return 'mdi-broadcast';
}
if (server.command) {
return 'mdi-console-line';
}
return 'mdi-file-code-outline';
};
}
},
mounted() {
@@ -544,6 +652,73 @@ export default {
padding-top: 8px;
}
.mcp-server-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.mcp-server-config {
align-items: center;
display: flex;
overflow: hidden;
word-break: break-all;
}
.mcp-server-config span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcp-server-tools {
align-items: center;
display: flex;
margin-top: 6px;
}
.mcp-server-tools__button {
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.62);
display: inline-flex;
text-decoration: underline;
}
.mcp-server-tools__button:hover {
color: rgb(var(--v-theme-primary));
}
.list-action-icon-btn {
color: rgba(var(--v-theme-on-surface), 0.78);
}
.list-action-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
}
.mcp-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.mcp-fab {
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.mcp-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
@@ -551,4 +726,5 @@ export default {
margin-top: 4px;
overflow: hidden;
}
</style>

View File

@@ -1,37 +1,22 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<v-btn
v-if="mode === 'local'"
color="primary"
prepend-icon="mdi-upload"
class="me-2"
variant="tonal"
@click="openUploadDialog"
>
{{ tm("skills.upload") }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="tonal"
@click="refreshCurrentMode"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-btn-toggle v-model="mode" mandatory divided density="comfortable">
<v-row
v-if="neoEnabled"
class="d-flex justify-end align-center px-4 py-3 pb-4"
>
<v-btn-toggle
v-model="mode"
mandatory
divided
density="comfortable"
>
<v-btn value="local">{{ tm("skills.modeLocal") }}</v-btn>
<v-btn value="neo" :disabled="!neoEnabled">{{
tm("skills.modeNeo")
}}</v-btn>
<v-btn value="neo">{{ tm("skills.modeNeo") }}</v-btn>
</v-btn-toggle>
</v-row>
<div v-if="mode === 'local'" class="px-2 pb-2 d-flex flex-column ga-2">
<small style="color: grey">{{ tm("skills.runtimeHint") }}</small>
<v-alert
v-if="runtime === 'sandbox' && !sandboxCache.ready"
type="info"
@@ -67,67 +52,89 @@
<small class="text-grey">{{ tm("skills.emptyHint") }}</small>
</div>
<v-row v-else align="stretch">
<v-col
<div v-else class="skills-list pb-3">
<OutlinedActionListItem
v-for="skill in skills"
:key="skill.name"
cols="12"
md="6"
lg="4"
xl="3"
class="d-flex"
:title="skill.name"
>
<item-card
:item="skill"
title-field="name"
enabled-field="active"
:loading="itemLoading[skill.name] || false"
:show-edit-button="false"
:disable-toggle="isSandboxPresetSkill(skill)"
:disable-delete="isSandboxPresetSkill(skill)"
@toggle-enabled="toggleSkill"
@delete="confirmDelete"
>
<template #item-details="{ item }">
<div class="d-flex align-center mb-2 ga-2 flex-wrap">
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(item.source_type)"
>
{{ sourceTypeLabel(item.source_type) }}
</v-chip>
<div
class="text-caption text-medium-emphasis skill-description"
>
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm("skills.noDescription") }}
</div>
</div>
<div class="text-caption text-medium-emphasis skill-path">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ item.path }}
</div>
</template>
<template #actions="{ item }">
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="
itemLoading[item.name] ||
false ||
isSandboxPresetSkill(item)
"
@click="downloadSkill(item)"
>
{{ tm("skills.download") }}
</v-btn>
</template>
</item-card>
</v-col>
</v-row>
<template #title-extra>
<v-chip
size="x-small"
variant="tonal"
:color="sourceTypeColor(skill.source_type)"
>
{{ sourceTypeLabel(skill.source_type) }}
</v-chip>
</template>
<div class="skill-description text-body-2 text-medium-emphasis">
{{ skill.description || tm("skills.noDescription") }}
</div>
<div class="skill-path text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm("skills.path") }}: {{ skill.path }}
</div>
<template #actions>
<v-tooltip :text="tm('skills.download')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-download-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="
itemLoading[skill.name] ||
false ||
isSandboxPresetSkill(skill)
"
@click="downloadSkill(skill)"
/>
</template>
</v-tooltip>
<v-tooltip :text="t('core.common.itemCard.delete')" location="top">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-delete-outline"
variant="text"
size="small"
class="list-action-icon-btn"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@click="confirmDelete(skill)"
/>
</template>
</v-tooltip>
</template>
<template #control>
<v-tooltip location="top">
<template #activator="{ props }">
<v-switch
v-bind="props"
color="primary"
density="compact"
hide-details
inset
:model-value="skill.active"
:loading="itemLoading[skill.name] || false"
:disabled="itemLoading[skill.name] || isSandboxPresetSkill(skill)"
@update:model-value="toggleSkill(skill)"
/>
</template>
<span>{{
skill.active
? t("core.common.itemCard.enabled")
: t("core.common.itemCard.disabled")
}}</span>
</v-tooltip>
</template>
</OutlinedActionListItem>
</div>
</template>
<template v-else-if="mode === 'neo' && neoEnabled">
@@ -141,14 +148,6 @@
{{ tm("skills.neoFilterHint") }}
</div>
</div>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
variant="flat"
@click="fetchNeoData"
>
{{ tm("skills.refresh") }}
</v-btn>
</div>
<v-row class="ga-md-0 ga-2">
@@ -339,6 +338,39 @@
</template>
</v-container>
<div class="skills-fab-stack">
<v-tooltip :text="tm('skills.refresh')" location="left">
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-refresh"
size="x-large"
variant="elevated"
class="skills-fab"
@click="refreshCurrentMode"
/>
</template>
</v-tooltip>
<v-tooltip
v-if="mode === 'local'"
:text="tm('skills.upload')"
location="left"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
color="darkprimary"
icon="mdi-upload"
size="x-large"
variant="elevated"
class="skills-fab"
@click="openUploadDialog"
/>
</template>
</v-tooltip>
</div>
<v-dialog v-model="uploadDialog" max-width="880px" :persistent="uploading">
<v-card class="skills-upload-dialog">
<v-card-title class="skills-upload-dialog__header px-6 pt-6 pb-2">
@@ -589,8 +621,8 @@
<script>
import axios from "axios";
import { computed, onMounted, reactive, ref, watch } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import OutlinedActionListItem from "@/components/shared/OutlinedActionListItem.vue";
const STATUS_WAITING = "waiting";
const STATUS_UPLOADING = "uploading";
@@ -600,7 +632,7 @@ const STATUS_SKIPPED = "skipped";
export default {
name: "SkillsSection",
components: { ItemCard },
components: { OutlinedActionListItem },
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
@@ -1448,20 +1480,56 @@ export default {
</script>
<style scoped>
.skills-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.list-action-icon-btn {
color: rgba(var(--v-theme-on-surface), 0.78);
}
.list-action-icon-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
}
.skills-fab-stack {
align-items: center;
bottom: 52px;
display: flex;
flex-direction: column;
gap: 16px;
position: fixed;
right: 52px;
z-index: 10000;
}
.skills-fab {
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.skills-fab:hover {
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
transform: translateY(-4px) scale(1.05);
}
.skill-description {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 20px;
}
.skill-path {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
margin-top: 6px;
overflow: hidden;
min-height: 40px;
word-break: break-all;
}

View File

@@ -0,0 +1,116 @@
<template>
<v-card class="outlined-action-list-item rounded-lg" variant="outlined">
<div class="outlined-action-list-item__main">
<div class="outlined-action-list-item__content">
<div class="outlined-action-list-item__header">
<div class="outlined-action-list-item__title">
{{ title }}
</div>
<slot name="title-extra"></slot>
</div>
<slot></slot>
</div>
<div
v-if="$slots.actions || $slots.control"
class="outlined-action-list-item__actions"
>
<div
v-if="$slots.actions"
class="outlined-action-list-item__hover-actions"
>
<slot name="actions"></slot>
</div>
<slot name="control"></slot>
</div>
</div>
</v-card>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
});
</script>
<style scoped>
.outlined-action-list-item {
background: rgb(var(--v-theme-surface));
}
.outlined-action-list-item__main {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
min-height: 104px;
padding: 16px;
}
.outlined-action-list-item__content {
min-width: 0;
}
.outlined-action-list-item__header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.outlined-action-list-item__title {
font-size: 1rem;
font-weight: 700;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outlined-action-list-item__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: 8px;
margin-left: auto;
}
.outlined-action-list-item__hover-actions {
align-items: center;
display: flex;
gap: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.16s ease;
}
.outlined-action-list-item:hover .outlined-action-list-item__hover-actions,
.outlined-action-list-item:focus-within .outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
@media (max-width: 860px) {
.outlined-action-list-item__main {
align-items: stretch;
flex-direction: column;
}
.outlined-action-list-item__actions {
flex-wrap: wrap;
justify-content: flex-end;
margin-left: 0;
}
.outlined-action-list-item__hover-actions {
opacity: 1;
pointer-events: auto;
}
}
</style>

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Servers",
"description": "Manage MCP servers",
"buttons": {
"refresh": "Refresh",
"add": "Add Server",

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP Сервера",
"description": "Управление MCP-серверами",
"buttons": {
"refresh": "Обновить",
"add": "Добавить сервер",

View File

@@ -12,6 +12,7 @@
},
"mcpServers": {
"title": "MCP 服务器",
"description": "管理 MCP 服务器",
"buttons": {
"refresh": "刷新",
"add": "新增服务器",

View File

@@ -237,8 +237,11 @@ const selectedMarketPlugin = computed(() => {
<!-- 已安装的 MCP 服务器标签页内容 -->
<v-tab-item v-if="activeTab === 'mcp'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.installedMcpServers") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ t("features.tooluse.mcpServers.description") }}
</div>
</div>
</div>
<v-card
@@ -255,8 +258,11 @@ const selectedMarketPlugin = computed(() => {
<!-- Skills 标签页内容 -->
<v-tab-item v-if="activeTab === 'skills'">
<div class="mb-4 pt-4 pb-4">
<div class="d-flex align-center flex-wrap" style="gap: 12px">
<div class="d-flex flex-column" style="gap: 6px">
<h2 class="text-h2 mb-0">{{ tm("tabs.skills") }}</h2>
<div class="text-body-2 text-medium-emphasis">
{{ tm("skills.runtimeHint") }}
</div>
</div>
</div>
<v-card