feat: SSE streaming runs + thinking/tool panel + typewriter effect
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 8s

This commit is contained in:
WangDL 2026-05-22 11:29:15 +08:00
parent 4292da4bf1
commit b6174c4762
2 changed files with 229 additions and 126 deletions

View File

@ -1,10 +1,10 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { Input, Button, Avatar, Spin, theme, Typography, App } from 'antd' import { Input, Button, Avatar, Spin, theme, Typography, App, Collapse } from 'antd'
import { import {
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined, SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
DeleteOutlined, StopOutlined, MessageOutlined, DeleteOutlined, StopOutlined, MessageOutlined, BulbOutlined, ToolOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { sendMessage } from '@/services/ai-chat' import { streamChat, type StreamEvent } from '@/services/ai-chat'
import { import {
listConversations, createConversation, deleteConversation, listConversations, createConversation, deleteConversation,
getMessages, updateConversation, type Conversation, getMessages, updateConversation, type Conversation,
@ -18,6 +18,8 @@ interface Message {
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
timestamp: number timestamp: number
thinking?: string
toolCalls?: { name: string; result?: string }[]
} }
function ChatPage() { function ChatPage() {
@ -27,12 +29,14 @@ function ChatPage() {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const editInputRef = useRef<any>(null) const editInputRef = useRef<any>(null)
const streamMsgRef = useRef<string>('')
const { token } = theme.useToken() const { token } = theme.useToken()
const loadConversations = useCallback(async () => { const loadConversations = useCallback(async () => {
@ -42,12 +46,11 @@ function ChatPage() {
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages, streamMsgRef.current])
const switchConversation = useCallback(async (id: string) => { const switchConversation = useCallback(async (id: string) => {
if (loading) { abortRef.current?.abort(); setLoading(false) } if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
setActiveId(id) setActiveId(id); setMessages([]); streamMsgRef.current = ''
setMessages([])
try { try {
const records = await getMessages(id) const records = await getMessages(id)
setMessages(records.map(m => ({ setMessages(records.map(m => ({
@ -58,23 +61,17 @@ function ChatPage() {
}, [loading]) }, [loading])
const handleNew = async () => { const handleNew = async () => {
if (loading) { abortRef.current?.abort(); setLoading(false) } if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) }
try { try {
const conv = await createConversation() const conv = await createConversation()
setConversations(prev => [conv, ...prev]) setConversations(prev => [conv, ...prev])
setActiveId(conv.id) setActiveId(conv.id); setMessages([]); setInput(''); streamMsgRef.current = ''
setMessages([])
setInput('')
} catch { /* */ } } catch { /* */ }
} }
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
modal.confirm({ modal.confirm({
title: '删除对话', title: '删除对话', content: '确定?', okText: '删除', okType: 'danger', cancelText: '取消',
content: '确定要删除这个对话吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => { onOk: async () => {
setDeleting(true) setDeleting(true)
try { try {
@ -88,11 +85,9 @@ function ChatPage() {
} }
const startEdit = (conv: Conversation) => { const startEdit = (conv: Conversation) => {
setEditingId(conv.id) setEditingId(conv.id); setEditTitle(conv.title)
setEditTitle(conv.title)
setTimeout(() => editInputRef.current?.focus(), 50) setTimeout(() => editInputRef.current?.focus(), 50)
} }
const saveTitle = async (id: string) => { const saveTitle = async (id: string) => {
const title = editTitle.trim() const title = editTitle.trim()
if (title && title !== conversations.find(c => c.id === id)?.title) { if (title && title !== conversations.find(c => c.id === id)?.title) {
@ -102,59 +97,109 @@ function ChatPage() {
setEditingId(null) setEditingId(null)
} }
// ── Send with SSE streaming ──
const handleSend = async () => { const handleSend = async () => {
const text = input.trim() const text = input.trim()
if (!text || loading) return if (!text || loading) return
const userMsg: Message = { const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now() }
id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now(), const prevMessages = [...messages, userMsg]
} setMessages(prevMessages)
const newMessages = [...messages, userMsg]
setMessages(newMessages)
setInput('') setInput('')
setLoading(true) setLoading(true)
setStreaming(true)
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller 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: [] }
setMessages(prev => [...prev, streamMsg])
streamMsgRef.current = ''
let currentContent = ''
let currentThinking = ''
let currentTools: { name: string; result?: string }[] = []
let completedConvId: string | undefined
const updateStreamMsg = (updates: Partial<Message>) => {
setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m))
}
try { try {
const result = await sendMessage( await streamChat(
newMessages.map(m => ({ role: m.role, content: m.content })), prevMessages.map(m => ({ role: m.role, content: m.content })),
activeId ?? undefined, controller.signal, activeId,
(event: StreamEvent) => {
switch (event.event) {
case 'meta':
completedConvId = event.conversationId
if (event.conversationId && event.conversationId !== activeId) {
setActiveId(event.conversationId)
loadConversations()
}
break
case 'message.delta':
currentContent += event.delta || ''
updateStreamMsg({ content: currentContent })
break
case 'reasoning.available':
currentThinking = event.text || ''
updateStreamMsg({ thinking: currentThinking })
break
case 'tool.start':
currentTools.push({ name: event.toolName || 'unknown' })
updateStreamMsg({ toolCalls: [...currentTools] })
break
case 'tool.result':
if (currentTools.length > 0) {
currentTools[currentTools.length - 1].result = event.output || ''
updateStreamMsg({ toolCalls: [...currentTools] })
}
break
case 'run.completed':
if (event.output) { currentContent = event.output; updateStreamMsg({ content: currentContent }) }
break
case 'done':
completedConvId = event.conversationId || completedConvId
break
case 'error':
updateStreamMsg({ content: `${event.error}` })
break
}
},
controller.signal,
) )
const convId = result.conversationId || activeId if (completedConvId) loadConversations()
if (convId && convId !== activeId) {
setActiveId(convId)
loadConversations()
}
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(), role: 'assistant',
content: result.content, timestamp: Date.now(),
}])
loadConversations()
} catch (err: any) { } catch (err: any) {
if (err.name === 'AbortError') return if (err.name === 'AbortError') {
setMessages(prev => [...prev, { updateStreamMsg({ content: currentContent || '(已停止)' })
id: (Date.now() + 1).toString(), role: 'assistant', } else {
content: '请求失败:' + (err instanceof Error ? err.message : '未知错误'), updateStreamMsg({ content: `${err.message}` })
timestamp: Date.now(), }
}])
} finally { } finally {
setLoading(false) setLoading(false)
setStreaming(false)
abortRef.current = null abortRef.current = null
} }
} }
const handleStop = () => { abortRef.current?.abort(); setLoading(false) } const handleStop = async () => {
abortRef.current?.abort()
setStreaming(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
} }
const inputPlaceholder = activeId ? '输入消息Enter 发送' : '请先新建或选择对话'
return ( return (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}> <div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
{/* Sidebar */}
<div style={{ <div style={{
width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column',
background: token.colorBgContainer, borderRadius: token.borderRadiusLG, background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
@ -174,58 +219,39 @@ function ChatPage() {
}}> }}>
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, marginRight: 8, flexShrink: 0 }} /> <MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, marginRight: 8, flexShrink: 0 }} />
{editingId === conv.id ? ( {editingId === conv.id ? (
<Input <Input ref={editInputRef} size="small" value={editTitle}
ref={editInputRef} size="small" value={editTitle}
onChange={e => setEditTitle(e.target.value)} onChange={e => setEditTitle(e.target.value)}
onBlur={() => saveTitle(conv.id)} onBlur={() => saveTitle(conv.id)} onPressEnter={() => 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 }}
/>
) : ( ) : (
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }} <Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}> onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
{conv.title}
</Text>
)} )}
<Button <Button type="text" size="small" danger icon={<DeleteOutlined />}
type="text" size="small" danger disabled={deleting} onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
icon={<DeleteOutlined />} style={{ marginLeft: 4, flexShrink: 0 }} />
disabled={deleting}
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
style={{ marginLeft: 4, flexShrink: 0 }}
/>
</div> </div>
))} ))}
{conversations.length === 0 && ( {conversations.length === 0 && (
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}> <div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
<Text type="secondary" style={{ fontSize: 13 }}></Text> <Text type="secondary" style={{ fontSize: 13 }}></Text>
<br />
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}></Text>
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ <div style={{
flex: 1, overflowY: 'auto', background: token.colorBgContainer, flex: 1, overflowY: 'auto', background: token.colorBgContainer,
borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`, borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`,
padding: '20px 24px', padding: '20px 24px', border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none',
border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none',
}}> }}>
{messages.length === 0 && ( {messages.length === 0 && (
<div style={{ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
display: 'flex', flexDirection: 'column', alignItems: 'center', <RobotOutlined style={{ fontSize: 48, marginBottom: 16, color: token.colorTextQuaternary }} />
justifyContent: 'center', height: '100%', color: token.colorTextQuaternary, <Text type="secondary" style={{ fontSize: 16 }}>{activeId ? '开始新对话' : '点击「新对话」开始'}</Text>
}}> <Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>Hermes Agent · · </Text>
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<Text type="secondary" style={{ fontSize: 16 }}>
{activeId ? '开始新对话' : '点击左侧「新对话」开始'}
</Text>
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
Hermes Agent DeepSeek
</Text>
</div> </div>
)} )}
@ -236,59 +262,68 @@ function ChatPage() {
}}> }}>
<Avatar size={32} <Avatar size={32}
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />} icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
style={{ style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0 }} />
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, <div style={{ maxWidth: '75%', minWidth: 0 }}>
flexShrink: 0, {/* Thinking panel */}
}} {msg.thinking && (
/> <Collapse size="small" ghost items={[{
<div style={{ key: 'thinking', label: <Text type="secondary" style={{ fontSize: 12 }}><BulbOutlined /> </Text>,
maxWidth: '75%', padding: '12px 16px', borderRadius: 12, children: <div style={{ fontSize: 13, color: token.colorTextSecondary, whiteSpace: 'pre-wrap', lineHeight: 1.7, maxHeight: 200, overflowY: 'auto' }}>{msg.thinking}</div>,
background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter, }]} style={{ marginBottom: 8 }} />
color: msg.role === 'user' ? '#fff' : token.colorText, )}
lineHeight: 1.8, wordBreak: 'break-word', {/* Tool calls */}
}}> {msg.toolCalls && msg.toolCalls.length > 0 && (
{msg.role === 'assistant' ? <Markdown content={msg.content} /> : msg.content} <Collapse size="small" ghost items={[{
key: 'tools', label: <Text type="secondary" style={{ fontSize: 12 }}><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 }} />
)}
{/* 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}
</div>
</div> </div>
</div> </div>
))} ))}
{loading && ( {/* Streaming indicator */}
{streaming && (
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}> <div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
<Avatar size={32} icon={<RobotOutlined />} <Avatar size={32} icon={<RobotOutlined />} style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} /> <div style={{ padding: '8px 16px', borderRadius: 12, background: token.colorFillAlter }}>
<div style={{ <Spin size="small" /> <Text type="secondary" style={{ fontSize: 13 }}>Hermes ...</Text>
maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
background: token.colorFillAlter,
}}>
<Spin size="small" /> <Text type="secondary">Hermes ...</Text>
</div> </div>
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input */}
<div style={{ <div style={{
background: token.colorBgContainer, background: token.colorBgContainer, border: `1px solid ${token.colorBorderSecondary}`,
border: `1px solid ${token.colorBorderSecondary}`, borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`, padding: '16px 20px',
borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`,
padding: '16px 20px',
}}> }}>
<div style={{ <div style={{
display: 'flex', alignItems: 'flex-end', gap: 10, display: 'flex', alignItems: 'flex-end', gap: 10,
background: token.colorFillTertiary, borderRadius: 12, background: token.colorFillTertiary, borderRadius: 12,
padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}`, padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}`,
}}> }}>
<Input.TextArea <Input.TextArea value={input} onChange={e => setInput(e.target.value)}
value={input} onKeyDown={handleKeyDown} placeholder={inputPlaceholder}
onChange={e => setInput(e.target.value)} autoSize={{ minRows: 1, maxRows: 5 }} disabled={loading || !activeId}
onKeyDown={handleKeyDown} variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
placeholder={activeId ? '输入消息Enter 发送Shift+Enter 换行' : '请先新建或选择对话'} {streaming ? (
autoSize={{ minRows: 1, maxRows: 5 }}
disabled={loading || !activeId}
variant="borderless"
style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }}
/>
{loading ? (
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop} <Button danger type="primary" icon={<StopOutlined />} onClick={handleStop}
style={{ borderRadius: 8, height: 34, minWidth: 72 }}></Button> style={{ borderRadius: 8, height: 34, minWidth: 72 }}></Button>
) : ( ) : (
@ -304,9 +339,5 @@ function ChatPage() {
} }
export default function TaskAssistant() { export default function TaskAssistant() {
return ( return <App><ChatPage /></App>
<App>
<ChatPage />
</App>
)
} }

View File

@ -1,22 +1,94 @@
import { api } from './http-client' import { getAccessToken } from './token-store'
interface ChatMessage { interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string }
role: 'user' | 'assistant' | 'system'
content: string export type StreamEvent =
| { event: 'meta'; conversationId: string }
| { event: 'message.delta'; delta: string; runId?: string }
| { event: 'reasoning.available'; text: string; runId?: string }
| { event: 'tool.start'; toolName?: string; input?: any; runId?: string }
| { event: 'tool.result'; output?: string; runId?: string }
| { event: 'run.completed'; output?: string; usage?: any; runId?: string }
| { event: 'done'; conversationId?: string }
| { event: 'stopped' }
| { event: 'error'; error: string }
export async function streamChat(
messages: ChatMessage[],
conversationId: string | null,
onEvent: (e: StreamEvent) => void,
signal: AbortSignal,
): Promise<void> {
const token = getAccessToken()
const body: Record<string, unknown> = { messages }
if (conversationId) body.conversationId = conversationId
const resp = await fetch('/admin-api/ai/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal,
})
if (!resp.ok) {
const err = await resp.text().catch(() => '')
throw new Error(`Stream request failed ${resp.status}: ${err}`)
}
const reader = resp.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let buffer = ''
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: ')) {
try {
const event: StreamEvent = JSON.parse(line.slice(6))
onEvent(event)
} catch { /* skip bad lines */ }
}
}
}
} }
export async function stopChat(runId: string): Promise<void> {
const token = getAccessToken()
await fetch('/admin-api/ai/chat/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ runId }),
})
}
// Legacy non-streaming (kept for fallback)
interface ChatResponse { interface ChatResponse {
content: string content: string
conversationId?: string conversationId?: string
usage?: { model?: string; inputTokens?: number; outputTokens?: number } usage?: { model?: string; inputTokens?: number; outputTokens?: number }
} }
export async function sendMessage( export async function sendMessage(
messages: ChatMessage[], messages: ChatMessage[], conversationId?: string, signal?: AbortSignal,
conversationId?: string,
signal?: AbortSignal,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
const token = getAccessToken()
const body: Record<string, unknown> = { messages } const body: Record<string, unknown> = { messages }
if (conversationId) body.conversationId = conversationId if (conversationId) body.conversationId = conversationId
return api.post<ChatResponse>('/admin-api/ai/chat', body, signal ? { signal } : undefined) const resp = await fetch('/admin-api/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
signal,
})
const json = await resp.json()
if (!json.success) throw new Error(json.message || 'Chat failed')
return json.data
} }