fix: smooth streaming + IME composition fix + remove double loading
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 7s

This commit is contained in:
WangDL 2026-05-22 11:46:44 +08:00
parent b6174c4762
commit 0789114e0a

View File

@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Input, Button, Avatar, Spin, theme, Typography, App, Collapse } from 'antd'
import { Input, Button, Avatar, theme, Typography, App, Collapse } from 'antd'
import {
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
DeleteOutlined, StopOutlined, MessageOutlined, BulbOutlined, ToolOutlined,
DeleteOutlined, StopOutlined, MessageOutlined, BulbOutlined, ToolOutlined, LoadingOutlined,
} from '@ant-design/icons'
import { streamChat, type StreamEvent } from '@/services/ai-chat'
import {
@ -20,6 +20,7 @@ interface Message {
timestamp: number
thinking?: string
toolCalls?: { name: string; result?: string }[]
streaming?: boolean
}
function ChatPage() {
@ -28,29 +29,25 @@ function ChatPage() {
const [activeId, setActiveId] = useState<string | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('')
const [deleting, setDeleting] = useState(false)
const composingRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const editInputRef = useRef<any>(null)
const streamMsgRef = useRef<string>('')
const { token } = theme.useToken()
const loadConversations = useCallback(async () => {
try { setConversations(await listConversations()) } catch { /* */ }
}, [])
useEffect(() => { loadConversations() }, [loadConversations])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamMsgRef.current])
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
const switchConversation = useCallback(async (id: string) => {
if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
setActiveId(id); setMessages([]); streamMsgRef.current = ''
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
setActiveId(id); setMessages([])
try {
const records = await getMessages(id)
setMessages(records.map(m => ({
@ -58,36 +55,27 @@ function ChatPage() {
timestamp: new Date(m.createdAt).getTime(),
})))
} catch { /* */ }
}, [loading])
}, [streaming])
const handleNew = async () => {
if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
try {
const conv = await createConversation()
setConversations(prev => [conv, ...prev])
setActiveId(conv.id); setMessages([]); setInput(''); streamMsgRef.current = ''
setActiveId(conv.id); setMessages([]); setInput('')
} catch { /* */ }
}
const handleDelete = (id: string) => {
modal.confirm({
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 { /* */ }
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 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) {
@ -97,35 +85,30 @@ function ChatPage() {
setEditingId(null)
}
// ── Send with SSE streaming ──
const handleSend = async () => {
const text = input.trim()
if (!text || loading) return
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)
setInput('')
setLoading(true)
setStreaming(true)
const controller = new AbortController()
abortRef.current = controller
// Placeholder for streaming assistant message
const streamMsgId = (Date.now() + 1).toString()
const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), thinking: '', toolCalls: [] }
const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), streaming: true }
setMessages(prev => [...prev, streamMsg])
streamMsgRef.current = ''
let currentContent = ''
let currentThinking = ''
let currentTools: { name: string; result?: string }[] = []
const currentTools: { name: string; result?: string }[] = []
let completedConvId: string | undefined
const updateStreamMsg = (updates: Partial<Message>) => {
const update = (updates: Partial<Message>) =>
setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m))
}
try {
await streamChat(
@ -136,117 +119,80 @@ function ChatPage() {
case 'meta':
completedConvId = event.conversationId
if (event.conversationId && event.conversationId !== activeId) {
setActiveId(event.conversationId)
loadConversations()
setActiveId(event.conversationId); loadConversations()
}
break
case 'message.delta':
currentContent += event.delta || ''
updateStreamMsg({ content: currentContent })
update({ content: currentContent, streaming: true })
break
case 'reasoning.available':
currentThinking = event.text || ''
updateStreamMsg({ thinking: currentThinking })
update({ thinking: currentThinking })
break
case 'tool.start':
currentTools.push({ name: event.toolName || 'unknown' })
updateStreamMsg({ toolCalls: [...currentTools] })
update({ toolCalls: [...currentTools] })
break
case 'tool.result':
if (currentTools.length > 0) {
currentTools[currentTools.length - 1].result = event.output || ''
updateStreamMsg({ toolCalls: [...currentTools] })
}
if (currentTools.length > 0) { currentTools[currentTools.length - 1].result = event.output || ''; update({ toolCalls: [...currentTools] }) }
break
case 'run.completed':
if (event.output) { currentContent = event.output; updateStreamMsg({ content: currentContent }) }
if (event.output) currentContent = event.output
update({ content: currentContent, streaming: false })
break
case 'done':
completedConvId = event.conversationId || completedConvId
completedConvId = event.conversationId || completedConvId; update({ streaming: false })
break
case 'error':
updateStreamMsg({ content: `${event.error}` })
update({ content: `${event.error}`, streaming: false })
break
}
},
controller.signal,
)
if (completedConvId) loadConversations()
} catch (err: any) {
if (err.name === 'AbortError') {
updateStreamMsg({ content: currentContent || '(已停止)' })
} else {
updateStreamMsg({ content: `${err.message}` })
}
if (err.name !== 'AbortError') update({ content: `${err.message}`, streaming: false })
else update({ streaming: false })
} finally {
setLoading(false)
setStreaming(false)
abortRef.current = null
setStreaming(false); abortRef.current = null
}
}
const handleStop = async () => {
abortRef.current?.abort()
setStreaming(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}
const inputPlaceholder = activeId ? '输入消息Enter 发送' : '请先新建或选择对话'
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
return (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
{/* Sidebar */}
<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={{ 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}` }}>
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}></Button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px' }}>
{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',
}}>
<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 }} />
{editingId === conv.id ? (
<Input ref={editInputRef} size="small" value={editTitle}
onChange={e => setEditTitle(e.target.value)}
<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 }} />
) : (
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
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 }} />
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled={deleting}
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ marginLeft: 4, flexShrink: 0 }} />
</div>
))}
{conversations.length === 0 && (
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
<Text type="secondary" style={{ fontSize: 13 }}></Text>
</div>
)}
{conversations.length === 0 && <div style={{ textAlign: 'center', padding: 24 }}><Text type="secondary" style={{ fontSize: 13 }}></Text></div>}
</div>
</div>
{/* Chat area */}
{/* Chat */}
<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',
}}>
<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 }} />
@ -256,80 +202,65 @@ function ChatPage() {
)}
{messages.map(msg => (
<div key={msg.id} style={{
display: 'flex', gap: 12, marginBottom: 20,
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
}}>
<Avatar size={32}
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
<div key={msg.id} style={{ display: 'flex', gap: 12, marginBottom: 20, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
<Avatar size={32} icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0 }} />
<div style={{ maxWidth: '75%', minWidth: 0 }}>
{/* Thinking panel */}
{/* Thinking — auto expand during streaming */}
{msg.thinking && (
<Collapse size="small" ghost items={[{
key: 'thinking', label: <Text type="secondary" style={{ fontSize: 12 }}><BulbOutlined /> </Text>,
children: <div style={{ fontSize: 13, color: token.colorTextSecondary, whiteSpace: 'pre-wrap', lineHeight: 1.7, maxHeight: 200, overflowY: 'auto' }}>{msg.thinking}</div>,
}]} style={{ marginBottom: 8 }} />
<Collapse size="small" ghost defaultActiveKey={msg.streaming ? ['thinking'] : []} items={[{
key: 'thinking', label: <Text style={{ fontSize: 12, color: token.colorTextSecondary }}><BulbOutlined /> {msg.streaming ? <LoadingOutlined style={{ marginLeft: 6, fontSize: 11 }} spin /> : null}</Text>,
children: <div style={{ fontSize: 13, color: token.colorTextSecondary, whiteSpace: 'pre-wrap', lineHeight: 1.7, maxHeight: 200, overflowY: 'auto', padding: '4px 0' }}>{msg.thinking}</div>,
}]} style={{ marginBottom: 8, background: 'transparent' }} />
)}
{/* Tool calls */}
{/* Tools */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<Collapse size="small" ghost items={[{
key: 'tools', label: <Text type="secondary" style={{ fontSize: 12 }}><ToolOutlined /> ({msg.toolCalls.length})</Text>,
key: 'tools', label: <Text style={{ fontSize: 12, color: token.colorTextSecondary }}><ToolOutlined /> ({msg.toolCalls.length})</Text>,
children: msg.toolCalls.map((t, i) => (
<div key={i} style={{ marginBottom: 8, fontSize: 12 }}>
<Text code style={{ fontSize: 12 }}>{t.name}</Text>
{t.result && <div style={{ marginTop: 4, color: token.colorTextSecondary, maxHeight: 150, overflowY: 'auto', whiteSpace: 'pre-wrap' }}>{t.result}</div>}
</div>
)),
}]} style={{ marginBottom: 8 }} />
}]} style={{ marginBottom: 8, background: 'transparent' }} />
)}
{/* Message content */}
<div style={{
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,
}}>
{msg.role === 'assistant' ? (
msg.content ? <Markdown content={msg.content} /> : <Spin size="small" />
) : msg.content}
{/* Content */}
<div style={{ 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 }}>
{msg.role === 'assistant'
? (msg.content
? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} />
: <Text type="secondary" style={{ fontSize: 13 }}><LoadingOutlined spin style={{ marginRight: 6 }} />...</Text>)
: msg.content}
</div>
</div>
</div>
))}
{/* Streaming indicator */}
{streaming && (
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
<Avatar size={32} icon={<RobotOutlined />} style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
<div style={{ padding: '8px 16px', borderRadius: 12, background: token.colorFillAlter }}>
<Spin size="small" /> <Text type="secondary" style={{ fontSize: 13 }}>Hermes ...</Text>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<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.TextArea value={input} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} placeholder={inputPlaceholder}
autoSize={{ minRows: 1, maxRows: 5 }} disabled={loading || !activeId}
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
<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.TextArea value={input}
onChange={e => setInput(e.target.value)}
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 发送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 type="primary" icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 8, height: 34, minWidth: 72 }}></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: 8, height: 34, minWidth: 72 }}></Button>
)}
</div>
</div>
@ -338,6 +269,4 @@ function ChatPage() {
)
}
export default function TaskAssistant() {
return <App><ChatPage /></App>
}
export default function TaskAssistant() { return <App><ChatPage /></App> }