@@ -1,215 +1,72 @@
< 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 API _BASE = 'http://localhost:8082'
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 ( )
// 模型列表
interface ChatModel {
id : string
name : string
model _type : string
provider : string
model : string
status : string
}
const chatModels = ref < ChatModel [ ] > ( [ ] )
const selectedModel = ref < ChatModel | null > ( null )
const modelsLoading = ref ( false )
const showModelDropdown = ref ( false )
// 根据 provider 获取图标
const getModelIcon = ( provider : string ) => {
const icons : Record < string , string > = {
'OpenAI' : '🤖' ,
'Claude' : '🧠' ,
'Google' : '✨' ,
'Gemini' : '✨' ,
'Ollama' : '🦙' ,
'DeepSeek' : '🔮' ,
'Moonshot' : '🌙' ,
'Kimi' : '🌙' ,
'Baidu' : '🐉' ,
'文心一言' : '🐉' ,
'Aliyun' : '☁️' ,
'Ali' : '☁️' ,
'通义千问' : '☁️' ,
'Azure' : '⬛' ,
'Anthropic' : '🧠' ,
}
return icons [ provider ] || '💬'
}
// 获取模型列表
const fetchModels = async ( ) => {
modelsLoading . value = true
try {
const response = await fetch ( ` ${ API _BASE } /model/list ` )
const data = await response . json ( )
if ( data . list ) {
// 过滤出 chat 类型且 active 状态的模型
chatModels . value = data . list . filter ( ( m : ChatModel ) => m . model _type === 'chat' && m . status === 'active' )
console . log ( 'Chat models:' , chatModels . value )
// 默认选择第一个
if ( chatModels . value . length > 0 && ! selectedModel . value ) {
selectedModel . value = chatModels . value [ 0 ]
}
}
} catch ( error ) {
console . error ( 'Failed to fetch models:' , error )
} finally {
modelsLoading . value = false
}
}
onMounted ( ( ) => {
fetchModels ( )
// 点击外部关闭下拉框
document . addEventListener ( 'click' , handleClickOutside )
} )
onUnmounted ( ( ) => {
document . removeEventListener ( 'click' , handleClickOutside )
} )
// 点击外部关闭下拉框
const handleClickOutside = ( e : MouseEvent ) => {
const target = e . target as HTMLElement
if ( ! target . closest ( '.model-dropdown' ) ) {
showModelDropdown . value = false
}
}
interface ChatMessage {
id : number
role : 'user' | 'assistant'
content : string
timestamp : Date
isStreaming ? : boolean
}
interface Agent {
id : number
name : string
avatar : string
description : string
accentColor : string
gradient : string
status : 'online' | 'offline'
}
interface ChatSession {
id : number
title : string
agentId : number
lastMessage : string
timestamp : Date
}
// AI 助手配置
const chatAgents = ref < Agent [ ] > ( [
{ id : 1 , name : 'Claude' , avatar : '🧠' , description : 'Anthropic AI' , accentColor : '#f97316' , gradient : 'from-orange-500/20 to-amber-500/20' , status : 'online' } ,
{ id : 2 , name : 'Gemini' , avatar : '✨' , description : 'Google DeepMind' , accentColor : '#8b5cf6' , gradient : 'from-violet-500/20 to-purple-500/20' , status : 'online' } ,
{ id : 3 , name : 'ChatGPT' , avatar : '💬' , description : 'OpenAI' , accentColor : '#10b981' , gradient : 'from-emerald-500/20 to-green-500/20' , status : 'offline' } ,
{ id : 4 , name : 'DeepSeek' , avatar : '🔮' , description : 'DeepSeek AI' , accentColor : '#3b82f6' , gradient : 'from-blue-500/20 to-cyan-500/20' , status : 'online' } ,
{ id : 5 , name : 'Kimi' , avatar : '🌙' , description : 'Moonshot AI' , accentColor : '#ec4899' , gradient : 'from-pink-500/20 to-rose-500/20' , status : 'online' } ,
{ id : 6 , name : '文心一言' , avatar : '🐉' , description : 'Baidu' , accentColor : '#ef4444' , gradient : 'from-red-500/20 to-orange-500/20' , status : 'offline' } ,
{ id : 7 , name : '通义千问' , avatar : '☁️' , description : 'Alibaba' , accentColor : '#06b6d4' , gradient : 'from-cyan-500/20 to-sky-500/20' , status : 'online' } ,
] )
// 当前选中的助手
const selectedAgent = ref < Agent | null > ( chatAgents . value [ 0 ] )
// 聊天消息
const messages = ref < ChatMessage [ ] > ( [
{ id : 1 , role : 'assistant' , content : '你好!我是 Claude, 你的 AI 助手。有什么我可以帮助你的吗?' , timestamp : new Date ( ) } ,
] )
// 模拟历史对话列表
const chatSessions = ref < ChatSession [ ] > ( [
{ id : 1 , title : '关于 Python 学习的讨论' , agentId : 1 , lastMessage : '谢谢你!' , timestamp : new Date ( Date . now ( ) - 3600000 ) } ,
{ id : 2 , title : '代码调试帮助' , agentId : 1 , lastMessage : '让我看看这个问题...' , timestamp : new Date ( Date . now ( ) - 7200000 ) } ,
{ id : 3 , title : '数据分析咨询' , agentId : 4 , lastMessage : 'DeepSeek: 好的' , timestamp : new Date ( Date . now ( ) - 86400000 ) } ,
] )
// 群聊数据
interface GroupChat {
id : number
name : string
members : string [ ]
lastMessage : string
timestamp : Date
}
const groupChats = ref < GroupChat [ ] > ( [
{ id : 1 , name : 'AI 讨论组' , members : [ 'Claude' , 'GPT-4' , 'Gemini' ] , lastMessage : '我们来讨论一下...' , timestamp : new Date ( Date . now ( ) - 1800000 ) } ,
{ id : 2 , name : '编程助手' , members : [ 'Claude' , 'DeepSeek' ] , lastMessage : '这段代码有问题吗?' , timestamp : new Date ( Date . now ( ) - 3600000 ) } ,
{ id : 3 , name : '创意头脑风暴' , members : [ 'GPT-4' , 'Claude' , 'Kimi' ] , lastMessage : '有个新想法...' , timestamp : new Date ( Date . now ( ) - 7200000 ) } ,
] )
// 智能体选择弹窗状态
const showAgentSelector = ref ( false )
const selectMode = ref < 'single' | 'group' > ( 'single' )
const selectedAgents = ref < Agent [ ] > ( [ ] )
const groupChatName = ref ( '' )
// 打开智能体选择器
const openAgentSelector = ( mode : 'single' | 'group' ) => {
selectMode . value = mode
selectedAgents . value = [ ]
groupChatName . value = ''
showAgentSelector . value = true
}
// 切换智能体选择(群聊模式)
const toggleAgentSelection = ( agent : Agent ) => {
const index = selectedAgents . value . findIndex ( a => a . id === agent . id )
if ( index > - 1 ) {
selectedAgents . value . splice ( index , 1 )
} else {
selectedAgents . value . push ( agent )
}
}
// 确认选择
const confirmAgentSelection = ( ) => {
if ( selectMode . value === 'single' ) {
// 单聊模式:选择一个智能体开始对话
if ( selectedAgents . value . length > 0 ) {
selectedAgent . value = selectedAgents . value [ 0 ]
messages . value = [
{ id : 1 , role : 'assistant' , content : ` 你好!我是 ${ selectedAgent . value . name } ,你的 AI 助手。有什么我可以帮助你的吗? ` , timestamp : new Date ( ) }
]
}
} else {
// 群聊模式:选择多个智能体
const name = groupChatName . value . trim ( ) || ` 群聊 ( ${ selectedAgents . value . length } 人) `
console . log ( '创建群聊:' , { name , members : selectedAgents . value } )
// 添加到群聊列表
groupChats . value . unshift ( {
id : Date . now ( ) ,
name : name ,
members : selectedAgents . value . map ( a => a . name ) ,
lastMessage : 'New group created' ,
timestamp : new Date ( )
} )
}
showAgentSelector . value = false
}
// 取消选择
const cancelAgentSelection = ( ) => {
showAgentSelector . value = false
}
// 侧边栏展开/收起状态
const sidebarCollapsed = ref ( false )
// 输入内容
const inputMessage = ref ( '' )
const isLoading = ref ( false )
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
@@ -217,17 +74,17 @@ const sendMessage = async () => {
const userContent = inputMessage . value . trim ( )
inputMessage . value = ''
const userMessage : ChatMessage = {
const userMessage = {
id : Date . now ( ) ,
role : 'user' ,
role : 'user' as const ,
content : userContent ,
timestamp : new Date ( )
}
messages . value . push ( userMessage )
const aiMessage : ChatMessage = {
const aiMessage = {
id : Date . now ( ) + 1 ,
role : 'assistant' ,
role : 'assistant' as const ,
content : '' ,
timestamp : new Date ( ) ,
isStreaming : true
@@ -238,20 +95,35 @@ 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 ,
message : userContent ,
}
// 如果选择了模型,只传递 model_id( 后端会根据 id 查询 api_key 和 base_url)
if ( selectedModel . value ) {
requestBody . model _id = selectedModel . value . id
}
// 调用后端 API
const response = await fetch ( ` ${ API _BASE } /api/agent/chat ` , {
// 先获取完整响应,再逐字符显示(模拟流式效果)
const response = await fetch ( ` /api/agent/chat/stream ` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
@@ -259,108 +131,130 @@ const sendMessage = async () => {
body : JSON . stringify ( requestBody ) ,
} )
const data = await response . json ( )
const fullResponse = data . reply || data . response || 'No response'
if ( ! response . ok ) {
throw new Error ( ` Request failed: ${ response . status } ` )
}
// 流式显示回复
let currentIn dex = 0
const words = fullResponse . split ( '' )
// 读取完整响应
const rea der = response . body . getReader ( )
const decoder = new TextDecoder ( )
let buffer = ''
let fullText = ''
const streamInterval = setInterval ( ( ) => {
if ( currentIndex < words . length ) {
aiMessage . c ontent += words [ currentIndex ]
currentIndex ++
nextTick ( ( ) => scrollToBottom ( ) )
} else {
clearInterval ( streamInterval )
aiMessage . isStreaming = false
isLoading . value = false
while ( true ) {
const { done , value } = await reader . read ( )
if ( d one ) 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
}
}
}
} , 30 )
}
// 逐字符显示( 模拟流式) , 每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 ) {
aiMessage . content = ` Error: ${ error . message || 'Failed to send message' } `
aiMessage . isStreaming = false
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
}
}
// 滚动到底部
const scrollToBottom = ( ) => {
if ( messagesContainer . value ) {
messagesContainer . value . scrollTop = messagesContainer . value . scrollHeight
}
}
// 复制消息
const copyMessage = ( content : string ) => {
navigator . clipboard . writeText ( content )
}
// 选择助手
const selectAgent = ( agent : Agent ) => {
selectedAgent . value = agent
messages . value = [
{ id : 1 , role : 'assistant' , content : ` 你好!我是 ${ agent . name } 。有什么我可以帮助你的吗? ` , timestamp : new Date ( ) }
]
}
// 选择历史对话
const selectSession = ( session : ChatSession ) => {
const agent = chatAgents . value . find ( a => a . id === session . agentId )
if ( agent ) {
selectedAgent . value = agent
}
messages . value = [
{ id : 1 , role : 'assistant' , content : ` 已加载会话: ${ session . title } ` , timestamp : new Date ( ) }
]
}
// 新建聊天
const newChat = ( ) => {
messages . value = [
{ id : 1 , role : 'assistant' , content : ` 你好!我是 ${ selectedAgent . value ? . name || 'Claude' } 。有什么我可以帮助你的吗? ` , timestamp : new Date ( ) }
]
}
// 格式化时间
const formatTime = ( date : Date ) => {
return date . toLocaleTimeString ( 'zh-CN' , { hour : '2-digit' , minute : '2-digit' } )
}
// 格式化相对时间
const formatRelativeTime = ( date : Date ) => {
const now = new Date ( )
const diff = now . getTime ( ) - date . getTime ( )
const hours = Math . floor ( diff / 3600000 )
const days = Math . floor ( diff / 86400000 )
if ( hours < 1 ) return '刚刚'
if ( hours < 24 ) return ` ${ hours } 小时前 `
if ( days < 7 ) return ` ${ days } 天前 `
return date . toLocaleDateString ( 'zh-CN' )
}
// 回车发送
const handleKeydown = ( e : KeyboardEvent ) => {
if ( e . key === 'Enter' && ! e . shiftKey ) {
e . preventDefault ( )
sendMessage ( )
}
}
// 调整输入框高度
const autoResize = ( e : Event ) => {
const target = e . target as HTMLTextAreaElement
target . style . height = 'auto'
target . style . height = Math . min ( target . scrollHeight , 160 ) + 'px'
}
// 折叠侧边栏
const toggleSidebar = ( ) => {
sidebarCollapsed . value = ! sidebarCollapsed . value
}
< / 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 : 6 px ;
@@ -379,18 +273,6 @@ const toggleSidebar = () => {
background : rgba ( 255 , 255 , 255 , 0.15 ) ;
}
/* 下拉框动画 */
. dropdown - enter - active ,
. dropdown - leave - active {
transition : all 0.2 s ease ;
}
. dropdown - enter - from ,
. dropdown - leave - to {
opacity : 0 ;
transform : translateY ( - 8 px ) ;
}
@ keyframes messageSlideIn {
from {
opacity : 0 ;
@@ -424,366 +306,3 @@ const toggleSidebar = () => {
animation : pulse - glow 2 s ease - in - out infinite ;
}
< / style >
< template >
< div class = "h-screen flex bg-[#09090b]" >
<!-- 主聊天区域 -- >
< div class = "flex-1 flex flex-col bg-[#09090b]" >
<!-- 顶部栏 -- >
< div class = "h-16 px-4 flex items-center justify-between border-b border-white/[0.06] bg-[#0c0c0f]/80 backdrop-blur-xl" >
<!-- 左侧 : 当前AI信息 -- >
< div class = "flex items-center gap-3" >
< div v-if = "selectedAgent" class="flex items-center gap-3" >
< div
class = "w-9 h-9 rounded-xl flex items-center justify-center text-lg shadow-lg"
: style = "{ backgroundColor: selectedAgent.accentColor + '15', color: selectedAgent.accentColor }"
>
{ { selectedAgent . avatar } }
< / div >
< div >
< div class = "text-sm font-semibold text-white tracking-wide" > { { selectedAgent ? . name || 'Chat' } } < / div >
< div class = "text-[11px] flex items-center gap-1.5" >
< span class = "w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" > < / span >
< span class = "text-white/40" > Online < / span >
< / div >
< / div >
< / div >
< / div >
<!-- 中间 : 空白 -- >
< div class = "flex-1" > < / div >
<!-- 右侧 : 模型选择和折叠按钮 -- >
< div class = "flex items-center gap-3" >
<!-- 模型选择下拉框 -- >
< div class = "relative model-dropdown" v-if = "chatModels.length > 0" >
< button
@click ="showModelDropdown = !showModelDropdown"
class = "flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-[#1a1a24] hover:border-orange-500/30 transition-all duration-200"
>
< div class = "flex items-center gap-2" >
<!-- 动态获取模型图标 -- >
< span class = "text-base" > { { getModelIcon ( selectedModel ? . provider || '' ) } } < / span >
< span class = "text-sm text-white" > { { selectedModel ? . name || 'Select Model' } } < / span >
< / div >
< span class = "w-1.5 h-1.5 rounded-full bg-emerald-400" > < / span >
< svg class = "w-3.5 h-3.5 text-white/40" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M19 9l-7 7-7-7" > < / path >
< / svg >
< / button >
<!-- 下拉菜单 -- >
< Transition name = "dropdown" >
< div
v-if = "showModelDropdown"
class = "absolute right-0 top-full mt-2 w-64 bg-[#1a1a24] border border-white/[0.08] rounded-xl shadow-2xl shadow-black/50 overflow-hidden z-50"
>
< div class = "p-2" >
< div class = "text-xs text-white/40 px-2 py-1 mb-1" > Select Chat Model < / div >
< button
v-for = "model in chatModels"
:key = "model.id"
@click ="selectedModel = model; showModelDropdown = false"
class = "w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-150"
: class = "selectedModel?.id === model.id
? 'bg-orange-500/15 border border-orange-500/30'
: 'hover:bg-white/5'"
>
< span class = "text-lg" > { { getModelIcon ( model . provider ) } } < / span >
< div class = "flex-1 text-left" >
< div class = "text-sm text-white" > { { model . name } } < / div >
< div class = "text-xs text-white/40" > { { model . provider } } · { { model . model } } < / div >
< / div >
< span class = "w-2 h-2 rounded-full bg-emerald-400" > < / span >
< / button >
< / div >
< / div >
< / Transition >
< / div >
<!-- 没有模型时的提示 -- >
< div v-else class = "text-xs text-white/30 px-2 flex items-center gap-2" >
< span class = "w-2 h-2 rounded-full bg-white/20" > < / span >
No models
< / div >
< button
@click ="toggleSidebar"
class = "p-2.5 rounded-xl hover:bg-white/[0.06] text-white/35 hover:text-white/80 transition-all duration-200"
: title = "sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'"
>
< svg class = "w-[18px] h-[18px] transition-transform duration-300" : class = "sidebarCollapsed ? '' : 'rotate-180'" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 1.5 " d = "M4 6h16M4 12h16M4 18h16" > < / path >
< / svg >
< / button >
< / div >
< / div >
<!-- 消息区域 -- >
< div ref = "messagesContainer" class = "flex-1 overflow-y-auto py-4" >
< div class = "px-6" >
< div
v-for = "message in messages"
:key = "message.id"
class = "message-enter flex items-start mb-4"
: class = "message.role === 'user' ? 'flex-row-reverse' : ''"
>
<!-- 头像 -- >
< div
class = "w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center mx-3 mt-1"
: class = "message.role === 'user' ? 'bg-gradient-to-br from-orange-500 to-amber-600' : ''"
: style = "message.role === 'assistant' && selectedAgent ? {
backgroundColor: selectedAgent.accentColor + '25',
color: selectedAgent.accentColor
} : {}"
>
< span v-if = "message.role === 'user'" class="text-white text-sm" > 👤 < / span >
< span v-else class = "text-lg" > { { selectedAgent ? . avatar || '🧠' } } < / span >
< / div >
<!-- 气泡和时间戳容器 -- >
< div : class = "message.role === 'user' ? 'mr-3 ml-auto' : 'ml-3'" >
<!-- 消息气泡 -- >
< div
class = "px-4 py-2.5 rounded-xl text-[14px] leading-6"
: class = "message.role === 'user'
? 'bg-gradient-to-br from-orange-500 to-orange-600 text-white rounded-tr-sm'
: 'bg-[#2a2a35] text-white/90 rounded-tl-sm max-w-[80%]'"
>
{ { message . content } }
< span v-if = "message.isStreaming" class="inline-block w-0.5 h-4 ml-0.5 bg-orange-300 cursor-blink align-middle" > < / span >
< / div >
< ! - - 时间戳 - - >
< div
class = "text-[11px] text-white/30 mt-1"
: class = "message.role === 'user' ? 'text-right' : ''"
>
{ { formatTime ( message . timestamp ) } }
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- 输入区域 -- >
< div class = "p-5 border-t border-white/[0.06] bg-[#0c0c0f]/60 backdrop-blur-xl" >
< div class = "max-w-3xl mx-auto" >
< div class = "relative bg-[#12121a] rounded-2xl border border-white/[0.08] focus-within:border-orange-500/40 focus-within:shadow-[0_0_30px_rgba(249,115,22,0.08)] transition-all duration-300" >
<!-- 附件按钮 -- >
< button class = "absolute left-4 top-1/2 -translate-y-1/2 text-white/25 hover:text-orange-400 transition-colors p-1" >
< svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 1.5 " d = "M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" > < / path >
< / svg >
< / button >
<!-- 输入框 -- >
< textarea
v-model = "inputMessage"
@keydown ="handleKeydown"
@input ="autoResize"
placeholder = "发送消息..."
rows = "1"
class = "w-full bg-transparent text-white placeholder-white/25 py-4 pl-12 pr-28 resize-none focus:outline-none text-[15px]"
> < / textarea >
<!-- 发送按钮 -- >
< button
@click ="sendMessage"
: disabled = "!inputMessage.trim() || isLoading"
class = "absolute right-2 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg flex items-center justify-center transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed"
: class = "inputMessage.trim() && !isLoading
? 'bg-orange-500 hover:bg-orange-400 shadow-lg shadow-orange-500/30 active:scale-90'
: 'bg-white/10'"
>
< svg v-if = "!isLoading" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor" >
< path d = "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" / >
< / svg >
< svg v-else class = "w-4 h-4 text-white animate-spin" fill = "none" viewBox = "0 0 24 24" >
< circle class = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" stroke -width = " 4 " > < / circle >
< path class = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > < / path >
< / svg >
< / button >
< / div >
<!-- 提示 -- >
< div class = "text-center mt-3" >
< span class = "text-[10px] text-white/20 tracking-wide" > AI 可能会产生错误信息 , 请核实重要内容 < / span >
< / div >
< / div >
< / div >
< / div >
<!-- 右侧边栏 : AI Hub -- >
< div
class = "flex-shrink-0 border-l border-white/[0.06] bg-[#0c0c0f] transition-all duration-300 ease-in-out overflow-hidden"
: class = "sidebarCollapsed ? 'w-0 opacity-0' : 'w-72 opacity-100'"
>
< div class = "w-72 h-full flex flex-col" >
<!-- 侧边栏头部 -- >
< div class = "p-4 border-b border-white/[0.06]" >
< div class = "flex items-center gap-2 text-white font-semibold" >
< svg class = "w-5 h-5 text-orange-500" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 1.5 " d = "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" > < / path >
< / svg >
< span > AI Hub < / span >
< / div >
< / div >
<!-- 新建对话按钮 -- >
< div class = "p-3" >
< div class = "flex gap-2" >
< button
@click ="openAgentSelector('single')"
class = "flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-orange-500 hover:bg-orange-400 rounded-lg text-white text-sm font-medium transition-all duration-200"
>
< svg class = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M12 4v16m8-8H4" > < / path >
< / svg >
< span > 新建对话 < / span >
< / button >
< button
@click ="openAgentSelector('group')"
class = "flex-1 flex items-center justify-center gap-2 px-3 py-2.5 bg-dark-700 hover:bg-dark-600 border border-dark-500 rounded-lg text-white/80 text-sm font-medium transition-all duration-200"
>
< svg class = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" > < / path >
< / svg >
< span > 新建群聊 < / span >
< / button >
< / div >
< / div >
<!-- AI 助手选择 -- >
< div class = "px-3 pb-3" >
< div class = "text-xs text-white/40 uppercase tracking-wider mb-2 px-1" > 选择 AI 助手 < / div >
< div class = "space-y-1" >
< button
v-for = "agent in chatAgents"
:key = "agent.id"
@click ="selectAgent(agent)"
class = "w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200"
: class = "selectedAgent?.id === agent.id
? 'bg-orange-500/15 text-orange-400'
: 'text-white/60 hover:bg-white/5 hover:text-white'"
>
< span class = "text-base" > { { agent . avatar } } < / span >
< span class = "text-sm truncate" > { { agent . name } } < / span >
< span
v-if = "agent.status === 'online'"
class = "w-1.5 h-1.5 rounded-full bg-emerald-400 ml-auto"
> < / span >
< / button >
< / div >
< / div >
<!-- 群聊列表 -- >
< div class = "flex-1 overflow-y-auto px-3 pb-3" >
< div class = "text-xs text-white/40 uppercase tracking-wider mb-2 px-1" > 群聊 < / div >
< div class = "space-y-1" >
< button
v-for = "group in groupChats"
:key = "group.id"
class = "w-full text-left px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all duration-200 group"
>
< div class = "flex items-center gap-2" >
< svg class = "w-4 h-4 text-white/30 flex-shrink-0" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 1.5 " d = "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" > < / path >
< / svg >
< span class = "text-sm text-white/70 group-hover:text-white truncate" > { { group . name } } < / span >
< / div >
< div class = "text-xs text-white/30 mt-1 pl-6" > { { group . members . length } } members < / div >
< / button >
< / div >
< / div >
< / div >
< / div >
< / div >
<!-- 智能体选择弹窗 -- >
< Teleport to = "body" >
< Transition name = "fade" >
< div v-if = "showAgentSelector" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50" @click="cancelAgentSelection" >
< div class = "bg-dark-800 rounded-xl w-full max-w-md border border-dark-600 shadow-2xl" @click.stop >
< div class = "p-4 border-b border-dark-600" >
< h3 class = "text-lg font-semibold text-white" >
{ { selectMode === 'single' ? '选择智能体' : '选择群聊成员' } }
< / h3 >
< p class = "text-sm text-gray-400 mt-1" >
{ { selectMode === 'single' ? '选择一个智能体开始对话' : '选择多个智能体创建群聊' } }
< / p >
< / div >
< div class = "p-4 max-h-80 overflow-y-auto" >
<!-- 群聊名称输入框 -- >
< div v-if = "selectMode === 'group'" class="mb-4" >
< input
v-model = "groupChatName"
type = "text"
placeholder = "Enter group name..."
class = "w-full bg-dark-700 border border-dark-500 rounded-lg px-4 py-2.5 text-white placeholder-gray-500 focus:outline-none focus:border-primary-orange"
>
< / div >
< div class = "space-y-2" >
< button
v-for = "agent in chatAgents"
:key = "agent.id"
@click ="selectMode === 'group' ? toggleAgentSelection(agent) : (selectedAgents = [agent], confirmAgentSelection())"
class = "w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200"
: class = "selectedAgents.some(a => a.id === agent.id)
? 'bg-orange-500/20 border border-orange-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'"
>
< span class = "text-xl" > { { agent . avatar } } < / span >
< div class = "flex-1 text-left" >
< div class = "text-white font-medium" > { { agent . name } } < / div >
< div class = "text-xs text-gray-400" > { { agent . description } } < / div >
< / div >
< span
v-if = "agent.status === 'online'"
class = "w-2 h-2 rounded-full bg-emerald-400"
> < / span >
< span
v-if = "selectMode === 'group' && selectedAgents.some(a => a.id === agent.id)"
class = "w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center"
>
< svg class = "w-3 h-3 text-white" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 3 " d = "M5 13l4 4L19 7" > < / path >
< / svg >
< / span >
< / button >
< / div >
< / div >
< div class = "p-4 border-t border-dark-600 flex gap-3" >
< button
@click ="cancelAgentSelection"
class = "flex-1 py-2.5 bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors"
>
Cancel
< / button >
< button
v-if = "selectMode === 'group'"
@click ="confirmAgentSelection"
: disabled = "selectedAgents.length < 2"
class = "flex-1 py-2.5 bg-orange-500 hover:bg-orange-400 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Group ( { { selectedAgents . length } } )
< / button >
< / div >
< / div >
< / div >
< / Transition >
< / Teleport >
< / template >
< style scoped >
. fade - enter - active ,
. fade - leave - active {
transition : all 0.2 s ease ;
}
. fade - enter - from ,
. fade - leave - to {
opacity : 0 ;
}
< / style >