2026-03-10 16:09:09 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-03-15 19:48:42 +08:00
|
|
|
|
import { ref, nextTick, watch, onMounted, onUnmounted } from 'vue'
|
2026-03-15 21:43:37 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
2026-03-13 14:33:25 +08:00
|
|
|
|
import { useChat } from './chat/chat'
|
2026-03-12 10:49:44 +08:00
|
|
|
|
import ChatHeader from '@/components/chat/ChatHeader.vue'
|
|
|
|
|
|
import ChatMessage from '@/components/chat/ChatMessage.vue'
|
|
|
|
|
|
import ChatInput from '@/components/chat/ChatInput.vue'
|
|
|
|
|
|
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
|
|
|
|
|
|
import ChatAgentSelector from '@/components/chat/ChatAgentSelector.vue'
|
2026-03-13 14:33:25 +08:00
|
|
|
|
import './chat/chat.css'
|
2026-03-12 10:49:44 +08:00
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
chatModels,
|
|
|
|
|
|
selectedModel,
|
|
|
|
|
|
showModelDropdown,
|
|
|
|
|
|
chatAgents,
|
|
|
|
|
|
selectedAgent,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
chatSessions,
|
2026-03-13 14:33:25 +08:00
|
|
|
|
currentSessionId,
|
2026-03-12 10:49:44 +08:00
|
|
|
|
groupChats,
|
|
|
|
|
|
showAgentSelector,
|
|
|
|
|
|
selectMode,
|
|
|
|
|
|
selectedAgents,
|
|
|
|
|
|
groupChatName,
|
|
|
|
|
|
inputMessage,
|
|
|
|
|
|
isLoading,
|
|
|
|
|
|
sidebarCollapsed,
|
|
|
|
|
|
fetchModels,
|
|
|
|
|
|
openAgentSelector,
|
|
|
|
|
|
toggleAgentSelection,
|
|
|
|
|
|
confirmAgentSelection,
|
|
|
|
|
|
cancelAgentSelection,
|
|
|
|
|
|
selectAgent,
|
2026-03-13 14:33:25 +08:00
|
|
|
|
selectGroup,
|
2026-03-12 10:49:44 +08:00
|
|
|
|
selectSession,
|
|
|
|
|
|
newChat,
|
2026-03-13 14:33:25 +08:00
|
|
|
|
clearMessages,
|
2026-03-12 10:49:44 +08:00
|
|
|
|
toggleSidebar,
|
2026-03-13 14:33:25 +08:00
|
|
|
|
createSession,
|
|
|
|
|
|
saveMessage,
|
|
|
|
|
|
deleteSession,
|
|
|
|
|
|
init,
|
|
|
|
|
|
cleanup,
|
2026-03-12 10:49:44 +08:00
|
|
|
|
} = useChat()
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
const messagesContainer = ref<HTMLElement | null>(null)
|
2026-03-11 17:22:47 +08:00
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
// 构建 API 请求体
|
|
|
|
|
|
const buildRequestBody = (userContent: string) => {
|
|
|
|
|
|
const requestBody: any = {
|
|
|
|
|
|
agent_id: String(selectedAgent.value?.id || 1),
|
|
|
|
|
|
message: userContent,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedModel.value) {
|
|
|
|
|
|
requestBody.model_id = selectedModel.value.id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (currentSessionId.value) {
|
|
|
|
|
|
requestBody.session_id = currentSessionId.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return requestBody
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析流式响应数据
|
|
|
|
|
|
const parseStreamData = (rawData: string): string => {
|
|
|
|
|
|
if (!rawData || rawData === '[DONE]') return ''
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(rawData)
|
|
|
|
|
|
if (typeof parsed === 'string') {
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
}
|
|
|
|
|
|
return parsed.content || parsed.delta?.content || ''
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理流式响应
|
|
|
|
|
|
const handleStreamResponse = async (response: Response) => {
|
|
|
|
|
|
const reader = response.body.getReader()
|
|
|
|
|
|
const decoder = new TextDecoder('utf-8')
|
|
|
|
|
|
let buffer = ''
|
|
|
|
|
|
const aiMessageIndex = messages.value.length - 1
|
|
|
|
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
|
if (done) break
|
|
|
|
|
|
|
|
|
|
|
|
const decoded = decoder.decode(value, { stream: true })
|
|
|
|
|
|
buffer += decoded
|
|
|
|
|
|
|
|
|
|
|
|
const lines = buffer.split('\n')
|
|
|
|
|
|
buffer = lines.pop() || ''
|
|
|
|
|
|
|
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
if (line.startsWith('data: ')) {
|
|
|
|
|
|
const content = parseStreamData(line.slice(6).trim())
|
|
|
|
|
|
if (content) {
|
|
|
|
|
|
messages.value[aiMessageIndex].content += content
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理剩余buffer
|
|
|
|
|
|
if (buffer.startsWith('data: ')) {
|
|
|
|
|
|
const content = parseStreamData(buffer.slice(6).trim())
|
|
|
|
|
|
if (content) {
|
|
|
|
|
|
messages.value[aiMessageIndex].content += content
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
messages.value[aiMessageIndex].isStreaming = false
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
|
|
|
|
|
|
// 保存 AI 消息
|
|
|
|
|
|
await saveMessage('assistant', messages.value[aiMessageIndex].content)
|
|
|
|
|
|
|
|
|
|
|
|
// 第二轮对话结束后生成标题
|
|
|
|
|
|
if (messages.value.length === 5 && currentSessionId.value) {
|
|
|
|
|
|
generateSessionTitle()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理消息发送错误
|
|
|
|
|
|
const handleMessageError = (error: any) => {
|
|
|
|
|
|
const errorIndex = messages.value.findIndex(m => m.isStreaming)
|
|
|
|
|
|
if (errorIndex > -1) {
|
|
|
|
|
|
messages.value[errorIndex].content = `Error: ${error.message || 'Failed to send message'}`
|
|
|
|
|
|
messages.value[errorIndex].isStreaming = false
|
2026-03-11 17:22:47 +08:00
|
|
|
|
}
|
2026-03-12 10:49:44 +08:00
|
|
|
|
isLoading.value = false
|
2026-03-11 17:22:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
// 重置输入框
|
|
|
|
|
|
const resetInputHeight = () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
|
|
|
|
|
|
if (textarea) {
|
|
|
|
|
|
textarea.style.height = 'auto'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 创建用户消息对象
|
|
|
|
|
|
const createUserMessage = (content: string) => ({
|
|
|
|
|
|
id: Date.now(),
|
|
|
|
|
|
role: 'user' as const,
|
|
|
|
|
|
content,
|
|
|
|
|
|
timestamp: new Date()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 创建 AI 消息对象
|
|
|
|
|
|
const createAssistantMessage = () => ({
|
|
|
|
|
|
id: Date.now() + 1,
|
|
|
|
|
|
role: 'assistant' as const,
|
|
|
|
|
|
content: '',
|
|
|
|
|
|
timestamp: new Date(),
|
|
|
|
|
|
isStreaming: true
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// 滚动到底部
|
|
|
|
|
|
const scrollToBottom = () => {
|
|
|
|
|
|
if (messagesContainer.value) {
|
|
|
|
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
2026-03-11 14:26:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
// 监听消息变化,自动滚动到底部
|
|
|
|
|
|
watch(messages, () => {
|
|
|
|
|
|
nextTick(() => scrollToBottom())
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// 切换模型下拉框
|
|
|
|
|
|
const toggleModelDropdown = () => {
|
|
|
|
|
|
showModelDropdown.value = !showModelDropdown.value
|
2026-03-11 14:26:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// 选择模型
|
|
|
|
|
|
const handleSelectModel = (model: any) => {
|
|
|
|
|
|
selectedModel.value = model
|
|
|
|
|
|
showModelDropdown.value = false
|
2026-03-11 14:26:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
// 删除会话
|
|
|
|
|
|
const handleDeleteSession = async (session: any) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`/api/chat/sessions/${session.id}`, {
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const index = chatSessions.value.findIndex((s: any) => s.id === session.id)
|
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
|
chatSessions.value.splice(index, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentSessionId.value === session.id) {
|
|
|
|
|
|
currentSessionId.value = null
|
|
|
|
|
|
messages.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成会话标题
|
|
|
|
|
|
const generateSessionTitle = async () => {
|
|
|
|
|
|
if (!currentSessionId.value) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch('/api/chat/sessions/generate-title', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ session_id: currentSessionId.value })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
const sessionIndex = chatSessions.value.findIndex((s: any) => s.id === currentSessionId.value)
|
|
|
|
|
|
if (sessionIndex > -1) {
|
|
|
|
|
|
chatSessions.value[sessionIndex].title = data.title
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 16:09:09 +08:00
|
|
|
|
// 发送消息
|
|
|
|
|
|
const sendMessage = async () => {
|
|
|
|
|
|
if (!inputMessage.value.trim() || isLoading.value) return
|
|
|
|
|
|
|
2026-03-15 21:43:37 +08:00
|
|
|
|
// 如果没有会话,提示用户先选择智能体
|
|
|
|
|
|
if (!currentSessionId.value) {
|
|
|
|
|
|
ElMessage.warning('请先选择或创建一个会话')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 16:09:09 +08:00
|
|
|
|
const userContent = inputMessage.value.trim()
|
|
|
|
|
|
inputMessage.value = ''
|
2026-03-15 19:48:42 +08:00
|
|
|
|
resetInputHeight()
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
const userMessage = createUserMessage(userContent)
|
2026-03-10 16:09:09 +08:00
|
|
|
|
messages.value.push(userMessage)
|
2026-03-13 14:33:25 +08:00
|
|
|
|
await saveMessage('user', userContent)
|
|
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
const aiMessage = createAssistantMessage()
|
2026-03-10 16:09:09 +08:00
|
|
|
|
messages.value.push(aiMessage)
|
|
|
|
|
|
nextTick(() => scrollToBottom())
|
|
|
|
|
|
isLoading.value = true
|
2026-03-11 16:26:10 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-15 19:48:42 +08:00
|
|
|
|
const response = await fetch('/api/agent/chat/stream', {
|
2026-03-11 16:26:10 +08:00
|
|
|
|
method: 'POST',
|
2026-03-15 19:48:42 +08:00
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(buildRequestBody(userContent)),
|
2026-03-11 16:26:10 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`Request failed: ${response.status}`)
|
|
|
|
|
|
}
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-15 19:48:42 +08:00
|
|
|
|
await handleStreamResponse(response)
|
2026-03-12 10:49:44 +08:00
|
|
|
|
} catch (error: any) {
|
2026-03-15 19:48:42 +08:00
|
|
|
|
handleMessageError(error)
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 14:33:25 +08:00
|
|
|
|
|
|
|
|
|
|
// 初始化
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
init()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
cleanup()
|
|
|
|
|
|
})
|
2026-03-12 10:49:44 +08:00
|
|
|
|
</script>
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="h-screen flex bg-[#09090b]">
|
|
|
|
|
|
<!-- 主聊天区域 -->
|
|
|
|
|
|
<div class="flex-1 flex flex-col bg-[#09090b]">
|
|
|
|
|
|
<!-- 顶部栏 -->
|
|
|
|
|
|
<ChatHeader
|
2026-03-15 21:43:37 +08:00
|
|
|
|
v-if="currentSessionId"
|
2026-03-12 10:49:44 +08:00
|
|
|
|
:selected-agent="selectedAgent"
|
|
|
|
|
|
:chat-models="chatModels"
|
|
|
|
|
|
:selected-model="selectedModel"
|
|
|
|
|
|
:show-model-dropdown="showModelDropdown"
|
|
|
|
|
|
:sidebar-collapsed="sidebarCollapsed"
|
|
|
|
|
|
@toggle-dropdown="toggleModelDropdown"
|
|
|
|
|
|
@select-model="handleSelectModel"
|
|
|
|
|
|
@toggle-sidebar="toggleSidebar"
|
2026-03-13 14:33:25 +08:00
|
|
|
|
@clear-chat="clearMessages"
|
|
|
|
|
|
@new-chat="newChat"
|
2026-03-12 10:49:44 +08:00
|
|
|
|
/>
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
<!-- 消息区域 -->
|
|
|
|
|
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
|
2026-03-15 21:43:37 +08:00
|
|
|
|
<!-- 无会话时显示引导界面 -->
|
|
|
|
|
|
<div v-if="!currentSessionId" class="h-full flex items-center justify-center empty-chat">
|
|
|
|
|
|
<div class="text-center" style="position: relative; z-index: 1;">
|
|
|
|
|
|
<div class="empty-logo">🧠</div>
|
|
|
|
|
|
<h2 class="empty-title">欢迎使用 X-Agents</h2>
|
|
|
|
|
|
<p class="empty-desc">与智能 AI 助手对话,获取专业解答与创意灵感</p>
|
|
|
|
|
|
<button @click="newChat" class="empty-btn">
|
|
|
|
|
|
<i class="fa-solid fa-plus mr-2"></i>
|
|
|
|
|
|
开始新对话
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 有会话但无消息时显示欢迎提示 -->
|
|
|
|
|
|
<div v-else-if="messages.length === 0" class="h-full flex items-center justify-center">
|
2026-03-13 14:33:25 +08:00
|
|
|
|
<div class="text-center">
|
|
|
|
|
|
<div class="text-5xl mb-4">{{ selectedAgent?.avatar || '🧠' }}</div>
|
|
|
|
|
|
<h2 class="text-xl font-semibold text-white mb-2">和 {{ selectedAgent?.name || 'AI' }} 开始对话</h2>
|
|
|
|
|
|
<p class="text-white/40 text-sm">发送消息开始聊天</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- 消息列表 -->
|
|
|
|
|
|
<div v-else class="px-6">
|
2026-03-12 10:49:44 +08:00
|
|
|
|
<ChatMessage
|
|
|
|
|
|
v-for="message in messages"
|
|
|
|
|
|
:key="message.id"
|
|
|
|
|
|
:message="message"
|
|
|
|
|
|
:selected-agent="selectedAgent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-15 21:43:37 +08:00
|
|
|
|
<!-- 输入区域 - 仅在有会话时显示 -->
|
2026-03-12 10:49:44 +08:00
|
|
|
|
<ChatInput
|
2026-03-15 21:43:37 +08:00
|
|
|
|
v-if="currentSessionId"
|
2026-03-12 10:49:44 +08:00
|
|
|
|
v-model="inputMessage"
|
|
|
|
|
|
:loading="isLoading"
|
|
|
|
|
|
@send="sendMessage"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 右侧边栏 -->
|
|
|
|
|
|
<ChatSidebar
|
|
|
|
|
|
:collapsed="sidebarCollapsed"
|
|
|
|
|
|
:chat-agents="chatAgents"
|
|
|
|
|
|
:selected-agent="selectedAgent"
|
|
|
|
|
|
:chat-sessions="chatSessions"
|
|
|
|
|
|
:group-chats="groupChats"
|
|
|
|
|
|
@open-agent-selector="openAgentSelector"
|
|
|
|
|
|
@select-agent="selectAgent"
|
|
|
|
|
|
@select-session="selectSession"
|
2026-03-13 14:33:25 +08:00
|
|
|
|
@select-group="selectGroup"
|
2026-03-15 19:48:42 +08:00
|
|
|
|
@delete-session="handleDeleteSession"
|
2026-03-12 10:49:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 智能体选择弹窗 -->
|
|
|
|
|
|
<ChatAgentSelector
|
|
|
|
|
|
:show="showAgentSelector"
|
|
|
|
|
|
:select-mode="selectMode"
|
|
|
|
|
|
:chat-agents="chatAgents"
|
|
|
|
|
|
:selected-agents="selectedAgents"
|
|
|
|
|
|
:group-chat-name="groupChatName"
|
|
|
|
|
|
@close="cancelAgentSelection"
|
|
|
|
|
|
@toggle-select="toggleAgentSelection"
|
|
|
|
|
|
@confirm="confirmAgentSelection"
|
|
|
|
|
|
@update:group-chat-name="groupChatName = $event"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|