Files
X-Agents/web/src/views/Chat.vue
DESKTOP-72TV0V4\caoxiaozhu 5c435ab21e Add streaming support and refactor Chat UI
- Add run_stream method to AgentCore for streaming output
- Add base_url parameter to LLM clients for OpenRouter support
- Add xbot module for new agent implementation
- Refactor Chat.vue into composable + components (ChatHeader, ChatMessage, ChatInput, ChatSidebar, ChatAgentSelector)
- Add ChatStream handler for SSE streaming in Go server
- Add UseXBot field to chat request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:49:44 +08:00

309 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
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()
const messagesContainer = ref<HTMLElement | null>(null)
// 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))
}
messages.value[messageIndex].isStreaming = false
isLoading.value = false
}
// 滚动到底部
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 切换模型下拉框
const toggleModelDropdown = () => {
showModelDropdown.value = !showModelDropdown.value
}
// 选择模型
const handleSelectModel = (model: any) => {
selectedModel.value = model
showModelDropdown.value = false
}
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim() || isLoading.value) return
const userContent = inputMessage.value.trim()
inputMessage.value = ''
const userMessage = {
id: Date.now(),
role: 'user' as const,
content: userContent,
timestamp: new Date()
}
messages.value.push(userMessage)
const aiMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
content: '',
timestamp: new Date(),
isStreaming: true
}
messages.value.push(aiMessage)
nextTick(() => scrollToBottom())
isLoading.value = true
// ====== 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 模式结束 ======
try {
const requestBody: any = {
agent_id: selectedAgent.value?.id || 1,
message: userContent,
}
if (selectedModel.value) {
requestBody.model_id = selectedModel.value.id
}
// 先获取完整响应,再逐字符显示(模拟流式效果)
const response = await fetch(`/api/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
// 读取完整响应
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data && data !== '[DONE]') {
fullText += data
}
}
}
}
// 逐字符显示模拟流式每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))
}
}
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
}
}
</script>
<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"
/>
<!-- 消息区域 -->
<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>
<style scoped>
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.message-enter {
animation: messageSlideIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.cursor-blink {
animation: blink 1s step-end infinite;
}
@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;
}
</style>