update at 2025-10-14 21:52:11
This commit is contained in:
@@ -1,35 +1,50 @@
|
||||
<template>
|
||||
<n-config-provider :theme="theme">
|
||||
<n-config-provider :theme="theme" :theme-overrides="themeOverrides">
|
||||
<n-global-style />
|
||||
<n-message-provider>
|
||||
<div class="app">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="app-logo">
|
||||
<n-icon size="24" color="#3b82f6">
|
||||
<Robot />
|
||||
</n-icon>
|
||||
<span class="app-title">MCP Client</span>
|
||||
<span v-if="!sidebarCollapsed" class="app-title">MCP Client</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button
|
||||
v-if="!sidebarCollapsed"
|
||||
quaternary
|
||||
circle
|
||||
@click="toggleTheme"
|
||||
class="theme-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Sun v-if="isDark" />
|
||||
<Moon v-else />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
@click="toggleSidebar"
|
||||
class="collapse-toggle"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Menu2 />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</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 v-if="!sidebarCollapsed" class="section-title">核心功能</div>
|
||||
<div class="nav-items">
|
||||
<div
|
||||
class="nav-item"
|
||||
@@ -113,113 +128,16 @@
|
||||
<!-- 主内容区域 -->
|
||||
<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>
|
||||
<ChatLayout v-if="currentRoute === 'chat'" />
|
||||
|
||||
<!-- 工具页面 -->
|
||||
<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>
|
||||
<ToolsManager v-else-if="currentRoute === 'tools'" />
|
||||
|
||||
<!-- 数据页面 -->
|
||||
<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>
|
||||
<DataManager v-else-if="currentRoute === 'data'" />
|
||||
|
||||
<!-- 模型服务页面 -->
|
||||
<ModelProviders v-else-if="currentRoute === 'model-providers'" />
|
||||
<ModelService v-else-if="currentRoute === 'model-providers'" />
|
||||
|
||||
<!-- 显示设置页面 -->
|
||||
<!-- 显示设置页面 -->
|
||||
@@ -319,11 +237,15 @@ import {
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Robot
|
||||
Robot,
|
||||
Menu2
|
||||
} from '@vicons/tabler'
|
||||
import ModelProviders from './components/ModelProviders.vue'
|
||||
import DisplaySettings from './components/DisplaySettings.vue'
|
||||
import MCPSettings from './components/MCPSettings.vue'
|
||||
import ModelService from './components/ModelService.vue'
|
||||
import ToolsManager from './components/ToolsManager.vue'
|
||||
import DataManager from './components/DataManager.vue'
|
||||
import ChatLayout from './components/Chat/ChatLayout.vue'
|
||||
import { useModelStore } from './stores/modelStore'
|
||||
|
||||
type RouteKey =
|
||||
@@ -340,22 +262,61 @@ const modelStore = useModelStore()
|
||||
// 响应式数据
|
||||
const currentRoute = ref<RouteKey>('chat')
|
||||
const isDark = ref(false)
|
||||
const currentThemeColor = ref('#18a058')
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 加载主题颜色设置
|
||||
const loadThemeColor = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('cherry-display-settings')
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved)
|
||||
currentThemeColor.value = settings.primaryColor || '#18a058'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取主题颜色失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const theme = computed<GlobalTheme | null>(() => {
|
||||
return isDark.value ? darkTheme : null
|
||||
})
|
||||
|
||||
const themeOverrides = computed(() => {
|
||||
const primaryColor = currentThemeColor.value
|
||||
return {
|
||||
common: {
|
||||
primaryColor: primaryColor,
|
||||
primaryColorHover: primaryColor + 'CC',
|
||||
primaryColorPressed: primaryColor + '99',
|
||||
primaryColorSuppl: primaryColor
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化模型服务状态
|
||||
modelStore.initialize()
|
||||
|
||||
// 加载主题颜色
|
||||
loadThemeColor()
|
||||
|
||||
// 监听主题颜色变化
|
||||
window.addEventListener('theme-color-changed', (event: any) => {
|
||||
currentThemeColor.value = event.detail
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -374,6 +335,11 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -384,6 +350,18 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
padding: 20px 12px;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -437,6 +415,17 @@ onMounted(() => {
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 12px;
|
||||
margin: 0 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
@@ -459,6 +448,10 @@ onMounted(() => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-indicator {
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
|
||||
1066
web/src/components/Chat/ChatLayout.vue
Normal file
1066
web/src/components/Chat/ChatLayout.vue
Normal file
File diff suppressed because it is too large
Load Diff
700
web/src/components/Chat/ChatLayout.vue.backup
Normal file
700
web/src/components/Chat/ChatLayout.vue.backup
Normal file
@@ -0,0 +1,700 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<!-- 对话区域 -->
|
||||
<div class="chat-area" :class="{ 'sidebar-visible': showSidebar }">
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索对话..."
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="SearchIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
|
||||
<!-- 话题列表 -->
|
||||
<n-scrollbar class="topics-list">
|
||||
<div
|
||||
v-for="topic in filteredTopics"
|
||||
:key="topic.id"
|
||||
class="topic-item"
|
||||
:class="{ active: store.state.currentTopicId === topic.id }"
|
||||
@click="store.setCurrentTopic(topic.id)"
|
||||
>
|
||||
<div class="topic-content">
|
||||
<div class="topic-header">
|
||||
<span class="topic-name">{{ topic.name }}</span>
|
||||
<n-dropdown
|
||||
trigger="click"
|
||||
:options="getTopicMenuOptions(topic)"
|
||||
@select="(key) => handleTopicMenu(key, topic)"
|
||||
>
|
||||
<n-button text size="tiny" @click.stop>
|
||||
<n-icon :component="DotsVerticalIcon" size="16" />
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
<div class="topic-preview">{{ topic.lastMessage || '暂无消息' }}</div>
|
||||
<div class="topic-meta">
|
||||
<span>{{ topic.messageCount }} 条消息</span>
|
||||
<span>{{ formatDate(topic.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTopics.length === 0" class="empty-topics">
|
||||
<p>{{ searchKeyword ? '未找到匹配的对话' : '暂无对话' }}</p>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 对话区域 -->
|
||||
<div class="chat-area">
|
||||
<div v-if="!store.state.currentTopicId" class="empty-chat">
|
||||
<div class="empty-icon">
|
||||
<n-icon :component="MessageIcon" size="64" />
|
||||
</div>
|
||||
<h3>选择一个对话开始聊天</h3>
|
||||
<p>或者创建一个新对话</p>
|
||||
<n-button type="primary" @click="handleCreateTopic">
|
||||
<template #icon>
|
||||
<n-icon :component="PlusIcon" />
|
||||
</template>
|
||||
新建对话
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 对话头部 -->
|
||||
<div class="chat-header">
|
||||
<div class="header-info">
|
||||
<h3>{{ store.currentTopic.value?.name }}</h3>
|
||||
<span class="message-count">{{ store.state.messages.length }} 条消息</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button text @click="handleClearMessages">
|
||||
<n-icon :component="TrashIcon" />
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<n-scrollbar class="messages-container" ref="messagesScrollRef">
|
||||
<div class="messages-list">
|
||||
<div
|
||||
v-for="msg in store.state.messages"
|
||||
:key="msg.id"
|
||||
class="message-item"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<n-icon
|
||||
:component="msg.role === 'user' ? UserIcon : RobotIcon"
|
||||
size="20"
|
||||
/>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<span class="message-role">{{
|
||||
msg.role === 'user' ? '你' : 'AI 助手'
|
||||
}}</span>
|
||||
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||
<n-tag v-if="msg.status === 'sending'" type="info" size="small">
|
||||
发送中...
|
||||
</n-tag>
|
||||
<n-tag v-else-if="msg.status === 'error'" type="error" size="small">
|
||||
发送失败
|
||||
</n-tag>
|
||||
</div>
|
||||
<div class="message-text">
|
||||
<div v-if="msg.status === 'sending'" class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ msg.content }}
|
||||
</template>
|
||||
<div v-if="msg.error" class="message-error">
|
||||
{{ msg.error }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.role === 'assistant' && msg.status === 'success'" class="message-actions">
|
||||
<n-button text size="tiny" @click="handleCopyMessage(msg.content)">
|
||||
<n-icon :component="CopyIcon" size="14" />
|
||||
复制
|
||||
</n-button>
|
||||
<n-button text size="tiny" @click="handleRegenerateMessage(msg.id)">
|
||||
<n-icon :component="RefreshIcon" size="14" />
|
||||
重新生成
|
||||
</n-button>
|
||||
<n-button text size="tiny" @click="handleDeleteMessage(msg.id)">
|
||||
<n-icon :component="TrashIcon" size="14" />
|
||||
删除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-scrollbar>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="input-container">
|
||||
<n-input
|
||||
v-model:value="inputText"
|
||||
type="textarea"
|
||||
placeholder="输入消息... (Shift + Enter 换行,Enter 发送)"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
:disabled="store.state.isSending"
|
||||
@keydown.enter="handleKeyDown"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<div class="input-info">
|
||||
<span v-if="selectedModel" class="model-info">
|
||||
模型: {{ selectedModel }}
|
||||
</span>
|
||||
</div>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!inputText.trim() || store.state.isSending"
|
||||
:loading="store.state.isSending"
|
||||
@click="handleSendMessage"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon :component="SendIcon" />
|
||||
</template>
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 编辑话题弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showEditModal"
|
||||
preset="card"
|
||||
title="编辑对话"
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-form>
|
||||
<n-form-item label="对话名称">
|
||||
<n-input v-model:value="editForm.name" placeholder="请输入对话名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="editForm.description"
|
||||
type="textarea"
|
||||
placeholder="可选"
|
||||
:rows="3"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div style="display: flex; justify-content: flex-end; gap: 12px">
|
||||
<n-button @click="showEditModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleSaveEdit">保存</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NIcon,
|
||||
NInput,
|
||||
NScrollbar,
|
||||
NTag,
|
||||
NDropdown,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Plus as PlusIcon,
|
||||
Search as SearchIcon,
|
||||
DotsVertical as DotsVerticalIcon,
|
||||
Message as MessageIcon,
|
||||
Trash as TrashIcon,
|
||||
User as UserIcon,
|
||||
Robot as RobotIcon,
|
||||
Copy as CopyIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Send as SendIcon
|
||||
} from '@vicons/tabler'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import type { Topic } from '../../types/chat'
|
||||
|
||||
const message = useMessage()
|
||||
const store = useChatStore()
|
||||
|
||||
// 响应式数据
|
||||
const searchKeyword = ref('')
|
||||
const inputText = ref('')
|
||||
const showEditModal = ref(false)
|
||||
const editingTopic = ref<Topic | null>(null)
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
const messagesScrollRef = ref()
|
||||
const selectedModel = ref<string>()
|
||||
|
||||
// 计算属性
|
||||
const filteredTopics = computed(() => {
|
||||
let topics = store.state.topics
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
topics = topics.filter(
|
||||
t =>
|
||||
t.name.toLowerCase().includes(keyword) ||
|
||||
t.lastMessage?.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return topics
|
||||
})
|
||||
|
||||
// 话题菜单选项
|
||||
const getTopicMenuOptions = (topic: Topic) => [
|
||||
{
|
||||
label: topic.pinned ? '取消置顶' : '置顶',
|
||||
key: 'pin'
|
||||
},
|
||||
{
|
||||
label: '重命名',
|
||||
key: 'rename'
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete'
|
||||
}
|
||||
]
|
||||
|
||||
// 处理话题菜单
|
||||
const handleTopicMenu = (key: string, topic: Topic) => {
|
||||
switch (key) {
|
||||
case 'pin':
|
||||
store.toggleTopicPin(topic.id)
|
||||
message.success(topic.pinned ? '已取消置顶' : '已置顶')
|
||||
break
|
||||
case 'rename':
|
||||
editingTopic.value = topic
|
||||
editForm.value.name = topic.name
|
||||
editForm.value.description = topic.description || ''
|
||||
showEditModal.value = true
|
||||
break
|
||||
case 'delete':
|
||||
if (confirm(`确定要删除对话"${topic.name}"吗?`)) {
|
||||
store.deleteTopic(topic.id)
|
||||
message.success('已删除')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 创建话题
|
||||
const handleCreateTopic = () => {
|
||||
const name = prompt('请输入对话名称:', '新对话')
|
||||
if (name) {
|
||||
store.createTopic(name.trim())
|
||||
message.success('已创建')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = () => {
|
||||
if (!editingTopic.value) return
|
||||
|
||||
store.updateTopic(editingTopic.value.id, {
|
||||
name: editForm.value.name,
|
||||
description: editForm.value.description
|
||||
})
|
||||
|
||||
showEditModal.value = false
|
||||
editingTopic.value = null
|
||||
message.success('已保存')
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
const handleClearMessages = () => {
|
||||
if (confirm('确定要清空当前对话的所有消息吗?')) {
|
||||
// TODO: 实现清空消息
|
||||
message.success('已清空')
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputText.value.trim() || store.state.isSending) return
|
||||
|
||||
const content = inputText.value.trim()
|
||||
inputText.value = ''
|
||||
|
||||
try {
|
||||
await store.sendMessageStream(content, selectedModel.value, () => {
|
||||
// 滚动到底部
|
||||
scrollToBottom()
|
||||
})
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// 复制消息
|
||||
const handleCopyMessage = (content: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => message.success('已复制'))
|
||||
.catch(() => message.error('复制失败'))
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const handleRegenerateMessage = async (messageId: string) => {
|
||||
try {
|
||||
await store.regenerateMessage(messageId)
|
||||
message.success('正在重新生成...')
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重新生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
if (confirm('确定要删除这条消息吗?')) {
|
||||
store.deleteMessage(messageId)
|
||||
message.success('已删除')
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesScrollRef.value) {
|
||||
messagesScrollRef.value.scrollTo({ top: messagesScrollRef.value.$el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date: Date): string => {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - new Date(date).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (mins < 1) return '刚刚'
|
||||
if (mins < 60) return `${mins}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
if (days < 7) return `${days}天前`
|
||||
|
||||
return new Date(date).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date): string => {
|
||||
return new Date(date).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
store.initialize()
|
||||
scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--color-base);
|
||||
}
|
||||
|
||||
/* 话题侧边栏 */
|
||||
.topics-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.topics-list {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.topic-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.topic-item:hover {
|
||||
background: var(--hover-color);
|
||||
}
|
||||
|
||||
.topic-item.active {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.topic-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.topic-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.topic-preview {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-3);
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topic-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.empty-topics {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
/* 对话区域 */
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.empty-chat h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.empty-chat p {
|
||||
margin: 0;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
/* 对话头部 */
|
||||
.chat-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-info h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-item.assistant .message-avatar {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-error {
|
||||
color: var(--error-color);
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
animation: typing 1.4s infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
.input-container {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.input-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.model-info {
|
||||
background: var(--tag-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
755
web/src/components/DataManager.vue
Normal file
755
web/src/components/DataManager.vue
Normal file
@@ -0,0 +1,755 @@
|
||||
<template>
|
||||
<div class="data-manager-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<h1>数据管理</h1>
|
||||
<p>管理对话历史、数据源和系统数据</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button @click="exportData">
|
||||
<template #icon>
|
||||
<n-icon :component="DownloadIcon" />
|
||||
</template>
|
||||
导出数据
|
||||
</n-button>
|
||||
<n-button type="error" @click="showClearModal = true">
|
||||
<template #icon>
|
||||
<n-icon :component="TrashIcon" />
|
||||
</template>
|
||||
清理数据
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<n-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<n-icon :component="MessageIcon" size="24" color="#18a058" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ conversations.length }}</div>
|
||||
<div class="stat-label">对话数量</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<n-icon :component="DatabaseIcon" size="24" color="#2080f0" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ getTotalMessages() }}</div>
|
||||
<div class="stat-label">消息总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<n-icon :component="ClockIcon" size="24" color="#f0a020" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ getStorageSize() }}</div>
|
||||
<div class="stat-label">存储使用</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon">
|
||||
<n-icon :component="CalendarIcon" size="24" color="#d03050" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ getLastActiveDate() }}</div>
|
||||
<div class="stat-label">最后活动</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 对话历史列表 -->
|
||||
<div class="conversations-section">
|
||||
<div class="section-header">
|
||||
<h2>对话历史</h2>
|
||||
<div class="section-actions">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索对话..."
|
||||
clearable
|
||||
style="width: 240px"
|
||||
>
|
||||
<template #prefix>
|
||||
<n-icon :component="SearchIcon" />
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredConversations.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<n-icon :component="MessageIcon" size="48" />
|
||||
</div>
|
||||
<h3>{{ searchKeyword ? '未找到匹配的对话' : '暂无对话历史' }}</h3>
|
||||
<p>{{ searchKeyword ? '尝试使用其他关键词搜索' : '开始一个新对话吧' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations-list">
|
||||
<n-card
|
||||
v-for="conv in filteredConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-card"
|
||||
hoverable
|
||||
>
|
||||
<div class="conversation-header">
|
||||
<div class="conversation-info">
|
||||
<h3 class="conversation-title">{{ conv.title || '未命名对话' }}</h3>
|
||||
<div class="conversation-meta">
|
||||
<span class="meta-item">
|
||||
<n-icon :component="MessageIcon" size="14" />
|
||||
{{ conv.messages.length }} 条消息
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<n-icon :component="ClockIcon" size="14" />
|
||||
{{ formatDate(conv.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conversation-actions">
|
||||
<n-button text @click="viewConversation(conv)">
|
||||
<n-icon :component="EyeIcon" size="18" />
|
||||
</n-button>
|
||||
<n-button text @click="exportConversation(conv)">
|
||||
<n-icon :component="DownloadIcon" size="18" />
|
||||
</n-button>
|
||||
<n-button text @click="deleteConversation(conv)">
|
||||
<n-icon :component="TrashIcon" size="18" color="#d03050" />
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="conversation-preview">
|
||||
<p>{{ getConversationPreview(conv) }}</p>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看对话详情弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showViewModal"
|
||||
preset="card"
|
||||
title="对话详情"
|
||||
style="width: 800px; max-height: 80vh"
|
||||
>
|
||||
<div v-if="selectedConversation" class="conversation-detail">
|
||||
<div class="detail-header">
|
||||
<h3>{{ selectedConversation.title || '未命名对话' }}</h3>
|
||||
<div class="detail-meta">
|
||||
<span>创建时间: {{ formatDate(selectedConversation.createdAt) }}</span>
|
||||
<span>更新时间: {{ formatDate(selectedConversation.updatedAt) }}</span>
|
||||
<span>消息数: {{ selectedConversation.messages.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages-list">
|
||||
<div
|
||||
v-for="(msg, index) in selectedConversation.messages"
|
||||
:key="index"
|
||||
class="message-item"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="message-header">
|
||||
<span class="message-role">{{ getRoleLabel(msg.role) }}</span>
|
||||
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
|
||||
<!-- 清理数据确认弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showClearModal"
|
||||
preset="dialog"
|
||||
title="确认清理数据"
|
||||
positive-text="确认清理"
|
||||
negative-text="取消"
|
||||
@positive-click="handleClearData"
|
||||
>
|
||||
<div class="clear-options">
|
||||
<p>请选择要清理的数据类型:</p>
|
||||
<n-checkbox-group v-model:value="clearOptions">
|
||||
<n-space vertical>
|
||||
<n-checkbox value="conversations" label="对话历史" />
|
||||
<n-checkbox value="tools" label="工具配置" />
|
||||
<n-checkbox value="services" label="模型服务" />
|
||||
<n-checkbox value="settings" label="系统设置" />
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
<n-alert type="warning" style="margin-top: 16px">
|
||||
此操作不可恢复,请谨慎操作!建议先导出数据备份。
|
||||
</n-alert>
|
||||
</div>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NIcon,
|
||||
NModal,
|
||||
NInput,
|
||||
NCheckbox,
|
||||
NCheckboxGroup,
|
||||
NSpace,
|
||||
NAlert,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
Trash as TrashIcon,
|
||||
Message as MessageIcon,
|
||||
Database as DatabaseIcon,
|
||||
Clock as ClockIcon,
|
||||
Calendar as CalendarIcon,
|
||||
Search as SearchIcon,
|
||||
Eye as EyeIcon
|
||||
} from '@vicons/tabler'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 消息接口
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
// 对话接口
|
||||
interface Conversation {
|
||||
id: string
|
||||
title: string
|
||||
messages: Message[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const conversations = ref<Conversation[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const showViewModal = ref(false)
|
||||
const showClearModal = ref(false)
|
||||
const selectedConversation = ref<Conversation | null>(null)
|
||||
const clearOptions = ref<string[]>([])
|
||||
|
||||
// 过滤后的对话列表
|
||||
const filteredConversations = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return conversations.value
|
||||
}
|
||||
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
return conversations.value.filter(conv => {
|
||||
return (
|
||||
conv.title.toLowerCase().includes(keyword) ||
|
||||
conv.messages.some(msg => msg.content.toLowerCase().includes(keyword))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// 获取总消息数
|
||||
const getTotalMessages = (): number => {
|
||||
return conversations.value.reduce((total, conv) => total + conv.messages.length, 0)
|
||||
}
|
||||
|
||||
// 获取存储大小
|
||||
const getStorageSize = (): string => {
|
||||
try {
|
||||
let totalSize = 0
|
||||
for (let key in localStorage) {
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
totalSize += localStorage[key].length + key.length
|
||||
}
|
||||
}
|
||||
const sizeInKB = totalSize / 1024
|
||||
if (sizeInKB < 1024) {
|
||||
return `${sizeInKB.toFixed(2)} KB`
|
||||
}
|
||||
return `${(sizeInKB / 1024).toFixed(2)} MB`
|
||||
} catch {
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最后活动日期
|
||||
const getLastActiveDate = (): string => {
|
||||
if (conversations.value.length === 0) {
|
||||
return '无'
|
||||
}
|
||||
|
||||
const latest = conversations.value.reduce((latest, conv) => {
|
||||
return new Date(conv.updatedAt) > new Date(latest.updatedAt) ? conv : latest
|
||||
})
|
||||
|
||||
return formatDate(latest.updatedAt)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date: Date | string): string => {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return '刚刚'
|
||||
if (diffMins < 60) return `${diffMins} 分钟前`
|
||||
if (diffHours < 24) return `${diffHours} 小时前`
|
||||
if (diffDays < 7) return `${diffDays} 天前`
|
||||
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date: Date | string | undefined): string => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
return d.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取角色标签
|
||||
const getRoleLabel = (role: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
user: '用户',
|
||||
assistant: '助手',
|
||||
system: '系统'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
// 获取对话预览
|
||||
const getConversationPreview = (conv: Conversation): string => {
|
||||
if (conv.messages.length === 0) {
|
||||
return '暂无消息'
|
||||
}
|
||||
|
||||
const lastMsg = conv.messages[conv.messages.length - 1]
|
||||
const preview = lastMsg.content.substring(0, 100)
|
||||
return preview.length < lastMsg.content.length ? `${preview}...` : preview
|
||||
}
|
||||
|
||||
// 查看对话
|
||||
const viewConversation = (conv: Conversation) => {
|
||||
selectedConversation.value = conv
|
||||
showViewModal.value = true
|
||||
}
|
||||
|
||||
// 导出单个对话
|
||||
const exportConversation = (conv: Conversation) => {
|
||||
try {
|
||||
const data = JSON.stringify(conv, null, 2)
|
||||
const blob = new Blob([data], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `conversation_${conv.id}_${Date.now()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('对话已导出')
|
||||
} catch (error) {
|
||||
message.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
const deleteConversation = (conv: Conversation) => {
|
||||
const index = conversations.value.findIndex(c => c.id === conv.id)
|
||||
if (index !== -1) {
|
||||
conversations.value.splice(index, 1)
|
||||
saveConversations()
|
||||
message.success('对话已删除')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出所有数据
|
||||
const exportData = () => {
|
||||
try {
|
||||
const data = {
|
||||
conversations: conversations.value,
|
||||
tools: JSON.parse(localStorage.getItem('mcp-tools') || '[]'),
|
||||
services: JSON.parse(localStorage.getItem('model-services') || '[]'),
|
||||
settings: JSON.parse(localStorage.getItem('mcp-settings') || '{}'),
|
||||
exportDate: new Date().toISOString()
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `mcp_backup_${Date.now()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('数据已导出')
|
||||
} catch (error) {
|
||||
message.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
const handleClearData = () => {
|
||||
if (clearOptions.value.length === 0) {
|
||||
message.warning('请选择要清理的数据类型')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
clearOptions.value.forEach(option => {
|
||||
switch (option) {
|
||||
case 'conversations':
|
||||
localStorage.removeItem('mcp-conversations')
|
||||
conversations.value = []
|
||||
break
|
||||
case 'tools':
|
||||
localStorage.removeItem('mcp-tools')
|
||||
break
|
||||
case 'services':
|
||||
localStorage.removeItem('model-services')
|
||||
break
|
||||
case 'settings':
|
||||
localStorage.removeItem('mcp-settings')
|
||||
localStorage.removeItem('display-settings')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
message.success('数据已清理')
|
||||
clearOptions.value = []
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error('清理失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存对话
|
||||
const saveConversations = () => {
|
||||
try {
|
||||
localStorage.setItem('mcp-conversations', JSON.stringify(conversations.value))
|
||||
} catch (error) {
|
||||
console.error('保存对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载对话
|
||||
const loadConversations = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('mcp-conversations')
|
||||
if (saved) {
|
||||
conversations.value = JSON.parse(saved)
|
||||
} else {
|
||||
// 加载示例数据(首次使用)
|
||||
loadSampleData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载示例数据
|
||||
const loadSampleData = () => {
|
||||
const sampleConversations: Conversation[] = [
|
||||
{
|
||||
id: 'sample_1',
|
||||
title: '欢迎使用 MCP 客户端',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '欢迎使用 MCP 客户端!这是一个示例对话。',
|
||||
timestamp: new Date(Date.now() - 86400000)
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '你好,这是我的第一条消息',
|
||||
timestamp: new Date(Date.now() - 86400000 + 1000)
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!很高兴见到你。我是 AI 助手,我可以帮助你完成各种任务。',
|
||||
timestamp: new Date(Date.now() - 86400000 + 2000)
|
||||
}
|
||||
],
|
||||
createdAt: new Date(Date.now() - 86400000),
|
||||
updatedAt: new Date(Date.now() - 86400000 + 2000)
|
||||
}
|
||||
]
|
||||
|
||||
conversations.value = sampleConversations
|
||||
saveConversations()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-manager-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.conversations-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-color-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.conversation-card {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
color: var(--text-color-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.conversation-preview p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversation-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.message-item.user {
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.message-item.assistant {
|
||||
background: rgba(32, 128, 240, 0.1);
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
color: var(--text-color-3);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.clear-options {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.clear-options p {
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -377,10 +377,31 @@ const applySettings = () => {
|
||||
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')
|
||||
const primaryColor = displaySettings.primaryColor
|
||||
root.style.setProperty('--primary-color', primaryColor)
|
||||
|
||||
// Naive UI 主题变量
|
||||
root.style.setProperty('--n-color-primary', primaryColor)
|
||||
root.style.setProperty('--n-color-primary-hover', primaryColor + 'CC') // 80% 透明度
|
||||
root.style.setProperty('--n-color-primary-pressed', primaryColor + '99') // 60% 透明度
|
||||
root.style.setProperty('--n-color-primary-suppl', primaryColor)
|
||||
|
||||
// 计算颜色变体
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(primaryColor)
|
||||
if (rgb) {
|
||||
root.style.setProperty('--n-color-primary-hover', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.8)`)
|
||||
root.style.setProperty('--n-color-primary-pressed', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.9)`)
|
||||
root.style.setProperty('--n-border-color-primary', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)`)
|
||||
}
|
||||
|
||||
// 应用缩放
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -421,6 +442,10 @@ const applySettings = () => {
|
||||
// 监听设置变化并自动应用
|
||||
watch(displaySettings, () => {
|
||||
applySettings()
|
||||
// 触发主题更新事件
|
||||
window.dispatchEvent(new CustomEvent('theme-color-changed', {
|
||||
detail: displaySettings.primaryColor
|
||||
}))
|
||||
}, { deep: true })
|
||||
|
||||
// 生命周期
|
||||
|
||||
1222
web/src/components/ModelService.vue
Normal file
1222
web/src/components/ModelService.vue
Normal file
File diff suppressed because it is too large
Load Diff
703
web/src/components/ToolsManager.vue
Normal file
703
web/src/components/ToolsManager.vue
Normal file
@@ -0,0 +1,703 @@
|
||||
<template>
|
||||
<div class="tools-manager-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<h1>工具管理</h1>
|
||||
<p>管理和配置 MCP 工具,扩展 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="tools-content">
|
||||
<div v-if="tools.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<n-icon :component="ToolIcon" size="48" />
|
||||
</div>
|
||||
<h3>暂无工具</h3>
|
||||
<p>添加您的第一个 MCP 工具,扩展 AI 模型的能力</p>
|
||||
<n-button type="primary" @click="showAddModal = true">添加工具</n-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tools-grid">
|
||||
<n-card
|
||||
v-for="tool in tools"
|
||||
:key="tool.id"
|
||||
class="tool-card"
|
||||
hoverable
|
||||
>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="tool-info">
|
||||
<n-icon :component="ToolIcon" size="20" class="tool-icon" />
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<n-switch
|
||||
v-model:value="tool.enabled"
|
||||
@update:value="toggleTool(tool)"
|
||||
size="small"
|
||||
/>
|
||||
<n-dropdown :options="getDropdownOptions()" @select="handleDropdownSelect($event, tool)">
|
||||
<n-button text>
|
||||
<n-icon :component="DotsVerticalIcon" size="18" />
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="tool-content">
|
||||
<p class="tool-description">{{ tool.description || '暂无描述' }}</p>
|
||||
|
||||
<div class="tool-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">端点:</span>
|
||||
<span class="meta-value">{{ tool.endpoint || '未配置' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">参数:</span>
|
||||
<span class="meta-value">{{ getParameterCount(tool) }} 个</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">状态:</span>
|
||||
<n-tag :type="tool.enabled ? 'success' : 'default'" size="small">
|
||||
{{ tool.enabled ? '已启用' : '已禁用' }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-actions">
|
||||
<n-button size="small" @click="testTool(tool)">
|
||||
<template #icon>
|
||||
<n-icon :component="TestIcon" />
|
||||
</template>
|
||||
测试工具
|
||||
</n-button>
|
||||
<n-button size="small" @click="editTool(tool)">
|
||||
<template #icon>
|
||||
<n-icon :component="EditIcon" />
|
||||
</template>
|
||||
编辑
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑工具弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showAddModal"
|
||||
preset="card"
|
||||
:title="editingTool ? '编辑工具' : '添加工具'"
|
||||
class="tool-modal"
|
||||
style="width: 600px"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-placement="left"
|
||||
label-width="100"
|
||||
>
|
||||
<n-form-item label="工具名称" path="name">
|
||||
<n-input
|
||||
v-model:value="formData.name"
|
||||
placeholder="例如: web_search"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="显示名称" path="displayName">
|
||||
<n-input
|
||||
v-model:value="formData.displayName"
|
||||
placeholder="例如: 网页搜索"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="描述" path="description">
|
||||
<n-input
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="描述工具的功能和用途"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="端点地址" path="endpoint">
|
||||
<n-input
|
||||
v-model:value="formData.endpoint"
|
||||
placeholder="http://localhost:8080/api/tool"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="参数配置" path="parameters">
|
||||
<n-input
|
||||
v-model:value="formData.parameters"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder='{"query": {"type": "string", "description": "搜索关键词"}}'
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="启用工具">
|
||||
<n-switch v-model:value="formData.enabled" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showAddModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="handleSaveTool" :loading="saving">
|
||||
保存
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 测试工具弹窗 -->
|
||||
<n-modal
|
||||
v-model:show="showTestModal"
|
||||
preset="card"
|
||||
title="测试工具"
|
||||
style="width: 600px"
|
||||
>
|
||||
<div class="test-content">
|
||||
<n-form label-placement="left" label-width="100">
|
||||
<n-form-item label="测试参数">
|
||||
<n-input
|
||||
v-model:value="testParams"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder='{"query": "测试搜索"}'
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div v-if="testResult.status === 'testing'" class="test-status">
|
||||
<n-spin size="small" />
|
||||
<span>测试中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testResult.status === 'success'" class="test-status success">
|
||||
<n-icon :component="CheckIcon" size="20" color="#18a058" />
|
||||
<span>测试成功</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="testResult.status === 'error'" class="test-status error">
|
||||
<n-icon :component="XIcon" size="20" color="#d03050" />
|
||||
<span>测试失败:{{ testResult.error }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.response" class="test-response">
|
||||
<h4>响应结果:</h4>
|
||||
<pre>{{ JSON.stringify(testResult.response, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="modal-footer">
|
||||
<n-button @click="showTestModal = false">关闭</n-button>
|
||||
<n-button type="primary" @click="runTest" :loading="testResult.status === 'testing'">
|
||||
运行测试
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import {
|
||||
NCard,
|
||||
NButton,
|
||||
NIcon,
|
||||
NTag,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NSwitch,
|
||||
NDropdown,
|
||||
NSpin,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
Plus as PlusIcon,
|
||||
Tool as ToolIcon,
|
||||
DotsVertical as DotsVerticalIcon,
|
||||
PlayerPlay as TestIcon,
|
||||
Edit as EditIcon,
|
||||
X as XIcon,
|
||||
Check as CheckIcon,
|
||||
Copy as CopyIcon,
|
||||
Trash as TrashIcon
|
||||
} from '@vicons/tabler'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 工具接口定义
|
||||
interface Tool {
|
||||
id: string
|
||||
name: string
|
||||
displayName?: string
|
||||
description: string
|
||||
endpoint: string
|
||||
parameters: Record<string, any>
|
||||
enabled: boolean
|
||||
createdAt?: Date
|
||||
updatedAt?: Date
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const tools = ref<Tool[]>([])
|
||||
const showAddModal = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const editingTool = ref<Tool | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
endpoint: '',
|
||||
parameters: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// 测试数据
|
||||
const testParams = ref('')
|
||||
const testResult = reactive({
|
||||
status: 'idle' as 'idle' | 'testing' | 'success' | 'error',
|
||||
error: '',
|
||||
response: null as any
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入工具名称', trigger: 'blur' }
|
||||
],
|
||||
endpoint: [
|
||||
{ required: true, message: '请输入端点地址', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取参数数量
|
||||
const getParameterCount = (tool: Tool): number => {
|
||||
return Object.keys(tool.parameters || {}).length
|
||||
}
|
||||
|
||||
// 下拉菜单选项
|
||||
const getDropdownOptions = () => [
|
||||
{
|
||||
label: '编辑',
|
||||
key: 'edit',
|
||||
icon: () => h(NIcon, null, { default: () => h(EditIcon) })
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
key: 'test',
|
||||
icon: () => h(NIcon, null, { default: () => h(TestIcon) })
|
||||
},
|
||||
{
|
||||
label: '复制配置',
|
||||
key: 'copy',
|
||||
icon: () => h(NIcon, null, { default: () => h(CopyIcon) })
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
key: 'delete',
|
||||
icon: () => h(NIcon, null, { default: () => h(TrashIcon) })
|
||||
}
|
||||
]
|
||||
|
||||
// 处理下拉菜单选择
|
||||
const handleDropdownSelect = (key: string, tool: Tool) => {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
editTool(tool)
|
||||
break
|
||||
case 'test':
|
||||
testTool(tool)
|
||||
break
|
||||
case 'copy':
|
||||
copyToolConfig(tool)
|
||||
break
|
||||
case 'delete':
|
||||
deleteTool(tool)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 切换工具启用状态
|
||||
const toggleTool = (tool: Tool) => {
|
||||
saveTools()
|
||||
message.success(`${tool.displayName || tool.name} 已${tool.enabled ? '启用' : '禁用'}`)
|
||||
}
|
||||
|
||||
// 编辑工具
|
||||
const editTool = (tool: Tool) => {
|
||||
editingTool.value = tool
|
||||
formData.name = tool.name
|
||||
formData.displayName = tool.displayName || ''
|
||||
formData.description = tool.description
|
||||
formData.endpoint = tool.endpoint
|
||||
formData.parameters = JSON.stringify(tool.parameters, null, 2)
|
||||
formData.enabled = tool.enabled
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 测试工具
|
||||
const testTool = (tool: Tool) => {
|
||||
editingTool.value = tool
|
||||
testParams.value = JSON.stringify({}, null, 2)
|
||||
testResult.status = 'idle'
|
||||
testResult.error = ''
|
||||
testResult.response = null
|
||||
showTestModal.value = true
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
const runTest = async () => {
|
||||
if (!editingTool.value) return
|
||||
|
||||
testResult.status = 'testing'
|
||||
testResult.error = ''
|
||||
testResult.response = null
|
||||
|
||||
try {
|
||||
const params = JSON.parse(testParams.value)
|
||||
|
||||
const response = await fetch(editingTool.value.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
testResult.status = 'success'
|
||||
testResult.response = data
|
||||
message.success('测试成功')
|
||||
} catch (error) {
|
||||
testResult.status = 'error'
|
||||
testResult.error = error instanceof Error ? error.message : '未知错误'
|
||||
message.error('测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 复制工具配置
|
||||
const copyToolConfig = (tool: Tool) => {
|
||||
const config = {
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
endpoint: tool.endpoint,
|
||||
parameters: tool.parameters
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(JSON.stringify(config, null, 2))
|
||||
.then(() => message.success('配置已复制到剪贴板'))
|
||||
.catch(() => message.error('复制失败'))
|
||||
}
|
||||
|
||||
// 删除工具
|
||||
const deleteTool = (tool: Tool) => {
|
||||
const index = tools.value.findIndex(t => t.id === tool.id)
|
||||
if (index !== -1) {
|
||||
tools.value.splice(index, 1)
|
||||
saveTools()
|
||||
message.success(`已删除工具 ${tool.displayName || tool.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存工具
|
||||
const handleSaveTool = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
let parameters = {}
|
||||
try {
|
||||
parameters = JSON.parse(formData.parameters || '{}')
|
||||
} catch {
|
||||
message.error('参数配置格式错误,请输入有效的 JSON')
|
||||
saving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const toolData: Tool = {
|
||||
id: editingTool.value?.id || generateId(),
|
||||
name: formData.name,
|
||||
displayName: formData.displayName,
|
||||
description: formData.description,
|
||||
endpoint: formData.endpoint,
|
||||
parameters,
|
||||
enabled: formData.enabled,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
|
||||
if (editingTool.value) {
|
||||
// 更新现有工具
|
||||
const index = tools.value.findIndex(t => t.id === editingTool.value!.id)
|
||||
if (index !== -1) {
|
||||
tools.value[index] = toolData
|
||||
}
|
||||
message.success('工具已更新')
|
||||
} else {
|
||||
// 添加新工具
|
||||
toolData.createdAt = new Date()
|
||||
tools.value.push(toolData)
|
||||
message.success('工具已添加')
|
||||
}
|
||||
|
||||
saveTools()
|
||||
showAddModal.value = false
|
||||
resetForm()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
editingTool.value = null
|
||||
formData.name = ''
|
||||
formData.displayName = ''
|
||||
formData.description = ''
|
||||
formData.endpoint = ''
|
||||
formData.parameters = ''
|
||||
formData.enabled = true
|
||||
}
|
||||
|
||||
// 生成 ID
|
||||
const generateId = (): string => {
|
||||
return `tool_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
const saveTools = () => {
|
||||
try {
|
||||
localStorage.setItem('mcp-tools', JSON.stringify(tools.value))
|
||||
} catch (error) {
|
||||
console.error('保存工具列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地存储加载
|
||||
const loadTools = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('mcp-tools')
|
||||
if (saved) {
|
||||
tools.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载工具列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadTools()
|
||||
})
|
||||
|
||||
// h 函数导入
|
||||
import { h } from 'vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-manager-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
font-size: 14px;
|
||||
color: var(--text-color-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-color-3);
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
color: var(--text-color-2);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tool-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--text-color-3);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: var(--text-color-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.test-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--color-target-bg);
|
||||
}
|
||||
|
||||
.test-status.success {
|
||||
background: rgba(24, 160, 88, 0.1);
|
||||
}
|
||||
|
||||
.test-status.error {
|
||||
background: rgba(208, 48, 80, 0.1);
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.test-response {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.test-response h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-response pre {
|
||||
background: var(--code-bg);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MCPServerConfig, ServerCapabilities, Tool, Resource, Prompt } from '../types';
|
||||
import type { MCPServerConfig } from '../types';
|
||||
import type { ServerCapabilities, Tool, Resource, Prompt } from '../types/index';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SSETransport } from './SSETransport';
|
||||
|
||||
@@ -302,6 +303,18 @@ export class MCPClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器的工具列表
|
||||
*/
|
||||
getTools(serverId: string): Tool[] {
|
||||
const serverInfo = this.clients.get(serverId);
|
||||
if (!serverInfo) {
|
||||
console.warn(`服务器 ${serverId} 未连接`);
|
||||
return [];
|
||||
}
|
||||
return serverInfo.capabilities?.tools || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示
|
||||
*/
|
||||
@@ -361,7 +374,7 @@ export class MCPClientService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { client, config } = serverInfo;
|
||||
const { client } = serverInfo;
|
||||
|
||||
try {
|
||||
if (client.type === 'sse') {
|
||||
|
||||
931
web/src/services/chatService.ts
Normal file
931
web/src/services/chatService.ts
Normal file
@@ -0,0 +1,931 @@
|
||||
import type {
|
||||
Topic,
|
||||
Message,
|
||||
Conversation,
|
||||
SendMessageOptions,
|
||||
StreamEvent,
|
||||
TopicFilter
|
||||
} from '../types/chat'
|
||||
import { modelServiceManager } from './modelServiceManager'
|
||||
import { MCPClientService } from './MCPClientService'
|
||||
|
||||
class ChatService {
|
||||
private static instance: ChatService
|
||||
private topics: Map<string, Topic> = new Map()
|
||||
private conversations: Map<string, Conversation> = new Map()
|
||||
private mcpClient: MCPClientService = new MCPClientService()
|
||||
|
||||
static getInstance(): ChatService {
|
||||
if (!ChatService.instance) {
|
||||
ChatService.instance = new ChatService()
|
||||
}
|
||||
return ChatService.instance
|
||||
}
|
||||
|
||||
// ==================== 话题管理 ====================
|
||||
|
||||
/**
|
||||
* 创建新话题
|
||||
*/
|
||||
createTopic(name: string, options?: {
|
||||
description?: string
|
||||
modelId?: string
|
||||
}): Topic {
|
||||
const topic: Topic = {
|
||||
id: this.generateId(),
|
||||
name: name || '新对话',
|
||||
description: options?.description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
messageCount: 0,
|
||||
pinned: false,
|
||||
archived: false,
|
||||
favorite: false,
|
||||
model: options?.modelId
|
||||
}
|
||||
|
||||
this.topics.set(topic.id, topic)
|
||||
this.saveTopics()
|
||||
|
||||
// 创建对应的对话
|
||||
const conversation: Conversation = {
|
||||
id: this.generateId(),
|
||||
topicId: topic.id,
|
||||
messages: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
metadata: {
|
||||
model: options?.modelId
|
||||
}
|
||||
}
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
|
||||
return topic
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有话题
|
||||
*/
|
||||
getTopics(filter?: TopicFilter): Topic[] {
|
||||
let topics = Array.from(this.topics.values())
|
||||
|
||||
if (filter) {
|
||||
if (filter.search) {
|
||||
const search = filter.search.toLowerCase()
|
||||
topics = topics.filter(t =>
|
||||
t.name.toLowerCase().includes(search) ||
|
||||
t.description?.toLowerCase().includes(search) ||
|
||||
t.lastMessage?.toLowerCase().includes(search)
|
||||
)
|
||||
}
|
||||
|
||||
if (filter.pinned !== undefined) {
|
||||
topics = topics.filter(t => t.pinned === filter.pinned)
|
||||
}
|
||||
|
||||
if (filter.archived !== undefined) {
|
||||
topics = topics.filter(t => t.archived === filter.archived)
|
||||
}
|
||||
|
||||
if (filter.favorite !== undefined) {
|
||||
topics = topics.filter(t => t.favorite === filter.favorite)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:置顶 > 更新时间
|
||||
return topics.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
return b.updatedAt.getTime() - a.updatedAt.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个话题
|
||||
*/
|
||||
getTopic(topicId: string): Topic | undefined {
|
||||
return this.topics.get(topicId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新话题
|
||||
*/
|
||||
updateTopic(topicId: string, updates: Partial<Topic>): Topic | undefined {
|
||||
const topic = this.topics.get(topicId)
|
||||
if (!topic) return undefined
|
||||
|
||||
Object.assign(topic, updates, {
|
||||
updatedAt: new Date()
|
||||
})
|
||||
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
|
||||
return topic
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除话题
|
||||
*/
|
||||
deleteTopic(topicId: string): boolean {
|
||||
const deleted = this.topics.delete(topicId)
|
||||
if (deleted) {
|
||||
// 删除关联的对话
|
||||
for (const [convId, conv] of this.conversations) {
|
||||
if (conv.topicId === topicId) {
|
||||
this.conversations.delete(convId)
|
||||
}
|
||||
}
|
||||
this.saveTopics()
|
||||
this.saveConversations()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换话题置顶状态
|
||||
*/
|
||||
toggleTopicPin(topicId: string): boolean {
|
||||
const topic = this.topics.get(topicId)
|
||||
if (!topic) return false
|
||||
|
||||
topic.pinned = !topic.pinned
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
|
||||
return topic.pinned
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换话题收藏状态
|
||||
*/
|
||||
toggleTopicFavorite(topicId: string): boolean {
|
||||
const topic = this.topics.get(topicId)
|
||||
if (!topic) return false
|
||||
|
||||
topic.favorite = !topic.favorite
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
|
||||
return topic.favorite
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档话题
|
||||
*/
|
||||
archiveTopic(topicId: string): boolean {
|
||||
const topic = this.topics.get(topicId)
|
||||
if (!topic) return false
|
||||
|
||||
topic.archived = true
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== 消息管理 ====================
|
||||
|
||||
/**
|
||||
* 获取话题的所有消息
|
||||
*/
|
||||
getMessages(topicId: string): Message[] {
|
||||
for (const conv of this.conversations.values()) {
|
||||
if (conv.topicId === topicId) {
|
||||
return conv.messages
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
*/
|
||||
async sendMessage(options: SendMessageOptions): Promise<Message> {
|
||||
const { topicId, content, role = 'user', model } = options
|
||||
|
||||
// 查找对话
|
||||
let conversation: Conversation | undefined
|
||||
for (const conv of this.conversations.values()) {
|
||||
if (conv.topicId === topicId) {
|
||||
conversation = conv
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('对话不存在')
|
||||
}
|
||||
|
||||
// 创建用户消息
|
||||
const userMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role,
|
||||
content,
|
||||
status: 'success',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
conversation.messages.push(userMessage)
|
||||
conversation.updatedAt = new Date()
|
||||
|
||||
// 更新话题
|
||||
const topic = this.topics.get(topicId)
|
||||
if (topic) {
|
||||
topic.messageCount = conversation.messages.length
|
||||
topic.lastMessage = this.getMessagePreview(content)
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
|
||||
// 如果不是用户消息,直接返回
|
||||
if (role !== 'user') {
|
||||
return userMessage
|
||||
}
|
||||
|
||||
// 创建助手消息占位符
|
||||
const assistantMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
status: 'sending',
|
||||
timestamp: new Date(),
|
||||
model: model || conversation.metadata?.model
|
||||
}
|
||||
|
||||
conversation.messages.push(assistantMessage)
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
|
||||
try {
|
||||
// 调用 AI 模型
|
||||
const response = await this.callModel(conversation, model)
|
||||
|
||||
// 更新助手消息
|
||||
assistantMessage.content = response.content
|
||||
assistantMessage.status = 'success'
|
||||
assistantMessage.tokens = response.tokens
|
||||
|
||||
conversation.updatedAt = new Date()
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
|
||||
// 更新话题
|
||||
if (topic) {
|
||||
topic.messageCount = conversation.messages.length
|
||||
topic.lastMessage = this.getMessagePreview(response.content)
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
|
||||
return assistantMessage
|
||||
} catch (error) {
|
||||
assistantMessage.status = 'error'
|
||||
assistantMessage.error = error instanceof Error ? error.message : '发送失败'
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送消息
|
||||
*/
|
||||
async sendMessageStream(
|
||||
options: SendMessageOptions,
|
||||
onChunk: (event: StreamEvent) => void,
|
||||
mcpServerId?: string // 新增:可选的 MCP 服务器 ID
|
||||
): Promise<void> {
|
||||
const { topicId, content, role = 'user', model } = options
|
||||
|
||||
// 查找对话
|
||||
let conversation: Conversation | undefined
|
||||
for (const conv of this.conversations.values()) {
|
||||
if (conv.topicId === topicId) {
|
||||
conversation = conv
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('对话不存在')
|
||||
}
|
||||
|
||||
// 创建用户消息
|
||||
const userMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role,
|
||||
content,
|
||||
status: 'success',
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
conversation.messages.push(userMessage)
|
||||
conversation.updatedAt = new Date()
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
|
||||
// 更新话题(用户消息)
|
||||
const topic = this.topics.get(topicId)
|
||||
if (topic) {
|
||||
topic.messageCount = conversation.messages.length
|
||||
topic.lastMessage = this.getMessagePreview(content)
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
|
||||
// 创建助手消息
|
||||
const assistantMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
status: 'sending',
|
||||
timestamp: new Date(),
|
||||
model: model || conversation.metadata?.model
|
||||
}
|
||||
|
||||
conversation.messages.push(assistantMessage)
|
||||
conversation.updatedAt = new Date()
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
|
||||
// 再次更新话题计数
|
||||
if (topic) {
|
||||
topic.messageCount = conversation.messages.length
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
|
||||
onChunk({ type: 'start', messageId: assistantMessage.id })
|
||||
|
||||
try {
|
||||
// 调用流式 API
|
||||
await this.callModelStream(
|
||||
conversation,
|
||||
model,
|
||||
(chunk) => {
|
||||
assistantMessage.content += chunk
|
||||
conversation.updatedAt = new Date()
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
onChunk({ type: 'delta', content: chunk, messageId: assistantMessage.id })
|
||||
},
|
||||
mcpServerId // 传递 MCP 服务器 ID
|
||||
)
|
||||
|
||||
assistantMessage.status = 'success'
|
||||
conversation.updatedAt = new Date()
|
||||
this.conversations.set(conversation.id, conversation)
|
||||
this.saveConversations()
|
||||
onChunk({ type: 'end', messageId: assistantMessage.id })
|
||||
|
||||
// 更新话题(完成)
|
||||
if (topic) {
|
||||
topic.messageCount = conversation.messages.length
|
||||
topic.lastMessage = this.getMessagePreview(assistantMessage.content)
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
} catch (error) {
|
||||
assistantMessage.status = 'error'
|
||||
assistantMessage.error = error instanceof Error ? error.message : '发送失败'
|
||||
this.saveConversations()
|
||||
onChunk({
|
||||
type: 'error',
|
||||
error: assistantMessage.error,
|
||||
messageId: assistantMessage.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
deleteMessage(topicId: string, messageId: string): boolean {
|
||||
for (const conv of this.conversations.values()) {
|
||||
if (conv.topicId === topicId) {
|
||||
const index = conv.messages.findIndex(m => m.id === messageId)
|
||||
if (index !== -1) {
|
||||
conv.messages.splice(index, 1)
|
||||
conv.updatedAt = new Date()
|
||||
this.conversations.set(conv.id, conv)
|
||||
this.saveConversations()
|
||||
|
||||
// 更新话题
|
||||
const topic = this.topics.get(topicId)
|
||||
if (topic) {
|
||||
topic.messageCount = conv.messages.length
|
||||
if (conv.messages.length > 0) {
|
||||
const lastMsg = conv.messages[conv.messages.length - 1]
|
||||
topic.lastMessage = this.getMessagePreview(lastMsg.content)
|
||||
} else {
|
||||
topic.lastMessage = undefined
|
||||
}
|
||||
topic.updatedAt = new Date()
|
||||
this.topics.set(topicId, topic)
|
||||
this.saveTopics()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成消息
|
||||
*/
|
||||
async regenerateMessage(topicId: string, messageId: string): Promise<Message> {
|
||||
// 找到要重新生成的消息
|
||||
let conversation: Conversation | undefined
|
||||
let messageIndex = -1
|
||||
|
||||
for (const conv of this.conversations.values()) {
|
||||
if (conv.topicId === topicId) {
|
||||
conversation = conv
|
||||
messageIndex = conv.messages.findIndex(m => m.id === messageId)
|
||||
if (messageIndex !== -1) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!conversation || messageIndex === -1) {
|
||||
throw new Error('消息不存在')
|
||||
}
|
||||
|
||||
// 删除该消息之后的所有消息
|
||||
conversation.messages.splice(messageIndex)
|
||||
|
||||
// 获取最后一条用户消息
|
||||
let lastUserMessage: Message | undefined
|
||||
for (let i = conversation.messages.length - 1; i >= 0; i--) {
|
||||
if (conversation.messages[i].role === 'user') {
|
||||
lastUserMessage = conversation.messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastUserMessage) {
|
||||
throw new Error('没有找到用户消息')
|
||||
}
|
||||
|
||||
// 重新发送
|
||||
return await this.sendMessage({
|
||||
topicId,
|
||||
content: lastUserMessage.content,
|
||||
model: conversation.metadata?.model
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 私有方法 ====================
|
||||
|
||||
/**
|
||||
* 调用模型
|
||||
*/
|
||||
private async callModel(
|
||||
conversation: Conversation,
|
||||
model?: string
|
||||
): Promise<{ content: string; tokens?: any }> {
|
||||
const callModelStartTime = performance.now()
|
||||
console.log('⏱️ [callModel] 开始处理', { model, 对话消息数: conversation.messages.length })
|
||||
|
||||
// 准备消息历史
|
||||
const beforePrepare = performance.now()
|
||||
const messages = conversation.messages
|
||||
.filter(m => m.status === 'success')
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
|
||||
const afterPrepare = performance.now()
|
||||
console.log('⏱️ [callModel] 准备消息耗时:', (afterPrepare - beforePrepare).toFixed(2), 'ms', '处理后消息数:', messages.length)
|
||||
|
||||
// 获取已连接的服务 - 从 modelServiceManager 获取
|
||||
const allServices = modelServiceManager.getAllServices()
|
||||
console.log('🔍 [callModel] 所有服务:', allServices.map(s => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
models: s.models?.length || 0
|
||||
})))
|
||||
|
||||
const services = allServices.filter(s => s.status === 'connected')
|
||||
console.log('🔍 [callModel] 已连接的服务:', services.length, '个')
|
||||
|
||||
if (services.length === 0) {
|
||||
console.error('❌ [callModel] 没有已连接的服务!')
|
||||
console.error('📋 [callModel] 请检查:')
|
||||
console.error(' 1. 是否在"模型服务"中添加了服务?')
|
||||
console.error(' 2. 服务是否已启用(enabled=true)?')
|
||||
console.error(' 3. 服务是否有可用的模型列表?')
|
||||
console.error(' 4. localStorage中的数据:', localStorage.getItem('model-providers'))
|
||||
throw new Error('没有可用的模型服务,请先在"模型服务"中添加并连接服务')
|
||||
}
|
||||
|
||||
let service = services[0] // 默认使用第一个可用服务
|
||||
let selectedModel = model || service.models?.[0] || 'default'
|
||||
|
||||
// 如果指定了模型,尝试找到拥有该模型的服务
|
||||
if (model) {
|
||||
const foundService = services.find(s =>
|
||||
s.models && s.models.includes(model)
|
||||
)
|
||||
if (foundService) {
|
||||
service = foundService
|
||||
selectedModel = model
|
||||
} else {
|
||||
console.warn(`⚠️ 未找到包含模型 "${model}" 的服务,使用默认服务`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 [callModel] 使用服务:', service.name, '模型:', selectedModel)
|
||||
|
||||
// 调用服务
|
||||
const beforeServiceCall = performance.now()
|
||||
const result = await modelServiceManager.sendChatRequest(
|
||||
service.id,
|
||||
messages,
|
||||
selectedModel
|
||||
)
|
||||
const afterServiceCall = performance.now()
|
||||
console.log('⏱️ [callModel] 服务调用耗时:', (afterServiceCall - beforeServiceCall).toFixed(2), 'ms')
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '请求失败')
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const beforeParse = performance.now()
|
||||
const parsedContent = this.parseModelResponse(result.data)
|
||||
const afterParse = performance.now()
|
||||
console.log('⏱️ [callModel] 解析响应耗时:', (afterParse - beforeParse).toFixed(2), 'ms')
|
||||
console.log('⏱️ [callModel] callModel总耗时:', (afterParse - callModelStartTime).toFixed(2), 'ms')
|
||||
|
||||
return {
|
||||
content: parsedContent,
|
||||
tokens: result.data?.usage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式调用模型
|
||||
*/
|
||||
private async callModelStream(
|
||||
conversation: Conversation,
|
||||
model: string | undefined,
|
||||
onChunk: (chunk: string) => void,
|
||||
mcpServerId?: string // 可选的 MCP 服务器 ID
|
||||
): Promise<void> {
|
||||
const streamStartTime = performance.now()
|
||||
console.log('⏱️ [callModelStream] 开始真流式处理')
|
||||
|
||||
// 获取 MCP 工具列表(如果选择了 MCP 服务器)
|
||||
let tools: any[] = []
|
||||
if (mcpServerId) {
|
||||
console.log('🔧 [callModelStream] 获取 MCP 服务器工具:', mcpServerId)
|
||||
const mcpTools = this.mcpClient.getTools(mcpServerId)
|
||||
tools = this.convertToolsToOpenAIFormat(mcpTools)
|
||||
console.log('🔧 [callModelStream] 转换后的工具:', tools.length, '个')
|
||||
}
|
||||
|
||||
// 准备消息历史
|
||||
const messages = conversation.messages
|
||||
.filter(m => m.status === 'success')
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
|
||||
// 获取已连接的服务
|
||||
const allServices = modelServiceManager.getAllServices()
|
||||
const services = allServices.filter(s => s.status === 'connected')
|
||||
|
||||
if (services.length === 0) {
|
||||
throw new Error('没有可用的模型服务,请先在"模型服务"中添加并连接服务')
|
||||
}
|
||||
|
||||
let service = services[0]
|
||||
let selectedModel = model || service.models?.[0] || 'default'
|
||||
|
||||
// 如果指定了模型,尝试找到拥有该模型的服务
|
||||
if (model) {
|
||||
const foundService = services.find(s =>
|
||||
s.models && s.models.includes(model)
|
||||
)
|
||||
if (foundService) {
|
||||
service = foundService
|
||||
selectedModel = model
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 [callModelStream] 使用流式服务:', service.name, '模型:', selectedModel)
|
||||
console.log('🚀 [callModelStream] === 开始真正的流式请求 ===')
|
||||
|
||||
// 调用真正的流式API
|
||||
const beforeStreamCall = performance.now()
|
||||
let chunkCount = 0
|
||||
let buffer = '' // 缓冲区,用于批量输出
|
||||
const BATCH_SIZE = 3 // 每3个字符输出一次,增强流式效果
|
||||
|
||||
const result = await modelServiceManager.sendChatRequestStream(
|
||||
service.id,
|
||||
messages,
|
||||
selectedModel,
|
||||
(chunk) => {
|
||||
// 实时输出,但批量处理增强视觉效果
|
||||
chunkCount++
|
||||
if (chunkCount === 1) {
|
||||
const firstChunkTime = performance.now()
|
||||
console.log('⚡ [callModelStream] 首字延迟:', (firstChunkTime - beforeStreamCall).toFixed(2), 'ms')
|
||||
}
|
||||
|
||||
// 累积到缓冲区
|
||||
buffer += chunk
|
||||
|
||||
// 当缓冲区达到批量大小时输出
|
||||
if (buffer.length >= BATCH_SIZE) {
|
||||
const output = buffer
|
||||
buffer = ''
|
||||
onChunk(output)
|
||||
}
|
||||
},
|
||||
tools.length > 0 ? tools : undefined
|
||||
)
|
||||
|
||||
// 输出剩余的缓冲区内容
|
||||
if (buffer.length > 0) {
|
||||
onChunk(buffer)
|
||||
}
|
||||
|
||||
const afterStreamCall = performance.now()
|
||||
console.log('🚀 [callModelStream] 流式请求完成,收到块数:', chunkCount)
|
||||
console.log('⏱️ [callModelStream] 流式调用总耗时:', (afterStreamCall - beforeStreamCall).toFixed(2), 'ms')
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '流式请求失败')
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (result.data?.toolCalls && result.data.toolCalls.length > 0 && mcpServerId) {
|
||||
console.log('🔧 [callModelStream] 开始执行工具调用')
|
||||
await this.executeToolCalls(conversation, result.data.toolCalls, mcpServerId, model, onChunk)
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log('⏱️ [callModelStream] 真流式总耗时:', (endTime - streamStartTime).toFixed(2), 'ms')
|
||||
} /**
|
||||
* 解析模型响应
|
||||
*/
|
||||
private parseModelResponse(data: any, _serviceType?: string): string {
|
||||
if (!data) return ''
|
||||
|
||||
// OpenAI 格式
|
||||
if (data.choices && data.choices[0]?.message?.content) {
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
// Claude 格式
|
||||
if (data.content && Array.isArray(data.content)) {
|
||||
return data.content
|
||||
.filter((c: any) => c.type === 'text')
|
||||
.map((c: any) => c.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Gemini 格式
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
return data.candidates[0].content.parts
|
||||
.map((p: any) => p.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
// 通用格式
|
||||
if (typeof data === 'string') return data
|
||||
if (data.content) return data.content
|
||||
if (data.text) return data.text
|
||||
if (data.message) return data.message
|
||||
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息预览
|
||||
*/
|
||||
private getMessagePreview(content: string, maxLength = 50): string {
|
||||
if (!content) return ''
|
||||
const text = content.replace(/\n/g, ' ').trim()
|
||||
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// ==================== 持久化 ====================
|
||||
|
||||
private saveTopics(): void {
|
||||
try {
|
||||
const data = Array.from(this.topics.values())
|
||||
localStorage.setItem('chat-topics', JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('保存话题失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private loadTopics(): void {
|
||||
try {
|
||||
const data = localStorage.getItem('chat-topics')
|
||||
if (data) {
|
||||
const topics = JSON.parse(data) as Topic[]
|
||||
topics.forEach(topic => {
|
||||
// 恢复 Date 对象
|
||||
topic.createdAt = new Date(topic.createdAt)
|
||||
topic.updatedAt = new Date(topic.updatedAt)
|
||||
this.topics.set(topic.id, topic)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载话题失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private saveConversations(): void {
|
||||
try {
|
||||
const data = Array.from(this.conversations.values())
|
||||
localStorage.setItem('chat-conversations', JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('保存对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private loadConversations(): void {
|
||||
try {
|
||||
const data = localStorage.getItem('chat-conversations')
|
||||
if (data) {
|
||||
const conversations = JSON.parse(data) as Conversation[]
|
||||
conversations.forEach(conv => {
|
||||
// 恢复 Date 对象
|
||||
conv.createdAt = new Date(conv.createdAt)
|
||||
conv.updatedAt = new Date(conv.updatedAt)
|
||||
conv.messages.forEach(msg => {
|
||||
msg.timestamp = new Date(msg.timestamp)
|
||||
})
|
||||
this.conversations.set(conv.id, conv)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载对话失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
initialize(): void {
|
||||
this.loadTopics()
|
||||
this.loadConversations()
|
||||
|
||||
// 如果没有话题,创建默认话题
|
||||
if (this.topics.size === 0) {
|
||||
this.createTopic('欢迎使用', {
|
||||
description: '开始你的第一次对话'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 MCP 工具转换为 OpenAI 函数调用格式
|
||||
*/
|
||||
private convertToolsToOpenAIFormat(mcpTools: any[]): any[] {
|
||||
return mcpTools.map(tool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
parameters: tool.inputSchema || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行工具调用并将结果返回给 AI
|
||||
*/
|
||||
private async executeToolCalls(
|
||||
conversation: Conversation,
|
||||
toolCalls: any[],
|
||||
mcpServerId: string,
|
||||
model: string | undefined,
|
||||
onChunk: (chunk: string) => void
|
||||
): Promise<void> {
|
||||
console.log('🔧 [executeToolCalls] 执行', toolCalls.length, '个工具调用')
|
||||
|
||||
// 添加工具调用信息到消息中
|
||||
const toolCallMessage = {
|
||||
role: 'assistant' as const,
|
||||
content: '',
|
||||
tool_calls: toolCalls
|
||||
}
|
||||
|
||||
// 执行每个工具调用
|
||||
const toolResults = []
|
||||
for (const toolCall of toolCalls) {
|
||||
try {
|
||||
const functionName = toolCall.function.name
|
||||
const functionArgs = JSON.parse(toolCall.function.arguments)
|
||||
|
||||
console.log(`🔧 [executeToolCalls] 调用工具: ${functionName}`, functionArgs)
|
||||
onChunk(`\n\n🔧 正在调用工具: ${functionName}...\n`)
|
||||
|
||||
const result = await this.mcpClient.callTool(mcpServerId, functionName, functionArgs)
|
||||
|
||||
console.log(`✅ [executeToolCalls] 工具调用成功: ${functionName}`, result)
|
||||
onChunk(`✅ 工具执行完成\n`)
|
||||
|
||||
toolResults.push({
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
name: functionName,
|
||||
content: JSON.stringify(result)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`❌ [executeToolCalls] 工具调用失败:`, error)
|
||||
const errorMsg = error instanceof Error ? error.message : '未知错误'
|
||||
onChunk(`❌ 工具执行失败: ${errorMsg}\n`)
|
||||
|
||||
toolResults.push({
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
name: toolCall.function.name,
|
||||
content: JSON.stringify({ error: errorMsg })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 将工具调用和结果添加到消息历史
|
||||
const messages = conversation.messages
|
||||
.filter(m => m.status === 'success')
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}))
|
||||
|
||||
messages.push(toolCallMessage as any)
|
||||
messages.push(...(toolResults as any[]))
|
||||
|
||||
// 获取已连接的服务
|
||||
const allServices = modelServiceManager.getAllServices()
|
||||
const services = allServices.filter(s => s.status === 'connected')
|
||||
if (services.length === 0) {
|
||||
throw new Error('没有可用的模型服务')
|
||||
}
|
||||
|
||||
let service = services[0]
|
||||
let selectedModel = model || service.models?.[0] || 'default'
|
||||
|
||||
if (model) {
|
||||
const foundService = services.find(s =>
|
||||
s.models && s.models.includes(model)
|
||||
)
|
||||
if (foundService) {
|
||||
service = foundService
|
||||
selectedModel = model
|
||||
}
|
||||
}
|
||||
|
||||
// 向 AI 发送工具结果,获取最终回复
|
||||
console.log('🤖 [executeToolCalls] 将工具结果发送给 AI')
|
||||
onChunk('\n\n🤖 正在生成回复...\n')
|
||||
|
||||
await modelServiceManager.sendChatRequestStream(
|
||||
service.id,
|
||||
messages,
|
||||
selectedModel,
|
||||
onChunk
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有服务(供外部使用)
|
||||
*/
|
||||
getAllServices() {
|
||||
return modelServiceManager.getAllServices()
|
||||
}
|
||||
}
|
||||
|
||||
export const chatService = ChatService.getInstance()
|
||||
954
web/src/services/modelServiceManager.ts
Normal file
954
web/src/services/modelServiceManager.ts
Normal file
@@ -0,0 +1,954 @@
|
||||
export interface ModelService {
|
||||
id: string
|
||||
name: string
|
||||
type: 'openai' | 'claude' | 'gemini' | 'azure' | 'local' | 'dashscope' | 'volcengine' | 'custom'
|
||||
url: string
|
||||
apiKey: string
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error'
|
||||
models?: string[]
|
||||
lastUsed?: Date
|
||||
customConfig?: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class ModelServiceManager {
|
||||
private services: Map<string, ModelService> = new Map()
|
||||
private static instance: ModelServiceManager
|
||||
|
||||
static getInstance(): ModelServiceManager {
|
||||
if (!ModelServiceManager.instance) {
|
||||
ModelServiceManager.instance = new ModelServiceManager()
|
||||
// 自动加载保存的服务
|
||||
ModelServiceManager.instance.loadFromModelStore()
|
||||
}
|
||||
return ModelServiceManager.instance
|
||||
}
|
||||
|
||||
// 从 modelStore (localStorage) 加载服务配置
|
||||
loadFromModelStore(): void {
|
||||
try {
|
||||
const saved = localStorage.getItem('model-providers')
|
||||
if (!saved) {
|
||||
console.log('🔍 [loadFromModelStore] 没有找到保存的服务')
|
||||
return
|
||||
}
|
||||
|
||||
const providers = JSON.parse(saved)
|
||||
console.log('🔍 [loadFromModelStore] 加载服务:', providers.length, '个')
|
||||
|
||||
providers.forEach((provider: any) => {
|
||||
// 将 modelStore 的 provider 格式转换为 ModelService 格式
|
||||
// 关键判断逻辑:
|
||||
// 1. enabled === true (明确启用)
|
||||
// 2. connected === true (已连接)
|
||||
// 3. 如果两者都是 undefined,但有 apiKey,也认为是可用的
|
||||
const isEnabled = provider.enabled === true || provider.connected === true
|
||||
const hasApiKey = provider.apiKey && provider.apiKey.length > 0
|
||||
const shouldConnect = isEnabled || (provider.enabled !== false && hasApiKey)
|
||||
|
||||
// 解析模型列表
|
||||
let modelList: string[] = []
|
||||
if (provider.models && Array.isArray(provider.models)) {
|
||||
modelList = provider.models.map((m: any) =>
|
||||
typeof m === 'string' ? m : (m.id || m.name || '')
|
||||
).filter((m: string) => m.length > 0)
|
||||
}
|
||||
|
||||
const service: ModelService = {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
type: this.mapProviderType(provider.type),
|
||||
url: provider.baseUrl || provider.url || '',
|
||||
apiKey: provider.apiKey || '',
|
||||
status: shouldConnect ? 'connected' : 'disconnected',
|
||||
models: modelList
|
||||
}
|
||||
|
||||
this.services.set(service.id, service)
|
||||
console.log('🔍 [loadFromModelStore] 添加服务:', {
|
||||
name: service.name,
|
||||
enabled: provider.enabled,
|
||||
connected: provider.connected,
|
||||
hasApiKey,
|
||||
shouldConnect,
|
||||
status: service.status,
|
||||
模型数: service.models?.length,
|
||||
前3个模型: service.models?.slice(0, 3)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ [loadFromModelStore] 加载失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 provider type 到 service type
|
||||
private mapProviderType(type: string): ModelService['type'] {
|
||||
const map: Record<string, ModelService['type']> = {
|
||||
'openai': 'openai',
|
||||
'claude': 'claude',
|
||||
'google': 'gemini',
|
||||
'ollama': 'local',
|
||||
'volcengine': 'volcengine', // 火山引擎
|
||||
'dashscope': 'dashscope', // 阿里云通义千问
|
||||
'azure': 'azure',
|
||||
'local': 'local',
|
||||
'custom': 'custom'
|
||||
}
|
||||
const mapped = map[type] || 'custom'
|
||||
console.log('🔍 [mapProviderType]', type, '→', mapped)
|
||||
return mapped
|
||||
}
|
||||
|
||||
// 测试服务连接
|
||||
async testConnection(service: ModelService): Promise<ApiResponse<{ models: string[] }>> {
|
||||
try {
|
||||
const models = await this.fetchModels(service)
|
||||
return {
|
||||
success: true,
|
||||
data: { models }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '连接失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试服务连接(用于预定义模型列表的服务)
|
||||
private async testServiceConnection(service: ModelService): Promise<void> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
// 设置认证头
|
||||
switch (service.type) {
|
||||
case 'volcengine':
|
||||
case 'openai':
|
||||
case 'local':
|
||||
case 'dashscope':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
break
|
||||
case 'claude':
|
||||
headers['x-api-key'] = service.apiKey
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
break
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
try {
|
||||
// 发送一个简单的测试请求
|
||||
const testUrl = `${service.url}/chat/completions`
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: service.type === 'volcengine' ? 'doubao-lite-4k' : 'test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1
|
||||
})
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// 只要不是认证错误就算通过
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`认证失败: ${errorText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('连接超时')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('连接测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取可用模型列表
|
||||
private async fetchModels(service: ModelService): Promise<string[]> {
|
||||
// 某些服务使用预定义模型列表,不需要 API 调用
|
||||
const url = this.getModelsEndpoint(service)
|
||||
if (!url) {
|
||||
// 对于使用预定义模型列表的服务,发送一个测试请求验证连接
|
||||
await this.testServiceConnection(service)
|
||||
// 返回预定义模型列表
|
||||
return this.parseModelsResponse({}, service.type)
|
||||
}
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
// 根据服务类型设置认证头
|
||||
switch (service.type) {
|
||||
case 'openai':
|
||||
case 'local':
|
||||
case 'dashscope':
|
||||
case 'volcengine':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
break
|
||||
case 'claude':
|
||||
headers['x-api-key'] = service.apiKey
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
break
|
||||
case 'gemini':
|
||||
// Gemini使用URL参数传递API密钥
|
||||
break
|
||||
case 'azure':
|
||||
headers['api-key'] = service.apiKey
|
||||
break
|
||||
case 'custom':
|
||||
// 解析自定义配置
|
||||
try {
|
||||
const config = JSON.parse(service.customConfig || '{}')
|
||||
Object.assign(headers, config.headers || {})
|
||||
} catch (e) {
|
||||
console.warn('自定义配置解析失败:', e)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10秒超时
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return this.parseModelsResponse(data, service.type)
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('连接超时')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
throw new Error('未知错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模型列表API端点
|
||||
private getModelsEndpoint(service: ModelService): string {
|
||||
switch (service.type) {
|
||||
case 'openai':
|
||||
case 'local':
|
||||
return `${service.url}/models`
|
||||
case 'dashscope':
|
||||
// 阿里云 DashScope 使用 /models 端点
|
||||
return `${service.url}/models`
|
||||
case 'volcengine':
|
||||
// 火山引擎使用预定义模型列表(API 不提供 /models 端点)
|
||||
return ''
|
||||
case 'claude':
|
||||
// Claude API 没有公开的模型列表端点,返回预定义模型
|
||||
return ''
|
||||
case 'gemini':
|
||||
return `${service.url}/models?key=${service.apiKey}`
|
||||
case 'azure':
|
||||
// Azure OpenAI 使用不同的端点格式
|
||||
const azureUrl = service.url.replace(/\/$/, '')
|
||||
return `${azureUrl}/openai/deployments?api-version=2023-12-01-preview`
|
||||
case 'custom':
|
||||
return `${service.url}/models`
|
||||
default:
|
||||
return `${service.url}/models`
|
||||
}
|
||||
}
|
||||
|
||||
// 解析不同服务的模型响应
|
||||
private parseModelsResponse(data: any, type: string): string[] {
|
||||
switch (type) {
|
||||
case 'openai':
|
||||
case 'local':
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data.data.map((model: any) => model.id).filter(Boolean)
|
||||
}
|
||||
break
|
||||
|
||||
case 'dashscope':
|
||||
// 阿里云 DashScope 格式
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data.data.map((model: any) => model.id || model.model_id).filter(Boolean)
|
||||
}
|
||||
// 如果返回格式不同,尝试其他可能的格式
|
||||
if (data.models && Array.isArray(data.models)) {
|
||||
return data.models.map((model: any) => model.id || model.model_id || model.name).filter(Boolean)
|
||||
}
|
||||
break
|
||||
|
||||
case 'volcengine':
|
||||
// 火山引擎推荐模型列表
|
||||
// 参考: https://www.volcengine.com/docs/82379/1330310
|
||||
return [
|
||||
// DeepSeek-V3 系列 - 深度思考模型
|
||||
'deepseek-v3-1-terminus', // DeepSeek V3.1 terminus版本
|
||||
'deepseek-v3-1-250821', // DeepSeek V3.1 250821版本
|
||||
|
||||
// Doubao Seed 1.6 系列 - 深度思考模型(推荐)
|
||||
'doubao-seed-1-6-vision-250815', // 多模态深度思考(图片+视频+GUI)
|
||||
'doubao-seed-1-6-250615', // 纯文本深度思考
|
||||
'doubao-seed-1-6-flash-250828', // 快速多模态深度思考
|
||||
'doubao-seed-1-6-thinking-250715', // 纯思考模型
|
||||
]
|
||||
|
||||
case 'claude':
|
||||
// Claude 预定义模型列表
|
||||
return [
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229'
|
||||
]
|
||||
|
||||
case 'gemini':
|
||||
if (data.models && Array.isArray(data.models)) {
|
||||
return data.models
|
||||
.map((model: any) => model.name?.replace('models/', ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
break
|
||||
|
||||
case 'azure':
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data.data.map((deployment: any) => deployment.id).filter(Boolean)
|
||||
}
|
||||
break
|
||||
|
||||
case 'custom':
|
||||
// 尝试多种可能的响应格式
|
||||
if (data.models && Array.isArray(data.models)) {
|
||||
return data.models.map((m: any) => typeof m === 'string' ? m : m.id || m.name).filter(Boolean)
|
||||
}
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
return data.data.map((m: any) => typeof m === 'string' ? m : m.id || m.name).filter(Boolean)
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((m: any) => typeof m === 'string' ? m : m.id || m.name).filter(Boolean)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
// 发送聊天请求
|
||||
async sendChatRequest(serviceId: string, messages: any[], model: string): Promise<ApiResponse<any>> {
|
||||
const startTime = performance.now()
|
||||
console.log('⏱️ [sendChatRequest] 开始请求', { serviceId, model, messages数量: messages.length })
|
||||
|
||||
const service = this.services.get(serviceId)
|
||||
console.log('🔍 [sendChatRequest] serviceId:', serviceId, 'service:', service)
|
||||
|
||||
if (!service || service.status !== 'connected') {
|
||||
return {
|
||||
success: false,
|
||||
error: '服务未连接'
|
||||
}
|
||||
}
|
||||
|
||||
// 检查URL是否有效
|
||||
if (!service.url || !service.url.startsWith('http')) {
|
||||
console.error('❌ [sendChatRequest] 无效的服务URL:', service.url)
|
||||
return {
|
||||
success: false,
|
||||
error: `服务URL无效: ${service.url}`
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const beforeRequest = performance.now()
|
||||
console.log('⏱️ [sendChatRequest] 准备耗时:', (beforeRequest - startTime).toFixed(2), 'ms')
|
||||
|
||||
const response = await this.makeChatRequest(service, messages, model)
|
||||
|
||||
const afterRequest = performance.now()
|
||||
console.log('⏱️ [sendChatRequest] 请求耗时:', (afterRequest - beforeRequest).toFixed(2), 'ms')
|
||||
console.log('⏱️ [sendChatRequest] 总耗时:', (afterRequest - startTime).toFixed(2), 'ms')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [sendChatRequest] 请求异常:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '请求失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送流式聊天请求
|
||||
async sendChatRequestStream(
|
||||
serviceId: string,
|
||||
messages: any[],
|
||||
model: string,
|
||||
onChunk: (chunk: string) => void,
|
||||
tools?: any[]
|
||||
): Promise<ApiResponse<{ toolCalls?: any[] }>> {
|
||||
const startTime = performance.now()
|
||||
console.log('🚀🚀🚀 [sendChatRequestStream] === 进入流式请求方法 ===')
|
||||
console.log('⏱️ [sendChatRequestStream] 开始流式请求', { serviceId, model, messages数量: messages.length })
|
||||
|
||||
const service = this.services.get(serviceId)
|
||||
|
||||
if (!service || service.status !== 'connected') {
|
||||
return {
|
||||
success: false,
|
||||
error: '服务未连接'
|
||||
}
|
||||
}
|
||||
|
||||
if (!service.url || !service.url.startsWith('http')) {
|
||||
return {
|
||||
success: false,
|
||||
error: `服务URL无效: ${service.url}`
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const toolCalls = await this.makeChatRequestStream(service, messages, model, onChunk, tools)
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log('⏱️ [sendChatRequestStream] 流式请求完成,总耗时:', (endTime - startTime).toFixed(2), 'ms')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { toolCalls }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [sendChatRequestStream] 流式请求异常:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '流式请求失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实际的聊天请求
|
||||
private async makeChatRequest(service: ModelService, messages: any[], model: string): Promise<any> {
|
||||
const requestStartTime = performance.now()
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
let url = ''
|
||||
let body: any = {}
|
||||
|
||||
console.log('🔍 [makeChatRequest] 服务信息:', {
|
||||
type: service.type,
|
||||
name: service.name,
|
||||
url: service.url,
|
||||
model
|
||||
})
|
||||
|
||||
switch (service.type) {
|
||||
case 'openai':
|
||||
case 'local':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false
|
||||
}
|
||||
break
|
||||
|
||||
case 'dashscope':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false
|
||||
}
|
||||
break
|
||||
|
||||
case 'volcengine':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false
|
||||
}
|
||||
break
|
||||
|
||||
case 'claude':
|
||||
headers['x-api-key'] = service.apiKey
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
url = `${service.url}/messages`
|
||||
body = {
|
||||
model,
|
||||
messages: this.convertToClaudeFormat(messages),
|
||||
max_tokens: 4096
|
||||
}
|
||||
break
|
||||
|
||||
case 'gemini':
|
||||
url = `${service.url}/models/${model}:generateContent?key=${service.apiKey}`
|
||||
body = {
|
||||
contents: this.convertToGeminiFormat(messages)
|
||||
}
|
||||
break
|
||||
|
||||
case 'azure':
|
||||
headers['api-key'] = service.apiKey
|
||||
url = `${service.url}/openai/deployments/${model}/chat/completions?api-version=2023-12-01-preview`
|
||||
body = {
|
||||
messages,
|
||||
stream: false
|
||||
}
|
||||
break
|
||||
|
||||
case 'custom':
|
||||
try {
|
||||
const config = JSON.parse(service.customConfig || '{}')
|
||||
Object.assign(headers, config.headers || {})
|
||||
} catch (e) {
|
||||
console.warn('自定义配置解析失败:', e)
|
||||
}
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
console.log('🔍 [makeChatRequest] 最终请求URL:', url)
|
||||
console.log('🔍 [makeChatRequest] 请求体大小:', JSON.stringify(body).length, '字节')
|
||||
|
||||
const beforeFetch = performance.now()
|
||||
console.log('⏱️ [makeChatRequest] 构建请求耗时:', (beforeFetch - requestStartTime).toFixed(2), 'ms')
|
||||
|
||||
// 添加30秒超时控制
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
const afterFetch = performance.now()
|
||||
console.log('⏱️ [makeChatRequest] 网络请求耗时:', (afterFetch - beforeFetch).toFixed(2), 'ms')
|
||||
console.log('🔍 [makeChatRequest] 响应状态:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('❌ [makeChatRequest] 请求失败:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url,
|
||||
errorText
|
||||
})
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const beforeParse = performance.now()
|
||||
const result = await response.json()
|
||||
const afterParse = performance.now()
|
||||
|
||||
console.log('⏱️ [makeChatRequest] 解析响应耗时:', (afterParse - beforeParse).toFixed(2), 'ms')
|
||||
console.log('⏱️ [makeChatRequest] makeChatRequest总耗时:', (afterParse - requestStartTime).toFixed(2), 'ms')
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('请求超时(30秒)')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 流式聊天请求
|
||||
private async makeChatRequestStream(
|
||||
service: ModelService,
|
||||
messages: any[],
|
||||
model: string,
|
||||
onChunk: (text: string) => void,
|
||||
tools?: any[]
|
||||
): Promise<any[] | undefined> {
|
||||
const requestStartTime = performance.now()
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
let url = ''
|
||||
let body: any = {}
|
||||
|
||||
// 构建请求 (与非流式相同,但 stream: true)
|
||||
switch (service.type) {
|
||||
case 'openai':
|
||||
case 'local':
|
||||
case 'dashscope':
|
||||
case 'volcengine':
|
||||
headers['Authorization'] = `Bearer ${service.apiKey}`
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: true, // ← 启用流式
|
||||
...(tools && tools.length > 0 ? { tools, tool_choice: 'auto' } : {})
|
||||
}
|
||||
break
|
||||
|
||||
case 'claude':
|
||||
headers['x-api-key'] = service.apiKey
|
||||
headers['anthropic-version'] = '2023-06-01'
|
||||
url = `${service.url}/messages`
|
||||
body = {
|
||||
model,
|
||||
messages: this.convertToClaudeFormat(messages),
|
||||
max_tokens: 4096,
|
||||
stream: true
|
||||
}
|
||||
break
|
||||
|
||||
case 'azure':
|
||||
headers['api-key'] = service.apiKey
|
||||
url = `${service.url}/openai/deployments/${model}/chat/completions?api-version=2023-12-01-preview`
|
||||
body = {
|
||||
messages,
|
||||
stream: true
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
url = `${service.url}/chat/completions`
|
||||
body = {
|
||||
model,
|
||||
messages,
|
||||
stream: true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
console.log('🔍 [makeChatRequestStream] 流式请求URL:', url)
|
||||
console.log('🔍 [makeChatRequestStream] 流式请求体大小:', JSON.stringify(body).length, '字节')
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000) // 流式请求60秒超时
|
||||
|
||||
try {
|
||||
const beforeFetch = performance.now()
|
||||
console.log('⏱️ [makeChatRequestStream] 构建请求耗时:', (beforeFetch - requestStartTime).toFixed(2), 'ms')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
const afterFetch = performance.now()
|
||||
console.log('⏱️ [makeChatRequestStream] 首字节响应耗时:', (afterFetch - beforeFetch).toFixed(2), 'ms')
|
||||
|
||||
// 读取流
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流')
|
||||
}
|
||||
|
||||
console.log('🌊🌊🌊 [makeChatRequestStream] === 开始读取流数据 ===')
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let chunkCount = 0
|
||||
let totalChars = 0
|
||||
const firstChunkTimeStart = performance.now()
|
||||
let collectedToolCalls: any[] = []
|
||||
const toolCallsMap = new Map<number, any>()
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
chunkCount++
|
||||
if (chunkCount === 1) {
|
||||
console.log('⚡⚡⚡ [makeChatRequestStream] 收到第一个数据块!耗时:', (performance.now() - firstChunkTimeStart).toFixed(2), 'ms')
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '' || line.trim() === 'data: [DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
const delta = data.choices?.[0]?.delta
|
||||
|
||||
// 处理普通内容
|
||||
const content = delta?.content
|
||||
if (content) {
|
||||
totalChars += content.length
|
||||
onChunk(content)
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const index = toolCall.index
|
||||
if (!toolCallsMap.has(index)) {
|
||||
toolCallsMap.set(index, {
|
||||
id: toolCall.id || '',
|
||||
type: toolCall.type || 'function',
|
||||
function: {
|
||||
name: toolCall.function?.name || '',
|
||||
arguments: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const existing = toolCallsMap.get(index)!
|
||||
if (toolCall.function?.name) {
|
||||
existing.function.name = toolCall.function.name
|
||||
}
|
||||
if (toolCall.function?.arguments) {
|
||||
existing.function.arguments += toolCall.function.arguments
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有工具调用
|
||||
if (toolCallsMap.size > 0) {
|
||||
collectedToolCalls = Array.from(toolCallsMap.values())
|
||||
console.log('🔧 [makeChatRequestStream] 检测到工具调用:', collectedToolCalls.length, '个')
|
||||
}
|
||||
|
||||
const endTime = performance.now()
|
||||
console.log('⏱️ [makeChatRequestStream] 流式接收完成')
|
||||
console.log('⏱️ [makeChatRequestStream] 接收块数:', chunkCount, '总字符数:', totalChars)
|
||||
console.log('⏱️ [makeChatRequestStream] 流式总耗时:', (endTime - requestStartTime).toFixed(2), 'ms')
|
||||
|
||||
return collectedToolCalls.length > 0 ? collectedToolCalls : undefined
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('流式请求超时(60秒)')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 转换消息格式为Claude格式
|
||||
private convertToClaudeFormat(messages: any[]): any[] {
|
||||
return messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map(msg => ({
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: msg.content
|
||||
}))
|
||||
}
|
||||
|
||||
// 转换消息格式为Gemini格式
|
||||
private convertToGeminiFormat(messages: any[]): any[] {
|
||||
return messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map(msg => ({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }]
|
||||
}))
|
||||
}
|
||||
|
||||
// 添加服务
|
||||
addService(service: ModelService): void {
|
||||
this.services.set(service.id, service)
|
||||
}
|
||||
|
||||
// 更新服务
|
||||
updateService(service: ModelService): void {
|
||||
this.services.set(service.id, service)
|
||||
}
|
||||
|
||||
// 删除服务
|
||||
removeService(serviceId: string): void {
|
||||
this.services.delete(serviceId)
|
||||
}
|
||||
|
||||
// 获取服务
|
||||
getService(serviceId: string): ModelService | undefined {
|
||||
return this.services.get(serviceId)
|
||||
}
|
||||
|
||||
// 获取所有服务
|
||||
getAllServices(): ModelService[] {
|
||||
return Array.from(this.services.values())
|
||||
}
|
||||
|
||||
// 连接服务
|
||||
async connectService(serviceId: string): Promise<void> {
|
||||
const service = this.services.get(serviceId)
|
||||
if (!service) throw new Error('服务不存在')
|
||||
|
||||
service.status = 'connecting'
|
||||
|
||||
try {
|
||||
const result = await this.testConnection(service)
|
||||
if (result.success && result.data) {
|
||||
service.status = 'connected'
|
||||
service.models = result.data.models
|
||||
service.errorMessage = undefined
|
||||
service.lastUsed = new Date()
|
||||
} else {
|
||||
service.status = 'error'
|
||||
service.errorMessage = result.error
|
||||
throw new Error(result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
service.status = 'error'
|
||||
service.errorMessage = error instanceof Error ? error.message : '连接失败'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 断开服务
|
||||
disconnectService(serviceId: string): void {
|
||||
const service = this.services.get(serviceId)
|
||||
if (service) {
|
||||
service.status = 'disconnected'
|
||||
service.models = []
|
||||
service.errorMessage = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检测 - 测试单个模型是否可用
|
||||
async testModelHealth(service: ModelService, modelId: string): Promise<{
|
||||
modelId: string
|
||||
available: boolean
|
||||
latency?: number
|
||||
error?: string
|
||||
}> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 发送一个最小的测试请求
|
||||
const result = await this.sendChatRequest(service.id, [
|
||||
{ role: 'user', content: 'hi' }
|
||||
], modelId)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '测试失败')
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime
|
||||
return {
|
||||
modelId,
|
||||
available: true,
|
||||
latency
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
modelId,
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : '测试失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量健康检测 - 测试所有模型
|
||||
async healthCheckAllModels(
|
||||
service: ModelService,
|
||||
onProgress?: (current: number, total: number, modelId: string) => void
|
||||
): Promise<{
|
||||
availableModels: string[]
|
||||
unavailableModels: string[]
|
||||
results: Array<{
|
||||
modelId: string
|
||||
available: boolean
|
||||
latency?: number
|
||||
error?: string
|
||||
}>
|
||||
}> {
|
||||
const models = service.models || []
|
||||
const results: Array<{
|
||||
modelId: string
|
||||
available: boolean
|
||||
latency?: number
|
||||
error?: string
|
||||
}> = []
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const modelId = models[i]
|
||||
|
||||
// 通知进度
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, models.length, modelId)
|
||||
}
|
||||
|
||||
// 测试模型健康状态
|
||||
const result = await this.testModelHealth(service, modelId)
|
||||
results.push(result)
|
||||
|
||||
// 添加小延迟避免过快请求
|
||||
if (i < models.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
// 统计结果
|
||||
const availableModels = results.filter(r => r.available).map(r => r.modelId)
|
||||
const unavailableModels = results.filter(r => !r.available).map(r => r.modelId)
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
unavailableModels,
|
||||
results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const modelServiceManager = ModelServiceManager.getInstance()
|
||||
226
web/src/stores/chatStore.ts
Normal file
226
web/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { chatService } from '../services/chatService'
|
||||
import type { Topic, Message, TopicFilter } from '../types/chat'
|
||||
|
||||
interface ChatState {
|
||||
topics: Topic[]
|
||||
currentTopicId: string | null
|
||||
messages: Message[]
|
||||
filter: TopicFilter
|
||||
isLoading: boolean
|
||||
isSending: boolean
|
||||
}
|
||||
|
||||
const state = reactive<ChatState>({
|
||||
topics: [],
|
||||
currentTopicId: null,
|
||||
messages: [],
|
||||
filter: {},
|
||||
isLoading: false,
|
||||
isSending: false
|
||||
})
|
||||
|
||||
// Getters
|
||||
export const useChatStore = () => {
|
||||
const currentTopic = computed(() => {
|
||||
if (!state.currentTopicId) return null
|
||||
return state.topics.find(t => t.id === state.currentTopicId)
|
||||
})
|
||||
|
||||
const filteredTopics = computed(() => {
|
||||
return chatService.getTopics(state.filter)
|
||||
})
|
||||
|
||||
const pinnedTopics = computed(() => {
|
||||
return state.topics.filter(t => t.pinned && !t.archived)
|
||||
})
|
||||
|
||||
const recentTopics = computed(() => {
|
||||
return state.topics
|
||||
.filter(t => !t.pinned && !t.archived)
|
||||
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
// Actions
|
||||
const loadTopics = () => {
|
||||
state.topics = chatService.getTopics()
|
||||
}
|
||||
|
||||
const createTopic = (name: string) => {
|
||||
const topic = chatService.createTopic(name)
|
||||
loadTopics()
|
||||
setCurrentTopic(topic.id)
|
||||
return topic
|
||||
}
|
||||
|
||||
const setCurrentTopic = (topicId: string | null) => {
|
||||
state.currentTopicId = topicId
|
||||
if (topicId) {
|
||||
loadMessages(topicId)
|
||||
} else {
|
||||
state.messages = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadMessages = (topicId: string) => {
|
||||
// 创建新数组以确保触发响应式更新
|
||||
state.messages = [...chatService.getMessages(topicId)]
|
||||
}
|
||||
|
||||
const sendMessage = async (content: string, model?: string) => {
|
||||
if (!state.currentTopicId || !content.trim()) return
|
||||
|
||||
state.isSending = true
|
||||
try {
|
||||
await chatService.sendMessage({
|
||||
topicId: state.currentTopicId,
|
||||
content,
|
||||
model,
|
||||
stream: false
|
||||
})
|
||||
loadMessages(state.currentTopicId)
|
||||
loadTopics() // 更新话题列表
|
||||
} finally {
|
||||
state.isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessageStream = async (
|
||||
content: string,
|
||||
model?: string,
|
||||
mcpServerId?: string,
|
||||
onChunk?: (chunk: string) => void
|
||||
) => {
|
||||
if (!state.currentTopicId || !content.trim()) return
|
||||
|
||||
state.isSending = true
|
||||
const currentTopicId = state.currentTopicId // 保存当前 ID
|
||||
|
||||
// 立即加载一次消息,显示用户消息
|
||||
loadMessages(currentTopicId)
|
||||
|
||||
try {
|
||||
await chatService.sendMessageStream(
|
||||
{
|
||||
topicId: currentTopicId,
|
||||
content,
|
||||
model,
|
||||
stream: true
|
||||
},
|
||||
(event) => {
|
||||
// 实时更新消息列表
|
||||
if (state.currentTopicId === currentTopicId) {
|
||||
// 强制创建新数组以触发响应式更新
|
||||
state.messages = [...chatService.getMessages(currentTopicId)]
|
||||
}
|
||||
|
||||
if (event.type === 'delta' && event.content && onChunk) {
|
||||
onChunk(event.content)
|
||||
}
|
||||
},
|
||||
mcpServerId // 传递 MCP 服务器 ID
|
||||
)
|
||||
|
||||
// 最终更新
|
||||
if (state.currentTopicId === currentTopicId) {
|
||||
state.messages = [...chatService.getMessages(currentTopicId)]
|
||||
}
|
||||
loadTopics()
|
||||
} finally {
|
||||
state.isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMessage = (messageId: string) => {
|
||||
if (!state.currentTopicId) return
|
||||
chatService.deleteMessage(state.currentTopicId, messageId)
|
||||
loadMessages(state.currentTopicId)
|
||||
loadTopics()
|
||||
}
|
||||
|
||||
const regenerateMessage = async (messageId: string) => {
|
||||
if (!state.currentTopicId) return
|
||||
state.isSending = true
|
||||
try {
|
||||
await chatService.regenerateMessage(state.currentTopicId, messageId)
|
||||
loadMessages(state.currentTopicId)
|
||||
loadTopics()
|
||||
} finally {
|
||||
state.isSending = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateTopic = (topicId: string, updates: Partial<Topic>) => {
|
||||
chatService.updateTopic(topicId, updates)
|
||||
loadTopics()
|
||||
}
|
||||
|
||||
const deleteTopic = (topicId: string) => {
|
||||
chatService.deleteTopic(topicId)
|
||||
loadTopics()
|
||||
if (state.currentTopicId === topicId) {
|
||||
// 删除当前话题后,选择第一个话题
|
||||
const topics = chatService.getTopics()
|
||||
if (topics.length > 0) {
|
||||
setCurrentTopic(topics[0].id)
|
||||
} else {
|
||||
setCurrentTopic(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTopicPin = (topicId: string) => {
|
||||
chatService.toggleTopicPin(topicId)
|
||||
loadTopics()
|
||||
}
|
||||
|
||||
const toggleTopicFavorite = (topicId: string) => {
|
||||
chatService.toggleTopicFavorite(topicId)
|
||||
loadTopics()
|
||||
}
|
||||
|
||||
const archiveTopic = (topicId: string) => {
|
||||
chatService.archiveTopic(topicId)
|
||||
loadTopics()
|
||||
}
|
||||
|
||||
const setFilter = (filter: TopicFilter) => {
|
||||
state.filter = filter
|
||||
}
|
||||
|
||||
const initialize = () => {
|
||||
chatService.initialize()
|
||||
loadTopics()
|
||||
// 默认选中第一个话题
|
||||
if (state.topics.length > 0 && !state.currentTopicId) {
|
||||
setCurrentTopic(state.topics[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
// Getters
|
||||
currentTopic,
|
||||
filteredTopics,
|
||||
pinnedTopics,
|
||||
recentTopics,
|
||||
// Actions
|
||||
loadTopics,
|
||||
createTopic,
|
||||
setCurrentTopic,
|
||||
loadMessages,
|
||||
sendMessage,
|
||||
sendMessageStream,
|
||||
deleteMessage,
|
||||
regenerateMessage,
|
||||
updateTopic,
|
||||
deleteTopic,
|
||||
toggleTopicPin,
|
||||
toggleTopicFavorite,
|
||||
archiveTopic,
|
||||
setFilter,
|
||||
initialize
|
||||
}
|
||||
}
|
||||
84
web/src/types/chat.ts
Normal file
84
web/src/types/chat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 聊天相关类型定义
|
||||
* 参考 Cherry Studio 的数据结构
|
||||
*/
|
||||
|
||||
// 消息角色
|
||||
export type MessageRole = 'user' | 'assistant' | 'system'
|
||||
|
||||
// 消息状态
|
||||
export type MessageStatus = 'pending' | 'sending' | 'success' | 'error'
|
||||
|
||||
// 消息
|
||||
export interface Message {
|
||||
id: string
|
||||
role: MessageRole
|
||||
content: string
|
||||
status: MessageStatus
|
||||
timestamp: Date
|
||||
model?: string
|
||||
error?: string
|
||||
tokens?: {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
// 话题
|
||||
export interface Topic {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
messageCount: number
|
||||
lastMessage?: string
|
||||
pinned?: boolean
|
||||
archived?: boolean
|
||||
favorite?: boolean
|
||||
model?: string
|
||||
}
|
||||
|
||||
// 对话
|
||||
export interface Conversation {
|
||||
id: string
|
||||
topicId: string
|
||||
messages: Message[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
metadata?: {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 话题过滤器
|
||||
export interface TopicFilter {
|
||||
search?: string
|
||||
pinned?: boolean
|
||||
archived?: boolean
|
||||
favorite?: boolean
|
||||
}
|
||||
|
||||
// 消息发送选项
|
||||
export interface SendMessageOptions {
|
||||
topicId: string
|
||||
content: string
|
||||
role?: MessageRole
|
||||
model?: string
|
||||
stream?: boolean
|
||||
systemPrompt?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
// 流式响应事件
|
||||
export interface StreamEvent {
|
||||
type: 'start' | 'delta' | 'end' | 'error'
|
||||
content?: string
|
||||
error?: string
|
||||
messageId?: string
|
||||
}
|
||||
Reference in New Issue
Block a user