1107 lines
28 KiB
Vue
1107 lines
28 KiB
Vue
<template>
|
|
<div class="chat-container">
|
|
<!-- 主对话区域 -->
|
|
<div class="chat-main">
|
|
<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="showSidebar = !showSidebar">
|
|
<n-icon :component="showSidebar ? ChevronRightIcon : ChevronLeftIcon" />
|
|
<span style="margin-left: 4px;">{{ showSidebar ? '隐藏列表' : '显示列表' }}</span>
|
|
</n-button>
|
|
<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 === 'paused'" type="warning" 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 === 'user'" class="message-actions">
|
|
<n-tooltip trigger="hover">
|
|
<template #trigger>
|
|
<n-button text size="tiny" type="primary" @click="handleCopyMessage(msg.content)">
|
|
<n-icon :component="CopyIcon" size="14" />
|
|
</n-button>
|
|
</template>
|
|
复制
|
|
</n-tooltip>
|
|
<n-tooltip trigger="hover">
|
|
<template #trigger>
|
|
<n-button text size="tiny" type="error" @click="handleDeleteMessage(msg.id)">
|
|
<n-icon :component="TrashIcon" size="14" />
|
|
</n-button>
|
|
</template>
|
|
删除
|
|
</n-tooltip>
|
|
</div>
|
|
<!-- AI 消息操作按钮 -->
|
|
<div v-if="msg.role === 'assistant' && (msg.status === 'success' || msg.status === 'paused')" class="message-actions">
|
|
<n-tooltip trigger="hover">
|
|
<template #trigger>
|
|
<n-button text size="tiny" type="primary" @click="handleCopyMessage(msg.content)">
|
|
<n-icon :component="CopyIcon" size="14" />
|
|
</n-button>
|
|
</template>
|
|
复制
|
|
</n-tooltip>
|
|
<n-tooltip trigger="hover">
|
|
<template #trigger>
|
|
<n-button text size="tiny" type="info" @click="handleRegenerateMessage(msg.id)">
|
|
<n-icon :component="RefreshIcon" size="14" />
|
|
</n-button>
|
|
</template>
|
|
重新生成
|
|
</n-tooltip>
|
|
<n-tooltip trigger="hover">
|
|
<template #trigger>
|
|
<n-button text size="tiny" type="error" @click="handleDeleteMessage(msg.id)">
|
|
<n-icon :component="TrashIcon" size="14" />
|
|
</n-button>
|
|
</template>
|
|
删除
|
|
</n-tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</n-scrollbar>
|
|
|
|
<!-- 输入框和工具栏 -->
|
|
<div class="input-container">
|
|
<!-- 工具栏 -->
|
|
<div class="toolbar">
|
|
<div class="toolbar-left">
|
|
<!-- MCP 选择器 -->
|
|
<n-dropdown
|
|
trigger="click"
|
|
:options="mcpOptions"
|
|
@select="handleSelectMCP"
|
|
>
|
|
<n-button size="small" quaternary>
|
|
<template #icon>
|
|
<n-icon :component="PlugIcon" />
|
|
</template>
|
|
{{ selectedMCPName }}
|
|
<n-icon :component="ChevronDownIcon" size="14" style="margin-left: 4px;" />
|
|
</n-button>
|
|
</n-dropdown>
|
|
|
|
<!-- 模型选择器 -->
|
|
<n-dropdown
|
|
trigger="click"
|
|
:options="modelOptions"
|
|
@select="handleSelectModel"
|
|
>
|
|
<n-button size="small" quaternary>
|
|
<template #icon>
|
|
<n-icon :component="BrainIcon" />
|
|
</template>
|
|
{{ selectedModelName || '选择模型' }}
|
|
<n-icon :component="ChevronDownIcon" size="14" style="margin-left: 4px;" />
|
|
</n-button>
|
|
</n-dropdown>
|
|
</div>
|
|
|
|
<!--
|
|
<span class="toolbar-divider">|</span>
|
|
-->
|
|
|
|
<div class="toolbar-right">
|
|
|
|
<!-- 快捷操作 -->
|
|
<n-button-group size="small">
|
|
<n-button quaternary>
|
|
<n-icon :component="FileTextIcon" size="16" />
|
|
</n-button>
|
|
<n-button quaternary>
|
|
<n-icon :component="PaperclipIcon" size="16" />
|
|
</n-button>
|
|
<n-button quaternary>
|
|
<n-icon :component="MicIcon" size="16" />
|
|
</n-button>
|
|
</n-button-group>
|
|
|
|
<!-- 发送/停止按钮 -->
|
|
<n-button
|
|
size="small"
|
|
:type="store.state.isSending ? 'error' : 'primary'"
|
|
:disabled="!store.state.isSending && !inputText.trim()"
|
|
@click="handleButtonClick"
|
|
>
|
|
{{ store.state.isSending ? '停止' : '发送' }}
|
|
</n-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 输入框 -->
|
|
<n-input
|
|
v-model:value="inputText"
|
|
type="textarea"
|
|
placeholder="在这里输入消息,按 Enter 发送"
|
|
:autosize="{ minRows: 3, maxRows: 10 }"
|
|
:disabled="store.state.isSending"
|
|
@keydown.enter="handleKeyDown"
|
|
class="message-input"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- 右侧对话列表 -->
|
|
<div class="topics-sidebar" :class="{ visible: showSidebar }">
|
|
<div class="sidebar-header">
|
|
<h3>对话列表</h3>
|
|
<div class="header-actions">
|
|
<n-button size="small" type="primary" @click="handleCreateTopic">
|
|
<template #icon>
|
|
<n-icon :component="PlusIcon" />
|
|
</template>
|
|
</n-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 搜索框 -->
|
|
<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="handleSelectTopic(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>
|
|
|
|
<!-- 编辑话题弹窗 -->
|
|
<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, h, watch } from 'vue'
|
|
import {
|
|
NButton,
|
|
NButtonGroup,
|
|
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,
|
|
Plug as PlugIcon,
|
|
Cpu as BrainIcon,
|
|
ChevronDown as ChevronDownIcon,
|
|
ChevronLeft as ChevronLeftIcon,
|
|
ChevronRight as ChevronRightIcon,
|
|
FileText as FileTextIcon,
|
|
Paperclip as PaperclipIcon,
|
|
Microphone as MicIcon
|
|
} from '@vicons/tabler'
|
|
import { useChatStore } from '../../stores/chatStore'
|
|
import { useModelStore } from '../../stores/modelStore'
|
|
import { useServerStore } from '../../stores/newServer'
|
|
import type { Topic } from '../../types/chat'
|
|
|
|
const message = useMessage()
|
|
const store = useChatStore()
|
|
const modelStore = useModelStore()
|
|
const mcpStore = useServerStore()
|
|
|
|
// 响应式数据
|
|
const searchKeyword = ref('')
|
|
const inputText = ref('')
|
|
const showEditModal = ref(false)
|
|
const showSidebar = ref(true)
|
|
const editingTopic = ref<Topic | null>(null)
|
|
const editForm = ref({
|
|
name: '',
|
|
description: ''
|
|
})
|
|
const messagesScrollRef = ref()
|
|
const selectedModel = ref<string>()
|
|
const selectedMCP = ref<string>()
|
|
|
|
// 从 localStorage 加载上次选择
|
|
const loadLastSelection = () => {
|
|
try {
|
|
const lastModel = localStorage.getItem('chat-selected-model')
|
|
const lastMCP = localStorage.getItem('chat-selected-mcp')
|
|
|
|
if (lastModel) {
|
|
selectedModel.value = lastModel
|
|
console.log('🔍 [loadLastSelection] 恢复模型选择:', lastModel)
|
|
}
|
|
|
|
if (lastMCP) {
|
|
selectedMCP.value = lastMCP
|
|
console.log('🔍 [loadLastSelection] 恢复MCP选择:', lastMCP)
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ [loadLastSelection] 加载失败:', error)
|
|
}
|
|
}
|
|
|
|
// 保存选择到 localStorage
|
|
const saveSelection = () => {
|
|
try {
|
|
if (selectedModel.value) {
|
|
localStorage.setItem('chat-selected-model', selectedModel.value)
|
|
}
|
|
if (selectedMCP.value) {
|
|
localStorage.setItem('chat-selected-mcp', selectedMCP.value)
|
|
}
|
|
} catch (error) {
|
|
console.warn('⚠️ [saveSelection] 保存失败:', error)
|
|
}
|
|
}
|
|
|
|
// 监听选择变化
|
|
watch(selectedModel, () => {
|
|
saveSelection()
|
|
})
|
|
|
|
watch(selectedMCP, () => {
|
|
saveSelection()
|
|
})
|
|
|
|
// 计算属性
|
|
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 modelOptions = computed(() => {
|
|
const services = modelStore.providers
|
|
const options: any[] = []
|
|
|
|
console.log('[modelOptions] 处理服务列表:', services)
|
|
|
|
services.forEach((service: any) => {
|
|
console.log(`[modelOptions] 服务: ${service.name}, enabled: ${service.enabled}, models数量: ${service.models?.length || 0}`)
|
|
|
|
// 检查 enabled 属性,如果不存在则默认为 true (兼容 status === 'connected')
|
|
const isEnabled = service.enabled !== undefined ? service.enabled : (service.status === 'connected')
|
|
|
|
if (isEnabled && service.models && Array.isArray(service.models) && service.models.length > 0) {
|
|
service.models.forEach((model: any) => {
|
|
// 支持两种格式:
|
|
// 1. 对象格式: {id: "xxx", name: "xxx"}
|
|
// 2. 字符串格式: "model-id"
|
|
let modelId: string
|
|
let modelName: string
|
|
|
|
if (typeof model === 'string') {
|
|
// 字符串格式
|
|
modelId = model
|
|
modelName = model
|
|
} else {
|
|
// 对象格式
|
|
modelId = model.id || model
|
|
modelName = model.name || model.id || model
|
|
}
|
|
|
|
const option = {
|
|
label: `${service.name} | ${modelName}`,
|
|
key: `${service.id}:${modelId}`,
|
|
icon: () => h(NIcon, { component: BrainIcon })
|
|
}
|
|
console.log('[modelOptions] 添加模型选项:', option.label)
|
|
options.push(option)
|
|
})
|
|
}
|
|
})
|
|
|
|
console.log('[modelOptions] 最终选项数量:', options.length, options)
|
|
return options
|
|
})
|
|
|
|
// 选中的模型名称
|
|
const selectedModelName = computed(() => {
|
|
if (!selectedModel.value) return undefined
|
|
const [serviceId, modelId] = selectedModel.value.split(':')
|
|
const service = modelStore.providers.find((s: any) => s.id === serviceId)
|
|
|
|
// 支持两种模型格式
|
|
let modelName: string | undefined
|
|
if (service?.models) {
|
|
const model = service.models.find((m: any) => {
|
|
if (typeof m === 'string') {
|
|
return m === modelId
|
|
} else {
|
|
return m.id === modelId
|
|
}
|
|
})
|
|
|
|
if (model) {
|
|
modelName = typeof model === 'string' ? model : (model.name || model.id)
|
|
}
|
|
}
|
|
|
|
return modelName ? `${service?.name} | ${modelName}` : undefined
|
|
})
|
|
|
|
// MCP 选项 - 从 mcpStore 动态读取已连接的服务器
|
|
const mcpOptions = computed(() => {
|
|
const options: any[] = [
|
|
{
|
|
label: '不启用',
|
|
key: 'none'
|
|
}
|
|
]
|
|
|
|
|
|
// 添加已连接的服务器
|
|
mcpStore.connectedServers.forEach((server) => {
|
|
const toolCount = server.capabilities?.tools?.length || 0
|
|
options.push({
|
|
label: `${server.name} (${toolCount} 个工具)`,
|
|
key: server.id,
|
|
icon: () => h(NIcon, { component: PlugIcon })
|
|
})
|
|
})
|
|
|
|
return options
|
|
})
|
|
|
|
// 选中的 MCP 服务器名称
|
|
const selectedMCPName = computed(() => {
|
|
if (!selectedMCP.value || selectedMCP.value === 'none') return '不启用 MCP 服务'
|
|
const server = mcpStore.servers.find(s => s.id === selectedMCP.value)
|
|
return server?.name || '未知服务'
|
|
})
|
|
|
|
// 话题菜单选项
|
|
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('已创建')
|
|
showSidebar.value = true
|
|
}
|
|
}
|
|
|
|
// 选择话题
|
|
const handleSelectTopic = (topicId: string) => {
|
|
store.setCurrentTopic(topicId)
|
|
if (window.innerWidth < 1200) {
|
|
showSidebar.value = false
|
|
}
|
|
}
|
|
|
|
// 保存编辑
|
|
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 handleSelectModel = (key: string) => {
|
|
selectedModel.value = key
|
|
message.success('已切换模型')
|
|
}
|
|
|
|
// 选择 MCP
|
|
const handleSelectMCP = (key: string) => {
|
|
if (key === 'none') {
|
|
selectedMCP.value = undefined
|
|
message.info('已禁用 MCP 服务')
|
|
} else {
|
|
selectedMCP.value = key
|
|
message.success(`已选择 ${key}`)
|
|
}
|
|
}
|
|
|
|
// 统一的按钮点击处理(参考 cherry-studio 的 PAUSED 状态逻辑)
|
|
const handleButtonClick = () => {
|
|
if (store.state.isSending) {
|
|
handleStopGeneration()
|
|
} else {
|
|
handleSendMessage()
|
|
}
|
|
}
|
|
|
|
// 停止生成
|
|
const handleStopGeneration = () => {
|
|
console.log('🛑 [handleStopGeneration] 用户请求停止生成')
|
|
store.stopGeneration()
|
|
message.info('已停止生成')
|
|
}
|
|
|
|
// 发送消息
|
|
const handleSendMessage = async () => {
|
|
if (!inputText.value.trim() || store.state.isSending) return
|
|
|
|
const content = inputText.value.trim()
|
|
inputText.value = ''
|
|
|
|
// 发送消息后立即滚动到底部(显示用户消息)
|
|
nextTick(() => {
|
|
scrollToBottom()
|
|
})
|
|
|
|
try {
|
|
// 传递选中的模型和 MCP 服务器 ID
|
|
const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
|
|
|
|
// 从 "serviceId:modelId" 格式中提取纯的 modelId
|
|
let modelId = selectedModel.value
|
|
if (modelId && modelId.includes(':')) {
|
|
const [, extractedModelId] = modelId.split(':')
|
|
modelId = extractedModelId
|
|
console.log('🔍 [handleSendMessage] 提取模型ID:', selectedModel.value, '→', modelId)
|
|
}
|
|
|
|
await store.sendMessageStream(
|
|
content,
|
|
modelId,
|
|
mcpId,
|
|
() => {
|
|
// 每次收到消息块时滚动
|
|
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) {
|
|
const scrollbarEl = messagesScrollRef.value
|
|
// Naive UI NScrollbar 的正确用法
|
|
if (scrollbarEl.scrollTo) {
|
|
scrollbarEl.scrollTo({ top: 999999, behavior: 'smooth' })
|
|
} else if (scrollbarEl.$el) {
|
|
// 降级方案:直接操作 DOM
|
|
const container = scrollbarEl.$el.querySelector('.n-scrollbar-container')
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// 格式化日期
|
|
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(async () => {
|
|
store.initialize()
|
|
modelStore.initialize() // 初始化模型服务
|
|
|
|
// 加载上次选择的模型和MCP服务器
|
|
loadLastSelection()
|
|
|
|
// 调试:打印模型服务配置
|
|
console.log('[ChatLayout] modelStore.providers:', modelStore.providers)
|
|
console.log('[ChatLayout] modelOptions:', modelOptions.value)
|
|
console.log('[ChatLayout] selectedModel:', selectedModel.value)
|
|
console.log('[ChatLayout] selectedMCP:', selectedMCP.value)
|
|
|
|
scrollToBottom()
|
|
|
|
// 加载 MCP 服务器配置
|
|
mcpStore.loadServers()
|
|
|
|
// 尝试自动重连之前已连接的服务器
|
|
try {
|
|
await mcpStore.autoReconnect()
|
|
} catch (error) {
|
|
console.warn('自动重连 MCP 服务器失败:', error)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chat-container {
|
|
display: flex;
|
|
height: 100%;
|
|
background: var(--color-base);
|
|
position: relative;
|
|
}
|
|
|
|
/* 主对话区域 */
|
|
.chat-main {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
transition: margin-right 0.3s ease;
|
|
}
|
|
|
|
.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;
|
|
background: var(--color-target-bg);
|
|
}
|
|
|
|
.header-info h3 {
|
|
margin: 0 0 4px 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.message-count {
|
|
font-size: 12px;
|
|
color: var(--text-color-3);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* 消息列表 */
|
|
.messages-container {
|
|
flex: 1;
|
|
padding: 24px;
|
|
background: var(--color-base);
|
|
}
|
|
|
|
.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 {
|
|
border-top: 1px solid var(--border-color);
|
|
background: var(--color-target-bg);
|
|
padding: 12px 16px;
|
|
}
|
|
|
|
/* 工具栏 */
|
|
.toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 8px;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.toolbar-left,
|
|
.toolbar-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.toolbar-divider {
|
|
color: var(--border-color);
|
|
margin: 0 4px;
|
|
}
|
|
|
|
/* 输入框 */
|
|
.message-input {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
/* 输入框底部 */
|
|
.input-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.hint-text {
|
|
font-size: 11px;
|
|
color: var(--text-color-3);
|
|
}
|
|
|
|
/* 右侧对话列表 */
|
|
.topics-sidebar {
|
|
width: 320px;
|
|
border-left: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--color-target-bg);
|
|
position: relative;
|
|
transition: all 0.3s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.topics-sidebar:not(.visible) {
|
|
width: 0;
|
|
border-left: none;
|
|
opacity: 0;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
/* 响应式 */
|
|
@media (max-width: 1200px) {
|
|
.topics-sidebar {
|
|
position: absolute;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
transform: translateX(100%);
|
|
z-index: 100;
|
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.topics-sidebar.visible {
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
</style>
|