redesign: clean modern Agent chat UI
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 4s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 4s
This commit is contained in:
parent
25c1af7366
commit
b2c2fd3805
@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Input, Button, theme, Typography, App } from 'antd'
|
||||
import {
|
||||
SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined,
|
||||
DeleteOutlined, StopOutlined, MessageOutlined,
|
||||
DeleteOutlined, StopOutlined, MessageOutlined, UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { streamChat, type StreamEvent } from '@/services/ai-chat'
|
||||
import {
|
||||
@ -14,10 +14,7 @@ import Markdown from '@/components/Markdown'
|
||||
const { Text } = Typography
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
id: string; role: 'user' | 'assistant'; content: string; timestamp: number
|
||||
streaming?: boolean
|
||||
toolCalls?: { tool: string; preview?: string; done: boolean; duration?: number; error?: boolean }[]
|
||||
}
|
||||
@ -38,209 +35,177 @@ function ChatPage() {
|
||||
const editInputRef = useRef<any>(null)
|
||||
const { token } = theme.useToken()
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
try { setConversations(await listConversations()) } catch { /* */ }
|
||||
}, [])
|
||||
const loadConversations = useCallback(async () => { try { setConversations(await listConversations()) } catch {} }, [])
|
||||
useEffect(() => { loadConversations() }, [loadConversations])
|
||||
|
||||
// Auto-select first conversation on load
|
||||
useEffect(() => {
|
||||
if (!activeId && conversations.length > 0) {
|
||||
switchConversation(conversations[0].id)
|
||||
}
|
||||
}, [conversations, activeId])
|
||||
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||
|
||||
useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations])
|
||||
|
||||
const switchConversation = useCallback(async (id: string) => {
|
||||
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
||||
setActiveId(id); setMessages([])
|
||||
try {
|
||||
const records = await getMessages(id)
|
||||
setMessages(records.map(m => ({
|
||||
id: m.id, role: m.role, content: m.content,
|
||||
timestamp: new Date(m.createdAt).getTime(),
|
||||
})))
|
||||
} catch { /* */ }
|
||||
try { const records = await getMessages(id); setMessages(records.map(m => ({ id: m.id, role: m.role, content: m.content, timestamp: new Date(m.createdAt).getTime() }))) } catch {}
|
||||
}, [streaming])
|
||||
|
||||
const handleNew = async () => {
|
||||
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
||||
try {
|
||||
const conv = await createConversation()
|
||||
setConversations(prev => [conv, ...prev])
|
||||
setActiveId(conv.id); setMessages([]); setInput('')
|
||||
} catch { /* */ }
|
||||
try { const conv = await createConversation(); setConversations(prev => [conv, ...prev]); setActiveId(conv.id); setMessages([]); setInput('') } catch {}
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => modal.confirm({
|
||||
title: '删除对话', content: '确定?', okText: '删除', okType: 'danger', cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setDeleting(true)
|
||||
try { await deleteConversation(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch { /* */ }
|
||||
setDeleting(false)
|
||||
},
|
||||
title: '删除对话', okText: '删除', okType: 'danger', cancelText: '取消',
|
||||
onOk: async () => { setDeleting(true); try { await deleteConversation(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch {}; setDeleting(false) },
|
||||
})
|
||||
|
||||
const startEdit = (conv: Conversation) => { setEditingId(conv.id); setEditTitle(conv.title); setTimeout(() => editInputRef.current?.focus(), 50) }
|
||||
const saveTitle = async (id: string) => {
|
||||
const title = editTitle.trim()
|
||||
if (title && title !== conversations.find(c => c.id === id)?.title) {
|
||||
await updateConversation(id, title).catch(() => {})
|
||||
setConversations(prev => prev.map(c => c.id === id ? { ...c, title } : c))
|
||||
}
|
||||
const t = editTitle.trim()
|
||||
if (t && t !== conversations.find(c => c.id === id)?.title) { await updateConversation(id, t).catch(() => {}); setConversations(prev => prev.map(c => c.id === id ? { ...c, title: t } : c)) }
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
setInput('')
|
||||
|
||||
const text = input.trim(); if (!text || streaming) return; setInput('')
|
||||
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now() }
|
||||
const prevMessages = [...messages, userMsg]
|
||||
setMessages(prevMessages)
|
||||
setStreaming(true)
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const prevMessages = [...messages, userMsg]; setMessages(prevMessages); setStreaming(true)
|
||||
const controller = new AbortController(); abortRef.current = controller
|
||||
const streamMsgId = (Date.now() + 1).toString()
|
||||
const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), streaming: true }
|
||||
setMessages(prev => [...prev, streamMsg])
|
||||
|
||||
let currentContent = ''
|
||||
const currentTools: any[] = []
|
||||
let completedConvId: string | undefined
|
||||
|
||||
const update = (updates: Partial<Message>) =>
|
||||
setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m))
|
||||
const update = (u: Partial<Message>) => setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...u } : m))
|
||||
|
||||
try {
|
||||
await streamChat(
|
||||
prevMessages.map(m => ({ role: m.role, content: m.content })),
|
||||
activeId,
|
||||
(event: StreamEvent) => {
|
||||
await streamChat(prevMessages.map(m => ({ role: m.role, content: m.content })), activeId, (event: StreamEvent) => {
|
||||
switch (event.event) {
|
||||
case 'meta':
|
||||
completedConvId = event.conversationId
|
||||
if (event.conversationId && event.conversationId !== activeId) { setActiveId(event.conversationId); loadConversations() }
|
||||
break
|
||||
case 'tool.started':
|
||||
currentTools.push({ tool: event.tool, preview: event.preview, done: false })
|
||||
update({ toolCalls: [...currentTools] })
|
||||
break
|
||||
case 'tool.completed':
|
||||
const idx = currentTools.findIndex((t: any) => t.tool === event.tool && !t.done)
|
||||
if (idx >= 0) { currentTools[idx] = { ...currentTools[idx], done: true, duration: event.duration, error: event.error }; update({ toolCalls: [...currentTools] }) }
|
||||
break
|
||||
case 'message.delta':
|
||||
currentContent += event.delta || ''
|
||||
update({ content: currentContent, streaming: true })
|
||||
break
|
||||
case 'run.completed':
|
||||
if (event.output) currentContent = event.output
|
||||
update({ content: currentContent, streaming: false })
|
||||
break
|
||||
case 'done':
|
||||
completedConvId = event.conversationId || completedConvId; update({ streaming: false })
|
||||
break
|
||||
case 'error':
|
||||
update({ content: `❌ ${event.error}`, streaming: false })
|
||||
break
|
||||
case 'meta': completedConvId = event.conversationId; if (event.conversationId && event.conversationId !== activeId) { setActiveId(event.conversationId); loadConversations() } break
|
||||
case 'tool.started': currentTools.push({ tool: event.tool, preview: event.preview, done: false }); update({ toolCalls: [...currentTools] }) break
|
||||
case 'tool.completed': { const i = currentTools.findIndex((t: any) => t.tool === event.tool && !t.done); if (i >= 0) { currentTools[i] = { ...currentTools[i], done: true, duration: event.duration, error: event.error }; update({ toolCalls: [...currentTools] }) } } break
|
||||
case 'message.delta': currentContent += event.delta || ''; update({ content: currentContent, streaming: true }) break
|
||||
case 'run.completed': if (event.output) currentContent = event.output; update({ content: currentContent, streaming: false }) break
|
||||
case 'done': completedConvId = event.conversationId || completedConvId; update({ streaming: false }) break
|
||||
case 'error': update({ content: `❌ ${event.error}`, streaming: false }) break
|
||||
}
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
}, controller.signal)
|
||||
if (completedConvId) loadConversations()
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') update({ content: `❌ ${err.message}`, streaming: false })
|
||||
else update({ streaming: false })
|
||||
} finally {
|
||||
setStreaming(false); abortRef.current = null
|
||||
}
|
||||
if (err.name !== 'AbortError') update({ content: `❌ ${err.message}`, streaming: false }); else update({ streaming: false })
|
||||
} finally { setStreaming(false); abortRef.current = null }
|
||||
}
|
||||
|
||||
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
|
||||
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRadius: token.borderRadiusLG, border: `1px solid ${token.colorBorderSecondary}`, overflow: 'hidden' }}>
|
||||
<div style={{ padding: 12, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 0 }}>
|
||||
{/* Sidebar */}
|
||||
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRight: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}>新对话</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
||||
{conversations.map(conv => (
|
||||
<div key={conv.id} onClick={() => activeId !== conv.id && switchConversation(conv.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', marginBottom: 2, borderRadius: 8, cursor: 'pointer', background: activeId === conv.id ? token.colorFillSecondary : 'transparent' }}>
|
||||
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, marginRight: 8, flexShrink: 0 }} />
|
||||
style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 2,
|
||||
background: activeId === conv.id ? token.colorFillSecondary : 'transparent' }}>
|
||||
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 13, marginRight: 8, flexShrink: 0 }} />
|
||||
{editingId === conv.id ? (
|
||||
<Input ref={editInputRef} size="small" value={editTitle} onChange={e => setEditTitle(e.target.value)}
|
||||
onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)}
|
||||
onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} />
|
||||
onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} bordered={false} />
|
||||
) : (
|
||||
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
|
||||
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
|
||||
<Text ellipsis style={{ flex: 1, fontSize: 13 }} onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
|
||||
)}
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled={deleting}
|
||||
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ marginLeft: 4, flexShrink: 0 }} />
|
||||
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
))}
|
||||
{conversations.length === 0 && <div style={{ textAlign: 'center', padding: 24 }}><Text type="secondary" style={{ fontSize: 13 }}>暂无对话</Text></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', background: token.colorBgContainer, borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`, padding: '20px 24px', border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none' }}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<RobotOutlined style={{ fontSize: 48, marginBottom: 16, color: token.colorTextQuaternary }} />
|
||||
<Text type="secondary" style={{ fontSize: 16 }}>{activeId ? '开始新对话' : '点击「新对话」开始'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>Hermes Agent · 流式响应 · xhigh 推理</Text>
|
||||
{/* Chat */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
|
||||
{/* Messages */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: 16, background: token.colorFillSecondary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<RobotOutlined style={{ fontSize: 28, color: token.colorPrimary }} />
|
||||
</div>
|
||||
<Text style={{ fontSize: 16, fontWeight: 500, color: token.colorText }}>有什么可以帮你的?</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · DeepSeek xhigh 推理 · 可执行任务</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} style={{ marginBottom: 24 }}>
|
||||
{/* Role label */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess }}>
|
||||
{msg.role === 'user' ? <UserOutlined style={{ color: '#fff', fontSize: 14 }} /> : <RobotOutlined style={{ color: '#fff', fontSize: 14 }} />}
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 13 }}>{msg.role === 'user' ? '你' : 'Hermes'}</Text>
|
||||
{msg.streaming && <Text type="secondary" style={{ fontSize: 11 }}>执行中...</Text>}
|
||||
</div>
|
||||
|
||||
{/* Tool calls */}
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div style={{ marginBottom: 8, marginLeft: 36, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{msg.toolCalls.map((t, i) => (
|
||||
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8,
|
||||
background: t.done ? (t.error ? token.colorErrorBg : token.colorSuccessBg) : token.colorFillSecondary,
|
||||
border: `1px solid ${t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary}`,
|
||||
fontSize: 12, maxWidth: 'fit-content' }}>
|
||||
<ToolOutlined style={{ color: t.done ? (t.error ? token.colorError : token.colorSuccess) : token.colorTextSecondary }} />
|
||||
<Text style={{ fontSize: 12 }}>{t.preview || t.tool}</Text>
|
||||
{t.done
|
||||
? <Text style={{ fontSize: 11, color: t.error ? token.colorError : token.colorSuccess }}>{t.error ? '失败' : `${t.duration?.toFixed(1)}s`}</Text>
|
||||
: <Text type="secondary" style={{ fontSize: 11 }}>执行中</Text>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} style={{ display: 'flex', gap: 12, marginBottom: 20, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||
<div style={{ maxWidth: '75%', minWidth: 0, padding: '12px 16px', borderRadius: 12, lineHeight: 1.8, wordBreak: 'break-word', background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter, color: msg.role === 'user' ? '#fff' : token.colorText }}>
|
||||
{/* Content */}
|
||||
<div style={{ marginLeft: 36, padding: '12px 16px', borderRadius: 12, lineHeight: 1.85, fontSize: 14,
|
||||
background: msg.role === 'user' ? token.colorPrimary : token.colorBgContainer,
|
||||
color: msg.role === 'user' ? '#fff' : token.colorText,
|
||||
border: msg.role === 'assistant' ? `1px solid ${token.colorBorderSecondary}` : 'none',
|
||||
boxShadow: msg.role === 'assistant' ? 'none' : undefined }}>
|
||||
{msg.role === 'assistant'
|
||||
? (msg.content
|
||||
? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} />
|
||||
: <Text type="secondary" style={{ fontSize: 13 }}>思考中...</Text>)
|
||||
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : <Text type="secondary">思考中...</Text>)
|
||||
: msg.content}
|
||||
</div>
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div style={{ marginTop: 4, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{msg.toolCalls.map((t, i) => (
|
||||
<span key={i} style={{ fontSize: 11, color: token.colorTextSecondary, background: token.colorFillSecondary, padding: '2px 8px', borderRadius: 4 }}>
|
||||
<ToolOutlined style={{ marginRight: 4, fontSize: 10 }} />{t.preview || t.tool}{t.done ? (t.error ? ' ❌' : ' ✓') : ' ···'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ background: token.colorBgContainer, border: `1px solid ${token.colorBorderSecondary}`, borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorFillTertiary, borderRadius: 12, padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
{/* Input */}
|
||||
<div style={{ padding: '16px 32px 20px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorFillTertiary, borderRadius: 16, padding: '10px 10px 10px 18px', border: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Input.TextArea value={input} onChange={e => setInput(e.target.value)}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onCompositionStart={() => { composingRef.current = true }} onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={e => { if (composingRef.current) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
|
||||
placeholder={activeId ? '输入消息,Enter 发送' : '请先新建或选择对话'}
|
||||
placeholder={activeId ? '输入消息,Enter 发送 / Shift+Enter 换行' : '请先新建或选择对话'}
|
||||
autoSize={{ minRows: 1, maxRows: 5 }} disabled={streaming || !activeId}
|
||||
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
|
||||
{streaming ? (
|
||||
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 8, height: 34, minWidth: 72 }}>停止</Button>
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}>停止</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
|
||||
disabled={!input.trim() || !activeId} style={{ borderRadius: 8, height: 34, minWidth: 72 }}>发送</Button>
|
||||
disabled={!input.trim() || !activeId} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}>发送</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user