feat: 更新Chat前端页面

- 优化Chat组件交互体验
- 修复智能体选择逻辑
- 新增chat页面样式和脚本

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:33:25 +08:00
parent 77f5b4872e
commit e19a0ba673
8 changed files with 823 additions and 107 deletions

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useChat } from '@/composables/useChat'
import { useChat } from './chat/chat'
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'
import './chat/chat.css'
const {
chatModels,
@@ -15,6 +16,7 @@ const {
selectedAgent,
messages,
chatSessions,
currentSessionId,
groupChats,
showAgentSelector,
selectMode,
@@ -29,9 +31,16 @@ const {
confirmAgentSelection,
cancelAgentSelection,
selectAgent,
selectGroup,
selectSession,
newChat,
clearMessages,
toggleSidebar,
createSession,
saveMessage,
deleteSession,
init,
cleanup,
} = useChat()
const messagesContainer = ref<HTMLElement | null>(null)
@@ -74,6 +83,19 @@ const sendMessage = async () => {
const userContent = inputMessage.value.trim()
inputMessage.value = ''
// 重置输入框高度
nextTick(() => {
const textarea = document.querySelector('.chat-input-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.style.height = 'auto'
}
})
// 如果没有会话,创建一个新会话
if (!currentSessionId.value) {
await createSession()
}
const userMessage = {
id: Date.now(),
role: 'user' as const,
@@ -82,6 +104,9 @@ const sendMessage = async () => {
}
messages.value.push(userMessage)
// 保存用户消息到后端
await saveMessage('user', userContent)
const aiMessage = {
id: Date.now() + 1,
role: 'assistant' as const,
@@ -95,23 +120,6 @@ const sendMessage = async () => {
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,
@@ -122,7 +130,11 @@ const sendMessage = async () => {
requestBody.model_id = selectedModel.value.id
}
// 先获取完整响应,再逐字符显示(模拟流式效果)
// 传入 session_id
if (currentSessionId.value) {
requestBody.session_id = currentSessionId.value
}
const response = await fetch(`/api/agent/chat/stream`, {
method: 'POST',
headers: {
@@ -135,11 +147,11 @@ const sendMessage = async () => {
throw new Error(`Request failed: ${response.status}`)
}
// 读取完整响应
// 真正的流式处理:边读取边显示
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
const aiMessageIndex = messages.value.length - 1
while (true) {
const { done, value } = await reader.read()
@@ -152,34 +164,31 @@ const sendMessage = async () => {
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
const data = line.slice(6).trim()
if (data && data !== '[DONE]') {
fullText += data
// 直接累加内容并显示(真正的流式)
messages.value[aiMessageIndex].content += data
await nextTick()
scrollToBottom()
}
}
}
}
// 逐字符显示模拟流式每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))
// 处理剩余buffer中的数据
if (buffer.startsWith('data: ')) {
const data = buffer.slice(6).trim()
if (data && data !== '[DONE]') {
messages.value[aiMessageIndex].content += data
}
}
messages.value[aiMessageIndex].isStreaming = false
isLoading.value = false
scrollToBottom()
// 保存 AI 消息到后端
await saveMessage('assistant', messages.value[aiMessageIndex].content)
} catch (error: any) {
console.error('[Stream] 错误:', error)
const errorIndex = messages.value.findIndex(m => m.isStreaming)
@@ -190,6 +199,16 @@ const sendMessage = async () => {
isLoading.value = false
}
}
// 初始化
onMounted(() => {
console.log('[Chat] Component mounted, calling init()')
init()
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
@@ -206,11 +225,22 @@ const sendMessage = async () => {
@toggle-dropdown="toggleModelDropdown"
@select-model="handleSelectModel"
@toggle-sidebar="toggleSidebar"
@clear-chat="clearMessages"
@new-chat="newChat"
/>
<!-- 消息区域 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto py-4">
<div class="px-6">
<!-- 空状态欢迎提示 -->
<div v-if="messages.length === 0" class="h-full flex items-center justify-center">
<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">
<ChatMessage
v-for="message in messages"
:key="message.id"
@@ -238,6 +268,7 @@ const sendMessage = async () => {
@open-agent-selector="openAgentSelector"
@select-agent="selectAgent"
@select-session="selectSession"
@select-group="selectGroup"
/>
<!-- 智能体选择弹窗 -->
@@ -254,55 +285,3 @@ const sendMessage = async () => {
/>
</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>