first commit

This commit is contained in:
douboer
2025-10-14 14:18:20 +08:00
commit d93bc02772
66 changed files with 21393 additions and 0 deletions

679
web/src/App.vue Normal file
View File

@@ -0,0 +1,679 @@
<template>
<n-config-provider :theme="theme">
<n-global-style />
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<div class="app">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="logo">
<h2>MCP Client</h2>
</div>
<nav class="nav">
<n-menu
v-model:value="activeRoute"
:options="menuOptions"
:collapsed="false"
@update:value="handleMenuSelect"
/>
</nav>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- 头部 -->
<header class="header">
<div class="header-left">
<h1>{{ currentPageTitle }}</h1>
</div>
<div class="header-right">
<!-- 连接状态 -->
<div class="connection-status">
<n-icon
:component="connectedServers.length > 0 ? WifiIcon : WifiOffIcon"
:color="connectedServers.length > 0 ? '#52c41a' : '#ff4d4f'"
size="18"
/>
<span>{{ connectedServers.length }} / {{ servers.length }} 服务器已连接</span>
</div>
<!-- 主题切换 -->
<n-button
quaternary
circle
@click="toggleTheme"
>
<template #icon>
<n-icon :component="isDark ? SunIcon : MoonIcon" />
</template>
</n-button>
</div>
</header>
<!-- 页面内容 -->
<div class="page-content">
<!-- 仪表盘 -->
<div v-if="activeRoute === 'dashboard'" class="dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<n-icon :component="ServerIcon" size="24" />
</div>
<div class="stat-content">
<div class="stat-number">{{ servers.length }}</div>
<div class="stat-label">MCP 服务器</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon connected">
<n-icon :component="PlugIcon" size="24" />
</div>
<div class="stat-content">
<div class="stat-number">{{ connectedServers.length }}</div>
<div class="stat-label">已连接</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tools">
<n-icon :component="ToolIcon" size="24" />
</div>
<div class="stat-content">
<div class="stat-number">{{ availableTools.length }}</div>
<div class="stat-label">可用工具</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon llm">
<n-icon :component="BrainIcon" size="24" />
</div>
<div class="stat-content">
<div class="stat-number">{{ llmConfig.enabled ? 'ON' : 'OFF' }}</div>
<div class="stat-label">LLM 助手</div>
</div>
</div>
</div>
<!-- 快速操作 -->
<div class="quick-actions">
<h3>快速操作</h3>
<div class="action-buttons">
<n-button type="primary" @click="showAddServerModal = true">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
添加服务器
</n-button>
<n-button @click="activeRoute = 'chat'">
<template #icon>
<n-icon :component="ChatIcon" />
</template>
开始对话
</n-button>
<n-button @click="activeRoute = 'tools'">
<template #icon>
<n-icon :component="ToolIcon" />
</template>
执行工具
</n-button>
</div>
</div>
<!-- 最近活动 -->
<div class="recent-activity">
<h3>服务器状态</h3>
<div class="server-list">
<ServerCard
v-for="server in servers"
:key="server.id"
:server="server"
@connect="connectServer"
@disconnect="disconnectServer"
@edit="editServer"
@delete="deleteServer"
/>
</div>
</div>
</div>
<!-- 服务器管理 -->
<div v-else-if="activeRoute === 'servers'" class="servers-page">
<div class="page-header">
<h2>服务器管理</h2>
<n-button type="primary" @click="showAddServerModal = true">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
添加服务器
</n-button>
</div>
<div class="server-grid">
<ServerCard
v-for="server in servers"
:key="server.id"
:server="server"
@connect="connectServer"
@disconnect="disconnectServer"
@edit="editServer"
@delete="deleteServer"
/>
</div>
</div>
<!-- 工具执行 -->
<div v-else-if="activeRoute === 'tools'" class="tools-page">
<div class="page-header">
<h2>工具执行</h2>
</div>
<div v-if="availableTools.length === 0" class="empty-state">
<n-icon :component="ToolIcon" size="48" />
<p>暂无可用工具</p>
<p>请先连接 MCP 服务器</p>
</div>
<div v-else class="tools-grid">
<ToolForm
v-for="tool in availableTools"
:key="`${tool.serverId}-${tool.name}`"
:tool="tool"
:llm-enabled="llmConfig.enabled"
@execute="executeTool"
/>
</div>
</div>
<!-- 对话界面 -->
<div v-else-if="activeRoute === 'chat'" class="chat-page">
<div class="chat-container">
<!-- 聊天消息 -->
<div class="messages">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="message.role"
>
<div class="message-content">
{{ message.content }}
</div>
<div v-if="message.toolCalls" class="tool-calls">
<div
v-for="call in message.toolCalls"
:key="call.id"
class="tool-call"
:class="call.status"
>
<strong>{{ call.toolName }}</strong>
<pre>{{ JSON.stringify(call.result, null, 2) }}</pre>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input">
<n-input
v-model:value="chatInput"
type="textarea"
placeholder="输入你的消息..."
:autosize="{ minRows: 2, maxRows: 6 }"
@keydown.enter.prevent="sendMessage"
/>
<n-button
type="primary"
:loading="chatLoading"
@click="sendMessage"
>
发送
</n-button>
</div>
</div>
</div>
<!-- 设置页面 -->
<div v-else-if="activeRoute === 'settings'" class="settings-page">
<div class="settings-section">
<h3>LLM 配置</h3>
<n-form :model="llmConfig" label-placement="left" label-width="120px">
<n-form-item label="启用 LLM">
<n-switch v-model:value="llmConfig.enabled" />
</n-form-item>
<n-form-item label="提供商">
<n-select
v-model:value="llmConfig.provider"
:options="[
{ label: 'OpenAI', value: 'openai' },
{ label: 'Claude', value: 'claude' },
{ label: 'Ollama', value: 'ollama' }
]"
/>
</n-form-item>
<n-form-item label="模型">
<n-input v-model:value="llmConfig.model" />
</n-form-item>
<n-form-item label="API Key">
<n-input
v-model:value="llmConfig.apiKey"
type="password"
show-password-on="click"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" @click="saveLLMConfig">
保存配置
</n-button>
<n-button @click="testLLMConnection">
测试连接
</n-button>
</n-form-item>
</n-form>
</div>
</div>
</div>
</main>
<!-- 添加服务器模态框 -->
<n-modal v-model:show="showAddServerModal">
<n-card title="添加 MCP 服务器" style="width: 600px">
<ServerForm @submit="handleAddServer" @cancel="showAddServerModal = false" />
</n-card>
</n-modal>
</div>
</n-notification-provider>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { useServerStore } from './stores/newServer'
import {
NConfigProvider,
NGlobalStyle,
NMessageProvider,
NDialogProvider,
NNotificationProvider,
NMenu,
NButton,
NIcon,
NModal,
NCard,
NForm,
NFormItem,
NSelect,
NInput,
NSwitch,
darkTheme
} from 'naive-ui'
import {
Dashboard as DashboardIcon,
Server as ServerIcon,
Tool as ToolIcon,
MessageCircle as ChatIcon,
Settings as SettingsIcon,
Wifi as WifiIcon,
WifiOff as WifiOffIcon,
Sun as SunIcon,
Moon as MoonIcon,
Plus as PlusIcon,
Plug as PlugIcon,
Brain as BrainIcon
} from '@vicons/tabler'
import ServerCard from './components/ServerCard.vue'
import ToolForm from './components/ToolForm.vue'
import ServerForm from './components/ServerForm.vue'
// 状态管理
const serverStore = useServerStore()
// 响应式数据
const activeRoute = ref('dashboard')
const showAddServerModal = ref(false)
const isDark = ref(false)
// 计算属性
const theme = computed(() => isDark.value ? darkTheme : null)
const servers = computed(() => serverStore.servers)
const connectedServers = computed(() => serverStore.connectedServers)
const availableTools = computed(() => serverStore.availableTools)
// 菜单配置
const menuOptions = [
{
label: '仪表盘',
key: 'dashboard',
icon: () => h(NIcon, { component: DashboardIcon })
},
{
label: '服务器',
key: 'servers',
icon: () => h(NIcon, { component: ServerIcon })
},
{
label: '工具',
key: 'tools',
icon: () => h(NIcon, { component: ToolIcon })
},
{
label: '对话',
key: 'chat',
icon: () => h(NIcon, { component: ChatIcon })
},
{
label: '设置',
key: 'settings',
icon: () => h(NIcon, { component: SettingsIcon })
}
]
const currentPageTitle = computed(() => {
const titleMap: Record<string, string> = {
dashboard: '仪表盘',
servers: '服务器管理',
tools: '工具执行',
chat: '智能对话',
settings: '系统设置'
}
return titleMap[activeRoute.value] || '仪表盘'
})
// 方法
const handleMenuSelect = (key: string) => {
activeRoute.value = key
}
const toggleTheme = () => {
isDark.value = !isDark.value
}
const connectServer = async (serverId: string) => {
await serverStore.connectServer(serverId)
}
const disconnectServer = async (serverId: string) => {
await serverStore.disconnectServer(serverId)
}
const editServer = (serverId: string) => {
// TODO: 实现编辑服务器功能
console.log('编辑服务器:', serverId)
}
const deleteServer = async (serverId: string) => {
await serverStore.removeServer(serverId)
}
const handleAddServer = async (config: any) => {
await serverStore.addServer(config)
showAddServerModal.value = false
}
const executeTool = async (payload: any) => {
// 通过 API 执行工具
console.log('执行工具:', payload)
}
// 生命周期
onMounted(() => {
// 应用启动时自动加载本地保存的服务器配置
console.log('🚀 MCP Vue 客户端启动')
})
</script>
<style scoped>
.app {
display: flex;
height: 100vh;
background: var(--body-color);
}
.sidebar {
width: 240px;
background: var(--card-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.logo {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.logo h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.nav {
flex: 1;
padding: 16px;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 60px;
padding: 0 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card-color);
}
.header-left h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.page-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--primary-color-pressed);
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.stat-icon.connected {
background: var(--success-color);
}
.stat-icon.tools {
background: var(--info-color);
}
.stat-icon.llm {
background: var(--warning-color);
}
.stat-number {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--text-color-3);
}
.quick-actions {
margin-bottom: 32px;
}
.quick-actions h3 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.action-buttons {
display: flex;
gap: 12px;
}
.recent-activity h3 {
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
}
.server-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 16px;
}
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-color-3);
}
.empty-state p {
margin: 8px 0;
}
.chat-container {
height: calc(100vh - 140px);
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px 0;
}
.message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
.message.user {
background: var(--primary-color);
color: white;
margin-left: auto;
}
.message.assistant {
background: var(--card-color);
border: 1px solid var(--border-color);
}
.chat-input {
display: flex;
gap: 12px;
align-items: end;
}
.settings-section {
max-width: 600px;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
}
.settings-section h3 {
margin: 0 0 20px 0;
font-size: 18px;
font-weight: 600;
}
</style>

764
web/src/SimpleApp.vue Normal file
View File

@@ -0,0 +1,764 @@
<template>
<n-config-provider :theme="theme">
<n-global-style />
<n-message-provider>
<div class="app">
<!-- 侧边栏 -->
<div class="sidebar">
<div class="sidebar-header">
<div class="app-logo">
<n-icon size="24" color="#3b82f6">
<Robot />
</n-icon>
<span class="app-title">MCP Client</span>
</div>
<n-button
quaternary
circle
@click="toggleTheme"
class="theme-toggle"
>
<template #icon>
<n-icon>
<Sun v-if="isDark" />
<Moon v-else />
</n-icon>
</template>
</n-button>
</div>
<n-scrollbar class="sidebar-content">
<div class="nav-section">
<div class="section-title">核心功能</div>
<div class="nav-items">
<div
class="nav-item"
:class="{ active: currentRoute === 'chat' }"
@click="currentRoute = 'chat'"
>
<n-icon size="18">
<MessageCircle />
</n-icon>
<span>聊天对话</span>
<div class="nav-indicator" v-if="currentRoute === 'chat'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'tools' }"
@click="currentRoute = 'tools'"
>
<n-icon size="18">
<Tool />
</n-icon>
<span>工具管理</span>
<div class="nav-indicator" v-if="currentRoute === 'tools'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'data' }"
@click="currentRoute = 'data'"
>
<n-icon size="18">
<Database />
</n-icon>
<span>数据管理</span>
<div class="nav-indicator" v-if="currentRoute === 'data'"></div>
</div>
</div>
</div>
<n-divider style="margin: 16px 0;" />
<div class="nav-section">
<div class="section-title">设置</div>
<div class="nav-items">
<div
class="nav-item"
:class="{ active: currentRoute === 'model-providers' }"
@click="currentRoute = 'model-providers'"
>
<n-icon size="18">
<Brain />
</n-icon>
<span>模型服务</span>
<div class="nav-indicator" v-if="currentRoute === 'model-providers'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'display-settings' }"
@click="currentRoute = 'display-settings'"
>
<n-icon size="18">
<Palette />
</n-icon>
<span>显示设置</span>
<div class="nav-indicator" v-if="currentRoute === 'display-settings'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'mcp' }"
@click="currentRoute = 'mcp'"
>
<n-icon size="18">
<Settings />
</n-icon>
<span>MCP 设置</span>
<div class="nav-indicator" v-if="currentRoute === 'mcp'"></div>
</div>
</div>
</div>
</n-scrollbar>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 聊天页面 -->
<div v-if="currentRoute === 'chat'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#3b82f6">
<MessageCircle />
</n-icon>
<div>
<h1>聊天对话</h1>
<p> MCP 服务器进行智能对话</p>
</div>
</div>
<div class="content-grid">
<n-card title="功能特性" class="feature-card">
<n-space vertical>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Robot />
</n-icon>
<span>多模型支持</span>
</div>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Tool />
</n-icon>
<span>工具调用</span>
</div>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Database />
</n-icon>
<span>上下文管理</span>
</div>
</n-space>
</n-card>
<n-card title="快速开始" class="action-card">
<n-space vertical size="large">
<n-button type="primary" size="large" block>
<template #icon>
<n-icon>
<MessageCircle />
</n-icon>
</template>
开始新对话
</n-button>
<n-button size="large" block>
<template #icon>
<n-icon>
<Settings />
</n-icon>
</template>
配置模型
</n-button>
</n-space>
</n-card>
</div>
</div>
<!-- 工具页面 -->
<div v-else-if="currentRoute === 'tools'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#f59e0b">
<Tool />
</n-icon>
<div>
<h1>工具管理</h1>
<p>管理和执行 MCP 工具</p>
</div>
</div>
<div class="content-grid">
<n-card title="工具列表" class="tools-card">
<n-empty description="暂无可用工具">
<template #extra>
<n-button size="small">
连接 MCP 服务器
</n-button>
</template>
</n-empty>
</n-card>
</div>
</div>
<!-- 数据页面 -->
<div v-else-if="currentRoute === 'data'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#8b5cf6">
<Database />
</n-icon>
<div>
<h1>数据管理</h1>
<p>管理 MCP 资源和数据</p>
</div>
</div>
<div class="content-grid">
<n-card title="资源统计" class="stats-card">
<n-statistic label="文件资源" :value="0" />
</n-card>
<n-card title="数据源" class="stats-card">
<n-statistic label="API 连接" :value="0" />
</n-card>
</div>
</div>
<!-- 模型服务页面 -->
<ModelProviders v-else-if="currentRoute === 'model-providers'" />
<!-- 显示设置页面 -->
<!-- 显示设置页面 -->
<DisplaySettings v-else-if="currentRoute === 'display-settings'" />
<!-- MCP 设置页面 -->
<MCPSettings v-else-if="currentRoute === 'mcp'" />
<!-- 默认首页 -->
<div v-else class="content-page">
<div class="welcome-header">
<div class="welcome-logo">
<n-icon size="48" color="#3b82f6">
<Robot />
</n-icon>
</div>
<h1>欢迎使用 MCP Vue Client</h1>
<p>现代化的模型上下文协议客户端</p>
</div>
<div class="welcome-grid">
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'chat'"
>
<template #cover>
<div class="card-icon chat-icon">
<n-icon size="32">
<MessageCircle />
</n-icon>
</div>
</template>
<h3>开始对话</h3>
<p>与AI模型进行智能对话体验强大的语言理解能力</p>
</n-card>
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'tools'"
>
<template #cover>
<div class="card-icon tools-icon">
<n-icon size="32">
<Tool />
</n-icon>
</div>
</template>
<h3>使用工具</h3>
<p>执行各种MCP工具扩展AI的能力边界</p>
</n-card>
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'mcp'"
>
<template #cover>
<div class="card-icon mcp-icon">
<n-icon size="32">
<Settings />
</n-icon>
</div>
</template>
<h3>配置服务</h3>
<p>管理MCP服务器连接搭建强大的AI生态</p>
</n-card>
</div>
<div class="stats-overview">
<n-card title="系统概览">
<div class="stats-grid">
<n-statistic label="已连接服务器" :value="0" />
<n-statistic label="可用工具" :value="0" />
<n-statistic label="对话次数" :value="0" />
<n-statistic label="配置模型" :value="0" />
</div>
</n-card>
</div>
</div>
</div>
</div>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { darkTheme, type GlobalTheme } from 'naive-ui'
import {
MessageCircle,
Tool,
Database,
Cpu as Brain,
Palette,
Settings,
Sun,
Moon,
Robot
} from '@vicons/tabler'
import ModelProviders from './components/ModelProviders.vue'
import DisplaySettings from './components/DisplaySettings.vue'
import MCPSettings from './components/MCPSettings.vue'
import { useModelStore } from './stores/modelStore'
type RouteKey =
| 'chat'
| 'tools'
| 'data'
| 'model-providers'
| 'display-settings'
| 'mcp'
// 状态管理
const modelStore = useModelStore()
// 响应式数据
const currentRoute = ref<RouteKey>('chat')
const isDark = ref(false)
// 计算属性
const theme = computed<GlobalTheme | null>(() => {
return isDark.value ? darkTheme : null
})
// 方法
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}
// 生命周期
onMounted(() => {
// 初始化模型服务状态
modelStore.initialize()
})
</script>
<style scoped>
.app {
display: flex;
height: 100vh;
background: #f8fafc;
}
/* 侧边栏样式 */
.sidebar {
width: 280px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
}
.sidebar-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-logo {
display: flex;
align-items: center;
gap: 12px;
}
.app-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.theme-toggle {
flex-shrink: 0;
}
.sidebar-content {
flex: 1;
padding: 16px 0;
}
.nav-section {
margin-bottom: 24px;
}
.section-title {
padding: 0 24px 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #64748b;
letter-spacing: 0.5px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
margin: 0 12px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
}
.nav-item.active {
background: #eff6ff;
color: #3b82f6;
font-weight: 600;
}
.nav-indicator {
position: absolute;
right: -12px;
width: 3px;
height: 20px;
background: #3b82f6;
border-radius: 2px;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
height: calc(100vh - 0px);
}
.content-panel {
flex: 1;
overflow-y: auto;
}
.content-page {
flex: 1;
padding: 32px;
overflow-y: auto;
background: #f8fafc;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #e2e8f0;
}
.page-header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: #1e293b;
}
.page-header p {
margin: 4px 0 0 0;
color: #64748b;
font-size: 16px;
}
/* 内容网格布局 */
.content-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.mcp-grid {
display: grid;
gap: 24px;
}
/* 卡片样式 */
.feature-card,
.action-card,
.tools-card,
.stats-card,
.provider-card,
.settings-card,
.mcp-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #475569;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #475569;
}
/* 欢迎页面样式 */
.welcome-header {
text-align: center;
margin-bottom: 48px;
}
.welcome-logo {
margin-bottom: 24px;
}
.welcome-header h1 {
margin: 0 0 12px 0;
font-size: 36px;
font-weight: 700;
color: #1e293b;
}
.welcome-header p {
margin: 0;
font-size: 18px;
color: #64748b;
}
.welcome-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.welcome-card {
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.welcome-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
border-radius: 12px 12px 0 0;
}
.chat-icon {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
}
.tools-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.mcp-icon {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.welcome-card h3 {
margin: 16px 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.welcome-card p {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.stats-overview {
margin-top: 32px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 24px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
max-height: 300px;
}
.content-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
}
.page-header h1 {
font-size: 24px;
}
.content-grid,
.providers-grid,
.settings-grid,
.welcome-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.welcome-header h1 {
font-size: 28px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 深色模式 */
[data-theme="dark"] .app {
background: #0f172a;
}
[data-theme="dark"] .sidebar {
background: #1e293b;
border-right-color: #334155;
}
[data-theme="dark"] .sidebar-header {
border-bottom-color: #334155;
}
[data-theme="dark"] .app-title {
color: #f8fafc;
}
[data-theme="dark"] .section-title {
color: #94a3b8;
}
[data-theme="dark"] .nav-item {
color: #94a3b8;
}
[data-theme="dark"] .nav-item:hover {
background: #334155;
color: #f1f5f9;
}
[data-theme="dark"] .nav-item.active {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
[data-theme="dark"] .content-page {
background: #0f172a;
}
[data-theme="dark"] .page-header {
border-bottom-color: #334155;
}
[data-theme="dark"] .page-header h1 {
color: #f8fafc;
}
[data-theme="dark"] .page-header p {
color: #94a3b8;
}
[data-theme="dark"] .welcome-header h1 {
color: #f8fafc;
}
[data-theme="dark"] .welcome-header p {
color: #94a3b8;
}
[data-theme="dark"] .welcome-card h3 {
color: #f8fafc;
}
[data-theme="dark"] .welcome-card p {
color: #94a3b8;
}
[data-theme="dark"] .feature-item {
color: #cbd5e1;
}
[data-theme="dark"] .setting-item {
color: #cbd5e1;
}
</style>

View File

@@ -0,0 +1,825 @@
<template>
<n-config-provider :theme="theme">
<n-global-style />
<n-message-provider>
<div class="app">
<!-- 侧边栏 -->
<div class="sidebar">
<div class="sidebar-header">
<div class="app-logo">
<n-icon size="24" color="#3b82f6">
<Robot />
</n-icon>
<span class="app-title">MCP Client</span>
</div>
<n-button
quaternary
circle
@click="toggleTheme"
class="theme-toggle"
>
<template #icon>
<n-icon>
<Sun v-if="isDark" />
<Moon v-else />
</n-icon>
</template>
</n-button>
</div>
<n-scrollbar class="sidebar-content">
<div class="nav-section">
<div class="section-title">核心功能</div>
<div class="nav-items">
<div
class="nav-item"
:class="{ active: currentRoute === 'chat' }"
@click="currentRoute = 'chat'"
>
<n-icon size="18">
<MessageCircle />
</n-icon>
<span>聊天对话</span>
<div class="nav-indicator" v-if="currentRoute === 'chat'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'tools' }"
@click="currentRoute = 'tools'"
>
<n-icon size="18">
<Tool />
</n-icon>
<span>工具管理</span>
<div class="nav-indicator" v-if="currentRoute === 'tools'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'data' }"
@click="currentRoute = 'data'"
>
<n-icon size="18">
<Database />
</n-icon>
<span>数据管理</span>
<div class="nav-indicator" v-if="currentRoute === 'data'"></div>
</div>
</div>
</div>
<n-divider style="margin: 16px 0;" />
<div class="nav-section">
<div class="section-title">设置</div>
<div class="nav-items">
<div
class="nav-item"
:class="{ active: currentRoute === 'model-providers' }"
@click="currentRoute = 'model-providers'"
>
<n-icon size="18">
<Brain />
</n-icon>
<span>模型服务</span>
<div class="nav-indicator" v-if="currentRoute === 'model-providers'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'display-settings' }"
@click="currentRoute = 'display-settings'"
>
<n-icon size="18">
<Palette />
</n-icon>
<span>显示设置</span>
<div class="nav-indicator" v-if="currentRoute === 'display-settings'"></div>
</div>
<div
class="nav-item"
:class="{ active: currentRoute === 'mcp' }"
@click="currentRoute = 'mcp'"
>
<n-icon size="18">
<Settings />
</n-icon>
<span>MCP 设置</span>
<div class="nav-indicator" v-if="currentRoute === 'mcp'"></div>
</div>
</div>
</div>
</n-scrollbar>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 聊天页面 -->
<div v-if="currentRoute === 'chat'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#3b82f6">
<MessageCircle />
</n-icon>
<div>
<h1>聊天对话</h1>
<p>与 MCP 服务器进行智能对话</p>
</div>
</div>
<div class="content-grid">
<n-card title="功能特性" class="feature-card">
<n-space vertical>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Robot />
</n-icon>
<span>多模型支持</span>
</div>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Tool />
</n-icon>
<span>工具调用</span>
</div>
<div class="feature-item">
<n-icon size="20" color="#10b981">
<Database />
</n-icon>
<span>上下文管理</span>
</div>
</n-space>
</n-card>
<n-card title="快速开始" class="action-card">
<n-space vertical size="large">
<n-button type="primary" size="large" block>
<template #icon>
<n-icon>
<MessageCircle />
</n-icon>
</template>
开始新对话
</n-button>
<n-button size="large" block>
<template #icon>
<n-icon>
<Settings />
</n-icon>
</template>
配置模型
</n-button>
</n-space>
</n-card>
</div>
</div>
<!-- 工具页面 -->
<div v-else-if="currentRoute === 'tools'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#f59e0b">
<Tool />
</n-icon>
<div>
<h1>工具管理</h1>
<p>管理和执行 MCP 工具</p>
</div>
</div>
<div class="content-grid">
<n-card title="工具列表" class="tools-card">
<n-empty description="暂无可用工具">
<template #extra>
<n-button size="small">
连接 MCP 服务器
</n-button>
</template>
</n-empty>
</n-card>
</div>
</div>
<!-- 数据页面 -->
<div v-else-if="currentRoute === 'data'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#8b5cf6">
<Database />
</n-icon>
<div>
<h1>数据管理</h1>
<p>管理 MCP 资源和数据</p>
</div>
</div>
<div class="content-grid">
<n-card title="资源统计" class="stats-card">
<n-statistic label="文件资源" :value="0" />
</n-card>
<n-card title="数据源" class="stats-card">
<n-statistic label="API 连接" :value="0" />
</n-card>
</div>
</div>
<!-- 模型服务页面 -->
<ModelProviders v-else-if="currentRoute === 'model-providers'" />
<!-- 显示设置页面 -->
<div v-else-if="currentRoute === 'display-settings'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#ec4899">
<Palette />
</n-icon>
<div>
<h1>显示设置</h1>
<p>自定义界面外观</p>
</div>
</div>
<div class="settings-grid">
<n-card title="主题设置" class="settings-card">
<n-space vertical size="large">
<div class="setting-item">
<span>深色模式</span>
<n-switch v-model:value="isDark" @update:value="toggleTheme" />
</div>
<div class="setting-item">
<span>主题颜色</span>
<n-color-picker size="small" />
</div>
</n-space>
</n-card>
<n-card title="界面设置" class="settings-card">
<n-space vertical size="large">
<div class="setting-item">
<span>紧凑模式</span>
<n-switch />
</div>
<div class="setting-item">
<span>动画效果</span>
<n-switch :value="true" />
</div>
</n-space>
</n-card>
</div>
</div>
<!-- MCP 设置页面 -->
<div v-else-if="currentRoute === 'mcp'" class="content-page">
<div class="page-header">
<n-icon size="28" color="#6366f1">
<Settings />
</n-icon>
<div>
<h1>MCP 设置</h1>
<p>管理 MCP 服务器连接</p>
</div>
</div>
<div class="mcp-grid">
<n-card title="服务器列表" class="mcp-card">
<template #header-extra>
<n-button size="small" type="primary">
<template #icon>
<n-icon>
<Settings />
</n-icon>
</template>
添加服务器
</n-button>
</template>
<n-empty description="暂无 MCP 服务器">
<template #extra>
<n-button size="small">
配置第一个服务器
</n-button>
</template>
</n-empty>
</n-card>
</div>
</div>
<!-- 默认首页 -->
<div v-else class="content-page">
<div class="welcome-header">
<div class="welcome-logo">
<n-icon size="48" color="#3b82f6">
<Robot />
</n-icon>
</div>
<h1>欢迎使用 MCP Vue Client</h1>
<p>现代化的模型上下文协议客户端</p>
</div>
<div class="welcome-grid">
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'chat'"
>
<template #cover>
<div class="card-icon chat-icon">
<n-icon size="32">
<MessageCircle />
</n-icon>
</div>
</template>
<h3>开始对话</h3>
<p>与AI模型进行智能对话体验强大的语言理解能力</p>
</n-card>
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'tools'"
>
<template #cover>
<div class="card-icon tools-icon">
<n-icon size="32">
<Tool />
</n-icon>
</div>
</template>
<h3>使用工具</h3>
<p>执行各种MCP工具扩展AI的能力边界</p>
</n-card>
<n-card
class="welcome-card"
hoverable
@click="currentRoute = 'mcp'"
>
<template #cover>
<div class="card-icon mcp-icon">
<n-icon size="32">
<Settings />
</n-icon>
</div>
</template>
<h3>配置服务</h3>
<p>管理MCP服务器连接搭建强大的AI生态</p>
</n-card>
</div>
<div class="stats-overview">
<n-card title="系统概览">
<div class="stats-grid">
<n-statistic label="已连接服务器" :value="0" />
<n-statistic label="可用工具" :value="0" />
<n-statistic label="对话次数" :value="0" />
<n-statistic label="配置模型" :value="0" />
</div>
</n-card>
</div>
</div>
</div>
</div>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { darkTheme, type GlobalTheme } from 'naive-ui'
import {
MessageCircle,
Tool,
Database,
Cpu as Brain,
Palette,
Settings,
Sun,
Moon,
Robot
} from '@vicons/tabler'
import ModelProviders from './components/ModelProviders.vue'
import { useModelStore } from './stores/modelStore'
type RouteKey =
| 'chat'
| 'tools'
| 'data'
| 'model-providers'
| 'display-settings'
| 'mcp'
// 状态管理
const modelStore = useModelStore()
// 响应式数据
const currentRoute = ref<RouteKey>('chat')
const isDark = ref(false)
// 计算属性
const theme = computed<GlobalTheme | null>(() => {
return isDark.value ? darkTheme : null
})
// 方法
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
}
// 生命周期
onMounted(() => {
// 初始化模型服务状态
modelStore.initialize()
})
</script>
<style scoped>
.app {
display: flex;
height: 100vh;
background: #f8fafc;
}
/* 侧边栏样式 */
.sidebar {
width: 280px;
background: #ffffff;
border-right: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
}
.sidebar-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.app-logo {
display: flex;
align-items: center;
gap: 12px;
}
.app-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.theme-toggle {
flex-shrink: 0;
}
.sidebar-content {
flex: 1;
padding: 16px 0;
}
.nav-section {
margin-bottom: 24px;
}
.section-title {
padding: 0 24px 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #64748b;
letter-spacing: 0.5px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
margin: 0 12px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: #64748b;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-item:hover {
background: #f1f5f9;
color: #334155;
}
.nav-item.active {
background: #eff6ff;
color: #3b82f6;
font-weight: 600;
}
.nav-indicator {
position: absolute;
right: -12px;
width: 3px;
height: 20px;
background: #3b82f6;
border-radius: 2px;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-page {
flex: 1;
padding: 32px;
overflow-y: auto;
background: #f8fafc;
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #e2e8f0;
}
.page-header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
color: #1e293b;
}
.page-header p {
margin: 4px 0 0 0;
color: #64748b;
font-size: 16px;
}
/* 内容网格布局 */
.content-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
}
.mcp-grid {
display: grid;
gap: 24px;
}
/* 卡片样式 */
.feature-card,
.action-card,
.tools-card,
.stats-card,
.provider-card,
.settings-card,
.mcp-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: #475569;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #475569;
}
/* 欢迎页面样式 */
.welcome-header {
text-align: center;
margin-bottom: 48px;
}
.welcome-logo {
margin-bottom: 24px;
}
.welcome-header h1 {
margin: 0 0 12px 0;
font-size: 36px;
font-weight: 700;
color: #1e293b;
}
.welcome-header p {
margin: 0;
font-size: 18px;
color: #64748b;
}
.welcome-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 48px;
}
.welcome-card {
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.welcome-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
border-radius: 12px 12px 0 0;
}
.chat-icon {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
}
.tools-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.mcp-icon {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.welcome-card h3 {
margin: 16px 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.welcome-card p {
margin: 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.stats-overview {
margin-top: 32px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 24px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
max-height: 300px;
}
.content-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
padding-bottom: 16px;
}
.page-header h1 {
font-size: 24px;
}
.content-grid,
.providers-grid,
.settings-grid,
.welcome-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.welcome-header h1 {
font-size: 28px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* 深色模式 */
[data-theme="dark"] .app {
background: #0f172a;
}
[data-theme="dark"] .sidebar {
background: #1e293b;
border-right-color: #334155;
}
[data-theme="dark"] .sidebar-header {
border-bottom-color: #334155;
}
[data-theme="dark"] .app-title {
color: #f8fafc;
}
[data-theme="dark"] .section-title {
color: #94a3b8;
}
[data-theme="dark"] .nav-item {
color: #94a3b8;
}
[data-theme="dark"] .nav-item:hover {
background: #334155;
color: #f1f5f9;
}
[data-theme="dark"] .nav-item.active {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
[data-theme="dark"] .content-page {
background: #0f172a;
}
[data-theme="dark"] .page-header {
border-bottom-color: #334155;
}
[data-theme="dark"] .page-header h1 {
color: #f8fafc;
}
[data-theme="dark"] .page-header p {
color: #94a3b8;
}
[data-theme="dark"] .welcome-header h1 {
color: #f8fafc;
}
[data-theme="dark"] .welcome-header p {
color: #94a3b8;
}
[data-theme="dark"] .welcome-card h3 {
color: #f8fafc;
}
[data-theme="dark"] .welcome-card p {
color: #94a3b8;
}
[data-theme="dark"] .feature-item {
color: #cbd5e1;
}
[data-theme="dark"] .setting-item {
color: #cbd5e1;
}
</style>

43
web/src/TestApp.vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<div id="app">
<h1>MCP Vue Client</h1>
<p>Test page - 测试页面</p>
<button @click="testClick">测试按钮</button>
<div v-if="showMessage">{{ message }}</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const showMessage = ref(false)
const message = ref('Hello from Vue!')
const testClick = () => {
showMessage.value = !showMessage.value
}
</script>
<style scoped>
#app {
padding: 20px;
font-family: Arial, sans-serif;
}
h1 {
color: #2c3e50;
}
button {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #2980b9;
}
</style>

View File

@@ -0,0 +1,609 @@
<template>
<div class="display-settings-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<h1>显示设置</h1>
<p>自定义应用外观和用户体验</p>
</div>
</div>
<!-- 设置内容 -->
<div class="settings-content">
<!-- 主题 -->
<n-card title="主题" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">主题模式</span>
<span class="label-desc">选择应用的主题风格</span>
</div>
<div class="setting-control">
<n-radio-group v-model:value="displaySettings.theme" name="theme">
<n-radio-button value="light">
<div class="theme-option">
<n-icon :component="SunIcon" />
<span>浅色</span>
</div>
</n-radio-button>
<n-radio-button value="dark">
<div class="theme-option">
<n-icon :component="MoonIcon" />
<span>深色</span>
</div>
</n-radio-button>
<n-radio-button value="system">
<div class="theme-option">
<n-icon :component="DeviceDesktopIcon" />
<span>系统</span>
</div>
</n-radio-button>
</n-radio-group>
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item">
<div class="setting-label">
<span class="label-text">主题颜色</span>
</div>
<div class="setting-control">
<div class="color-picker-grid">
<div
v-for="color in themeColors"
:key="color"
class="color-option"
:class="{ active: displaySettings.primaryColor === color }"
:style="{ backgroundColor: color }"
@click="displaySettings.primaryColor = color"
>
<n-icon v-if="displaySettings.primaryColor === color" :component="CheckIcon" color="white" />
</div>
<!-- 自定义颜色输入 -->
<n-input
v-model:value="displaySettings.primaryColor"
placeholder="#00B96B"
size="small"
style="width: 80px; margin-left: 8px;"
/>
</div>
</div>
</div>
<!-- 透明窗口 -->
<div class="setting-item">
<div class="setting-label">
<span class="label-text">透明窗口</span>
</div>
<div class="setting-control">
<n-switch v-model:value="displaySettings.transparentWindow" />
</div>
</div>
</div>
</n-card>
<!-- 导航栏设置 -->
<n-card title="导航栏设置" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">导航位置</span>
</div>
<div class="setting-control">
<n-radio-group v-model:value="displaySettings.navPosition" name="navPosition">
<n-radio value="left">左侧</n-radio>
<n-radio value="top">顶部</n-radio>
</n-radio-group>
</div>
</div>
</div>
</n-card>
<!-- 缩放设置 -->
<n-card title="缩放设置" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">缩放</span>
</div>
<div class="setting-control">
<div class="zoom-controls">
<n-button size="small" @click="decreaseZoom">-</n-button>
<span class="zoom-value">{{ displaySettings.zoomLevel }}%</span>
<n-button size="small" @click="increaseZoom">+</n-button>
<n-button size="small" @click="resetZoom">
<n-icon :component="RefreshIcon" />
</n-button>
</div>
</div>
</div>
</div>
</n-card>
<!-- 字体设置 -->
<n-card title="字体设置" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">全局字体</span>
</div>
<div class="setting-control">
<n-select
v-model:value="displaySettings.globalFont"
:options="globalFontOptions"
style="width: 200px"
/>
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">代码字体</span>
</div>
<div class="setting-control">
<n-select
v-model:value="displaySettings.codeFont"
:options="codeFontOptions"
style="width: 200px"
/>
</div>
</div>
</div>
</n-card>
<!-- 话题设置 -->
<n-card title="话题设置" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">话题位置</span>
</div>
<div class="setting-control">
<n-radio-group v-model:value="displaySettings.topicPosition" name="topicPosition">
<n-radio value="left">左侧</n-radio>
<n-radio value="right">右侧</n-radio>
</n-radio-group>
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">自动切换到话题</span>
</div>
<div class="setting-control">
<n-switch v-model:value="displaySettings.autoSwitchTopic" />
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">显示话题时间</span>
</div>
<div class="setting-control">
<n-switch v-model:value="displaySettings.showTopicTime" />
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">固定话题置顶</span>
</div>
<div class="setting-control">
<n-switch v-model:value="displaySettings.pinnedTopicsTop" />
</div>
</div>
</div>
</n-card>
<!-- 助手设置 -->
<n-card title="助手设置" size="large">
<div class="setting-section">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">模型图标类型</span>
</div>
<div class="setting-control">
<n-radio-group v-model:value="displaySettings.modelIconType" name="modelIconType">
<n-radio value="modelIcon">模型图标</n-radio>
<n-radio value="emoji">Emoji 表情</n-radio>
<n-radio value="none">不显示</n-radio>
</n-radio-group>
</div>
</div>
</div>
</n-card>
<!-- 自定义 CSS -->
<n-card title="自定义 CSS" size="large">
<div class="setting-section">
<div class="css-editor">
<div class="css-header">
<span>/* 这里写自定义 CSS */</span>
<a href="#" target="_blank" class="css-help"> cherryCss.com 获取</a>
</div>
<n-input
v-model:value="displaySettings.customCSS"
type="textarea"
:rows="10"
placeholder="/* 在此添加您的自定义CSS样式 */"
/>
</div>
</div>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, onMounted } from 'vue'
import {
NCard,
NRadioGroup,
NRadioButton,
NRadio,
NSelect,
NSwitch,
NButton,
NIcon,
NInput,
useMessage
} from 'naive-ui'
import {
Sun as SunIcon,
Moon as MoonIcon,
DeviceDesktop as DeviceDesktopIcon,
Check as CheckIcon,
Refresh as RefreshIcon
} from '@vicons/tabler'
const message = useMessage()
// 显示设置数据
const displaySettings = reactive({
// 主题设置
theme: 'light' as 'light' | 'dark' | 'system',
primaryColor: '#00B96B',
transparentWindow: true,
// 导航栏设置
navPosition: 'left' as 'left' | 'top',
// 缩放设置
zoomLevel: 100,
// 字体设置
globalFont: 'default',
codeFont: 'default',
// 话题设置
topicPosition: 'left' as 'left' | 'right',
autoSwitchTopic: true,
showTopicTime: false,
pinnedTopicsTop: false,
// 助手设置
modelIconType: 'modelIcon' as 'modelIcon' | 'emoji' | 'none',
// 自定义 CSS
customCSS: ''
})
// 主题颜色选项
const themeColors = [
'#18a058', // 翠绿
'#d03050', // 红色
'#2080f0', // 蓝色
'#7c3aed', // 紫色
'#d946ef', // 品红
'#0ea5e9', // 天蓝
'#f59e0b', // 橙色
'#8b5cf6', // 紫罗兰
'#06b6d4', // 青色
]
// 全局字体选项
const globalFontOptions = [
{ label: '默认', value: 'default' },
{ label: 'Arial', value: 'Arial' },
{ label: 'Helvetica', value: 'Helvetica' },
{ label: 'Microsoft YaHei', value: 'Microsoft YaHei' },
{ label: 'PingFang SC', value: 'PingFang SC' },
{ label: 'Source Han Sans', value: 'Source Han Sans' }
]
// 代码字体选项
const codeFontOptions = [
{ label: '默认', value: 'default' },
{ label: 'Monaco', value: 'Monaco' },
{ label: 'Menlo', value: 'Menlo' },
{ label: 'Consolas', value: 'Consolas' },
{ label: 'Source Code Pro', value: 'Source Code Pro' },
{ label: 'JetBrains Mono', value: 'JetBrains Mono' },
{ label: 'Fira Code', value: 'Fira Code' }
]
// 缩放控制方法
const decreaseZoom = () => {
if (displaySettings.zoomLevel > 50) {
displaySettings.zoomLevel -= 10
applySettings()
}
}
const increaseZoom = () => {
if (displaySettings.zoomLevel < 200) {
displaySettings.zoomLevel += 10
applySettings()
}
}
const resetZoom = () => {
displaySettings.zoomLevel = 100
applySettings()
}
// 保存设置
const saveSettings = () => {
try {
localStorage.setItem('cherry-display-settings', JSON.stringify(displaySettings))
message.success('显示设置已保存')
} catch (error) {
message.error('保存设置失败')
}
}
// 加载设置
const loadSettings = () => {
try {
const saved = localStorage.getItem('cherry-display-settings')
if (saved) {
const settings = JSON.parse(saved)
Object.assign(displaySettings, settings)
}
} catch (error) {
console.error('加载显示设置失败:', error)
}
}
// 应用设置
const applySettings = () => {
const root = document.documentElement
// 应用主题
let actualTheme = displaySettings.theme
if (displaySettings.theme === 'system') {
actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
root.setAttribute('data-theme', actualTheme)
// 应用主色调 - 修复颜色应用逻辑
root.style.setProperty('--primary-color', displaySettings.primaryColor)
root.style.setProperty('--n-color-primary', displaySettings.primaryColor)
root.style.setProperty('--n-color-primary-hover', displaySettings.primaryColor + '20')
root.style.setProperty('--n-color-primary-pressed', displaySettings.primaryColor + '40')
// 应用缩放
if (typeof document !== 'undefined') {
document.body.style.zoom = `${displaySettings.zoomLevel}%`
}
// 应用字体
if (displaySettings.globalFont !== 'default') {
root.style.setProperty('--font-family', displaySettings.globalFont)
} else {
root.style.removeProperty('--font-family')
}
if (displaySettings.codeFont !== 'default') {
root.style.setProperty('--code-font-family', displaySettings.codeFont)
} else {
root.style.removeProperty('--code-font-family')
}
// 应用自定义CSS
let customStyleElement = document.getElementById('custom-styles')
if (!customStyleElement) {
customStyleElement = document.createElement('style')
customStyleElement.id = 'custom-styles'
document.head.appendChild(customStyleElement)
}
customStyleElement.textContent = displaySettings.customCSS
// 应用其他样式类
root.classList.toggle('transparent-window', displaySettings.transparentWindow)
root.classList.toggle('nav-top', displaySettings.navPosition === 'top')
root.classList.toggle('topic-right', displaySettings.topicPosition === 'right')
// 保存设置
saveSettings()
}
// 监听设置变化并自动应用
watch(displaySettings, () => {
applySettings()
}, { deep: true })
// 生命周期
onMounted(() => {
loadSettings()
applySettings()
})
</script>
<style scoped>
.display-settings-page {
padding: 32px;
background: #f8fafc;
height: 100%;
overflow-y: auto;
}
.page-header {
margin-bottom: 32px;
}
.page-header .header-info h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1e293b;
}
.page-header .header-info p {
margin: 0;
color: #64748b;
font-size: 16px;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 900px;
}
.setting-section {
display: flex;
flex-direction: column;
gap: 0;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #e2e8f0;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-label {
flex: 1;
display: flex;
flex-direction: column;
}
.label-text {
font-weight: 500;
margin-bottom: 4px;
color: #374151;
}
.label-desc {
font-size: 14px;
color: #6b7280;
}
.setting-control {
flex-shrink: 0;
}
.theme-option {
display: flex;
align-items: center;
gap: 6px;
}
.color-picker-grid {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.color-option {
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid transparent;
transition: all 0.2s ease;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.active {
border-color: #ffffff;
box-shadow: 0 0 0 2px var(--primary-color);
}
.zoom-controls {
display: flex;
align-items: center;
gap: 12px;
}
.zoom-value {
min-width: 50px;
text-align: center;
font-weight: 500;
color: #374151;
}
.css-editor {
width: 100%;
}
.css-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
.css-header span {
color: #6b7280;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.css-help {
color: #3b82f6;
text-decoration: none;
}
.css-help:hover {
text-decoration: underline;
}
/* 深色模式 */
[data-theme="dark"] .display-settings-page {
background: #0f172a;
}
[data-theme="dark"] .page-header .header-info h1 {
color: #f8fafc;
}
[data-theme="dark"] .page-header .header-info p {
color: #94a3b8;
}
[data-theme="dark"] .setting-item {
border-bottom-color: #334155;
}
[data-theme="dark"] .label-text {
color: #f1f5f9;
}
[data-theme="dark"] .label-desc {
color: #94a3b8;
}
[data-theme="dark"] .zoom-value {
color: #f1f5f9;
}
[data-theme="dark"] .css-header span {
color: #94a3b8;
}
</style>

View File

@@ -0,0 +1,848 @@
<template>
<div class="mcp-server-detail">
<!-- 顶部导航 -->
<div class="detail-header">
<n-button text @click="$emit('back')" class="back-button">
<template #icon>
<n-icon :component="ArrowLeftIcon" />
</template>
</n-button>
<div class="server-title">
<h2>{{ server?.name || '未知服务器' }}</h2>
<div class="server-meta">
<n-tag :type="getStatusType(server?.status)" size="small">
{{ getStatusText(server?.status) }}
</n-tag>
<n-tag v-if="server?.version" size="small" type="info">
{{ server.version }}
</n-tag>
</div>
</div>
<div class="header-actions">
<n-switch
v-model:value="serverEnabled"
@update:value="handleToggleServer"
:loading="toggling"
/>
<n-button @click="handleSave" type="primary" :loading="saving">
保存
</n-button>
</div>
</div>
<!-- 标签页内容 -->
<div class="detail-content">
<n-tabs v-model:value="activeTab" type="segment">
<!-- 通用配置 -->
<n-tab-pane name="general" tab="通用">
<div class="tab-content">
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="left"
label-width="120px"
>
<n-form-item label="名称" path="name" required>
<n-input v-model:value="formData.name" placeholder="输入服务器名称" />
</n-form-item>
<n-form-item label="描述" path="description">
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="输入服务器描述"
:rows="3"
/>
</n-form-item>
<n-form-item label="类型" path="type" required>
<n-select
v-model:value="formData.type"
:options="typeOptions"
placeholder="选择连接类型"
/>
</n-form-item>
<n-form-item label="URL" path="url" required>
<n-input v-model:value="formData.url" placeholder="http://127.0.0.1:3100/mcp" />
</n-form-item>
<n-form-item label="请求头">
<n-dynamic-input
v-model:value="formData.headers"
:on-create="createHeader"
#="{ value }"
>
<div class="header-input">
<n-input
v-model:value="value.key"
placeholder="键"
style="margin-right: 8px;"
/>
<n-input
v-model:value="value.value"
placeholder="值"
/>
</div>
</n-dynamic-input>
</n-form-item>
</n-form>
</div>
</n-tab-pane>
<!-- 工具管理 -->
<n-tab-pane name="tools" tab="工具" :disabled="!server?.capabilities?.tools?.length">
<div class="tab-content">
<div class="tools-header">
<h3>可用工具 ({{ server?.capabilities?.tools?.length || 0 }})</h3>
<div class="tools-actions">
<n-button-group>
<n-button @click="toggleAllTools(true)" size="small">
<n-icon :component="CheckIcon" />
启用工具
</n-button>
<n-button @click="toggleAutoApproveAll(true)" size="small">
<n-icon :component="LightningIcon" />
自动批准
</n-button>
</n-button-group>
</div>
</div>
<div class="tools-list">
<div
v-for="tool in server?.capabilities?.tools"
:key="tool.name"
class="tool-item"
>
<div class="tool-main">
<div class="tool-info">
<div class="tool-header-row">
<h4>{{ tool.name }}</h4>
<div class="tool-switches">
<n-space>
<div class="switch-item">
<span class="switch-label">启用工具</span>
<n-switch
:value="getToolEnabled(tool.name)"
@update:value="(val) => handleToggleTool(tool.name, val)"
/>
</div>
<div class="switch-item">
<span class="switch-label">自动批准</span>
<n-switch
:value="getToolAutoApprove(tool.name)"
@update:value="(val) => handleToggleAutoApprove(tool.name, val)"
/>
</div>
</n-space>
</div>
</div>
<p v-if="tool.description" class="tool-description">
{{ tool.description }}
</p>
</div>
</div>
<!-- 工具参数展开 -->
<div v-if="tool.inputSchema?.properties" class="tool-params">
<n-collapse>
<n-collapse-item title="参数详情" :name="tool.name">
<div class="params-list">
<div
v-for="(param, paramName) in tool.inputSchema.properties"
:key="paramName"
class="param-item"
>
<div class="param-info">
<span class="param-name">{{ paramName }}</span>
<n-tag size="small" :type="param.type === 'string' ? 'default' : 'info'">
{{ param.type }}
</n-tag>
<n-tag v-if="tool.inputSchema.required?.includes(paramName)" size="small" type="error">
必填
</n-tag>
</div>
<p v-if="param.description" class="param-description">
{{ param.description }}
</p>
</div>
</div>
</n-collapse-item>
</n-collapse>
</div>
</div>
</div>
</div>
</n-tab-pane>
<!-- 提示管理 -->
<n-tab-pane name="prompts" tab="提示" :disabled="!server?.capabilities?.prompts?.length">
<div class="tab-content">
<div class="prompts-header">
<h3>可用提示 ({{ server?.capabilities?.prompts?.length || 0 }})</h3>
</div>
<div class="prompts-list">
<div
v-for="prompt in server?.capabilities?.prompts"
:key="prompt.name"
class="prompt-item"
>
<div class="prompt-info">
<h4>{{ prompt.name }}</h4>
<p v-if="prompt.description" class="prompt-description">
{{ prompt.description }}
</p>
<div v-if="prompt.arguments?.length" class="prompt-args">
<span class="args-label">参数:</span>
<n-space>
<n-tag
v-for="arg in prompt.arguments"
:key="arg.name"
size="small"
>
{{ arg.name }}
</n-tag>
</n-space>
</div>
</div>
<div class="prompt-actions">
<n-button size="small" @click="executePrompt(prompt)">
执行提示
</n-button>
</div>
</div>
</div>
</div>
</n-tab-pane>
<!-- 资源管理 -->
<n-tab-pane name="resources" tab="资源" :disabled="!server?.capabilities?.resources?.length">
<div class="tab-content">
<div class="resources-header">
<h3>可用资源 ({{ server?.capabilities?.resources?.length || 0 }})</h3>
</div>
<div class="resources-list">
<div
v-for="resource in server?.capabilities?.resources"
:key="resource.uri"
class="resource-item"
>
<div class="resource-info">
<h4>{{ resource.name || extractResourceName(resource.uri) }}</h4>
<p class="resource-uri">{{ resource.uri }}</p>
<p v-if="resource.description" class="resource-description">
{{ resource.description }}
</p>
<div v-if="resource.mimeType" class="resource-meta">
<n-tag size="small" type="info">{{ resource.mimeType }}</n-tag>
</div>
</div>
<div class="resource-actions">
<n-button size="small" @click="readResource(resource)">
读取资源
</n-button>
</div>
</div>
</div>
</div>
</n-tab-pane>
</n-tabs>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import {
NButton,
NIcon,
NInput,
NSelect,
NSwitch,
NTabs,
NTabPane,
NForm,
NFormItem,
NDynamicInput,
NButtonGroup,
NSpace,
NTag,
NCollapse,
NCollapseItem,
useMessage,
type FormInst
} from 'naive-ui'
import {
ArrowLeft as ArrowLeftIcon,
Check as CheckIcon,
Bolt as LightningIcon
} from '@vicons/tabler'
import type { MCPServerConfig } from '../types'
interface Props {
server?: MCPServerConfig | null
}
interface Emits {
(event: 'back'): void
(event: 'save', server: MCPServerConfig): void
(event: 'toggle-server', serverId: string, enabled: boolean): void
(event: 'toggle-tool', serverId: string, toolName: string, enabled: boolean): void
(event: 'toggle-auto-approve', serverId: string, toolName: string, autoApprove: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const message = useMessage()
// 调试日志
try {
console.log('🎯 MCPServerDetail 组件加载')
console.log('📦 接收到的 server prop:', props.server)
// 验证 props
if (!props.server) {
console.warn('⚠️ server prop 为空')
} else if (typeof props.server !== 'object') {
console.error('❌ server prop 类型错误:', typeof props.server)
} else {
console.log('✅ server prop 有效,包含字段:', Object.keys(props.server))
}
} catch (error) {
console.error('❌ 组件初始化错误:', error)
}
// 表单引用
const formRef = ref<FormInst>()
// 状态
const activeTab = ref('general')
const serverEnabled = ref(true)
const toggling = ref(false)
const saving = ref(false)
// 表单数据
const formData = reactive({
name: '',
description: '',
type: 'http' as 'http' | 'sse' | 'websocket',
url: '',
headers: [] as Array<{ key: string; value: string }>
})
// 工具状态
const toolSettings = ref<Record<string, { enabled: boolean; autoApprove: boolean }>>({})
// 计算属性
const typeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'Server-Sent Events', value: 'sse' },
{ label: 'WebSocket', value: 'websocket' }
]
// 表单验证规则
const formRules = {
name: {
required: true,
message: '请输入服务器名称',
trigger: 'blur'
},
type: {
required: true,
message: '请选择连接类型',
trigger: 'change'
},
url: {
required: true,
message: '请输入服务器URL',
trigger: 'blur'
}
}
// 监听服务器数据变化
watch(() => props.server, (newServer) => {
console.log('👀 MCPServerDetail watch 触发, newServer:', newServer)
if (newServer && typeof newServer === 'object') {
try {
updateFormData(newServer)
initializeToolSettings(newServer)
console.log('✅ 表单数据已更新:', formData)
} catch (error) {
console.error('❌ 更新表单数据失败:', error)
}
} else {
console.warn('⚠️ newServer 为空或类型错误:', newServer)
}
}, { immediate: true, deep: true })
// 方法
const updateFormData = (server: MCPServerConfig) => {
console.log('📝 更新表单数据, server:', server)
console.log('📝 server.name:', server.name)
console.log('📝 server.url:', server.url)
console.log('📝 server.type:', server.type)
try {
// 逐个赋值以确保 reactive 响应性
formData.name = server.name || ''
formData.description = server.description || ''
formData.type = server.type || 'http'
formData.url = server.url || ''
formData.headers = Array.isArray(server.headers) ? [...server.headers] : []
serverEnabled.value = server.enabled !== false
console.log('✅ formData 更新完成:')
console.log(' - name:', formData.name)
console.log(' - url:', formData.url)
console.log(' - type:', formData.type)
console.log(' - description:', formData.description)
} catch (error) {
console.error('❌ updateFormData 出错:', error)
throw error
}
}
const initializeToolSettings = (server: MCPServerConfig) => {
const settings: Record<string, { enabled: boolean; autoApprove: boolean }> = {}
server.capabilities?.tools?.forEach(tool => {
settings[tool.name] = {
enabled: tool.enabled ?? true,
autoApprove: tool.autoApprove ?? false
}
})
toolSettings.value = settings
}
const getStatusType = (status?: string): 'success' | 'error' | 'info' | 'default' => {
const types: Record<string, 'success' | 'error' | 'info' | 'default'> = {
connected: 'success',
disconnected: 'default',
connecting: 'info',
error: 'error'
}
return types[status || 'disconnected'] || 'default'
}
const getStatusText = (status?: string) => {
const texts = {
connected: '已连接',
disconnected: '未连接',
connecting: '连接中',
error: '连接失败'
}
return texts[status as keyof typeof texts] || '未知'
}
const createHeader = () => ({ key: '', value: '' })
const handleToggleServer = async (enabled: boolean) => {
if (!props.server) return
toggling.value = true
try {
emit('toggle-server', props.server.id, enabled)
message.success(enabled ? '服务器已启用' : '服务器已禁用')
} catch (error) {
serverEnabled.value = !enabled // 回退
message.error('操作失败')
} finally {
toggling.value = false
}
}
const handleSave = async () => {
if (!props.server) return
try {
await formRef.value?.validate()
} catch (error) {
message.error('请检查表单填写')
return
}
saving.value = true
try {
const updatedServer: MCPServerConfig = {
...props.server,
name: formData.name.trim(),
description: formData.description.trim(),
type: formData.type,
url: formData.url.trim(),
enabled: serverEnabled.value,
headers: formData.headers.filter(h => h.key && h.value)
}
// 更新工具设置
if (updatedServer.capabilities?.tools) {
updatedServer.capabilities.tools = updatedServer.capabilities.tools.map(tool => ({
...tool,
enabled: toolSettings.value[tool.name]?.enabled ?? true,
autoApprove: toolSettings.value[tool.name]?.autoApprove ?? false
}))
}
emit('save', updatedServer)
message.success('保存成功')
} catch (error) {
message.error('保存失败')
} finally {
saving.value = false
}
}
const getToolEnabled = (toolName: string) => {
return toolSettings.value[toolName]?.enabled ?? true
}
const getToolAutoApprove = (toolName: string) => {
return toolSettings.value[toolName]?.autoApprove ?? false
}
const handleToggleTool = (toolName: string, enabled: boolean) => {
if (!toolSettings.value[toolName]) {
toolSettings.value[toolName] = { enabled: true, autoApprove: false }
}
toolSettings.value[toolName].enabled = enabled
if (props.server) {
emit('toggle-tool', props.server.id, toolName, enabled)
}
}
const handleToggleAutoApprove = (toolName: string, autoApprove: boolean) => {
if (!toolSettings.value[toolName]) {
toolSettings.value[toolName] = { enabled: true, autoApprove: false }
}
toolSettings.value[toolName].autoApprove = autoApprove
if (props.server) {
emit('toggle-auto-approve', props.server.id, toolName, autoApprove)
}
}
const toggleAllTools = (enabled: boolean) => {
if (!props.server?.capabilities?.tools) return
props.server.capabilities.tools.forEach(tool => {
handleToggleTool(tool.name, enabled)
})
message.success(enabled ? '已启用所有工具' : '已禁用所有工具')
}
const toggleAutoApproveAll = (autoApprove: boolean) => {
if (!props.server?.capabilities?.tools) return
props.server.capabilities.tools.forEach(tool => {
handleToggleAutoApprove(tool.name, autoApprove)
})
message.success(autoApprove ? '已启用所有工具自动批准' : '已禁用所有工具自动批准')
}
const executePrompt = (prompt: any) => {
message.info(`执行提示: ${prompt.name}`)
// TODO: 实现提示执行逻辑
}
const readResource = (resource: any) => {
message.info(`读取资源: ${resource.uri}`)
// TODO: 实现资源读取逻辑
}
const extractResourceName = (uri: string) => {
const parts = uri.split('/')
return parts[parts.length - 1] || uri
}
</script>
<style scoped>
.mcp-server-detail {
min-height: 500px;
display: flex;
flex-direction: column;
}
.detail-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--card-color);
}
.back-button {
flex-shrink: 0;
}
.server-title {
flex: 1;
}
.server-title h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.server-meta {
display: flex;
gap: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.detail-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.tab-content {
padding: 16px 0;
}
.header-input {
display: flex;
width: 100%;
}
/* 工具相关样式 */
.tools-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.tools-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.tools-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.tool-item {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
background: var(--card-color);
}
.tool-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.tool-header-row h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
.tool-switches {
flex-shrink: 0;
}
.switch-item {
display: flex;
align-items: center;
gap: 8px;
}
.switch-label {
font-size: 14px;
color: var(--text-color-2);
}
.tool-description {
margin: 0 0 12px 0;
color: var(--text-color-2);
line-height: 1.5;
}
.tool-params {
margin-top: 16px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.params-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.param-item {
padding: 12px;
background: var(--hover-color);
border-radius: 8px;
}
.param-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.param-name {
font-weight: 500;
color: var(--text-color-1);
}
.param-description {
margin: 0;
font-size: 14px;
color: var(--text-color-2);
}
/* 提示相关样式 */
.prompts-header {
margin-bottom: 20px;
}
.prompts-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.prompts-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.prompt-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-color);
}
.prompt-info {
flex: 1;
}
.prompt-info h4 {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
}
.prompt-description {
margin: 0 0 8px 0;
color: var(--text-color-2);
line-height: 1.5;
}
.prompt-args {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.args-label {
font-size: 14px;
color: var(--text-color-2);
}
.prompt-actions {
flex-shrink: 0;
margin-left: 16px;
}
/* 资源相关样式 */
.resources-header {
margin-bottom: 20px;
}
.resources-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.resources-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.resource-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-color);
}
.resource-info {
flex: 1;
}
.resource-info h4 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
}
.resource-uri {
margin: 0 0 8px 0;
font-family: monospace;
font-size: 14px;
color: var(--text-color-3);
word-break: break-all;
}
.resource-description {
margin: 0 0 8px 0;
color: var(--text-color-2);
line-height: 1.5;
}
.resource-meta {
margin-top: 8px;
}
.resource-actions {
flex-shrink: 0;
margin-left: 16px;
}
:deep(.n-form-item-label) {
font-weight: 500;
}
:deep(.n-tabs .n-tab-pane) {
padding: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,679 @@
<template>
<div class="model-providers-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-info">
<h1>模型服务</h1>
<p>配置AI模型提供商支持多种主流服务</p>
</div>
<div class="header-actions">
<n-button type="primary" @click="showAddModal = true">
<template #icon>
<n-icon :component="PlusIcon" />
</template>
添加服务
</n-button>
</div>
</div>
<!-- 服务商列表 -->
<div class="providers-grid">
<div
v-for="provider in providers"
:key="provider.id"
class="provider-card"
:class="{ 'active': provider.enabled, 'disabled': !provider.enabled }"
>
<div class="provider-header">
<div class="provider-info">
<div class="provider-icon">
<n-icon :component="getProviderIcon(provider.type)" size="24" />
</div>
<div class="provider-details">
<h3>{{ provider.name }}</h3>
<p>{{ getProviderDescription(provider.type) }}</p>
</div>
</div>
<div class="provider-status">
<n-switch
v-model:value="provider.enabled"
@update:value="handleToggleProvider(provider.id)"
/>
</div>
</div>
<div class="provider-content" v-if="provider.enabled">
<div class="provider-models">
<div class="models-header">
<span class="models-label">可用模型</span>
<n-tag :type="provider.connected ? 'success' : 'error'" size="small">
{{ provider.connected ? '已连接' : '未连接' }}
</n-tag>
</div>
<div class="models-list" v-if="provider.models.length > 0">
<div
v-for="model in provider.models"
:key="model.id"
class="model-item"
:class="{ 'selected': model.id === provider.selectedModel }"
@click="selectModel(provider.id, model.id)"
>
<div class="model-info">
<span class="model-name">{{ model.name }}</span>
<span class="model-type">{{ model.type }}</span>
</div>
<div class="model-actions">
<n-icon v-if="model.id === provider.selectedModel" :component="CheckIcon" size="16" />
</div>
</div>
</div>
<div v-else class="models-empty">
<span>暂无可用模型</span>
<n-button text type="primary" @click="testConnection(provider.id)">
测试连接
</n-button>
</div>
</div>
<div class="provider-actions">
<n-button @click="editProvider(provider)" size="small">
<template #icon>
<n-icon :component="EditIcon" />
</template>
配置
</n-button>
<n-button @click="testConnection(provider.id)" size="small">
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
测试
</n-button>
<n-button @click="removeProvider(provider.id)" type="error" size="small">
<template #icon>
<n-icon :component="TrashIcon" />
</template>
删除
</n-button>
</div>
</div>
</div>
<!-- 添加新服务卡片 -->
<div class="provider-card add-card" @click="showAddModal = true">
<div class="add-content">
<n-icon :component="PlusIcon" size="32" />
<span>添加新的模型服务</span>
</div>
</div>
</div>
<!-- 添加/编辑模态框 -->
<n-modal v-model:show="showAddModal">
<n-card
style="width: 600px"
:title="editingProvider ? '编辑模型服务' : '添加模型服务'"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<ProviderForm
:provider="editingProvider"
@save="handleSaveProvider"
@cancel="handleCancelEdit"
/>
</n-card>
</n-modal>
<!-- 全局配置 -->
<div class="global-settings">
<n-card title="全局配置">
<div class="settings-grid">
<div class="setting-item">
<label>默认温度</label>
<n-slider
v-model:value="globalSettings.temperature"
:min="0"
:max="2"
:step="0.1"
:format-tooltip="(value: number) => value.toFixed(1)"
/>
</div>
<div class="setting-item">
<label>最大Token数</label>
<n-input-number
v-model:value="globalSettings.maxTokens"
:min="1"
:max="32000"
style="width: 100%"
/>
</div>
<div class="setting-item">
<label>请求超时 (秒)</label>
<n-input-number
v-model:value="globalSettings.timeout"
:min="5"
:max="120"
style="width: 100%"
/>
</div>
<div class="setting-item">
<label>启用流式响应</label>
<n-switch v-model:value="globalSettings.streaming" />
</div>
</div>
<template #action>
<div class="settings-actions">
<n-button @click="resetGlobalSettings">重置</n-button>
<n-button type="primary" @click="saveGlobalSettings">保存配置</n-button>
</div>
</template>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import {
NButton,
NIcon,
NSwitch,
NTag,
NModal,
NCard,
NSlider,
NInputNumber,
useMessage
} from 'naive-ui'
import {
Plus as PlusIcon,
Edit as EditIcon,
Refresh as RefreshIcon,
Trash as TrashIcon,
Check as CheckIcon,
Robot as OpenAIIcon,
BrandGoogle as GoogleIcon,
Cloud as ClaudeIcon,
Server as OllamaIcon,
Settings as CustomIcon
} from '@vicons/tabler'
import ProviderForm from '@/components/ProviderForm.vue'
interface ModelProvider {
id: string
name: string
type: 'openai' | 'claude' | 'google' | 'ollama' | 'custom'
enabled: boolean
connected: boolean
apiKey?: string
baseUrl?: string
selectedModel?: string
models: Array<{
id: string
name: string
type: string
}>
config: Record<string, any>
}
const message = useMessage()
// 响应式数据
const showAddModal = ref(false)
const editingProvider = ref<ModelProvider | null>(null)
const providers = ref<ModelProvider[]>([
{
id: '1',
name: 'OpenAI',
type: 'openai',
enabled: true,
connected: false,
models: [],
config: {}
},
{
id: '2',
name: 'Claude',
type: 'claude',
enabled: false,
connected: false,
models: [],
config: {}
}
])
const globalSettings = reactive({
temperature: 0.7,
maxTokens: 2000,
timeout: 30,
streaming: true
})
// 方法
const getProviderIcon = (type: string) => {
const icons = {
openai: OpenAIIcon,
claude: ClaudeIcon,
google: GoogleIcon,
ollama: OllamaIcon,
custom: CustomIcon
}
return icons[type as keyof typeof icons] || CustomIcon
}
const getProviderDescription = (type: string) => {
const descriptions = {
openai: 'GPT-4, GPT-3.5等OpenAI模型',
claude: 'Claude系列模型Anthropic出品',
google: 'Gemini Pro, PaLM等Google模型',
ollama: '本地部署的开源模型',
custom: '自定义API端点'
}
return descriptions[type as keyof typeof descriptions] || '自定义模型服务'
}
const handleToggleProvider = async (providerId: string) => {
const provider = providers.value.find(p => p.id === providerId)
if (!provider) return
if (provider.enabled) {
// 启用时尝试连接
await testConnection(providerId)
} else {
// 禁用时清空连接状态
provider.connected = false
provider.models = []
}
}
const selectModel = (providerId: string, modelId: string) => {
const provider = providers.value.find(p => p.id === providerId)
if (provider) {
provider.selectedModel = modelId
saveProviders()
}
}
const editProvider = (provider: ModelProvider) => {
editingProvider.value = { ...provider }
showAddModal.value = true
}
const handleSaveProvider = (providerData: any) => {
if (editingProvider.value) {
// 编辑现有服务
const index = providers.value.findIndex(p => p.id === editingProvider.value!.id)
if (index !== -1) {
providers.value[index] = { ...providers.value[index], ...providerData }
}
} else {
// 添加新服务
const newProvider: ModelProvider = {
id: Date.now().toString(),
...providerData,
enabled: false,
connected: false,
models: []
}
providers.value.push(newProvider)
}
saveProviders()
handleCancelEdit()
message.success(editingProvider.value ? '服务配置已更新' : '服务添加成功')
}
const handleCancelEdit = () => {
editingProvider.value = null
showAddModal.value = false
}
const testConnection = async (providerId: string) => {
const provider = providers.value.find(p => p.id === providerId)
if (!provider) return
try {
message.loading('正在测试连接...', { duration: 0 })
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 2000))
// 模拟获取模型列表
const mockModels = {
openai: [
{ id: 'gpt-4', name: 'GPT-4', type: 'chat' },
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', type: 'chat' }
],
claude: [
{ id: 'claude-3-opus', name: 'Claude 3 Opus', type: 'chat' },
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', type: 'chat' }
]
}
provider.models = mockModels[provider.type as keyof typeof mockModels] || []
provider.connected = true
if (provider.models.length > 0 && !provider.selectedModel) {
provider.selectedModel = provider.models[0].id
}
message.destroyAll()
message.success('连接测试成功')
} catch (error) {
message.destroyAll()
message.error('连接测试失败')
provider.connected = false
provider.models = []
}
}
const removeProvider = (providerId: string) => {
const index = providers.value.findIndex(p => p.id === providerId)
if (index !== -1) {
providers.value.splice(index, 1)
saveProviders()
message.success('服务已删除')
}
}
const saveProviders = () => {
localStorage.setItem('model-providers', JSON.stringify(providers.value))
}
const loadProviders = () => {
try {
const saved = localStorage.getItem('model-providers')
if (saved) {
providers.value = JSON.parse(saved)
}
} catch (error) {
console.error('加载模型服务配置失败:', error)
}
}
const saveGlobalSettings = () => {
localStorage.setItem('global-model-settings', JSON.stringify(globalSettings))
message.success('全局配置已保存')
}
const resetGlobalSettings = () => {
Object.assign(globalSettings, {
temperature: 0.7,
maxTokens: 2000,
timeout: 30,
streaming: true
})
message.success('已重置为默认配置')
}
// 生命周期
onMounted(() => {
loadProviders()
// 加载全局设置
try {
const saved = localStorage.getItem('global-model-settings')
if (saved) {
Object.assign(globalSettings, JSON.parse(saved))
}
} catch (error) {
console.error('加载全局设置失败:', error)
}
})
</script>
<style scoped>
.model-providers-page {
padding: 32px;
background: #f8fafc;
height: 100%;
overflow-y: auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #e2e8f0;
}
.header-info h1 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: #1e293b;
}
.header-info p {
margin: 0;
color: #64748b;
font-size: 16px;
}
.providers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.provider-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.provider-card:hover {
border-color: #3b82f6;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
}
.provider-card.active {
border-color: #10b981;
background: #f0fdf4;
}
.provider-card.disabled {
opacity: 0.6;
}
.add-card {
border: 2px dashed #cbd5e1;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
transition: all 0.3s ease;
}
.add-card:hover {
border-color: #3b82f6;
background: #eff6ff;
transform: translateY(-2px);
}
.add-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #64748b;
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.provider-info {
display: flex;
align-items: center;
gap: 12px;
}
.provider-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: #eff6ff;
display: flex;
align-items: center;
justify-content: center;
color: #3b82f6;
}
.provider-details h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.provider-details p {
margin: 0;
font-size: 14px;
color: #64748b;
}
.provider-content {
border-top: 1px solid #e2e8f0;
padding-top: 16px;
}
.models-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.models-label {
font-weight: 500;
color: #64748b;
}
.models-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
max-height: 200px;
overflow-y: auto;
padding-right: 4px;
}
.models-list::-webkit-scrollbar {
width: 6px;
}
.models-list::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.models-list::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.models-list::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.model-item:hover {
border-color: #3b82f6;
background: #f8fafc;
}
.model-item.selected {
border-color: #3b82f6;
background: #eff6ff;
}
.model-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.model-name {
font-weight: 500;
}
.model-type {
font-size: 12px;
color: #94a3b8;
}
.models-empty {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--info-color-suppl);
border-radius: 6px;
margin-bottom: 16px;
}
.provider-actions {
display: flex;
gap: 8px;
}
.global-settings {
margin-top: 32px;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
margin-bottom: 20px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-item label {
font-weight: 500;
color: var(--text-color-2);
}
.settings-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,322 @@
<template>
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="left"
label-width="100px"
>
<n-form-item label="服务类型" path="type">
<n-select
v-model:value="formData.type"
:options="providerTypeOptions"
placeholder="选择服务类型"
@update:value="handleTypeChange"
/>
</n-form-item>
<n-form-item label="服务名称" path="name">
<n-input
v-model:value="formData.name"
placeholder="输入自定义名称"
clearable
/>
</n-form-item>
<n-form-item label="API密钥" path="apiKey" v-if="needsApiKey">
<n-input
v-model:value="formData.apiKey"
type="password"
show-password-on="click"
placeholder="输入API密钥"
clearable
/>
</n-form-item>
<n-form-item label="API地址" path="baseUrl" v-if="needsBaseUrl">
<n-input
v-model:value="formData.baseUrl"
placeholder="输入API基础地址"
clearable
/>
</n-form-item>
<n-form-item label="组织ID" path="organization" v-if="formData.type === 'openai'">
<n-input
v-model:value="formData.organization"
placeholder="输入组织ID可选"
clearable
/>
</n-form-item>
<!-- 高级配置 -->
<n-collapse>
<n-collapse-item title="高级配置" name="advanced">
<div class="advanced-settings">
<n-form-item label="请求超时">
<n-input-number
v-model:value="formData.timeout"
:min="1"
:max="120"
placeholder="秒"
style="width: 100%"
>
<template #suffix></template>
</n-input-number>
</n-form-item>
<n-form-item label="最大重试">
<n-input-number
v-model:value="formData.maxRetries"
:min="0"
:max="5"
style="width: 100%"
/>
</n-form-item>
<n-form-item label="代理设置">
<n-input
v-model:value="formData.proxy"
placeholder="http://proxy:port可选"
clearable
/>
</n-form-item>
<n-form-item label="自定义头部">
<n-dynamic-input
v-model:value="formData.headers"
:on-create="() => ({ key: '', value: '' })"
>
<template #default="{ value }">
<div style="display: flex; align-items: center; width: 100%; gap: 8px;">
<n-input
v-model:value="value.key"
placeholder="Header名称"
style="flex: 1;"
/>
<n-input
v-model:value="value.value"
placeholder="Header值"
style="flex: 1;"
/>
</div>
</template>
</n-dynamic-input>
</n-form-item>
</div>
</n-collapse-item>
</n-collapse>
<!-- 操作按钮 -->
<div class="form-actions">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleSubmit" :loading="testing">
{{ provider ? '保存' : '添加' }}
</n-button>
<n-button @click="handleTest" :loading="testing">测试连接</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
NForm,
NFormItem,
NInput,
NInputNumber,
NSelect,
NButton,
NCollapse,
NCollapseItem,
NDynamicInput,
useMessage
} from 'naive-ui'
interface Props {
provider?: any
}
interface Emits {
(e: 'save', data: any): void
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const message = useMessage()
const formRef = ref()
const testing = ref(false)
// 表单数据
const formData = ref({
type: 'openai',
name: '',
apiKey: '',
baseUrl: '',
organization: '',
timeout: 30,
maxRetries: 3,
proxy: '',
headers: [] as Array<{ key: string; value: string }>
})
// 服务类型选项
const providerTypeOptions = [
{ label: 'OpenAI', value: 'openai' },
{ label: 'Claude (Anthropic)', value: 'claude' },
{ label: 'Google Gemini', value: 'google' },
{ label: 'Ollama', value: 'ollama' },
{ label: '自定义API', value: 'custom' }
]
// 计算属性
const needsApiKey = computed(() => {
return ['openai', 'claude', 'google', 'custom'].includes(formData.value.type)
})
const needsBaseUrl = computed(() => {
return ['ollama', 'custom'].includes(formData.value.type)
})
// 表单验证规则
const formRules = {
type: {
required: true,
message: '请选择服务类型',
trigger: 'change'
},
name: {
required: true,
message: '请输入服务名称',
trigger: ['blur', 'input']
},
apiKey: {
required: needsApiKey.value,
message: '请输入API密钥',
trigger: ['blur', 'input']
},
baseUrl: {
required: needsBaseUrl.value,
message: '请输入API地址',
trigger: ['blur', 'input']
}
}
// 监听 props 变化
watch(() => props.provider, (newProvider) => {
if (newProvider) {
Object.assign(formData.value, {
type: newProvider.type || 'openai',
name: newProvider.name || '',
apiKey: newProvider.apiKey || '',
baseUrl: newProvider.baseUrl || '',
organization: newProvider.config?.organization || '',
timeout: newProvider.config?.timeout || 30,
maxRetries: newProvider.config?.maxRetries || 3,
proxy: newProvider.config?.proxy || '',
headers: newProvider.config?.headers || []
})
}
}, { immediate: true })
// 处理类型变化
const handleTypeChange = (type: string) => {
// 设置默认名称
const defaultNames = {
openai: 'OpenAI',
claude: 'Claude',
google: 'Google Gemini',
ollama: 'Ollama',
custom: '自定义服务'
}
if (!formData.value.name || Object.values(defaultNames).includes(formData.value.name)) {
formData.value.name = defaultNames[type as keyof typeof defaultNames]
}
// 设置默认baseUrl
const defaultUrls = {
openai: 'https://api.openai.com/v1',
claude: 'https://api.anthropic.com',
google: 'https://generativelanguage.googleapis.com/v1beta',
ollama: 'http://localhost:11434',
custom: ''
}
if (needsBaseUrl.value) {
formData.value.baseUrl = defaultUrls[type as keyof typeof defaultUrls]
}
}
// 测试连接
const handleTest = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
testing.value = true
// 模拟测试连接
await new Promise(resolve => setTimeout(resolve, 2000))
message.success('连接测试成功!')
} catch (error) {
if (error instanceof Error) {
message.error(`连接测试失败: ${error.message}`)
} else {
message.error('请检查表单填写')
}
} finally {
testing.value = false
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const config = {
organization: formData.value.organization,
timeout: formData.value.timeout,
maxRetries: formData.value.maxRetries,
proxy: formData.value.proxy,
headers: formData.value.headers.filter(h => h.key && h.value)
}
emit('save', {
type: formData.value.type,
name: formData.value.name,
apiKey: formData.value.apiKey,
baseUrl: formData.value.baseUrl,
config
})
} catch (error) {
message.error('请检查表单填写')
}
}
// 取消
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.advanced-settings {
padding: 16px 0;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
</style>

View File

@@ -0,0 +1,239 @@
<template>
<div class="server-card" :class="{ 'connected': server.status === 'connected' }">
<!-- 服务器状态指示器 -->
<div class="status-bar" :class="server.status">
<div class="status-dot"></div>
<span class="status-text">{{ getStatusText(server.status) }}</span>
</div>
<!-- 服务器信息 -->
<div class="server-info">
<div class="server-header">
<h3 class="server-name">{{ server.name }}</h3>
<n-tag
:type="getTagType(server.type)"
size="small"
round
>
{{ server.type.toUpperCase() }}
</n-tag>
</div>
<p class="server-url">{{ server.url }}</p>
<p v-if="server.description" class="server-description">
{{ server.description }}
</p>
</div>
<!-- 服务器能力 -->
<div v-if="server.capabilities" class="capabilities">
<div class="capability-item">
<n-icon :component="ToolIcon" size="16" />
<span>{{ server.capabilities.tools?.length || 0 }} 工具</span>
</div>
<div class="capability-item">
<n-icon :component="ResourceIcon" size="16" />
<span>{{ server.capabilities.resources?.length || 0 }} 资源</span>
</div>
<div class="capability-item">
<n-icon :component="PromptIcon" size="16" />
<span>{{ server.capabilities.prompts?.length || 0 }} 提示</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="actions">
<n-button-group size="small">
<n-button
v-if="server.status !== 'connected'"
type="primary"
:loading="server.status === 'connecting'"
@click="$emit('connect', server.id)"
>
连接
</n-button>
<n-button
v-else
type="default"
@click="$emit('disconnect', server.id)"
>
断开
</n-button>
<n-button
type="default"
@click="$emit('edit', server.id)"
>
编辑
</n-button>
<n-button
type="error"
@click="$emit('delete', server.id)"
>
删除
</n-button>
</n-button-group>
</div>
<!-- 错误提示 -->
<div v-if="server.status === 'error'" class="error-message">
<n-alert type="error" size="small">
连接失败请检查服务器配置
</n-alert>
</div>
</div>
</template>
<script setup lang="ts">
import type { MCPServerConfig } from '../types'
import {
NTag,
NIcon,
NButton,
NButtonGroup,
NAlert
} from 'naive-ui'
import {
Tool as ToolIcon,
Document as ResourceIcon,
ChatDotRound as PromptIcon
} from '@vicons/tabler'
interface Props {
server: MCPServerConfig
}
interface Emits {
(e: 'connect', serverId: string): void
(e: 'disconnect', serverId: string): void
(e: 'edit', serverId: string): void
(e: 'delete', serverId: string): void
}
defineProps<Props>()
defineEmits<Emits>()
const getStatusText = (status: string) => {
const statusMap = {
'connected': '已连接',
'disconnected': '未连接',
'connecting': '连接中...',
'error': '连接失败'
}
return statusMap[status as keyof typeof statusMap] || status
}
const getTagType = (type: string) => {
const typeMap = {
'http': 'info',
'websocket': 'success',
'sse': 'warning'
}
return typeMap[type as keyof typeof typeMap] || 'default'
}
</script>
<style scoped>
.server-card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
background: var(--card-color);
transition: all 0.3s ease;
position: relative;
}
.server-card:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.server-card.connected {
border-left: 4px solid var(--success-color);
}
.status-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-color-3);
}
.status-bar.connected .status-dot {
background: var(--success-color);
animation: pulse 2s infinite;
}
.status-bar.connecting .status-dot {
background: var(--warning-color);
animation: pulse 1s infinite;
}
.status-bar.error .status-dot {
background: var(--error-color);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.server-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.server-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color-1);
}
.server-url {
margin: 0 0 8px 0;
font-size: 12px;
color: var(--text-color-3);
font-family: 'Monaco', 'Menlo', monospace;
}
.server-description {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--text-color-2);
line-height: 1.4;
}
.capabilities {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.capability-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color-3);
}
.actions {
margin-top: 12px;
}
.error-message {
margin-top: 12px;
}
</style>

View File

View File

@@ -0,0 +1,172 @@
<template>
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="top"
size="medium"
>
<n-form-item label="服务器名称" path="name">
<n-input
v-model:value="formData.name"
placeholder="输入服务器名称"
clearable
/>
</n-form-item>
<n-form-item label="服务器 URL" path="url">
<n-input
v-model:value="formData.url"
placeholder="http://localhost:3000 或 ws://localhost:3001"
clearable
/>
</n-form-item>
<n-form-item label="连接类型" path="type">
<n-select
v-model:value="formData.type"
:options="[
{ label: 'HTTP', value: 'http' },
{ label: 'WebSocket', value: 'websocket' },
{ label: 'Server-Sent Events', value: 'sse' }
]"
placeholder="选择连接类型"
/>
</n-form-item>
<n-form-item label="描述" path="description">
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="输入服务器描述(可选)"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</n-form-item>
<n-form-item label="高级设置">
<n-collapse>
<n-collapse-item title="连接设置" name="connection">
<n-form-item label="自动连接">
<n-switch v-model:value="formData.settings.autoConnect" />
</n-form-item>
<n-form-item label="重试次数">
<n-input-number
v-model:value="formData.settings.retryAttempts"
:min="0"
:max="10"
placeholder="重试次数"
/>
</n-form-item>
<n-form-item label="超时时间 (秒)">
<n-input-number
v-model:value="formData.settings.timeout"
:min="5"
:max="60"
placeholder="超时时间"
/>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form-item>
<div class="form-actions">
<n-button type="primary" @click="handleSubmit">
添加服务器
</n-button>
<n-button @click="handleCancel">
取消
</n-button>
</div>
</n-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { MCPServerConfig } from '../types'
import {
NForm,
NFormItem,
NInput,
NInputNumber,
NSelect,
NSwitch,
NButton,
NCollapse,
NCollapseItem
} from 'naive-ui'
interface Emits {
(e: 'submit', config: Omit<MCPServerConfig, 'id' | 'status'>): void
(e: 'cancel'): void
}
const emit = defineEmits<Emits>()
const formRef = ref()
const formData = reactive({
name: '',
url: '',
type: 'http' as 'http' | 'websocket' | 'sse',
description: '',
enabled: true,
settings: {
autoConnect: true,
retryAttempts: 3,
timeout: 30
}
})
const formRules = {
name: {
required: true,
message: '请输入服务器名称',
trigger: ['blur', 'input']
},
url: {
required: true,
message: '请输入服务器 URL',
trigger: ['blur', 'input'],
validator: (rule: any, value: string) => {
const urlPattern = /^(https?|wss?):\/\/.+/
if (!urlPattern.test(value)) {
return new Error('请输入有效的 URL')
}
return true
}
},
type: {
required: true,
message: '请选择连接类型',
trigger: 'change'
}
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('submit', { ...formData })
} catch (error) {
console.error('表单验证失败:', error)
}
}
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped>
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="sidebar">
<!-- Logo区域 -->
<div class="sidebar-header">
<div class="logo">
<n-icon :component="BrainIcon" size="24" />
<span class="logo-text">MCP Studio</span>
</div>
</div>
<!-- 导航菜单 -->
<div class="sidebar-content">
<nav class="nav-menu">
<div class="nav-section">
<div class="section-label">核心功能</div>
<div class="nav-items">
<div
v-for="item in coreMenuItems"
:key="item.key"
class="nav-item"
:class="{ 'active': activeKey === item.key }"
@click="handleMenuClick(item.key)"
>
<div class="nav-item-content">
<n-icon :component="item.icon" size="18" />
<span class="nav-item-label">{{ item.label }}</span>
</div>
<div v-if="item.badge" class="nav-badge">{{ item.badge }}</div>
</div>
</div>
</div>
<div class="nav-section">
<div class="section-label">设置</div>
<div class="nav-items">
<div
v-for="item in settingsMenuItems"
:key="item.key"
class="nav-item"
:class="{ 'active': activeKey === item.key }"
@click="handleMenuClick(item.key)"
>
<div class="nav-item-content">
<n-icon :component="item.icon" size="18" />
<span class="nav-item-label">{{ item.label }}</span>
</div>
<div v-if="item.status" class="nav-status" :class="item.status">
<n-icon :component="item.status === 'connected' ? CheckCircleIcon : XCircleIcon" size="12" />
</div>
</div>
</div>
</div>
</nav>
</div>
<!-- 底部信息 -->
<div class="sidebar-footer">
<div class="version-info">
<span class="version">v1.0.0</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NIcon } from 'naive-ui'
import {
MessageCircle as ChatIcon,
Tool as ToolIcon,
Robot as BrainIcon,
Palette as DisplayIcon,
Plug as MCPIcon,
Check as CheckCircleIcon,
X as XCircleIcon,
Database as DataIcon
} from '@vicons/tabler'
import { useServerStore } from '@/stores/newServer'
interface Props {
activeKey?: string
}
interface Emits {
(e: 'update:activeKey', key: string): void
(e: 'menuSelect', key: string): void
}
const props = withDefaults(defineProps<Props>(), {
activeKey: 'chat'
})
const emit = defineEmits<Emits>()
const serverStore = useServerStore()
// 核心功能菜单
const coreMenuItems = computed(() => [
{
key: 'chat',
label: '智能对话',
icon: ChatIcon
},
{
key: 'tools',
label: '工具执行',
icon: ToolIcon,
badge: serverStore.availableTools.length || undefined
},
{
key: 'data',
label: '数据管理',
icon: DataIcon
}
])
// 设置菜单
const settingsMenuItems = computed(() => [
{
key: 'model-providers',
label: '模型服务',
icon: BrainIcon,
status: 'disconnected' // TODO: 从store获取真实状态
},
{
key: 'display-settings',
label: '显示设置',
icon: DisplayIcon
},
{
key: 'mcp',
label: 'MCP',
icon: MCPIcon,
status: serverStore.connectedServers.length > 0 ? 'connected' : 'disconnected'
}
])
const handleMenuClick = (key: string) => {
emit('update:activeKey', key)
emit('menuSelect', key)
}
</script>
<style scoped>
.sidebar {
width: 240px;
height: 100vh;
background: var(--card-color, #ffffff);
border-right: 1px solid var(--border-color, #e0e0e6);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #e0e0e6);
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-color, #333333);
}
.logo-text {
color: var(--primary-color, #18a058);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.nav-menu {
padding: 0 8px;
}
.nav-section {
margin-bottom: 24px;
}
.section-label {
font-size: 12px;
font-weight: 500;
color: var(--text-color-3, #8a8a8a);
margin: 0 12px 8px 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.nav-item:hover {
background: var(--hover-color, #f5f5f5);
}
.nav-item.active {
background: var(--primary-color-suppl, #e6f7ff);
color: var(--primary-color, #18a058);
}
.nav-item-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.nav-item-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
background: var(--primary-color, #18a058);
color: white;
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 10px;
min-width: 16px;
text-align: center;
line-height: 1.2;
}
.nav-status {
display: flex;
align-items: center;
justify-content: center;
}
.nav-status.connected {
color: var(--success-color, #52c41a);
}
.nav-status.disconnected {
color: var(--error-color, #ff4d4f);
}
.sidebar-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-color, #e0e0e6);
}
.version-info {
text-align: center;
}
.version {
font-size: 12px;
color: var(--text-color-3, #8a8a8a);
}
/* 暗色主题适配 */
:root[theme-mode="dark"] .sidebar {
background: var(--card-color, #1a1a1a);
border-right-color: var(--border-color, #333333);
}
:root[theme-mode="dark"] .nav-item:hover {
background: var(--hover-color, #333333);
}
:root[theme-mode="dark"] .nav-item.active {
background: var(--primary-color-suppl, #0f3460);
}
:root[theme-mode="dark"] .sidebar-header {
border-bottom-color: var(--border-color, #333333);
}
:root[theme-mode="dark"] .sidebar-footer {
border-top-color: var(--border-color, #333333);
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<div class="tool-executor">
<div class="tool-info">
<h3>{{ tool.name }}</h3>
<p v-if="tool.description">{{ tool.description }}</p>
</div>
<n-form
ref="formRef"
:model="formData"
label-placement="left"
label-width="120px"
>
<div v-if="tool.inputSchema?.properties">
<n-form-item
v-for="(_property, key) in tool.inputSchema.properties"
:key="String(key)"
:label="String(key)"
:path="String(key)"
>
<n-input
v-model:value="formData[String(key)]"
:placeholder="`输入 ${key}`"
clearable
/>
</n-form-item>
</div>
<div v-else class="no-params">
<p>此工具无需参数</p>
</div>
</n-form>
<div class="executor-actions">
<n-button @click="$emit('close')">取消</n-button>
<n-button type="primary" @click="executetool" :loading="executing">
执行工具
</n-button>
</div>
<div v-if="result" class="execution-result">
<h4>执行结果:</h4>
<pre>{{ JSON.stringify(result, null, 2) }}</pre>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
import { useServerStore } from '@/stores/newServer'
interface Props {
serverId: string
tool: any
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
defineEmits<Emits>()
const message = useMessage()
const serverStore = useServerStore()
const formRef = ref()
const executing = ref(false)
const result = ref<any>(null)
const formData = reactive<Record<string, any>>({})
const executetool = async () => {
executing.value = true
try {
result.value = await serverStore.callTool(props.serverId, props.tool.name, formData)
message.success('工具执行成功')
} catch (error) {
message.error('工具执行失败')
console.error(error)
} finally {
executing.value = false
}
}
</script>
<style scoped>
.tool-executor {
display: flex;
flex-direction: column;
gap: 20px;
}
.tool-info h3 {
margin: 0 0 8px 0;
font-size: 18px;
}
.tool-info p {
margin: 0;
color: var(--text-color-2);
}
.no-params {
text-align: center;
padding: 20px;
color: var(--text-color-3);
}
.executor-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.execution-result {
border-top: 1px solid var(--border-color);
padding-top: 20px;
}
.execution-result h4 {
margin: 0 0 12px 0;
}
.execution-result pre {
background: var(--hover-color);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div class="tool-form">
<!-- 工具信息 -->
<div class="tool-header">
<div class="tool-title">
<h3>{{ tool.name }}</h3>
<n-tag :type="tool.enabled ? 'success' : 'default'" size="small">
{{ tool.serverName }}
</n-tag>
</div>
<p v-if="tool.description" class="tool-description">
{{ tool.description }}
</p>
</div>
<!-- 参数表单 -->
<n-form
ref="formRef"
:model="formData"
:rules="formRules"
label-placement="top"
size="medium"
>
<div v-if="parameters.length === 0" class="no-parameters">
该工具不需要参数
</div>
<n-form-item
v-for="param in parameters"
:key="param.name"
:label="param.name"
:path="param.name"
:show-require-mark="param.required"
>
<template #label>
<div class="param-label">
<span>{{ param.name }}</span>
<n-tooltip v-if="param.description">
<template #trigger>
<n-icon :component="InfoIcon" size="14" />
</template>
{{ param.description }}
</n-tooltip>
</div>
</template>
<!-- 字符串输入 -->
<n-input
v-if="param.type === 'string' && !param.enum"
v-model:value="formData[param.name]"
:placeholder="param.description || '请输入' + param.name"
clearable
/>
<!-- 枚举选择 -->
<n-select
v-else-if="param.enum"
v-model:value="formData[param.name]"
:options="param.enum.map(val => ({ label: val, value: val }))"
:placeholder="'请选择' + param.name"
clearable
/>
<!-- 数字输入 -->
<n-input-number
v-else-if="param.type === 'number' || param.type === 'integer'"
v-model:value="formData[param.name]"
:placeholder="param.description || '请输入' + param.name"
:precision="param.type === 'integer' ? 0 : undefined"
clearable
/>
<!-- 布尔值选择 -->
<n-switch
v-else-if="param.type === 'boolean'"
v-model:value="formData[param.name]"
/>
<!-- 数组输入 */
<div v-else-if="param.type === 'array'" class="array-input">
<n-dynamic-input
v-model:value="formData[param.name]"
:on-create="() => ''"
>
<template #default="{ value, index }">
<n-input
:value="value"
@update:value="updateArrayItem(param.name, index, $event)"
placeholder="输入数组项"
/>
</template>
</n-dynamic-input>
</div>
<!-- 对象输入 (JSON) -->
<n-input
v-else-if="param.type === 'object'"
v-model:value="formData[param.name]"
type="textarea"
:placeholder="'请输入 JSON 格式的' + param.name"
:autosize="{ minRows: 3, maxRows: 8 }"
/>
<!-- 其他类型 -->
<n-input
v-else
v-model:value="formData[param.name]"
:placeholder="param.description || '请输入' + param.name"
clearable
/>
</n-form-item>
</n-form>
<!-- LLM 智能填充 -->
<div v-if="llmEnabled" class="llm-section">
<n-divider />
<div class="llm-header">
<h4>智能参数生成</h4>
<n-button
size="small"
type="primary"
ghost
:loading="llmLoading"
@click="generateParameters"
>
<template #icon>
<n-icon :component="SparklesIcon" />
</template>
生成参数
</n-button>
</div>
<n-input
v-model:value="userIntent"
type="textarea"
placeholder="描述你想要做什么AI 将帮你生成合适的参数..."
:autosize="{ minRows: 2, maxRows: 4 }"
clearable
/>
</div>
<!-- 操作按钮 -->
<div class="actions">
<n-button-group>
<n-button
type="primary"
:loading="loading"
:disabled="!canExecute"
@click="handleExecute"
>
<template #icon>
<n-icon :component="PlayIcon" />
</template>
执行工具
</n-button>
<n-button
type="default"
@click="handleReset"
>
重置
</n-button>
<n-button
v-if="tool.autoApprove"
type="warning"
@click="toggleAutoApprove"
>
{{ tool.autoApprove ? '取消自动执行' : '启用自动执行' }}
</n-button>
</n-button-group>
</div>
<!-- 执行结果 -->
<div v-if="result" class="result-section">
<n-divider />
<h4>执行结果</h4>
<n-code
:code="formatResult(result)"
language="json"
show-line-numbers
:hljs="hljs"
/>
</div>
<!-- 错误信息 -->
<div v-if="error" class="error-section">
<n-alert type="error" :title="error" closable @close="error = ''" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { Tool } from '../types'
import {
NForm,
NFormItem,
NInput,
NInputNumber,
NSelect,
NSwitch,
NDynamicInput,
NButton,
NButtonGroup,
NTag,
NIcon,
NTooltip,
NDivider,
NCode,
NAlert
} from 'naive-ui'
import {
InfoCircle as InfoIcon,
Play as PlayIcon,
Sparkles as SparklesIcon
} from '@vicons/tabler'
import hljs from 'highlight.js/lib/core'
import json from 'highlight.js/lib/languages/json'
hljs.registerLanguage('json', json)
interface ExtendedTool extends Tool {
serverId: string
serverName: string
}
interface Props {
tool: ExtendedTool
llmEnabled?: boolean
}
interface Emits {
(e: 'execute', payload: { toolName: string; serverId: string; parameters: Record<string, any> }): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const formRef = ref()
const formData = ref<Record<string, any>>({})
const userIntent = ref('')
const loading = ref(false)
const llmLoading = ref(false)
const result = ref<any>(null)
const error = ref('')
// 计算参数列表
const parameters = computed(() => {
if (!props.tool.inputSchema?.properties) return []
return Object.entries(props.tool.inputSchema.properties).map(([name, schema]: [string, any]) => ({
name,
type: schema.type || 'string',
description: schema.description,
enum: schema.enum,
required: props.tool.inputSchema?.required?.includes(name),
default: schema.default,
format: schema.format
}))
})
// 表单验证规则
const formRules = computed(() => {
const rules: Record<string, any> = {}
parameters.value.forEach(param => {
if (param.required) {
rules[param.name] = {
required: true,
message: `${param.name} 是必填项`,
trigger: ['blur', 'input']
}
}
if (param.type === 'object') {
rules[param.name] = {
...rules[param.name],
validator: (rule: any, value: string) => {
if (value && value.trim()) {
try {
JSON.parse(value)
} catch {
return new Error('请输入有效的 JSON 格式')
}
}
return true
},
trigger: ['blur']
}
}
})
return rules
})
// 是否可以执行
const canExecute = computed(() => {
return parameters.value.every(param => {
if (!param.required) return true
const value = formData.value[param.name]
return value !== undefined && value !== null && value !== ''
})
})
// 初始化表单数据
const initFormData = () => {
const data: Record<string, any> = {}
parameters.value.forEach(param => {
if (param.default !== undefined) {
data[param.name] = param.default
} else if (param.type === 'boolean') {
data[param.name] = false
} else if (param.type === 'array') {
data[param.name] = []
}
})
formData.value = data
}
// 更新数组项
const updateArrayItem = (paramName: string, index: number, value: string) => {
const arr = [...(formData.value[paramName] || [])]
arr[index] = value
formData.value[paramName] = arr
}
// 生成参数
const generateParameters = async () => {
if (!userIntent.value.trim()) {
error.value = '请输入你的需求描述'
return
}
llmLoading.value = true
error.value = ''
try {
const response = await fetch('/api/llm/generate-parameters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: props.tool.name,
description: props.tool.description,
userInput: userIntent.value,
schema: props.tool.inputSchema
})
})
const result = await response.json()
if (result.success) {
// 合并生成的参数到表单
Object.assign(formData.value, result.data)
} else {
error.value = result.error || '参数生成失败'
}
} catch (err) {
error.value = '网络错误'
} finally {
llmLoading.value = false
}
}
// 执行工具
const handleExecute = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
loading.value = true
error.value = ''
result.value = null
// 处理参数类型转换
const parameters: Record<string, any> = {}
Object.entries(formData.value).forEach(([key, value]) => {
const param = parameters.value.find(p => p.name === key)
if (!param || value === undefined || value === null || value === '') return
if (param.type === 'object' && typeof value === 'string') {
try {
parameters[key] = JSON.parse(value)
} catch {
parameters[key] = value
}
} else {
parameters[key] = value
}
})
try {
const response = await fetch('/api/tools/call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverId: props.tool.serverId,
toolName: props.tool.name,
parameters
})
})
const apiResult = await response.json()
if (apiResult.success) {
result.value = apiResult.data
} else {
error.value = apiResult.error || '工具执行失败'
}
} catch (err) {
error.value = '网络错误'
} finally {
loading.value = false
}
}
// 重置表单
const handleReset = () => {
initFormData()
result.value = null
error.value = ''
userIntent.value = ''
}
// 切换自动执行
const toggleAutoApprove = () => {
// 这里需要调用 API 更新工具设置
emit('execute', {
toolName: props.tool.name,
serverId: props.tool.serverId,
parameters: { autoApprove: !props.tool.autoApprove }
})
}
// 格式化结果
const formatResult = (data: any) => {
try {
return JSON.stringify(data, null, 2)
} catch {
return String(data)
}
}
// 监听工具变化,重新初始化表单
watch(() => props.tool, initFormData, { immediate: true })
</script>
<style scoped>
.tool-form {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.tool-header {
margin-bottom: 20px;
}
.tool-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.tool-title h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.tool-description {
margin: 0;
color: var(--text-color-2);
line-height: 1.5;
}
.no-parameters {
text-align: center;
color: var(--text-color-3);
padding: 20px;
font-style: italic;
}
.param-label {
display: flex;
align-items: center;
gap: 4px;
}
.array-input {
width: 100%;
}
.llm-section {
margin-top: 20px;
}
.llm-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.llm-header h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
.result-section {
margin-top: 20px;
}
.result-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
}
.error-section {
margin-top: 16px;
}
</style>

9
web/src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import SimpleApp from './SimpleApp.vue'
const app = createApp(SimpleApp)
app.use(createPinia())
app.mount('#app')

View File

@@ -0,0 +1,483 @@
import type { MCPServerConfig, ServerCapabilities, Tool, Resource, Prompt } from '../types';
import { v4 as uuidv4 } from 'uuid';
import { SSETransport } from './SSETransport';
/**
* 纯前端 MCP 客户端服务
* 直接在浏览器中连接 MCP 服务器,无需后端中间层
*/
export class MCPClientService {
private clients = new Map<string, any>();
private listeners = new Map<string, Array<(event: string, data: any) => void>>();
/**
* 添加并连接到 MCP 服务器
*/
async addServer(config: MCPServerConfig): Promise<ServerCapabilities> {
try {
console.log(`🔗 正在连接到 MCP 服务器: ${config.name} (${config.url})`);
let client;
if (config.type === 'http') {
// HTTP 连接
client = await this.createHttpClient(config);
} else if (config.type === 'sse') {
// SSE 连接
client = await this.createSSEClient(config);
} else {
throw new Error(`不支持的连接类型: ${config.type}`);
}
// 获取服务器能力
const capabilities = await this.getServerCapabilities(client);
this.clients.set(config.id, { client, config, capabilities });
console.log(`✅ 成功连接到 MCP 服务器: ${config.name}`);
console.log('服务器能力:', capabilities);
this.emit(config.id, 'connected', capabilities);
return capabilities;
} catch (error) {
console.error(`❌ 连接 MCP 服务器失败: ${config.name}`);
console.error('错误详情:', error);
// 检查是否是 CORS 错误
if (error instanceof TypeError && error.message.includes('Failed to fetch')) {
const corsError = new Error(`CORS 错误: 无法连接到 ${config.url}。请确保 MCP 服务器启用了 CORS 支持。`);
this.emit(config.id, 'error', corsError);
throw corsError;
}
this.emit(config.id, 'error', error);
throw error;
}
}
/**
* 创建 HTTP 客户端
*/
private async createHttpClient(config: MCPServerConfig) {
// 将 0.0.0.0 替换为 localhost浏览器无法访问 0.0.0.0
let baseUrl = config.url.replace(/\/$/, '');
baseUrl = baseUrl.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
// 确保URL包含 /mcp 路径
if (!baseUrl.includes('/mcp')) {
baseUrl = baseUrl + '/mcp';
}
console.log(`🔄 HTTP原始URL: ${config.url}`);
console.log(`🔄 HTTP转换后URL: ${baseUrl}`);
// 先测试MCP端点是否可访问
try {
console.log(`🔍 测试MCP端点可达性: ${baseUrl}`);
const testResponse = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'test-' + Date.now(),
method: 'ping' // 随便发一个方法测试连通性
})
});
console.log(`MCP端点响应状态: ${testResponse.status}`);
// 如果完全无法连接fetch会抛出错误
// 如果能连接但返回错误状态码,我们也认为连接有问题
if (!testResponse.ok && testResponse.status >= 500) {
throw new Error(`服务器错误: HTTP ${testResponse.status}`);
}
} catch (error) {
console.error(`❌ MCP端点连接失败:`, error);
// 检查是否是网络错误
if (error instanceof TypeError) {
throw new Error(`网络连接失败: 无法访问 ${baseUrl}。请检查服务器是否运行以及网络连接。`);
}
throw error;
}
return {
type: 'http',
baseUrl,
async call(method: string, params: any) {
const response = await fetch(baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: uuidv4(),
method,
params
})
});
console.log(`MCP 请求 (${method}):`, response.status, response.statusText);
if (!response.ok) {
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log(`MCP 响应 (${method}):`, result);
if (result.error) {
throw new Error(result.error.message || '请求错误');
}
return result.result;
}
};
}
/**
* 创建SSE客户端
*/
private async createSSEClient(config: MCPServerConfig): Promise<any> {
// 将 0.0.0.0 替换为 localhost浏览器无法访问 0.0.0.0
let url = config.url;
url = url.replace('0.0.0.0', 'localhost').replace('127.0.0.1', 'localhost');
console.log(`🔄 SSE 原始URL: ${config.url}`);
console.log(`🔄 SSE 转换后URL: ${url}`);
const transport = new SSETransport(url);
// 连接SSE
await transport.connect();
console.log(`✓ SSE 连接已建立: ${url}`);
return {
type: 'sse',
transport,
async call(method: string, params: any) {
try {
const result = await transport.sendRequest(method, params);
return result;
} catch (error) {
console.error(`SSE 请求失败 (${method}):`, error);
throw error;
}
},
async disconnect() {
await transport.disconnect();
},
get connected() {
return transport.isConnected;
},
// 事件监听
on(event: string, callback: Function) {
transport.on(event, callback);
},
off(event: string, callback: Function) {
transport.off(event, callback);
}
};
}
/**
* 获取服务器能力
*/
private async getServerCapabilities(client: any): Promise<ServerCapabilities> {
try {
console.log('🔄 正在初始化MCP服务器...');
// 初始化请求 - 这是必须成功的
const initResult = await client.call('initialize', {
protocolVersion: '2024-11-05',
capabilities: {
roots: {
listChanged: true
},
sampling: {}
},
clientInfo: {
name: 'MCP-Vue-Client',
version: '1.0.0'
}
});
console.log('✅ MCP服务器初始化成功:', initResult);
// 获取工具列表
let tools: Tool[] = [];
try {
const toolsResult = await client.call('tools/list', {});
tools = toolsResult.tools || [];
console.log(`📋 发现 ${tools.length} 个工具`);
} catch (error) {
console.warn('获取工具列表失败:', error);
}
// 获取资源列表
let resources: Resource[] = [];
try {
const resourcesResult = await client.call('resources/list', {});
resources = resourcesResult.resources || [];
console.log(`📁 发现 ${resources.length} 个资源`);
} catch (error) {
console.warn('获取资源列表失败:', error);
}
// 获取提示列表
let prompts: Prompt[] = [];
try {
const promptsResult = await client.call('prompts/list', {});
prompts = promptsResult.prompts || [];
console.log(`💡 发现 ${prompts.length} 个提示`);
} catch (error) {
console.warn('获取提示列表失败:', error);
}
return { tools, resources, prompts };
} catch (error) {
console.error('❌ MCP服务器初始化失败:', error);
// 初始化失败应该抛出错误,而不是返回空能力
throw new Error(`MCP服务器初始化失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}
/**
* 调用工具
*/
async callTool(serverId: string, toolName: string, parameters: Record<string, any>): Promise<any> {
const serverInfo = this.clients.get(serverId);
if (!serverInfo) {
throw new Error(`服务器 ${serverId} 未连接`);
}
const { client } = serverInfo;
try {
console.log(`🔧 调用工具: ${toolName}`, parameters);
const result = await client.call('tools/call', {
name: toolName,
arguments: parameters
});
console.log(`✅ 工具调用成功: ${toolName}`, result);
return result;
} catch (error) {
console.error(`❌ 工具调用失败: ${toolName}`, error);
throw error;
}
}
/**
* 读取资源
*/
async readResource(serverId: string, uri: string): Promise<any> {
const serverInfo = this.clients.get(serverId);
if (!serverInfo) {
throw new Error(`服务器 ${serverId} 未连接`);
}
const { client } = serverInfo;
try {
console.log(`📖 读取资源: ${uri}`);
const result = await client.call('resources/read', { uri });
console.log(`✅ 资源读取成功: ${uri}`, result);
return result;
} catch (error) {
console.error(`❌ 资源读取失败: ${uri}`, error);
throw error;
}
}
/**
* 获取提示
*/
async getPrompt(serverId: string, name: string, args?: Record<string, any>): Promise<any> {
const serverInfo = this.clients.get(serverId);
if (!serverInfo) {
throw new Error(`服务器 ${serverId} 未连接`);
}
const { client } = serverInfo;
try {
console.log(`💭 获取提示: ${name}`, args);
const result = await client.call('prompts/get', {
name,
arguments: args || {}
});
console.log(`✅ 提示获取成功: ${name}`, result);
return result;
} catch (error) {
console.error(`❌ 提示获取失败: ${name}`, error);
throw error;
}
}
/**
* 断开服务器连接
*/
async removeServer(serverId: string): Promise<void> {
const serverInfo = this.clients.get(serverId);
if (serverInfo) {
const { client } = serverInfo;
try {
if (client.type === 'sse' && client.disconnect) {
await client.disconnect();
}
} catch (error) {
console.warn('关闭连接时出错:', error);
}
this.clients.delete(serverId);
}
this.listeners.delete(serverId);
console.log(`🔌 服务器 ${serverId} 已断开连接`);
}
/**
* 测试服务器连接
*/
async testConnection(serverId: string): Promise<boolean> {
const serverInfo = this.clients.get(serverId);
if (!serverInfo) {
console.log(`❌ 服务器 ${serverId} 未找到客户端实例`);
return false;
}
const { client, config } = serverInfo;
try {
if (client.type === 'sse') {
return client.connected;
} else if (client.type === 'http') {
// HTTP 连接测试 - 发送真实的MCP初始化请求
console.log(`🔍 测试HTTP MCP连接: ${client.baseUrl}`);
const response = await fetch(client.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'test-' + Date.now(),
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'MCP-Test-Client', version: '1.0.0' }
}
})
});
if (!response.ok) {
console.log(`❌ HTTP响应失败: ${response.status} ${response.statusText}`);
return false;
}
const data = await response.json();
if (data.error) {
console.log(`❌ MCP协议错误:`, data.error);
return false;
}
console.log(`✅ MCP连接测试成功`);
return true;
}
return false;
} catch (error) {
console.log(`❌ 连接测试异常:`, error);
return false;
}
}
/**
* 获取所有连接的服务器
*/
getConnectedServers(): string[] {
return Array.from(this.clients.keys());
}
/**
* 获取服务器信息
*/
getServerInfo(serverId: string) {
return this.clients.get(serverId);
}
/**
* 事件监听
*/
on(serverId: string, callback: (event: string, data: any) => void): void {
if (!this.listeners.has(serverId)) {
this.listeners.set(serverId, []);
}
this.listeners.get(serverId)!.push(callback);
}
/**
* 移除事件监听
*/
off(serverId: string, callback?: (event: string, data: any) => void): void {
if (!callback) {
this.listeners.delete(serverId);
} else {
const callbacks = this.listeners.get(serverId) || [];
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
}
/**
* 触发事件
*/
private emit(serverId: string, event: string, data: any): void {
const callbacks = this.listeners.get(serverId) || [];
callbacks.forEach(callback => {
try {
callback(event, data);
} catch (error) {
console.error('事件回调执行失败:', error);
}
});
}
/**
* 清理所有连接
*/
async cleanup(): Promise<void> {
const serverIds = Array.from(this.clients.keys());
await Promise.all(serverIds.map(id => this.removeServer(id)));
console.log('🧹 所有连接已清理');
}
}
// 单例导出
export const mcpClientService = new MCPClientService();
// 在页面卸载时清理连接
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
mcpClientService.cleanup();
});
}

View File

@@ -0,0 +1,247 @@
import { v4 as uuidv4 } from 'uuid';
/**
* SSE (Server-Sent Events) 传输层实现
* 用于MCP协议的单向数据流传输
*/
export class SSETransport {
private eventSource: EventSource | null = null;
private url: string;
private pendingRequests = new Map<string, { resolve: Function; reject: Function; timeout: NodeJS.Timeout }>();
private listeners = new Map<string, Function[]>();
private connected = false;
constructor(url: string) {
this.url = url;
}
async connect(): Promise<void> {
return new Promise(async (resolve, reject) => {
try {
// 首先建立SSE连接获取sessionId
console.log('📡 连接SSE端点:', this.url);
// 第一步连接SSE获取endpoint信息
this.eventSource = new EventSource(this.url);
let resolveTimeout: NodeJS.Timeout;
this.eventSource.addEventListener('endpoint', (event: any) => {
const endpointData = event.data;
console.log('✅ 收到SSE endpoint:', endpointData);
// 提取sessionId格式: /message?sessionId=xxx
const match = endpointData.match(/sessionId=([^&]+)/);
if (match) {
const sessionId = match[1];
console.log('📝 SSE sessionId:', sessionId);
// 保存sessionId以便后续请求使用
(this as any).sessionId = sessionId;
}
this.connected = true;
resolve();
});
this.eventSource.onopen = () => {
console.log('📡 SSE连接已打开');
// 设置超时如果10秒内没有收到endpoint事件则认为失败
resolveTimeout = setTimeout(() => {
if (!this.connected) {
reject(new Error('SSE连接超时未收到endpoint'));
}
}, 10000);
};
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📨 收到SSE消息:', data);
this.handleMessage(data);
} catch (error) {
// 如果不是JSON可能是普通文本消息
console.log('📨 收到SSE文本消息:', event.data);
}
};
this.eventSource.onerror = (error) => {
console.error('❌ SSE连接错误:', error);
this.connected = false;
this.emit('disconnected');
if (this.eventSource?.readyState === EventSource.CLOSED) {
reject(new Error('SSE连接失败'));
}
};
// 监听message事件MCP响应
this.eventSource.addEventListener('message', (event: any) => {
try {
const message = JSON.parse(event.data);
console.log('📨 收到MCP消息:', message);
this.handleMessage(message);
} catch (error) {
console.error('❌ MCP消息解析失败:', error, event.data);
}
});
// 清理resolve超时
this.eventSource.addEventListener('endpoint', () => {
if (resolveTimeout) {
clearTimeout(resolveTimeout);
}
});
} catch (error) {
console.error('❌ 创建SSE连接失败:', error);
reject(error);
}
});
}
async sendRequest(method: string, params?: any): Promise<any> {
const id = uuidv4();
const request = {
jsonrpc: '2.0',
id,
method,
params: params || {}
};
return new Promise(async (resolve, reject) => {
// 设置超时
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`SSE请求超时: ${method}`));
}, 30000); // 30秒超时
this.pendingRequests.set(id, { resolve, reject, timeout });
try {
console.log(`📤 发送SSE请求 (${method}):`, request);
// 获取sessionId
const sessionId = (this as any).sessionId;
if (!sessionId) {
throw new Error('SSE sessionId未就绪');
}
// 根据服务器endpoint构建URL
// 例如: http://localhost:3200/message?sessionId=xxx
const baseUrl = this.url.replace('/sse', '');
const messageUrl = `${baseUrl}/message?sessionId=${sessionId}`;
console.log(`📤 发送到: ${messageUrl}`);
// SSE模式通过HTTP POST发送请求到/message端点响应通过SSE事件流返回
const response = await fetch(messageUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(request)
});
if (!response.ok) {
clearTimeout(timeout);
this.pendingRequests.delete(id);
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`);
}
// 对于某些简单请求可能直接返回JSON响应
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
clearTimeout(timeout);
this.pendingRequests.delete(id);
const result = await response.json();
if (result.error) {
reject(new Error(result.error.message || '请求失败'));
} else {
resolve(result.result);
}
}
// 否则等待SSE响应
} catch (error) {
const pending = this.pendingRequests.get(id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(id);
}
reject(error);
}
});
}
private handleMessage(message: any): void {
if (message.id && this.pendingRequests.has(message.id)) {
const pending = this.pendingRequests.get(message.id)!;
clearTimeout(pending.timeout);
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || '请求失败'));
} else {
pending.resolve(message.result);
}
} else if (!message.id && message.method) {
// 处理通知消息
console.log('📢 收到通知:', message.method, message.params);
this.emit('notification', message);
}
}
on(event: string, callback: Function): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(callback);
}
off(event: string, callback: Function): void {
const callbacks = this.listeners.get(event) || [];
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
private emit(event: string, data?: any): void {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('❌ SSE事件回调错误:', error);
}
});
}
async disconnect(): Promise<void> {
console.log('🔌 断开SSE连接');
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.connected = false;
// 清理待处理的请求
this.pendingRequests.forEach(pending => {
clearTimeout(pending.timeout);
pending.reject(new Error('连接已断开'));
});
this.pendingRequests.clear();
this.listeners.clear();
}
get isConnected(): boolean {
return this.connected && this.eventSource?.readyState === EventSource.OPEN;
}
}

View File

@@ -0,0 +1,349 @@
import { defineStore } from 'pinia'
import { reactive, watch } from 'vue'
export interface DisplaySettings {
// 主题设置
theme: 'light' | 'dark' | 'auto'
primaryColor: string
backgroundMaterial: 'default' | 'glass' | 'acrylic' | 'solid'
// 语言和地区
language: 'zh-CN' | 'en-US' | 'ja-JP' | 'ko-KR' | 'fr-FR' | 'es-ES'
timeFormat: '12' | '24'
dateFormat: 'YYYY-MM-DD' | 'MM/DD/YYYY' | 'MM-DD-YYYY' | 'YYYY年M月D日'
// 界面布局
sidebarWidth: number
compactMode: boolean
showStatusBar: boolean
sidebarCollapsed: boolean
// 字体和缩放
zoomLevel: number
fontSize: 'small' | 'medium' | 'large' | 'extra-large'
fontFamily: string
codeFont: string
// 动画和效果
enableAnimations: boolean
animationSpeed: 'slow' | 'normal' | 'fast' | 'instant'
blurEffects: boolean
shadowEffects: boolean
// 其他设置
enableNotifications: boolean
enableSounds: boolean
autoSaveInterval: number
maxHistoryItems: number
}
const defaultSettings: DisplaySettings = {
theme: 'light',
primaryColor: '#18a058',
backgroundMaterial: 'default',
language: 'zh-CN',
timeFormat: '24',
dateFormat: 'YYYY-MM-DD',
sidebarWidth: 240,
compactMode: false,
showStatusBar: true,
sidebarCollapsed: false,
zoomLevel: 100,
fontSize: 'medium',
fontFamily: 'system',
codeFont: 'monaco',
enableAnimations: true,
animationSpeed: 'normal',
blurEffects: true,
shadowEffects: true,
enableNotifications: true,
enableSounds: false,
autoSaveInterval: 30,
maxHistoryItems: 100
}
export const useDisplayStore = defineStore('display', () => {
// 响应式设置对象
const settings = reactive<DisplaySettings>({ ...defaultSettings })
// 应用设置到DOM
const applySettings = () => {
const root = document.documentElement
// 应用主题
root.setAttribute('data-theme', settings.theme)
// 应用主色调
root.style.setProperty('--primary-color', settings.primaryColor)
// 应用缩放
root.style.zoom = `${settings.zoomLevel}%`
// 应用字体
if (settings.fontFamily !== 'system') {
root.style.setProperty('--font-family', settings.fontFamily)
} else {
root.style.removeProperty('--font-family')
}
// 应用代码字体
root.style.setProperty('--code-font-family', settings.codeFont)
// 应用侧边栏宽度
root.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
// 应用CSS类
root.classList.toggle('compact-mode', settings.compactMode)
root.classList.toggle('sidebar-collapsed', settings.sidebarCollapsed)
root.classList.toggle('no-animations', !settings.enableAnimations)
root.classList.toggle('no-blur', !settings.blurEffects)
root.classList.toggle('no-shadow', !settings.shadowEffects)
root.classList.toggle('hide-status-bar', !settings.showStatusBar)
// 应用字体大小
const fontSizeMap = {
'small': '14px',
'medium': '16px',
'large': '18px',
'extra-large': '20px'
}
root.style.setProperty('--base-font-size', fontSizeMap[settings.fontSize])
// 应用动画速度
const animationSpeedMap = {
'slow': '0.5s',
'normal': '0.3s',
'fast': '0.15s',
'instant': '0s'
}
root.style.setProperty('--animation-duration', animationSpeedMap[settings.animationSpeed])
// 应用背景材质
root.setAttribute('data-material', settings.backgroundMaterial)
}
// 监听系统主题变化
const setupThemeWatcher = () => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const updateAutoTheme = () => {
if (settings.theme === 'auto') {
document.documentElement.setAttribute('data-theme', mediaQuery.matches ? 'dark' : 'light')
}
}
mediaQuery.addEventListener('change', updateAutoTheme)
updateAutoTheme()
}
}
// 保存设置
const saveSettings = () => {
try {
localStorage.setItem('display-settings', JSON.stringify(settings))
} catch (error) {
console.error('保存显示设置失败:', error)
}
}
// 加载设置
const loadSettings = () => {
try {
const saved = localStorage.getItem('display-settings')
if (saved) {
const savedSettings = JSON.parse(saved)
Object.assign(settings, savedSettings)
}
} catch (error) {
console.error('加载显示设置失败:', error)
}
}
// 重置为默认设置
const resetSettings = () => {
Object.assign(settings, defaultSettings)
saveSettings()
applySettings()
}
// 更新单个设置
const updateSetting = <K extends keyof DisplaySettings>(
key: K,
value: DisplaySettings[K]
) => {
settings[key] = value
saveSettings()
applySettings()
}
// 批量更新设置
const updateSettings = (newSettings: Partial<DisplaySettings>) => {
Object.assign(settings, newSettings)
saveSettings()
applySettings()
}
// 导出设置
const exportSettings = () => {
const config = {
displaySettings: settings,
exportTime: new Date().toISOString(),
version: '1.0.0'
}
return JSON.stringify(config, null, 2)
}
// 导入设置
const importSettings = (configJson: string) => {
try {
const config = JSON.parse(configJson)
if (config.displaySettings) {
Object.assign(settings, config.displaySettings)
saveSettings()
applySettings()
return true
}
return false
} catch (error) {
console.error('导入设置失败:', error)
return false
}
}
// 获取当前主题解析auto
const getCurrentTheme = () => {
if (settings.theme === 'auto') {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return 'light'
}
return settings.theme
}
// 切换主题
const toggleTheme = () => {
const currentTheme = getCurrentTheme()
settings.theme = currentTheme === 'dark' ? 'light' : 'dark'
saveSettings()
applySettings()
}
// 预设主题
const applyPresetTheme = (preset: 'default' | 'dark' | 'blue' | 'purple' | 'green') => {
const presets = {
default: {
theme: 'light' as const,
primaryColor: '#18a058',
backgroundMaterial: 'default' as const
},
dark: {
theme: 'dark' as const,
primaryColor: '#63e2b7',
backgroundMaterial: 'default' as const
},
blue: {
theme: 'light' as const,
primaryColor: '#2080f0',
backgroundMaterial: 'glass' as const
},
purple: {
theme: 'light' as const,
primaryColor: '#7c3aed',
backgroundMaterial: 'acrylic' as const
},
green: {
theme: 'light' as const,
primaryColor: '#10b981',
backgroundMaterial: 'default' as const
}
}
updateSettings(presets[preset])
}
// 初始化
const initialize = () => {
loadSettings()
applySettings()
setupThemeWatcher()
// 监听设置变化并自动应用
watch(settings, () => {
saveSettings()
applySettings()
}, { deep: true })
}
// 格式化时间
const formatTime = (date: Date) => {
const options: Intl.DateTimeFormatOptions = {
hour12: settings.timeFormat === '12',
hour: '2-digit',
minute: '2-digit'
}
return new Intl.DateTimeFormat(settings.language, options).format(date)
}
// 格式化日期
const formatDate = (date: Date) => {
if (settings.dateFormat === 'YYYY年M月D日') {
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
const formatMap = {
'YYYY-MM-DD': 'sv-SE', // Swedish format for YYYY-MM-DD
'MM/DD/YYYY': 'en-US',
'MM-DD-YYYY': 'en-US'
}
const locale = formatMap[settings.dateFormat] || settings.language
return new Intl.DateTimeFormat(locale).format(date)
}
// 获取本地化文本
const getLocalizedText = (key: string) => {
// 这里可以集成i18n库
const texts: Record<string, Record<string, string>> = {
'zh-CN': {
'settings': '设置',
'theme': '主题',
'language': '语言'
},
'en-US': {
'settings': 'Settings',
'theme': 'Theme',
'language': 'Language'
}
}
return texts[settings.language]?.[key] || key
}
return {
// State
settings,
// Getters
getCurrentTheme,
// Actions
saveSettings,
loadSettings,
resetSettings,
updateSetting,
updateSettings,
exportSettings,
importSettings,
toggleTheme,
applyPresetTheme,
initialize,
applySettings,
// Utilities
formatTime,
formatDate,
getLocalizedText
}
})

View File

@@ -0,0 +1,417 @@
import { defineStore } from 'pinia'
import { ref, reactive, computed } from 'vue'
export interface ModelProvider {
id: string
name: string
type: 'openai' | 'claude' | 'google' | 'ollama' | 'custom'
enabled: boolean
connected: boolean
apiKey?: string
baseUrl?: string
selectedModel?: string
models: Array<{
id: string
name: string
type: string
contextLength?: number
pricing?: {
input: number
output: number
}
}>
config: {
organization?: string
timeout?: number
maxRetries?: number
proxy?: string
headers?: Array<{ key: string; value: string }>
temperature?: number
maxTokens?: number
}
stats?: {
totalRequests: number
successRequests: number
totalTokens: number
totalCost: number
lastUsed?: Date
}
}
export interface GlobalModelSettings {
temperature: number
maxTokens: number
timeout: number
streaming: boolean
autoSave: boolean
logRequests: boolean
enableUsageTracking: boolean
}
export const useModelStore = defineStore('model', () => {
// 状态
const providers = ref<ModelProvider[]>([])
const selectedProviderId = ref<string>('')
const isLoading = ref(false)
const error = ref<string>('')
const globalSettings = reactive<GlobalModelSettings>({
temperature: 0.7,
maxTokens: 2000,
timeout: 30,
streaming: true,
autoSave: true,
logRequests: false,
enableUsageTracking: true
})
// 计算属性
const activeProviders = computed(() => providers.value.filter(p => p.enabled))
const connectedProviders = computed(() => providers.value.filter(p => p.connected))
const selectedProvider = computed(() =>
providers.value.find(p => p.id === selectedProviderId.value)
)
const availableModels = computed(() => {
return connectedProviders.value.flatMap(provider =>
provider.models.map(model => ({
...model,
providerId: provider.id,
providerName: provider.name,
providerType: provider.type
}))
)
})
// Actions
const addProvider = async (config: Omit<ModelProvider, 'id' | 'connected' | 'models' | 'stats'>) => {
isLoading.value = true
error.value = ''
try {
const provider: ModelProvider = {
...config,
id: Date.now().toString(),
connected: false,
models: [],
stats: {
totalRequests: 0,
successRequests: 0,
totalTokens: 0,
totalCost: 0
}
}
providers.value.push(provider)
await saveProviders()
// 如果启用了,尝试连接
if (provider.enabled) {
await connectProvider(provider.id)
}
return provider
} catch (err) {
error.value = err instanceof Error ? err.message : '添加服务商失败'
throw err
} finally {
isLoading.value = false
}
}
const updateProvider = async (id: string, updates: Partial<ModelProvider>) => {
const provider = providers.value.find(p => p.id === id)
if (!provider) {
throw new Error('服务商不存在')
}
Object.assign(provider, updates)
await saveProviders()
// 如果更新了关键配置,重新连接
if (updates.apiKey || updates.baseUrl || updates.config) {
if (provider.enabled) {
await connectProvider(id)
}
}
}
const removeProvider = async (id: string) => {
const index = providers.value.findIndex(p => p.id === id)
if (index === -1) return
await disconnectProvider(id)
providers.value.splice(index, 1)
if (selectedProviderId.value === id) {
selectedProviderId.value = ''
}
await saveProviders()
}
const connectProvider = async (id: string) => {
const provider = providers.value.find(p => p.id === id)
if (!provider) return
try {
isLoading.value = true
provider.connected = false
provider.models = []
// 根据服务商类型连接
const models = await fetchProviderModels(provider)
provider.models = models
provider.connected = true
// 设置默认模型
if (models.length > 0 && !provider.selectedModel) {
provider.selectedModel = models[0].id
}
console.log(`✅ 服务商 ${provider.name} 连接成功`)
} catch (err) {
provider.connected = false
provider.models = []
console.error(`❌ 服务商 ${provider.name} 连接失败:`, err)
throw err
} finally {
isLoading.value = false
}
}
const disconnectProvider = async (id: string) => {
const provider = providers.value.find(p => p.id === id)
if (provider) {
provider.connected = false
provider.models = []
}
}
const testProvider = async (id: string): Promise<boolean> => {
const provider = providers.value.find(p => p.id === id)
if (!provider) return false
try {
// 这里应该实现实际的测试逻辑
await new Promise(resolve => setTimeout(resolve, 1000))
return true
} catch {
return false
}
}
const selectModel = (providerId: string, modelId: string) => {
const provider = providers.value.find(p => p.id === providerId)
if (provider) {
provider.selectedModel = modelId
saveProviders()
}
}
const updateUsageStats = (providerId: string, stats: {
tokens?: number
cost?: number
success: boolean
}) => {
const provider = providers.value.find(p => p.id === providerId)
if (provider && provider.stats) {
provider.stats.totalRequests++
if (stats.success) {
provider.stats.successRequests++
}
if (stats.tokens) {
provider.stats.totalTokens += stats.tokens
}
if (stats.cost) {
provider.stats.totalCost += stats.cost
}
provider.stats.lastUsed = new Date()
saveProviders()
}
}
const fetchProviderModels = async (provider: ModelProvider) => {
// 模拟获取模型列表
const mockModels = {
openai: [
{
id: 'gpt-4',
name: 'GPT-4',
type: 'chat',
contextLength: 8192,
pricing: { input: 0.03, output: 0.06 }
},
{
id: 'gpt-4-32k',
name: 'GPT-4 32K',
type: 'chat',
contextLength: 32768,
pricing: { input: 0.06, output: 0.12 }
},
{
id: 'gpt-3.5-turbo',
name: 'GPT-3.5 Turbo',
type: 'chat',
contextLength: 4096,
pricing: { input: 0.0015, output: 0.002 }
}
],
claude: [
{
id: 'claude-3-opus',
name: 'Claude 3 Opus',
type: 'chat',
contextLength: 200000,
pricing: { input: 0.015, output: 0.075 }
},
{
id: 'claude-3-sonnet',
name: 'Claude 3 Sonnet',
type: 'chat',
contextLength: 200000,
pricing: { input: 0.003, output: 0.015 }
}
],
google: [
{
id: 'gemini-pro',
name: 'Gemini Pro',
type: 'chat',
contextLength: 30720
}
],
ollama: [
{
id: 'llama2',
name: 'Llama 2',
type: 'chat',
contextLength: 4096
},
{
id: 'mistral',
name: 'Mistral',
type: 'chat',
contextLength: 8192
}
],
custom: []
}
return mockModels[provider.type] || []
}
const saveProviders = async () => {
try {
localStorage.setItem('model-providers', JSON.stringify(providers.value))
} catch (err) {
console.error('保存服务商配置失败:', err)
}
}
const loadProviders = () => {
try {
const saved = localStorage.getItem('model-providers')
if (saved) {
providers.value = JSON.parse(saved)
}
} catch (err) {
console.error('加载服务商配置失败:', err)
}
}
const saveGlobalSettings = () => {
try {
localStorage.setItem('global-model-settings', JSON.stringify(globalSettings))
} catch (err) {
console.error('保存全局设置失败:', err)
}
}
const loadGlobalSettings = () => {
try {
const saved = localStorage.getItem('global-model-settings')
if (saved) {
Object.assign(globalSettings, JSON.parse(saved))
}
} catch (err) {
console.error('加载全局设置失败:', err)
}
}
const resetGlobalSettings = () => {
Object.assign(globalSettings, {
temperature: 0.7,
maxTokens: 2000,
timeout: 30,
streaming: true,
autoSave: true,
logRequests: false,
enableUsageTracking: true
})
}
const exportConfiguration = () => {
const config = {
providers: providers.value,
globalSettings: globalSettings,
exportTime: new Date().toISOString()
}
return JSON.stringify(config, null, 2)
}
const importConfiguration = (configJson: string) => {
try {
const config = JSON.parse(configJson)
if (config.providers) {
providers.value = config.providers
}
if (config.globalSettings) {
Object.assign(globalSettings, config.globalSettings)
}
saveProviders()
saveGlobalSettings()
} catch (err) {
throw new Error('配置格式无效')
}
}
// 初始化
const initialize = () => {
loadProviders()
loadGlobalSettings()
}
return {
// State
providers,
selectedProviderId,
isLoading,
error,
globalSettings,
// Getters
activeProviders,
connectedProviders,
selectedProvider,
availableModels,
// Actions
addProvider,
updateProvider,
removeProvider,
connectProvider,
disconnectProvider,
testProvider,
selectModel,
updateUsageStats,
saveProviders,
loadProviders,
saveGlobalSettings,
loadGlobalSettings,
resetGlobalSettings,
exportConfiguration,
importConfiguration,
initialize
}
})

344
web/src/stores/newServer.ts Normal file
View File

@@ -0,0 +1,344 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { MCPServerConfig } from '../types'
import { mcpClientService } from '../services/MCPClientService'
import { v4 as uuidv4 } from 'uuid'
export const useServerStore = defineStore('server', () => {
// 状态
const servers = ref<MCPServerConfig[]>([])
const selectedServerId = ref<string>('')
const isLoading = ref(false)
const error = ref<string>('')
// 计算属性
const selectedServer = computed(() =>
servers.value.find(s => s.id === selectedServerId.value)
)
const connectedServers = computed(() =>
servers.value.filter(s => s.status === 'connected')
)
const availableTools = computed(() => {
return connectedServers.value.flatMap(server =>
server.capabilities?.tools?.map(tool => ({
...tool,
serverId: server.id,
serverName: server.name
})) || []
)
})
const availableResources = computed(() => {
return connectedServers.value.flatMap(server =>
server.capabilities?.resources?.map(resource => ({
...resource,
serverId: server.id,
serverName: server.name
})) || []
)
})
// 从本地存储加载服务器配置
const loadServers = () => {
try {
const stored = localStorage.getItem('mcp-servers')
if (stored) {
const parsedServers = JSON.parse(stored) as MCPServerConfig[]
servers.value = parsedServers.map(server => ({
...server,
// 保留之前的连接状态,但将 'connected' 改为 'disconnected'
// 因为页面刷新后实际连接已断开
status: server.status === 'connected' ? 'disconnected' : server.status,
// 清除能力信息,因为连接已断开
capabilities: undefined
}))
console.log(`📦 加载了 ${servers.value.length} 个服务器配置`)
}
} catch (err) {
console.error('加载服务器配置失败:', err)
error.value = '加载服务器配置失败'
}
}
// 自动重连之前已连接的服务器
const autoReconnect = async () => {
const stored = localStorage.getItem('mcp-servers')
if (!stored) return
try {
const parsedServers = JSON.parse(stored) as MCPServerConfig[]
const wasConnected = parsedServers.filter(s => s.status === 'connected')
if (wasConnected.length > 0) {
console.log(`🔄 发现 ${wasConnected.length} 个之前已连接的服务器,尝试自动重连...`)
// 并行重连所有服务器
const reconnectPromises = wasConnected.map(async (server) => {
const currentServer = servers.value.find(s => s.id === server.id)
if (currentServer) {
try {
console.log(`🔌 自动重连: ${server.name}`)
await connectServer(server.id)
console.log(`✅ 自动重连成功: ${server.name}`)
} catch (err) {
console.warn(`⚠️ 自动重连失败: ${server.name}`, err)
// 失败了也不要抛出错误,继续尝试其他服务器
}
}
})
await Promise.allSettled(reconnectPromises)
console.log(`✅ 自动重连完成`)
}
} catch (err) {
console.error('自动重连失败:', err)
}
}
// 保存服务器配置到本地存储
const saveServers = () => {
try {
localStorage.setItem('mcp-servers', JSON.stringify(servers.value))
} catch (err) {
console.error('保存服务器配置失败:', err)
}
}
// 添加服务器
const addServer = async (config: Omit<MCPServerConfig, 'id' | 'status'>) => {
isLoading.value = true
error.value = ''
const serverConfig: MCPServerConfig = {
...config,
id: uuidv4(),
status: 'disconnected' // 改为默认未连接状态,让用户手动连接
}
try {
// 添加到列表
servers.value.push(serverConfig)
console.log(`✅ 服务器 ${serverConfig.name} 已添加,状态: ${serverConfig.status}`)
// 保存配置
saveServers()
return serverConfig
} catch (err) {
// 彻底失败,移除服务器
const index = servers.value.findIndex(s => s.id === serverConfig.id)
if (index !== -1) {
servers.value.splice(index, 1)
}
error.value = err instanceof Error ? err.message : '添加服务器失败'
console.error('添加服务器失败:', err)
throw err
} finally {
isLoading.value = false
}
}
// 移除服务器
const removeServer = async (id: string) => {
try {
await mcpClientService.removeServer(id)
const index = servers.value.findIndex(s => s.id === id)
if (index !== -1) {
servers.value.splice(index, 1)
saveServers()
}
if (selectedServerId.value === id) {
selectedServerId.value = ''
}
console.log('✅ 服务器删除成功')
} catch (err) {
error.value = err instanceof Error ? err.message : '删除服务器失败'
console.error('删除服务器失败:', err)
throw err
}
}
// 连接服务器
const connectServer = async (id: string) => {
const server = servers.value.find(s => s.id === id)
if (!server) {
throw new Error('服务器不存在')
}
if (server.status === 'connected') {
console.log(`⚠️ 服务器 ${server.name} 已经连接`)
return // 已经连接
}
console.log(`🔗 开始连接服务器: ${server.name} (${server.url})`)
server.status = 'connecting'
try {
const capabilities = await mcpClientService.addServer(server)
server.status = 'connected'
server.capabilities = capabilities
// 保存状态
saveServers()
console.log(`✅ 服务器 ${server.name} 连接成功,工具数: ${capabilities.tools?.length || 0}`)
} catch (err) {
server.status = 'disconnected' // 改为disconnected而不是error让用户可以重试
error.value = err instanceof Error ? err.message : '连接服务器失败'
console.error(`❌ 连接服务器失败 (${server.name}):`, err)
throw err
}
}
// 断开服务器
const disconnectServer = async (id: string) => {
const server = servers.value.find(s => s.id === id)
if (!server) return
console.log(`🔌 断开服务器: ${server.name}`)
try {
await mcpClientService.removeServer(id)
server.status = 'disconnected'
server.capabilities = undefined
// 保存状态
saveServers()
console.log(`✅ 服务器 ${server.name} 已断开连接`)
} catch (err) {
error.value = err instanceof Error ? err.message : '断开服务器失败'
console.error(`❌ 断开服务器失败 (${server.name}):`, err)
}
}
// 调用工具
const callTool = async (serverId: string, toolName: string, parameters: Record<string, any>) => {
try {
const result = await mcpClientService.callTool(serverId, toolName, parameters)
console.log(`✅ 工具 ${toolName} 调用成功:`, result)
return result
} catch (err) {
error.value = err instanceof Error ? err.message : '工具调用失败'
console.error(`❌ 工具 ${toolName} 调用失败:`, err)
throw err
}
}
// 执行工具 (ToolExecutor使用的别名方法)
const executeTool = async (serverId: string, toolName: string, parameters: Record<string, any>) => {
return await callTool(serverId, toolName, parameters)
}
// 更新服务器配置
const updateServer = async (serverId: string, updates: Partial<MCPServerConfig>) => {
const index = servers.value.findIndex(s => s.id === serverId)
if (index === -1) {
throw new Error('服务器不存在')
}
// 更新服务器配置
servers.value[index] = { ...servers.value[index], ...updates }
// 保存到本地存储
saveServers()
console.log(`✅ 服务器 ${serverId} 配置已更新`)
}
// 读取资源
const readResource = async (serverId: string, uri: string) => {
try {
const result = await mcpClientService.readResource(serverId, uri)
console.log(`✅ 资源 ${uri} 读取成功:`, result)
return result
} catch (err) {
error.value = err instanceof Error ? err.message : '资源读取失败'
console.error(`❌ 资源 ${uri} 读取失败:`, err)
throw err
}
}
// 清除错误
const clearError = () => {
error.value = ''
}
// 选择服务器
const selectServer = (id: string) => {
selectedServerId.value = id
}
// 检查服务器真实连接状态
const checkServerStatus = async (id: string): Promise<'connected' | 'disconnected' | 'error'> => {
const server = servers.value.find(s => s.id === id)
if (!server) return 'error'
try {
// 使用MCP客户端检查连接状态
const isConnected = await mcpClientService.testConnection(id)
if (isConnected) {
return 'connected'
} else {
return 'disconnected'
}
} catch (error) {
console.error(`检查服务器 ${server.name} 状态失败:`, error)
return 'error'
}
}
// 刷新所有服务器状态
const refreshAllStatus = async () => {
for (const server of servers.value) {
const status = await checkServerStatus(server.id)
server.status = status
if (status === 'disconnected' || status === 'error') {
server.capabilities = undefined
}
}
saveServers()
}
// 初始化时加载服务器配置
loadServers()
return {
// 状态
servers,
selectedServerId,
isLoading,
error,
// 计算属性
selectedServer,
connectedServers,
availableTools,
availableResources,
// Actions
loadServers,
saveServers,
addServer,
updateServer,
removeServer,
connectServer,
disconnectServer,
callTool,
executeTool,
readResource,
clearError,
selectServer,
checkServerStatus,
refreshAllStatus,
autoReconnect // 导出自动重连函数
}
})

77
web/src/style.css Normal file
View File

@@ -0,0 +1,77 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#app {
height: 100vh;
overflow: hidden;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* 代码块样式 */
code {
font-family: 'Fira Code', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
/* 卡片动画 */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* 加载动画 */
.loading-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.app {
flex-direction: column;
}
.sidebar {
width: 100% !important;
height: auto;
}
.main-content {
height: calc(100vh - 60px);
}
}

137
web/src/types.ts Normal file
View File

@@ -0,0 +1,137 @@
// MCP 客户端相关类型定义
export type RouteKey =
| 'chat'
| 'tools'
| 'data'
| 'model-providers'
| 'display-settings'
| 'mcp'
export interface MCPServerConfig {
id: string
name: string
url: string
type: 'http' | 'sse' | 'websocket'
description?: string
enabled: boolean
status: 'connected' | 'disconnected' | 'connecting' | 'error'
capabilities?: {
tools?: Array<{ name: string; description?: string; inputSchema?: any; enabled?: boolean; autoApprove?: boolean }>
prompts?: Array<{ name: string; description?: string; arguments?: Array<{ name: string }> }>
resources?: Array<{ name: string; description?: string; uri: string; mimeType?: string }>
}
headers?: Array<{ key: string; value: string }>
lastConnected?: string
error?: string
version?: string
}
export interface MCPTool {
serverId: string
serverName: string
name: string
description?: string
inputSchema?: any
}
export interface MCPResource {
serverId: string
serverName: string
uri: string
name: string
description?: string
mimeType?: string
}
export interface MCPPrompt {
serverId: string
serverName: string
name: string
description?: string
arguments?: Array<{
name: string
description?: string
required?: boolean
}>
}
export interface ModelProvider {
id: string
name: string
type: 'openai' | 'claude' | 'google' | 'ollama' | 'custom'
apiKey?: string
baseUrl?: string
models: string[]
defaultModel?: string
enabled: boolean
maxTokens?: number
temperature?: number
timeout?: number
description?: string
usage?: {
requestCount: number
tokenCount: number
errorCount: number
lastUsed?: string
}
}
export interface DisplaySettings {
theme: 'light' | 'dark' | 'auto'
primaryColor: string
backgroundMaterial: 'default' | 'glass' | 'acrylic' | 'solid'
language: 'zh-CN' | 'en-US' | 'ja-JP' | 'ko-KR' | 'fr-FR' | 'es-ES'
fontSize: number
fontFamily: string
lineHeight: number
borderRadius: number
compactMode: boolean
animations: {
enabled: boolean
duration: number
easing: string
}
sidebar: {
width: number
collapsed: boolean
position: 'left' | 'right'
}
layout: {
maxWidth: number
padding: number
gap: number
}
accessibility: {
highContrast: boolean
reduceMotion: boolean
focusVisible: boolean
}
advanced: {
enableGpu: boolean
maxHistoryItems: number
autoSave: boolean
debugMode: boolean
}
}
export interface ToolExecution {
id: string
toolName: string
serverId: string
parameters: Record<string, any>
status: 'pending' | 'running' | 'completed' | 'error'
startTime: string
endTime?: string
result?: any
error?: string
}
export interface NotificationMessage {
id: string
type: 'success' | 'error' | 'warning' | 'info'
title: string
content?: string
duration?: number
timestamp: string
}

106
web/src/types/index.ts Normal file
View File

@@ -0,0 +1,106 @@
// 复制后端类型定义到前端
export interface MCPServerConfig {
id: string;
name: string;
version?: string;
url: string;
type: 'http' | 'websocket' | 'sse';
enabled: boolean;
description?: string;
status: 'connected' | 'disconnected' | 'connecting' | 'error';
capabilities?: ServerCapabilities;
settings?: {
autoConnect?: boolean;
retryAttempts?: number;
timeout?: number;
};
}
export interface ServerCapabilities {
tools: Tool[];
resources: Resource[];
prompts: Prompt[];
}
export interface Tool {
name: string;
description?: string;
inputSchema?: {
type: 'object';
properties?: Record<string, any>;
required?: string[];
};
enabled?: boolean;
autoApprove?: boolean;
}
export interface ToolParameter {
type: string;
description?: string;
enum?: string[];
default?: any;
format?: string;
}
export interface Resource {
uri: string;
name?: string;
description?: string;
mimeType?: string;
}
export interface Prompt {
name: string;
description?: string;
arguments?: Array<{
name: string;
type?: string;
description?: string;
required?: boolean;
}>;
}
export interface LLMConfig {
provider: 'openai' | 'claude' | 'ollama' | 'custom';
model: string;
apiKey?: string;
baseUrl?: string;
enabled: boolean;
temperature?: number;
maxTokens?: number;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
serverId?: string;
}
export interface ToolCall {
id: string;
toolName: string;
serverId: string;
parameters: Record<string, any>;
result?: any;
error?: string;
status: 'pending' | 'success' | 'error';
}
export interface AppConfig {
servers: MCPServerConfig[];
llm: LLMConfig;
ui: {
theme: 'light' | 'dark' | 'auto';
language: 'zh-CN' | 'en-US';
compactMode: boolean;
};
}
export interface APIResponse<T = any> {
success: boolean;
data?: T;
error?: string;
}