2026-03-10 16:09:09 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-03-11 17:22:47 +08:00
|
|
|
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
2026-03-12 10:49:44 +08:00
|
|
|
|
import { useChat } from '@/composables/useChat'
|
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
|
chatModels,
|
|
|
|
|
|
selectedModel,
|
|
|
|
|
|
showModelDropdown,
|
|
|
|
|
|
chatAgents,
|
|
|
|
|
|
selectedAgent,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
chatSessions,
|
|
|
|
|
|
groupChats,
|
|
|
|
|
|
showAgentSelector,
|
|
|
|
|
|
selectMode,
|
|
|
|
|
|
selectedAgents,
|
|
|
|
|
|
groupChatName,
|
|
|
|
|
|
inputMessage,
|
|
|
|
|
|
isLoading,
|
|
|
|
|
|
sidebarCollapsed,
|
|
|
|
|
|
fetchModels,
|
|
|
|
|
|
openAgentSelector,
|
|
|
|
|
|
toggleAgentSelection,
|
|
|
|
|
|
confirmAgentSelection,
|
|
|
|
|
|
cancelAgentSelection,
|
|
|
|
|
|
selectAgent,
|
|
|
|
|
|
selectSession,
|
|
|
|
|
|
newChat,
|
|
|
|
|
|
toggleSidebar,
|
|
|
|
|
|
} = 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-12 10:49:44 +08:00
|
|
|
|
// Mock 流式响应(用于测试前端流式效果)
|
|
|
|
|
|
const mockStreamResponse = async (content: string, messageIndex: number) => {
|
|
|
|
|
|
const chars = content.split('')
|
|
|
|
|
|
for (let i = 0; i < chars.length; i++) {
|
|
|
|
|
|
messages.value[messageIndex].content += chars[i]
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 15))
|
2026-03-11 17:22:47 +08:00
|
|
|
|
}
|
2026-03-12 10:49:44 +08:00
|
|
|
|
messages.value[messageIndex].isStreaming = false
|
|
|
|
|
|
isLoading.value = false
|
2026-03-11 17:22:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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-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-10 16:09:09 +08:00
|
|
|
|
// 发送消息
|
|
|
|
|
|
const sendMessage = async () => {
|
|
|
|
|
|
if (!inputMessage.value.trim() || isLoading.value) return
|
|
|
|
|
|
|
|
|
|
|
|
const userContent = inputMessage.value.trim()
|
|
|
|
|
|
inputMessage.value = ''
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
const userMessage = {
|
2026-03-10 16:09:09 +08:00
|
|
|
|
id: Date.now(),
|
2026-03-12 10:49:44 +08:00
|
|
|
|
role: 'user' as const,
|
2026-03-10 16:09:09 +08:00
|
|
|
|
content: userContent,
|
|
|
|
|
|
timestamp: new Date()
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(userMessage)
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
const aiMessage = {
|
2026-03-10 16:09:09 +08:00
|
|
|
|
id: Date.now() + 1,
|
2026-03-12 10:49:44 +08:00
|
|
|
|
role: 'assistant' as const,
|
2026-03-10 16:09:09 +08:00
|
|
|
|
content: '',
|
|
|
|
|
|
timestamp: new Date(),
|
|
|
|
|
|
isStreaming: true
|
|
|
|
|
|
}
|
|
|
|
|
|
messages.value.push(aiMessage)
|
|
|
|
|
|
|
|
|
|
|
|
nextTick(() => scrollToBottom())
|
|
|
|
|
|
|
|
|
|
|
|
isLoading.value = true
|
2026-03-11 16:26:10 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// ====== MOCK 模式:使用 mock 测试流式效果 ======
|
|
|
|
|
|
const USE_MOCK = false
|
|
|
|
|
|
|
|
|
|
|
|
if (USE_MOCK) {
|
|
|
|
|
|
const mockResponses = [
|
|
|
|
|
|
"你好!我是你的 AI 助手。",
|
|
|
|
|
|
"我收到了你的消息:\"" + userContent + "\"",
|
|
|
|
|
|
"这是一个模拟的流式响应效果。",
|
|
|
|
|
|
"现在你可以看到文字逐字出现的效果了!"
|
|
|
|
|
|
]
|
|
|
|
|
|
const fullResponse = mockResponses.join(' ')
|
|
|
|
|
|
const aiMessageIndex = messages.value.length - 1
|
|
|
|
|
|
await mockStreamResponse(fullResponse, aiMessageIndex)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// ====== MOCK 模式结束 ======
|
|
|
|
|
|
|
2026-03-11 16:26:10 +08:00
|
|
|
|
try {
|
2026-03-11 17:22:47 +08:00
|
|
|
|
const requestBody: any = {
|
|
|
|
|
|
agent_id: selectedAgent.value?.id || 1,
|
|
|
|
|
|
message: userContent,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (selectedModel.value) {
|
|
|
|
|
|
requestBody.model_id = selectedModel.value.id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// 先获取完整响应,再逐字符显示(模拟流式效果)
|
|
|
|
|
|
const response = await fetch(`/api/agent/chat/stream`, {
|
2026-03-11 16:26:10 +08:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
2026-03-11 17:22:47 +08:00
|
|
|
|
body: JSON.stringify(requestBody),
|
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-12 10:49:44 +08:00
|
|
|
|
// 读取完整响应
|
|
|
|
|
|
const reader = response.body.getReader()
|
|
|
|
|
|
const decoder = new TextDecoder()
|
|
|
|
|
|
let buffer = ''
|
|
|
|
|
|
let fullText = ''
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
while (true) {
|
|
|
|
|
|
const { done, value } = await reader.read()
|
|
|
|
|
|
if (done) break
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
const lines = buffer.split('\n')
|
|
|
|
|
|
buffer = lines.pop() || ''
|
2026-03-10 17:38:57 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
for (const line of lines) {
|
|
|
|
|
|
if (line.startsWith('data: ')) {
|
|
|
|
|
|
const data = line.slice(6)
|
|
|
|
|
|
if (data && data !== '[DONE]') {
|
|
|
|
|
|
fullText += data
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
// 逐字符显示(模拟流式),每3个字符更新一次UI,减少重绘
|
|
|
|
|
|
const aiMessageIndex = messages.value.length - 1
|
|
|
|
|
|
messages.value[aiMessageIndex].content = ''
|
|
|
|
|
|
let tempText = ''
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < fullText.length; i++) {
|
|
|
|
|
|
tempText += fullText[i]
|
|
|
|
|
|
// 每3个字符更新一次UI,减少重绘次数
|
|
|
|
|
|
if ((i + 1) % 3 === 0 || i === fullText.length - 1) {
|
|
|
|
|
|
messages.value[aiMessageIndex].content = tempText
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
// 添加小延迟,让动画更自然
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 8))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 17:38:57 +08:00
|
|
|
|
|
2026-03-12 10:49:44 +08:00
|
|
|
|
messages.value[aiMessageIndex].isStreaming = false
|
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
|
scrollToBottom()
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error('[Stream] 错误:', error)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
isLoading.value = false
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
: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-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">
|
|
|
|
|
|
<div class="px-6">
|
|
|
|
|
|
<ChatMessage
|
|
|
|
|
|
v-for="message in messages"
|
|
|
|
|
|
:key="message.id"
|
|
|
|
|
|
:message="message"
|
|
|
|
|
|
:selected-agent="selectedAgent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 输入区域 -->
|
|
|
|
|
|
<ChatInput
|
|
|
|
|
|
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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 智能体选择弹窗 -->
|
|
|
|
|
|
<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>
|
2026-03-10 16:09:09 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
::-webkit-scrollbar {
|
2026-03-10 17:38:57 +08:00
|
|
|
|
width: 6px;
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
::-webkit-scrollbar-track {
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
::-webkit-scrollbar-thumb {
|
2026-03-10 17:38:57 +08:00
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border-radius: 3px;
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
2026-03-10 17:38:57 +08:00
|
|
|
|
background: rgba(255, 255, 255, 0.15);
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes messageSlideIn {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
2026-03-10 17:38:57 +08:00
|
|
|
|
transform: translateY(16px) scale(0.96);
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
2026-03-10 17:38:57 +08:00
|
|
|
|
transform: translateY(0) scale(1);
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-enter {
|
2026-03-10 17:38:57 +08:00
|
|
|
|
animation: messageSlideIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
2026-03-10 16:09:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes blink {
|
|
|
|
|
|
0%, 100% { opacity: 1; }
|
|
|
|
|
|
50% { opacity: 0; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cursor-blink {
|
|
|
|
|
|
animation: blink 1s step-end infinite;
|
|
|
|
|
|
}
|
2026-03-10 17:38:57 +08:00
|
|
|
|
|
|
|
|
|
|
@keyframes pulse-glow {
|
|
|
|
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.4); }
|
|
|
|
|
|
50% { box-shadow: 0 0 20px 4px rgba(249, 115, 22, 0.2); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.agent-glow {
|
|
|
|
|
|
animation: pulse-glow 2s ease-in-out infinite;
|
|
|
|
|
|
}
|
2026-03-10 16:09:09 +08:00
|
|
|
|
</style>
|