[codex] fix MCP management lifecycle (#1144)
* feat(mcp): add MCP server management UI - Server CRUD: add/edit/remove with YAML/JSON Monaco editor - raw_config passthrough: zero field loss on edit/toggle - tool_details embedding: single-request card data (1+N → 1) - Auto-retry exponential backoff (2s→32s, max 5 retries) - Route safety guards (hasRoute) for dynamic sidebar - i18n: 9 languages (de/en/es/fr/ja/ko/pt/zh/zh-TW) - 19 unit tests + 8 UX browser tests - 35 files, +2933 lines * fix mcp management lifecycle --------- Co-authored-by: Crafter-feng <succeed_happu@163.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export interface McpServerInfo {
|
||||
name: string
|
||||
transport: 'stdio' | 'http' | 'sse'
|
||||
connected: boolean
|
||||
tools: number
|
||||
tools_registered: number
|
||||
tool_names: string[]
|
||||
tool_names_registered: string[]
|
||||
tool_details: Array<{ name: string; description?: string }>
|
||||
error?: string | null
|
||||
raw_config: McpServerConfig
|
||||
}
|
||||
|
||||
export interface McpServersResponse {
|
||||
ok: boolean
|
||||
servers: McpServerInfo[]
|
||||
total_tools: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface McpToolsResponse {
|
||||
ok: boolean
|
||||
results: Array<{
|
||||
server: string
|
||||
tools: Array<{
|
||||
name: string
|
||||
description: string
|
||||
input_schema: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface McpServerConfig {
|
||||
command?: string
|
||||
args?: string[]
|
||||
url?: string
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
timeout?: number
|
||||
connect_timeout?: number
|
||||
enabled?: boolean
|
||||
transport?: 'stdio' | 'http' | 'sse'
|
||||
tools?: { include?: string[]; exclude?: string[] }
|
||||
prompts?: boolean
|
||||
resources?: boolean
|
||||
}
|
||||
|
||||
export async function fetchMcpServers(): Promise<McpServersResponse> {
|
||||
return request<McpServersResponse>('/api/hermes/mcp/servers')
|
||||
}
|
||||
|
||||
export async function fetchMcpTools(server?: string): Promise<McpToolsResponse> {
|
||||
const query = server ? `?server=${encodeURIComponent(server)}` : ''
|
||||
return request<McpToolsResponse>(`/api/hermes/mcp/tools${query}`)
|
||||
}
|
||||
|
||||
export async function mcpServerAdd(name: string, config: McpServerConfig): Promise<{ ok: boolean; name?: string; error?: string }> {
|
||||
return request('/api/hermes/mcp/servers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, config }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpServerRemove(name: string): Promise<{ ok: boolean; error?: string }> {
|
||||
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpServerUpdate(name: string, config: McpServerConfig): Promise<{ ok: boolean; error?: string }> {
|
||||
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function mcpReload(name?: string): Promise<{ ok: boolean; message?: string; error?: string }> {
|
||||
const query = name ? `?server=${encodeURIComponent(name)}` : ''
|
||||
return request(`/api/hermes/mcp/reload${query}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function mcpServerTest(name: string): Promise<{ ok: boolean; tools?: string[]; error?: string }> {
|
||||
return request(`/api/hermes/mcp/servers/${encodeURIComponent(name)}/test`, { method: 'POST' })
|
||||
}
|
||||
@@ -44,6 +44,7 @@ const bridgeCommands = computed(() => [
|
||||
{ name: 'compress', args: '', description: t('chat.slashCommands.compress') },
|
||||
{ name: 'steer', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.steer') },
|
||||
{ name: 'destroy', args: '', description: t('chat.slashCommands.destroy') },
|
||||
{ name: 'reload-mcp', args: '', description: t('chat.slashCommands.reloadMcp') },
|
||||
])
|
||||
|
||||
const slashActive = ref(false)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton, NSwitch, NPopconfirm } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { McpServerInfo } from '@/api/hermes/mcp'
|
||||
|
||||
const props = defineProps<{
|
||||
server: McpServerInfo
|
||||
toolsByServer: Record<string, Array<{ name: string; description?: string }>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [server: McpServerInfo]
|
||||
test: [server: McpServerInfo]
|
||||
reload: [name: string]
|
||||
remove: [server: McpServerInfo]
|
||||
toggleEnabled: [server: McpServerInfo]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function statusClass(server: McpServerInfo) {
|
||||
if (server.raw_config.enabled === false) return 'disabled'
|
||||
return server.connected ? 'connected' : 'disconnected'
|
||||
}
|
||||
|
||||
function statusLabel(server: McpServerInfo) {
|
||||
if (server.raw_config.enabled === false) return t('mcp.disabledStatus')
|
||||
return server.connected ? t('mcp.connectedStatus') : t('mcp.disconnectedStatus')
|
||||
}
|
||||
|
||||
const tools = computed(() => props.toolsByServer[props.server.name] || [])
|
||||
const MAX_VISIBLE_TOOLS = 20
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-card" :class="{ disconnected: !server.connected, disabled: server.raw_config.enabled === false }">
|
||||
<!-- 第一行:标题 + 标签 -->
|
||||
<div class="card-header">
|
||||
<h3 class="server-name">{{ server.name }}</h3>
|
||||
<div class="server-badges">
|
||||
<span class="type-badge transport">{{ server.transport }}</span>
|
||||
<span class="type-badge" :class="statusClass(server)">{{ statusLabel(server) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:工具列表 + 数量 -->
|
||||
<div class="card-body">
|
||||
<div v-if="server.error" class="error-row">
|
||||
<span class="error-text">{{ server.error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ t('mcp.toolList') }}</span>
|
||||
<span class="info-value">
|
||||
{{ server.tools_registered }}/{{ server.tools }}{{ t('mcp.count') }}{{ t('mcp.tools') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 工具标签列表 -->
|
||||
<div v-if="server.tools > 0" class="tools-list">
|
||||
<span
|
||||
v-for="tool in tools.slice(0, MAX_VISIBLE_TOOLS)"
|
||||
:key="tool.name"
|
||||
class="tool-tag"
|
||||
:title="tool.description"
|
||||
>
|
||||
{{ tool.name }}
|
||||
</span>
|
||||
<span v-if="tools.length > MAX_VISIBLE_TOOLS" class="tool-tag tool-tag-more">
|
||||
+{{ tools.length - MAX_VISIBLE_TOOLS }} {{ t('mcp.more') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="tools-empty">
|
||||
<span class="muted">{{ t('mcp.zeroTools') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:按钮 + 开关 -->
|
||||
<div class="card-footer">
|
||||
<div class="card-actions">
|
||||
<NButton size="tiny" quaternary @click="emit('edit', server)">{{ t('mcp.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('test', server)">{{ t('mcp.test') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click="emit('reload', server.name)">{{ t('mcp.reload') }}</NButton>
|
||||
<NPopconfirm @positive-click="emit('remove', server)">
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="error">{{ t('mcp.remove') }}</NButton>
|
||||
</template>
|
||||
{{ t('mcp.confirmRemove', { name: server.name }) }}
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<NSwitch
|
||||
:value="server.raw_config.enabled !== false"
|
||||
size="small"
|
||||
@update:value="() => emit('toggleEnabled', server)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.mcp-card {
|
||||
background-color: $bg-card;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--accent-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
border-color: rgba(var(--error-rgb), 0.3);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.server-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
|
||||
&.transport {
|
||||
background: rgba(var(--accent-primary-rgb), 0.12);
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&.connected {
|
||||
background: rgba(var(--success-rgb), 0.12);
|
||||
color: $success;
|
||||
}
|
||||
|
||||
&.disconnected {
|
||||
background: rgba(var(--error-rgb), 0.12);
|
||||
color: $error;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: rgba(var(--text-muted-rgb, 128,128,128), 0.12);
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: $error;
|
||||
font-size: 11px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
height: 88px;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.tool-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
font-size: 10px;
|
||||
font-family: $font-code;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.08);
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--accent-primary-rgb), 0.16);
|
||||
}
|
||||
|
||||
&-more {
|
||||
background: rgba(var(--accent-primary-rgb), 0.15);
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.tools-empty {
|
||||
height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $text-muted;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid $border-light;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,9 @@ const isVersionPreview = import.meta.env.VITE_HERMES_PREVIEW === '1';
|
||||
function isNavActive(...names: string[]) {
|
||||
return names.includes(selectedKey.value);
|
||||
}
|
||||
function hasRoute(name: string): boolean {
|
||||
return router.hasRoute(name);
|
||||
}
|
||||
const logoPath = '/logo.png';
|
||||
|
||||
const { record: collapsedGroups, persist: persistCollapsedGroups } = usePersistentRecord('hermes.sidebar.collapsedGroups');
|
||||
@@ -186,6 +189,15 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.plugins") }}</span>
|
||||
</RouteLinkItem>
|
||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.mcp' }" :active="selectedKey === 'hermes.mcp'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7V4h16v3" />
|
||||
<path d="M9 20h6" />
|
||||
<path d="M12 7v13" />
|
||||
<rect x="4" y="7" width="16" height="7" rx="2" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.mcp") }}</span>
|
||||
</RouteLinkItem>
|
||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18h6" />
|
||||
@@ -263,7 +275,7 @@ function openChangelog() {
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!isGroupCollapsed('tools')" class="nav-group-items">
|
||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.codingAgents' }" :active="selectedKey === 'hermes.codingAgents'">
|
||||
<RouteLinkItem v-if="hasRoute('hermes.codingAgents')" class="nav-item" :to="{ name: 'hermes.codingAgents' }" :active="selectedKey === 'hermes.codingAgents'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
@@ -271,7 +283,7 @@ function openChangelog() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.codingAgents") }}</span>
|
||||
</RouteLinkItem>
|
||||
<RouteLinkItem v-if="isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: 'hermes.versionPreview' }" :active="selectedKey === 'hermes.versionPreview'">
|
||||
<RouteLinkItem v-if="hasRoute('hermes.versionPreview') && isSuperAdmin && !isVersionPreview" class="nav-item" :to="{ name: 'hermes.versionPreview' }" :active="selectedKey === 'hermes.versionPreview'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="7.5 4.21 12 6.81 16.5 4.21" />
|
||||
|
||||
@@ -105,6 +105,60 @@ export default {
|
||||
expired: 'Abgelaufen',
|
||||
},
|
||||
|
||||
// MCP-Verwaltung
|
||||
mcp: {
|
||||
title: 'MCP-Server',
|
||||
loadFailed: 'MCP-Server konnten nicht geladen werden',
|
||||
reloadAll: 'Alle neu laden',
|
||||
refresh: 'Aktualisieren',
|
||||
total: 'Gesamt',
|
||||
connected: 'Verbunden',
|
||||
disconnected: 'Getrennt',
|
||||
tools: 'werkzeuge',
|
||||
tool: 'Werkzeuge',
|
||||
searchPlaceholder: 'Server suchen...',
|
||||
addServer: '+ Server hinzufuegen',
|
||||
zeroTools: '0 Werkzeuge',
|
||||
loading: 'Wird geladen...',
|
||||
empty: 'Keine MCP-Server konfiguriert',
|
||||
reloaded: '{server} neu geladen',
|
||||
reloadedAll: 'Alle MCP-Server neu geladen',
|
||||
reloadFailed: 'Neuladen fehlgeschlagen',
|
||||
serverAdded: 'Server "{name}" hinzugefuegt',
|
||||
addFailed: 'Server konnte nicht hinzugefuegt werden',
|
||||
serverUpdated: 'Server "{name}" aktualisiert',
|
||||
updateFailed: 'Server konnte nicht aktualisiert werden',
|
||||
saveFailed: 'Speichern fehlgeschlagen',
|
||||
serverRemoved: '"{name}" entfernt',
|
||||
enabled: "Aktiviert: {name}",
|
||||
disabled: "Deaktiviert: {name}",
|
||||
connectedStatus: 'Verbunden',
|
||||
disconnectedStatus: 'Getrennt',
|
||||
disabledStatus: 'Deaktiviert',
|
||||
toolList: 'Werkzeugliste',
|
||||
count: ' ',
|
||||
more: 'mehr',
|
||||
removeFailed: 'Server konnte nicht entfernt werden',
|
||||
testOk: 'Test OK — {count} Werkzeuge verfuegbar',
|
||||
testEmpty: 'Test lieferte keine Werkzeuge',
|
||||
testFailed: 'Test fehlgeschlagen',
|
||||
edit: 'Bearbeiten',
|
||||
test: 'Testen',
|
||||
reload: 'Neu laden',
|
||||
remove: 'Entfernen',
|
||||
confirmRemove: 'Server "{name}" entfernen?',
|
||||
cancel: 'Abbrechen',
|
||||
add: 'Hinzufuegen',
|
||||
save: 'Speichern',
|
||||
addTitle: 'MCP-Server hinzufuegen',
|
||||
editTitle: 'MCP-Server bearbeiten',
|
||||
invalidJson: 'Ungültiges JSON',
|
||||
invalidYaml: 'Ungültiges YAML-Format',
|
||||
invalidConfig: 'Ungültige Konfiguration',
|
||||
invalidServerConfig: 'Ungültige Serverkonfiguration',
|
||||
missingCommandOrUrl: 'Muss command oder url enthalten',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
chat: 'Chat',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: 'Modelle',
|
||||
profiles: 'Profile',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
skills: 'Fahigkeiten',
|
||||
memory: 'Gedachtnis',
|
||||
logs: 'Protokolle',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: 'Kontextkomprimierung im Leerlauf ausführen',
|
||||
steer: 'Steuertext an den aktiven Bridge-Lauf senden',
|
||||
destroy: 'Bridge-Agent für diese Sitzung freigeben',
|
||||
reloadMcp: 'MCP-Server neu laden',
|
||||
},
|
||||
attachFiles: 'Dateien anhangen',
|
||||
showToolCalls: 'Tool-Aufrufe anzeigen',
|
||||
|
||||
@@ -105,6 +105,60 @@ export default {
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
// MCP Management
|
||||
mcp: {
|
||||
title: 'MCP Servers',
|
||||
loadFailed: 'Failed to load MCP servers',
|
||||
reloadAll: 'Reload All',
|
||||
refresh: 'Refresh',
|
||||
total: 'Total',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
tools: 'tools',
|
||||
tool: 'Tools',
|
||||
searchPlaceholder: 'Search servers...',
|
||||
addServer: '+ Add Server',
|
||||
zeroTools: '0 tools',
|
||||
loading: 'Loading...',
|
||||
empty: 'No MCP servers configured',
|
||||
reloaded: 'Reloaded {server}',
|
||||
reloadedAll: 'All MCP servers reloaded',
|
||||
reloadFailed: 'Reload failed',
|
||||
serverAdded: 'Server "{name}" added',
|
||||
addFailed: 'Failed to add server',
|
||||
serverUpdated: 'Server "{name}" updated',
|
||||
updateFailed: 'Failed to update server',
|
||||
saveFailed: 'Save failed',
|
||||
serverRemoved: 'Removed "{name}"',
|
||||
enabled: "Enabled {name}",
|
||||
disabled: "Disabled {name}",
|
||||
connectedStatus: 'Connected',
|
||||
disconnectedStatus: 'Disconnected',
|
||||
disabledStatus: 'Disabled',
|
||||
toolList: 'Tool List',
|
||||
count: ' ',
|
||||
more: 'more',
|
||||
removeFailed: 'Failed to remove server',
|
||||
testOk: 'Test OK — {count} tools available',
|
||||
testEmpty: 'Test returned no tools',
|
||||
testFailed: 'Test failed',
|
||||
edit: 'Edit',
|
||||
test: 'Test',
|
||||
reload: 'Reload',
|
||||
remove: 'Remove',
|
||||
confirmRemove: 'Remove server "{name}"?',
|
||||
cancel: 'Cancel',
|
||||
add: 'Add',
|
||||
save: 'Save',
|
||||
addTitle: 'Add MCP Server',
|
||||
editTitle: 'Edit MCP Server',
|
||||
invalidJson: 'Invalid JSON format',
|
||||
invalidYaml: 'Invalid YAML format',
|
||||
invalidConfig: 'Invalid configuration',
|
||||
invalidServerConfig: 'Invalid server configuration',
|
||||
missingCommandOrUrl: 'Must have command or url',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
chat: 'Chat',
|
||||
@@ -117,6 +171,7 @@ export default {
|
||||
profiles: 'Profiles',
|
||||
skills: 'Skills',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
memory: 'Memory',
|
||||
logs: 'Logs',
|
||||
usage: 'Usage',
|
||||
@@ -236,6 +291,7 @@ export default {
|
||||
compress: 'Run context compression while idle',
|
||||
steer: 'Send steering text to the active bridge run',
|
||||
destroy: 'Release the bridge agent for this session',
|
||||
reloadMcp: 'Reload MCP servers',
|
||||
},
|
||||
attachFiles: 'Attach files',
|
||||
autoPlaySpeech: 'Auto-play voice',
|
||||
|
||||
@@ -105,6 +105,60 @@ export default {
|
||||
expired: 'Expirado',
|
||||
},
|
||||
|
||||
// Gestion de MCP
|
||||
mcp: {
|
||||
title: 'Servidores MCP',
|
||||
loadFailed: 'Error al cargar servidores MCP',
|
||||
reloadAll: 'Recargar todos',
|
||||
refresh: 'Actualizar',
|
||||
total: 'Total',
|
||||
connected: 'Conectado',
|
||||
disconnected: 'Desconectado',
|
||||
tools: 'herramientas',
|
||||
tool: 'Herramientas',
|
||||
searchPlaceholder: 'Buscar servidores...',
|
||||
addServer: '+ Agregar servidor',
|
||||
zeroTools: '0 herramientas',
|
||||
loading: 'Cargando...',
|
||||
empty: 'No hay servidores MCP configurados',
|
||||
reloaded: '{server} recargado',
|
||||
reloadedAll: 'Todos los servidores MCP recargados',
|
||||
reloadFailed: 'Error al recargar',
|
||||
serverAdded: 'Servidor "{name}" agregado',
|
||||
addFailed: 'Error al agregar servidor',
|
||||
serverUpdated: 'Servidor "{name}" actualizado',
|
||||
updateFailed: 'Error al actualizar servidor',
|
||||
saveFailed: 'Error al guardar',
|
||||
serverRemoved: '"{name}" eliminado',
|
||||
enabled: "Habilitado: {name}",
|
||||
disabled: "Deshabilitado: {name}",
|
||||
connectedStatus: 'Conectado',
|
||||
disconnectedStatus: 'Desconectado',
|
||||
disabledStatus: 'Deshabilitado',
|
||||
toolList: 'Lista de herramientas',
|
||||
count: ' ',
|
||||
more: 'más',
|
||||
removeFailed: 'Error al eliminar servidor',
|
||||
testOk: 'Prueba OK — {count} herramientas disponibles',
|
||||
testEmpty: 'La prueba no devolvio herramientas',
|
||||
testFailed: 'Error en la prueba',
|
||||
edit: 'Editar',
|
||||
test: 'Probar',
|
||||
reload: 'Recargar',
|
||||
remove: 'Eliminar',
|
||||
confirmRemove: '¿Eliminar servidor "{name}"?',
|
||||
cancel: 'Cancelar',
|
||||
add: 'Agregar',
|
||||
save: 'Guardar',
|
||||
addTitle: 'Agregar servidor MCP',
|
||||
editTitle: 'Editar servidor MCP',
|
||||
invalidJson: 'JSON inválido',
|
||||
invalidYaml: 'Formato YAML no válido',
|
||||
invalidConfig: 'Configuración no válida',
|
||||
invalidServerConfig: 'Configuración del servidor no válida',
|
||||
missingCommandOrUrl: 'Debe incluir command o url',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
chat: 'Chat',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: 'Modelos',
|
||||
profiles: 'Perfiles',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
skills: 'Habilidades',
|
||||
memory: 'Memoria',
|
||||
logs: 'Registros',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: 'Ejecutar compresión de contexto cuando esté inactiva',
|
||||
steer: 'Enviar texto de guía a la ejecución activa de Bridge',
|
||||
destroy: 'Liberar el agente Bridge de esta sesión',
|
||||
reloadMcp: 'Recargar servidores MCP',
|
||||
},
|
||||
attachFiles: 'Adjuntar archivos',
|
||||
showToolCalls: 'Mostrar llamadas de herramientas',
|
||||
|
||||
@@ -105,6 +105,60 @@ export default {
|
||||
expired: 'Expiré',
|
||||
},
|
||||
|
||||
// Gestion de MCP
|
||||
mcp: {
|
||||
title: 'Serveurs MCP',
|
||||
loadFailed: 'Echec du chargement des serveurs MCP',
|
||||
reloadAll: 'Tout recharger',
|
||||
refresh: 'Rafraichir',
|
||||
total: 'Total',
|
||||
connected: 'Connecte',
|
||||
disconnected: 'Deconnecte',
|
||||
tools: 'outils',
|
||||
tool: 'Outils',
|
||||
searchPlaceholder: 'Rechercher des serveurs...',
|
||||
addServer: '+ Ajouter un serveur',
|
||||
zeroTools: '0 outils',
|
||||
loading: 'Chargement...',
|
||||
empty: 'Aucun serveur MCP configure',
|
||||
reloaded: '{server} recharge',
|
||||
reloadedAll: 'Tous les serveurs MCP recharges',
|
||||
reloadFailed: 'Echec du rechargement',
|
||||
serverAdded: 'Serveur "{name}" ajoute',
|
||||
addFailed: 'Echec de l ajout du serveur',
|
||||
serverUpdated: 'Serveur "{name}" mis a jour',
|
||||
updateFailed: 'Echec de la mise a jour du serveur',
|
||||
saveFailed: 'Echec de la sauvegarde',
|
||||
serverRemoved: '"{name}" supprime',
|
||||
enabled: "Activé : {name}",
|
||||
disabled: "Désactivé : {name}",
|
||||
connectedStatus: 'Connecté',
|
||||
disconnectedStatus: 'Déconnecté',
|
||||
disabledStatus: 'Désactivé',
|
||||
toolList: 'Liste des outils',
|
||||
count: ' ',
|
||||
more: 'de plus',
|
||||
removeFailed: 'Echec de la suppression du serveur',
|
||||
testOk: 'Test OK — {count} outils disponibles',
|
||||
testEmpty: 'Le test n a retourne aucun outil',
|
||||
testFailed: 'Echec du test',
|
||||
edit: 'Modifier',
|
||||
test: 'Tester',
|
||||
reload: 'Recharger',
|
||||
remove: 'Supprimer',
|
||||
confirmRemove: 'Supprimer le serveur "{name}" ?',
|
||||
cancel: 'Annuler',
|
||||
add: 'Ajouter',
|
||||
save: 'Enregistrer',
|
||||
addTitle: 'Ajouter un serveur MCP',
|
||||
editTitle: 'Modifier le serveur MCP',
|
||||
invalidJson: 'JSON invalide',
|
||||
invalidYaml: 'Format YAML invalide',
|
||||
invalidConfig: 'Configuration invalide',
|
||||
invalidServerConfig: 'Configuration du serveur invalide',
|
||||
missingCommandOrUrl: 'Doit contenir command ou url',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
chat: 'Discussion',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: 'Modeles',
|
||||
profiles: 'Profils',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
skills: 'Competences',
|
||||
memory: 'Memoire',
|
||||
logs: 'Journaux',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: 'Lancer la compression du contexte au repos',
|
||||
steer: 'Envoyer un guidage à l’exécution Bridge active',
|
||||
destroy: 'Libérer l’agent Bridge de cette session',
|
||||
reloadMcp: 'Recharger les serveurs MCP',
|
||||
},
|
||||
attachFiles: 'Joindre des fichiers',
|
||||
showToolCalls: 'Afficher les appels d’outils',
|
||||
|
||||
@@ -106,6 +106,60 @@ export default {
|
||||
},
|
||||
|
||||
// サイドバー
|
||||
// MCP 管理
|
||||
mcp: {
|
||||
title: 'MCP サーバー',
|
||||
loadFailed: 'MCP サーバーの読み込みに失敗しました',
|
||||
reloadAll: 'すべて再読み込み',
|
||||
refresh: '更新',
|
||||
total: '合計',
|
||||
connected: '接続済み',
|
||||
disconnected: '未接続',
|
||||
tools: 'ツール',
|
||||
tool: 'ツール',
|
||||
searchPlaceholder: 'サーバーを検索...',
|
||||
addServer: '+ サーバーを追加',
|
||||
zeroTools: '0 個のツール',
|
||||
loading: '読み込み中...',
|
||||
empty: 'MCP サーバーが設定されていません',
|
||||
reloaded: '{server} を再読み込みしました',
|
||||
reloadedAll: 'すべての MCP サーバーを再読み込みしました',
|
||||
reloadFailed: '再読み込みに失敗しました',
|
||||
serverAdded: 'サーバー "{name}" を追加しました',
|
||||
addFailed: 'サーバーの追加に失敗しました',
|
||||
serverUpdated: 'サーバー "{name}" を更新しました',
|
||||
updateFailed: 'サーバーの更新に失敗しました',
|
||||
saveFailed: '保存に失敗しました',
|
||||
serverRemoved: '"{name}" を削除しました',
|
||||
enabled: "有効化: {name}",
|
||||
disabled: "無効化: {name}",
|
||||
connectedStatus: '接続済み',
|
||||
disconnectedStatus: '未接続',
|
||||
disabledStatus: '無効',
|
||||
toolList: 'ツール一覧',
|
||||
count: ' ',
|
||||
more: '件',
|
||||
removeFailed: 'サーバーの削除に失敗しました',
|
||||
testOk: 'テスト成功 — {count} 個のツールが利用可能',
|
||||
testEmpty: 'テスト結果にツールがありません',
|
||||
testFailed: 'テストに失敗しました',
|
||||
edit: '編集',
|
||||
test: 'テスト',
|
||||
reload: '再読み込み',
|
||||
remove: '削除',
|
||||
confirmRemove: 'サーバー "{name}" を削除しますか?',
|
||||
cancel: 'キャンセル',
|
||||
add: '追加',
|
||||
save: '保存',
|
||||
addTitle: 'MCP サーバーを追加',
|
||||
editTitle: 'MCP サーバーを編集',
|
||||
invalidJson: 'JSON形式エラー',
|
||||
invalidYaml: 'YAML 形式が無効です',
|
||||
invalidConfig: '設定形式が無効です',
|
||||
invalidServerConfig: 'サーバー設定が無効です',
|
||||
missingCommandOrUrl: 'command または url が必要です',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
chat: 'チャット',
|
||||
search: '検索',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: 'モデル',
|
||||
profiles: 'プロファイル',
|
||||
plugins: 'プラグイン',
|
||||
mcp: 'MCP',
|
||||
skills: 'スキル',
|
||||
memory: 'メモリ',
|
||||
logs: 'ログ',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: 'アイドル時にコンテキスト圧縮を実行',
|
||||
steer: '実行中の Bridge に誘導テキストを送信',
|
||||
destroy: 'このセッションの Bridge Agent を解放',
|
||||
reloadMcp: 'MCP サーバーを再読み込み',
|
||||
},
|
||||
attachFiles: 'ファイルを添付',
|
||||
showToolCalls: 'ツール呼び出しを表示',
|
||||
|
||||
@@ -106,6 +106,60 @@ export default {
|
||||
},
|
||||
|
||||
// 사이드바
|
||||
// MCP 관리
|
||||
mcp: {
|
||||
title: 'MCP 서버',
|
||||
loadFailed: 'MCP 서버를 불러오지 못했습니다',
|
||||
reloadAll: '모두 다시 로드',
|
||||
refresh: '새로고침',
|
||||
total: '합계',
|
||||
connected: '연결됨',
|
||||
disconnected: '연결 끊김',
|
||||
tools: '도구',
|
||||
tool: '도구',
|
||||
searchPlaceholder: '서버 검색...',
|
||||
addServer: '+ 서버 추가',
|
||||
zeroTools: '0개 도구',
|
||||
loading: '로딩...',
|
||||
empty: 'MCP 서버가 설정되지 않았습니다',
|
||||
reloaded: '{server} 다시 로드됨',
|
||||
reloadedAll: '모든 MCP 서버가 다시 로드되었습니다',
|
||||
reloadFailed: '다시 로드 실패',
|
||||
serverAdded: '서버 "{name}" 추가됨',
|
||||
addFailed: '서버 추가 실패',
|
||||
serverUpdated: '서버 "{name}" 업데이트됨',
|
||||
updateFailed: '서버 업데이트 실패',
|
||||
saveFailed: '저장 실패',
|
||||
serverRemoved: '"{name}" 제거됨',
|
||||
enabled: "{name} 활성화됨",
|
||||
disabled: "{name} 비활성화됨",
|
||||
connectedStatus: '연결됨',
|
||||
disconnectedStatus: '연결 끊김',
|
||||
disabledStatus: '비활성화됨',
|
||||
toolList: '도구 목록',
|
||||
count: ' ',
|
||||
more: '개 더보기',
|
||||
removeFailed: '서버 제거 실패',
|
||||
testOk: '테스트 성공 — {count}개 도구 사용 가능',
|
||||
testEmpty: '테스트에서 도구가 반환되지 않았습니다',
|
||||
testFailed: '테스트 실패',
|
||||
edit: '편집',
|
||||
test: '테스트',
|
||||
reload: '다시 로드',
|
||||
remove: '제거',
|
||||
confirmRemove: '서버 "{name}"을(를) 제거하시겠습니까?',
|
||||
cancel: '취소',
|
||||
add: '추가',
|
||||
save: '저장',
|
||||
addTitle: 'MCP 서버 추가',
|
||||
editTitle: 'MCP 서버 편집',
|
||||
invalidJson: 'JSON 형식 오류',
|
||||
invalidYaml: 'YAML 형식이 올바르지 않습니다',
|
||||
invalidConfig: '올바르지 않은 설정',
|
||||
invalidServerConfig: '서버 설정이 올바르지 않습니다',
|
||||
missingCommandOrUrl: 'command 또는 url이 필요합니다',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
chat: '채팅',
|
||||
search: '검색',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: '모델',
|
||||
profiles: '프로필',
|
||||
plugins: '플러그인',
|
||||
mcp: 'MCP',
|
||||
skills: '스킬',
|
||||
memory: '메모리',
|
||||
logs: '로그',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: '유휴 상태에서 컨텍스트 압축 실행',
|
||||
steer: '활성 Bridge 실행에 지시 텍스트 보내기',
|
||||
destroy: '이 세션의 Bridge Agent 해제',
|
||||
reloadMcp: 'MCP 서버 다시 로드',
|
||||
},
|
||||
attachFiles: '파일 첨부',
|
||||
showToolCalls: '도구 호출 표시',
|
||||
|
||||
@@ -105,6 +105,60 @@ export default {
|
||||
expired: 'Expirado',
|
||||
},
|
||||
|
||||
// Gestao de MCP
|
||||
mcp: {
|
||||
title: 'Servidores MCP',
|
||||
loadFailed: 'Falha ao carregar servidores MCP',
|
||||
reloadAll: 'Recarregar todos',
|
||||
refresh: 'Atualizar',
|
||||
total: 'Total',
|
||||
connected: 'Conectado',
|
||||
disconnected: 'Desconectado',
|
||||
tools: 'ferramentas',
|
||||
tool: 'Ferramentas',
|
||||
searchPlaceholder: 'Pesquisar servidores...',
|
||||
addServer: '+ Adicionar servidor',
|
||||
zeroTools: '0 ferramentas',
|
||||
loading: 'Carregando...',
|
||||
empty: 'Nenhum servidor MCP configurado',
|
||||
reloaded: '{server} recarregado',
|
||||
reloadedAll: 'Todos os servidores MCP recarregados',
|
||||
reloadFailed: 'Falha ao recarregar',
|
||||
serverAdded: 'Servidor "{name}" adicionado',
|
||||
addFailed: 'Falha ao adicionar servidor',
|
||||
serverUpdated: 'Servidor "{name}" atualizado',
|
||||
updateFailed: 'Falha ao atualizar servidor',
|
||||
saveFailed: 'Falha ao salvar',
|
||||
serverRemoved: '"{name}" removido',
|
||||
enabled: "Habilitado: {name}",
|
||||
disabled: "Desabilitado: {name}",
|
||||
connectedStatus: 'Conectado',
|
||||
disconnectedStatus: 'Desconectado',
|
||||
disabledStatus: 'Desativado',
|
||||
toolList: 'Lista de ferramentas',
|
||||
count: ' ',
|
||||
more: 'mais',
|
||||
removeFailed: 'Falha ao remover servidor',
|
||||
testOk: 'Teste OK — {count} ferramentas disponiveis',
|
||||
testEmpty: 'O teste nao retornou ferramentas',
|
||||
testFailed: 'Falha no teste',
|
||||
edit: 'Editar',
|
||||
test: 'Testar',
|
||||
reload: 'Recarregar',
|
||||
remove: 'Remover',
|
||||
confirmRemove: 'Remover servidor "{name}"?',
|
||||
cancel: 'Cancelar',
|
||||
add: 'Adicionar',
|
||||
save: 'Salvar',
|
||||
addTitle: 'Adicionar servidor MCP',
|
||||
editTitle: 'Editar servidor MCP',
|
||||
invalidJson: 'JSON inválido',
|
||||
invalidYaml: 'Formato YAML inválido',
|
||||
invalidConfig: 'Configuração inválida',
|
||||
invalidServerConfig: 'Configuração do servidor inválida',
|
||||
missingCommandOrUrl: 'Deve conter command ou url',
|
||||
},
|
||||
|
||||
// Sidebar
|
||||
sidebar: {
|
||||
chat: 'Chat',
|
||||
@@ -115,6 +169,7 @@ export default {
|
||||
models: 'Modelos',
|
||||
profiles: 'Perfis',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
skills: 'Habilidades',
|
||||
memory: 'Memoria',
|
||||
logs: 'Logs',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: 'Executar compressão de contexto quando ocioso',
|
||||
steer: 'Enviar texto de orientação para a execução ativa do Bridge',
|
||||
destroy: 'Liberar o Bridge Agent desta sessão',
|
||||
reloadMcp: 'Recarregar servidores MCP',
|
||||
},
|
||||
attachFiles: 'Anexar arquivos',
|
||||
showToolCalls: 'Mostrar chamadas de ferramentas',
|
||||
|
||||
@@ -106,6 +106,60 @@ export default {
|
||||
},
|
||||
|
||||
// 側邊欄
|
||||
// MCP 管理
|
||||
mcp: {
|
||||
title: 'MCP 伺服器',
|
||||
loadFailed: '載入 MCP 伺服器失敗',
|
||||
reloadAll: '全部重載',
|
||||
refresh: '重新整理',
|
||||
total: '總計',
|
||||
connected: '已連線',
|
||||
disconnected: '未連線',
|
||||
tools: '工具',
|
||||
tool: '工具',
|
||||
searchPlaceholder: '搜尋伺服器...',
|
||||
addServer: '+ 新增伺服器',
|
||||
zeroTools: '0 個工具',
|
||||
loading: '載入中...',
|
||||
empty: '暫無 MCP 伺服器設定',
|
||||
reloaded: '已重載 {server}',
|
||||
reloadedAll: '所有 MCP 伺服器已重載',
|
||||
reloadFailed: '重載失敗',
|
||||
serverAdded: '伺服器 "{name}" 已新增',
|
||||
addFailed: '新增伺服器失敗',
|
||||
serverUpdated: '伺服器 "{name}" 已更新',
|
||||
updateFailed: '更新伺服器失敗',
|
||||
saveFailed: '儲存失敗',
|
||||
serverRemoved: '已移除 "{name}"',
|
||||
enabled: "已啟用 {name}",
|
||||
disabled: "已禁用 {name}",
|
||||
connectedStatus: '已連線',
|
||||
disconnectedStatus: '未連線',
|
||||
disabledStatus: '已停用',
|
||||
toolList: '工具列表',
|
||||
count: '個',
|
||||
more: '更多',
|
||||
removeFailed: '移除伺服器失敗',
|
||||
testOk: '測試成功 — {count} 個工具可用',
|
||||
testEmpty: '測試未回傳工具',
|
||||
testFailed: '測試失敗',
|
||||
edit: '編輯',
|
||||
test: '測試',
|
||||
reload: '重載',
|
||||
remove: '移除',
|
||||
confirmRemove: '確認刪除伺服器 "{name}"?',
|
||||
cancel: '取消',
|
||||
add: '新增',
|
||||
save: '儲存',
|
||||
addTitle: '新增 MCP 伺服器',
|
||||
editTitle: '編輯 MCP 伺服器',
|
||||
invalidJson: 'JSON 格式錯誤',
|
||||
invalidYaml: 'YAML 格式錯誤',
|
||||
invalidConfig: '配置格式錯誤',
|
||||
invalidServerConfig: '伺服器配置無效',
|
||||
missingCommandOrUrl: '必須包含 command 或 url',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
chat: '對話',
|
||||
search: '搜尋',
|
||||
@@ -116,6 +170,7 @@ export default {
|
||||
models: '模型',
|
||||
profiles: '使用者',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
skills: '技能',
|
||||
memory: '記憶',
|
||||
logs: '日誌',
|
||||
@@ -235,6 +290,7 @@ export default {
|
||||
compress: '空閒時觸發上下文壓縮',
|
||||
steer: '向目前 Bridge 執行傳送引導文字',
|
||||
destroy: '釋放目前會話的 Bridge Agent',
|
||||
reloadMcp: '重載 MCP 伺服器',
|
||||
},
|
||||
attachFiles: '新增附件',
|
||||
autoPlaySpeech: '自動播放語音',
|
||||
|
||||
@@ -106,6 +106,60 @@ export default {
|
||||
},
|
||||
|
||||
// 侧边栏
|
||||
// MCP 管理
|
||||
mcp: {
|
||||
title: 'MCP 服务器',
|
||||
loadFailed: '加载 MCP 服务器失败',
|
||||
reloadAll: '全部重载',
|
||||
refresh: '刷新',
|
||||
total: '总计',
|
||||
connected: '已连接',
|
||||
disconnected: '未连接',
|
||||
tools: '工具',
|
||||
tool: '工具',
|
||||
searchPlaceholder: '搜索服务器...',
|
||||
addServer: '+ 添加服务器',
|
||||
zeroTools: '0 个工具',
|
||||
loading: '加载中...',
|
||||
empty: '暂无 MCP 服务器配置',
|
||||
reloaded: '已重载 {server}',
|
||||
reloadedAll: '所有 MCP 服务器已重载',
|
||||
reloadFailed: '重载失败',
|
||||
serverAdded: '服务器 "{name}" 已添加',
|
||||
addFailed: '添加服务器失败',
|
||||
serverUpdated: '服务器 "{name}" 已更新',
|
||||
updateFailed: '更新服务器失败',
|
||||
saveFailed: '保存失败',
|
||||
serverRemoved: '已移除 "{name}"',
|
||||
enabled: "已启用 {name}",
|
||||
disabled: "已禁用 {name}",
|
||||
connectedStatus: '已连接',
|
||||
disconnectedStatus: '未连接',
|
||||
disabledStatus: '已禁用',
|
||||
toolList: '工具列表',
|
||||
count: '个',
|
||||
more: '更多',
|
||||
removeFailed: '移除服务器失败',
|
||||
testOk: '测试成功 — {count} 个工具可用',
|
||||
testEmpty: '测试未返回工具',
|
||||
testFailed: '测试失败',
|
||||
edit: '编辑',
|
||||
test: '测试',
|
||||
reload: '重载',
|
||||
remove: '移除',
|
||||
confirmRemove: '确认删除服务器 "{name}"?',
|
||||
cancel: '取消',
|
||||
add: '添加',
|
||||
save: '保存',
|
||||
addTitle: '添加 MCP 服务器',
|
||||
editTitle: '编辑 MCP 服务器',
|
||||
invalidJson: 'JSON 格式错误',
|
||||
invalidYaml: 'YAML 格式错误',
|
||||
invalidConfig: '配置格式错误',
|
||||
invalidServerConfig: '服务器配置无效',
|
||||
missingCommandOrUrl: '必须包含 command 或 url',
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
chat: '对话',
|
||||
search: '搜索',
|
||||
@@ -116,6 +170,7 @@ export default {
|
||||
models: '模型',
|
||||
profiles: '用户',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
skills: '技能',
|
||||
memory: '记忆',
|
||||
logs: '日志',
|
||||
@@ -236,6 +291,7 @@ export default {
|
||||
compress: '空闲时触发上下文压缩',
|
||||
steer: '向当前 Bridge 运行发送引导文本',
|
||||
destroy: '释放当前会话的 Bridge Agent',
|
||||
reloadMcp: '重载 MCP 服务器',
|
||||
},
|
||||
attachFiles: '添加附件',
|
||||
autoPlaySpeech: '自动播放语音',
|
||||
|
||||
@@ -37,7 +37,7 @@ const rawMessages: Record<string, LocaleMessages> = { en, zh, 'zh-TW': zhTW, ja,
|
||||
|
||||
export const messages: Record<string, LocaleMessages> = {}
|
||||
for (const [locale, msg] of Object.entries(rawMessages)) {
|
||||
messages[locale] = locale === 'en' ? msg : mergeMessagesWithFallback(en, msg)
|
||||
messages[locale] = locale === 'en' ? msg : mergeMessagesWithFallback({ ...en }, { ...msg })
|
||||
}
|
||||
|
||||
export { en }
|
||||
|
||||
@@ -128,6 +128,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/hermes/VersionPreviewView.vue'),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: '/hermes/mcp',
|
||||
name: 'hermes.mcp',
|
||||
component: () => import('@/views/hermes/McpManagerView.vue'),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import yaml from 'js-yaml'
|
||||
import {
|
||||
NAlert, NButton, NEmpty, NInput, NModal,
|
||||
NSpin, NRadioGroup, NRadioButton, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import McpServerCard from '@/components/hermes/mcp/McpServerCard.vue'
|
||||
import {
|
||||
fetchMcpServers, mcpServerAdd, mcpServerRemove,
|
||||
mcpServerUpdate, mcpServerTest, mcpReload,
|
||||
type McpServerInfo, type McpServerConfig,
|
||||
} from '@/api/hermes/mcp'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const servers = ref<McpServerInfo[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showModal = ref(false)
|
||||
const modalMode = ref<'add' | 'edit'>('add')
|
||||
const editingName = ref('')
|
||||
const jsonText = ref('')
|
||||
const jsonError = ref('')
|
||||
const saving = ref(false)
|
||||
const inputMode = ref<'json' | 'yaml'>('json')
|
||||
|
||||
const jsonPlaceholder = '{\n "my-server": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]\n }\n}'
|
||||
const yamlPlaceholder = 'my-server:\n command: npx\n args:\n - "-y"\n - "@modelcontextprotocol/server-filesystem"\n - "/path"'
|
||||
|
||||
const placeholder = computed(() => inputMode.value === 'json' ? jsonPlaceholder : yamlPlaceholder)
|
||||
|
||||
let formatTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let _pendingReload: ReturnType<typeof setTimeout> | null = null
|
||||
let _autoRetryCount = 0
|
||||
const MAX_AUTO_RETRIES = 5
|
||||
const BASE_RETRY_DELAY = 2000 // 2s base
|
||||
|
||||
function scheduleReload(delay = 3000) {
|
||||
if (_pendingReload) clearTimeout(_pendingReload)
|
||||
_pendingReload = setTimeout(() => { _pendingReload = null; loadServers() }, delay)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (formatTimer) { clearTimeout(formatTimer); formatTimer = null }
|
||||
if (_pendingReload) { clearTimeout(_pendingReload); _pendingReload = null }
|
||||
})
|
||||
|
||||
function handleInput(text: string) {
|
||||
if (formatTimer) clearTimeout(formatTimer)
|
||||
if (!text.trim()) {
|
||||
jsonError.value = ''
|
||||
return
|
||||
}
|
||||
const { data, error: parseErr } = parseConfig(text)
|
||||
if (parseErr) {
|
||||
jsonError.value = parseErr
|
||||
return
|
||||
}
|
||||
const { servers: extracted, error: extractErr } = extractServers(data)
|
||||
if (extractErr) {
|
||||
jsonError.value = extractErr
|
||||
return
|
||||
}
|
||||
jsonError.value = ''
|
||||
formatTimer = setTimeout(() => {
|
||||
const formatted = inputMode.value === 'json'
|
||||
? JSON.stringify(extracted, null, 2)
|
||||
: yaml.dump(extracted, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||
if (formatted !== text) jsonText.value = formatted
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
function handleModeChange(mode: 'json' | 'yaml') {
|
||||
if (!jsonText.value.trim()) return
|
||||
// Try to parse current content in old format
|
||||
const oldMode = mode === 'json' ? 'yaml' : 'json'
|
||||
let data: Record<string, unknown> | null = null
|
||||
try {
|
||||
if (oldMode === 'json') {
|
||||
data = JSON.parse(jsonText.value)
|
||||
} else {
|
||||
data = yaml.load(jsonText.value, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// If parse fails, try the new format
|
||||
try {
|
||||
if (mode === 'json') {
|
||||
data = JSON.parse(jsonText.value)
|
||||
} else {
|
||||
data = yaml.load(jsonText.value, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!data || typeof data !== 'object') return
|
||||
// Convert to new format
|
||||
if (mode === 'json') {
|
||||
jsonText.value = JSON.stringify(data, null, 2)
|
||||
} else {
|
||||
jsonText.value = yaml.dump(data, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||
}
|
||||
jsonError.value = ''
|
||||
}
|
||||
|
||||
function parseConfig(text: string): { data: Record<string, unknown> | null; error: string } {
|
||||
if (inputMode.value === 'json') {
|
||||
try {
|
||||
const obj = JSON.parse(text)
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||
return { data: null, error: t('mcp.invalidJson') }
|
||||
}
|
||||
return { data: obj, error: '' }
|
||||
} catch {
|
||||
return { data: null, error: t('mcp.invalidJson') }
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const obj = yaml.load(text, { schema: yaml.JSON_SCHEMA })
|
||||
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
||||
return { data: null, error: t('mcp.invalidYaml') }
|
||||
}
|
||||
return { data: obj as Record<string, unknown>, error: '' }
|
||||
} catch (e: any) {
|
||||
return { data: null, error: `${t('mcp.invalidYaml')}: ${e.message || ''}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractServers(data: Record<string, unknown> | null): { servers: Record<string, unknown>; error: string } {
|
||||
if (!data) return { servers: {}, error: t('mcp.invalidConfig') }
|
||||
// Unwrap mcpServers/mcp_servers wrapper
|
||||
if (data.mcpServers && typeof data.mcpServers === 'object' && !data.command) {
|
||||
return { servers: data.mcpServers as Record<string, unknown>, error: '' }
|
||||
}
|
||||
if (data.mcp_servers && typeof data.mcp_servers === 'object' && !data.command) {
|
||||
return { servers: data.mcp_servers as Record<string, unknown>, error: '' }
|
||||
}
|
||||
return { servers: data, error: '' }
|
||||
}
|
||||
|
||||
function validateServerConfig(name: string, config: unknown): string | null {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return `${name}: ${t('mcp.invalidServerConfig')}`
|
||||
}
|
||||
const cfg = config as Record<string, unknown>
|
||||
if (!cfg.command && !cfg.url) {
|
||||
return `${name}: ${t('mcp.missingCommandOrUrl')}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseAndValidate(text: string): { servers: Record<string, unknown>; error: string } {
|
||||
const { data, error: parseErr } = parseConfig(text)
|
||||
if (parseErr) return { servers: {}, error: parseErr }
|
||||
const { servers, error: extractErr } = extractServers(data)
|
||||
if (extractErr) return { servers: {}, error: extractErr }
|
||||
// Validate each server has command or url
|
||||
for (const [name, config] of Object.entries(servers)) {
|
||||
const err = validateServerConfig(name, config)
|
||||
if (err) return { servers: {}, error: err }
|
||||
}
|
||||
return { servers, error: '' }
|
||||
}
|
||||
|
||||
const toolsByServer = ref<Record<string, {name: string, description: string}[]>>({})
|
||||
|
||||
const summary = computed(() => {
|
||||
let connected = 0, totalTools = 0
|
||||
for (const s of servers.value) {
|
||||
if (s.connected) connected++
|
||||
totalTools += s.tools_registered
|
||||
}
|
||||
return { total: servers.value.length, connected, disconnected: servers.value.length - connected, totalTools }
|
||||
})
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return servers.value
|
||||
return servers.value.filter(s =>
|
||||
s.name.toLowerCase().includes(query) ||
|
||||
s.transport.includes(query) ||
|
||||
s.tool_names.some(n => n.toLowerCase().includes(query))
|
||||
)
|
||||
})
|
||||
|
||||
async function loadServers() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await fetchMcpServers()
|
||||
servers.value = data.servers ?? []
|
||||
// Populate toolsByServer from embedded tool_details
|
||||
for (const s of servers.value) {
|
||||
if (s.tool_details?.length) {
|
||||
toolsByServer.value[s.name] = s.tool_details.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
}))
|
||||
}
|
||||
}
|
||||
// Auto-retry with exponential backoff if enabled servers are still disconnected
|
||||
const hasPending = servers.value.some(s => s.raw_config.enabled !== false && !s.connected)
|
||||
if (hasPending && _autoRetryCount < MAX_AUTO_RETRIES) {
|
||||
const delay = BASE_RETRY_DELAY * Math.pow(2, _autoRetryCount) // 2s, 4s, 8s, 16s, 32s
|
||||
_autoRetryCount++
|
||||
scheduleReload(delay)
|
||||
} else {
|
||||
_autoRetryCount = 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || t('mcp.loadFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReload(server?: string) {
|
||||
try {
|
||||
const res = await mcpReload(server)
|
||||
if (res.ok) {
|
||||
if (server) {
|
||||
const { [server]: _, ...rest } = toolsByServer.value
|
||||
toolsByServer.value = rest
|
||||
} else {
|
||||
toolsByServer.value = {}
|
||||
}
|
||||
message.success(server ? t('mcp.reloaded', { server }) : t('mcp.reloadedAll'))
|
||||
scheduleReload()
|
||||
} else {
|
||||
message.error(res.error || t('mcp.reloadFailed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.reloadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingName.value = ''
|
||||
jsonText.value = ''
|
||||
jsonError.value = ''
|
||||
inputMode.value = 'json'
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(server: McpServerInfo) {
|
||||
modalMode.value = 'edit'
|
||||
editingName.value = server.name
|
||||
const serverConfig = { [server.name]: server.raw_config }
|
||||
jsonText.value = inputMode.value === 'yaml'
|
||||
? yaml.dump(serverConfig, { indent: 2, lineWidth: -1 }).trimEnd()
|
||||
: JSON.stringify(serverConfig, null, 2)
|
||||
jsonError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveServer() {
|
||||
if (formatTimer) { clearTimeout(formatTimer); formatTimer = null }
|
||||
const { servers: parsed, error: validationErr } = parseAndValidate(jsonText.value)
|
||||
if (validationErr) {
|
||||
jsonError.value = validationErr
|
||||
return
|
||||
}
|
||||
jsonError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (modalMode.value === 'add') {
|
||||
// Expect: { "server-name": { "command": "...", ... } }
|
||||
const entries = Object.entries(parsed)
|
||||
if (entries.length === 0) {
|
||||
jsonError.value = t('mcp.invalidConfig')
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
let added = 0
|
||||
for (const [name, config] of entries) {
|
||||
if (typeof config !== 'object' || config === null) continue
|
||||
const res = await mcpServerAdd(name, config as McpServerConfig)
|
||||
if (res.ok) added++
|
||||
else message.error(`${name}: ${res.error || t('mcp.addFailed')}`)
|
||||
}
|
||||
if (added > 0) {
|
||||
showModal.value = false
|
||||
message.success(t('mcp.serverAdded', { name: `${added} server(s)` }))
|
||||
// Immediately show server from config (disconnected)
|
||||
await loadServers()
|
||||
// Delayed refresh to show updated connection status after discovery
|
||||
scheduleReload()
|
||||
}
|
||||
} else {
|
||||
const name = editingName.value
|
||||
// For edit, config can be flat or wrapped: { "name": { ... } }
|
||||
const config = (parsed[name] && typeof parsed[name] === 'object')
|
||||
? parsed[name] as Record<string, unknown>
|
||||
: parsed
|
||||
const res = await mcpServerUpdate(name, config)
|
||||
if (res.ok) {
|
||||
showModal.value = false
|
||||
message.success(t('mcp.serverUpdated', { name: editingName.value }))
|
||||
// Immediately show updated config
|
||||
await loadServers()
|
||||
// Delayed refresh to show reconnection status
|
||||
scheduleReload()
|
||||
} else {
|
||||
message.error(res.error || t('mcp.updateFailed'))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(server: McpServerInfo) {
|
||||
try {
|
||||
const res = await mcpServerRemove(server.name)
|
||||
if (res.ok) {
|
||||
message.success(t('mcp.serverRemoved', { name: server.name }))
|
||||
const { [server.name]: _, ...rest } = toolsByServer.value
|
||||
toolsByServer.value = rest
|
||||
await loadServers()
|
||||
} else {
|
||||
message.error(res.error || t('mcp.removeFailed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.removeFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(server: McpServerInfo) {
|
||||
const newValue = !server.raw_config.enabled
|
||||
try {
|
||||
const config = { ...server.raw_config, enabled: newValue }
|
||||
const res = await mcpServerUpdate(server.name, config)
|
||||
if (res.ok) {
|
||||
message.success(t(newValue ? 'mcp.enabled' : 'mcp.disabled', { name: server.name }))
|
||||
const { [server.name]: _, ...rest } = toolsByServer.value
|
||||
toolsByServer.value = rest
|
||||
await mcpReload(server.name)
|
||||
scheduleReload()
|
||||
} else {
|
||||
message.error(res.error || t('mcp.updateFailed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.updateFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(server: McpServerInfo) {
|
||||
try {
|
||||
const res = await mcpServerTest(server.name)
|
||||
if (res.ok && res.tools) {
|
||||
message.success(t('mcp.testOk', { count: res.tools.length }), { duration: 3000 })
|
||||
} else {
|
||||
message.warning(res.error || t('mcp.testEmpty'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.message || t('mcp.testFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void loadServers()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('mcp.title') }}</h2>
|
||||
<div class="header-actions">
|
||||
<NButton size="small" quaternary :loading="loading" @click="_autoRetryCount = 0; loadServers()">
|
||||
{{ t('mcp.refresh') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mcp-content">
|
||||
<NAlert v-if="error" type="error" class="mcp-notice">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">{{ t('mcp.total') }}</span>
|
||||
<strong>{{ summary.total }}</strong>
|
||||
</div>
|
||||
<div class="summary-card success">
|
||||
<span class="summary-label">{{ t('mcp.connected') }}</span>
|
||||
<strong>{{ summary.connected }}</strong>
|
||||
</div>
|
||||
<div class="summary-card warning">
|
||||
<span class="summary-label">{{ t('mcp.disconnected') }}</span>
|
||||
<strong>{{ summary.disconnected }}</strong>
|
||||
</div>
|
||||
<div class="summary-card info">
|
||||
<span class="summary-label">{{ t('mcp.tool') }}</span>
|
||||
<strong>{{ summary.totalTools }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
<NInput
|
||||
v-model:value="searchQuery"
|
||||
:placeholder="t('mcp.searchPlaceholder')"
|
||||
clearable
|
||||
size="small"
|
||||
class="search-input"
|
||||
/>
|
||||
<div class="btn-group">
|
||||
<NButton size="small" type="primary" @click="handleReload()">
|
||||
{{ t('mcp.reloadAll') }}
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="openAddModal">
|
||||
{{ t('mcp.addServer') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NSpin :show="loading && servers.length === 0">
|
||||
<div v-if="filteredServers.length" class="servers-grid">
|
||||
<McpServerCard
|
||||
v-for="server in filteredServers"
|
||||
:key="server.name"
|
||||
:server="server"
|
||||
:tools-by-server="toolsByServer"
|
||||
@edit="openEditModal"
|
||||
@test="handleTest"
|
||||
@reload="handleReload"
|
||||
@remove="handleRemove"
|
||||
@toggle-enabled="handleToggleEnabled"
|
||||
/>
|
||||
</div>
|
||||
<NEmpty v-else-if="!loading" :description="t('mcp.empty')" />
|
||||
</NSpin>
|
||||
</div>
|
||||
|
||||
<NModal v-model:show="showModal" :title="modalMode === 'add' ? t('mcp.addTitle') : t('mcp.editTitle')" preset="card" :style="{ width: 'min(520px, calc(100vw - 32px))' }">
|
||||
<div class="mode-switch-row">
|
||||
<NRadioGroup v-model:value="inputMode" size="small" @update:value="handleModeChange">
|
||||
<NRadioButton value="json">JSON</NRadioButton>
|
||||
<NRadioButton value="yaml">YAML</NRadioButton>
|
||||
</NRadioGroup>
|
||||
</div>
|
||||
<NInput
|
||||
v-model:value="jsonText"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
class="config-textarea"
|
||||
:placeholder="placeholder"
|
||||
:status="jsonError ? 'error' : undefined"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div v-if="jsonError" class="config-error">{{ jsonError }}</div>
|
||||
<div class="modal-actions">
|
||||
<NButton @click="showModal = false">{{ t('mcp.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="saveServer">
|
||||
{{ modalMode === 'add' ? t('mcp.add') : t('mcp.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.mcp-view {
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcp-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 21px 20px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
color: $text-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 80px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mcp-notice {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 14px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 12px;
|
||||
background: $bg-secondary;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
strong {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&.success strong { color: $success; }
|
||||
&.warning strong { color: $warning; }
|
||||
&.error strong { color: $error; }
|
||||
&.info strong { color: $accent-primary; }
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
.n-button {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 420px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.mode-switch-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
color: var(--n-error-color);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.search-input {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user