first commit
This commit is contained in:
679
web/src/App.vue
Normal file
679
web/src/App.vue
Normal 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
764
web/src/SimpleApp.vue
Normal 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>
|
||||
825
web/src/SimpleApp.vue.backup
Normal file
825
web/src/SimpleApp.vue.backup
Normal 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
43
web/src/TestApp.vue
Normal 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>
|
||||
609
web/src/components/DisplaySettings.vue
Normal file
609
web/src/components/DisplaySettings.vue
Normal 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>
|
||||
848
web/src/components/MCPServerDetail.vue
Normal file
848
web/src/components/MCPServerDetail.vue
Normal 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>
|
||||
1091
web/src/components/MCPSettings.vue
Normal file
1091
web/src/components/MCPSettings.vue
Normal file
File diff suppressed because it is too large
Load Diff
679
web/src/components/ModelProviders.vue
Normal file
679
web/src/components/ModelProviders.vue
Normal 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>
|
||||
322
web/src/components/ProviderForm.vue
Normal file
322
web/src/components/ProviderForm.vue
Normal 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>
|
||||
239
web/src/components/ServerCard.vue
Normal file
239
web/src/components/ServerCard.vue
Normal 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>
|
||||
0
web/src/components/ServerDetail.vue
Normal file
0
web/src/components/ServerDetail.vue
Normal file
172
web/src/components/ServerForm.vue
Normal file
172
web/src/components/ServerForm.vue
Normal 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>
|
||||
295
web/src/components/Sidebar.vue
Normal file
295
web/src/components/Sidebar.vue
Normal 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>
|
||||
132
web/src/components/ToolExecutor.vue
Normal file
132
web/src/components/ToolExecutor.vue
Normal 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>
|
||||
537
web/src/components/ToolForm.vue
Normal file
537
web/src/components/ToolForm.vue
Normal 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
9
web/src/main.ts
Normal 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')
|
||||
483
web/src/services/MCPClientService.ts
Normal file
483
web/src/services/MCPClientService.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
247
web/src/services/SSETransport.ts
Normal file
247
web/src/services/SSETransport.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
349
web/src/stores/displayStore.ts
Normal file
349
web/src/stores/displayStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
417
web/src/stores/modelStore.ts
Normal file
417
web/src/stores/modelStore.ts
Normal 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
344
web/src/stores/newServer.ts
Normal 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
77
web/src/style.css
Normal 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
137
web/src/types.ts
Normal 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
106
web/src/types/index.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user