From b6174c4762f544745eb6c2ccc5a0062c2ce3f270 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 11:29:15 +0800 Subject: [PATCH] feat: SSE streaming runs + thinking/tool panel + typewriter effect --- src/pages/TaskAssistant.tsx | 265 ++++++++++++++++++++---------------- src/services/ai-chat.ts | 90 ++++++++++-- 2 files changed, 229 insertions(+), 126 deletions(-) diff --git a/src/pages/TaskAssistant.tsx b/src/pages/TaskAssistant.tsx index 76544ac..fc82f6c 100644 --- a/src/pages/TaskAssistant.tsx +++ b/src/pages/TaskAssistant.tsx @@ -1,10 +1,10 @@ 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 { SendOutlined, RobotOutlined, UserOutlined, PlusOutlined, - DeleteOutlined, StopOutlined, MessageOutlined, + DeleteOutlined, StopOutlined, MessageOutlined, BulbOutlined, ToolOutlined, } from '@ant-design/icons' -import { sendMessage } from '@/services/ai-chat' +import { streamChat, type StreamEvent } from '@/services/ai-chat' import { listConversations, createConversation, deleteConversation, getMessages, updateConversation, type Conversation, @@ -18,6 +18,8 @@ interface Message { role: 'user' | 'assistant' content: string timestamp: number + thinking?: string + toolCalls?: { name: string; result?: string }[] } function ChatPage() { @@ -27,12 +29,14 @@ function ChatPage() { const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) + const [streaming, setStreaming] = useState(false) const [editingId, setEditingId] = useState(null) const [editTitle, setEditTitle] = useState('') const [deleting, setDeleting] = useState(false) const abortRef = useRef(null) const messagesEndRef = useRef(null) const editInputRef = useRef(null) + const streamMsgRef = useRef('') const { token } = theme.useToken() const loadConversations = useCallback(async () => { @@ -42,12 +46,11 @@ function ChatPage() { useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages]) + }, [messages, streamMsgRef.current]) const switchConversation = useCallback(async (id: string) => { - if (loading) { abortRef.current?.abort(); setLoading(false) } - setActiveId(id) - setMessages([]) + if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) } + setActiveId(id); setMessages([]); streamMsgRef.current = '' try { const records = await getMessages(id) setMessages(records.map(m => ({ @@ -58,23 +61,17 @@ function ChatPage() { }, [loading]) const handleNew = async () => { - if (loading) { abortRef.current?.abort(); setLoading(false) } + if (loading) { abortRef.current?.abort(); setLoading(false); setStreaming(false) } try { const conv = await createConversation() setConversations(prev => [conv, ...prev]) - setActiveId(conv.id) - setMessages([]) - setInput('') + setActiveId(conv.id); setMessages([]); setInput(''); streamMsgRef.current = '' } catch { /* */ } } const handleDelete = (id: string) => { modal.confirm({ - title: '删除对话', - content: '确定要删除这个对话吗?', - okText: '删除', - okType: 'danger', - cancelText: '取消', + title: '删除对话', content: '确定?', okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { setDeleting(true) try { @@ -88,11 +85,9 @@ function ChatPage() { } const startEdit = (conv: Conversation) => { - setEditingId(conv.id) - setEditTitle(conv.title) + 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) { @@ -102,59 +97,109 @@ function ChatPage() { setEditingId(null) } + // ── Send with SSE streaming ── const handleSend = async () => { const text = input.trim() if (!text || loading) return - const userMsg: Message = { - id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now(), - } - const newMessages = [...messages, userMsg] - setMessages(newMessages) + 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: [] } + setMessages(prev => [...prev, streamMsg]) + streamMsgRef.current = '' + + let currentContent = '' + let currentThinking = '' + let currentTools: { name: string; result?: string }[] = [] + let completedConvId: string | undefined + + const updateStreamMsg = (updates: Partial) => { + setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m)) + } + try { - const result = await sendMessage( - newMessages.map(m => ({ role: m.role, content: m.content })), - activeId ?? undefined, controller.signal, + 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 '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 (convId && convId !== activeId) { - setActiveId(convId) - loadConversations() - } - - setMessages(prev => [...prev, { - id: (Date.now() + 1).toString(), role: 'assistant', - content: result.content, timestamp: Date.now(), - }]) - loadConversations() + if (completedConvId) loadConversations() } catch (err: any) { - if (err.name === 'AbortError') return - setMessages(prev => [...prev, { - id: (Date.now() + 1).toString(), role: 'assistant', - content: '请求失败:' + (err instanceof Error ? err.message : '未知错误'), - timestamp: Date.now(), - }]) + if (err.name === 'AbortError') { + updateStreamMsg({ content: currentContent || '(已停止)' }) + } else { + updateStreamMsg({ content: `❌ ${err.message}` }) + } } finally { setLoading(false) + setStreaming(false) abortRef.current = null } } - const handleStop = () => { abortRef.current?.abort(); setLoading(false) } + 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 发送' : '请先新建或选择对话' + return (
+ {/* Sidebar */}
{editingId === conv.id ? ( - setEditTitle(e.target.value)} - onBlur={() => saveTitle(conv.id)} - onPressEnter={() => saveTitle(conv.id)} - onClick={e => e.stopPropagation()} - style={{ flex: 1, fontSize: 13 }} - /> + onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)} + onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} /> ) : ( { e.stopPropagation(); startEdit(conv) }}> - {conv.title} - + onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title} )} -
))} {conversations.length === 0 && (
暂无对话 -
- 双击标题可重命名
)}
+ {/* Chat area */}
{messages.length === 0 && ( -
- - - {activeId ? '开始新对话' : '点击左侧「新对话」开始'} - - - Hermes Agent 通过 DeepSeek 提供回答 - +
+ + {activeId ? '开始新对话' : '点击「新对话」开始'} + Hermes Agent · 流式响应 · 思考过程可见
)} @@ -236,59 +262,68 @@ function ChatPage() { }}> : } - style={{ - backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, - flexShrink: 0, - }} - /> -
- {msg.role === 'assistant' ? : msg.content} + style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0 }} /> +
+ {/* Thinking panel */} + {msg.thinking && ( + 思考过程, + children:
{msg.thinking}
, + }]} style={{ marginBottom: 8 }} /> + )} + {/* Tool calls */} + {msg.toolCalls && msg.toolCalls.length > 0 && ( + 工具调用 ({msg.toolCalls.length}), + children: msg.toolCalls.map((t, i) => ( +
+ {t.name} + {t.result &&
{t.result}
} +
+ )), + }]} style={{ marginBottom: 8 }} /> + )} + {/* Message content */} +
+ {msg.role === 'assistant' ? ( + msg.content ? : + ) : msg.content} +
))} - {loading && ( + {/* Streaming indicator */} + {streaming && (
- } - style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} /> -
- Hermes 思考中... + } style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} /> +
+ Hermes 执行中...
)}
+ {/* Input */}
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={activeId ? '输入消息,Enter 发送,Shift+Enter 换行' : '请先新建或选择对话'} - autoSize={{ minRows: 1, maxRows: 5 }} - disabled={loading || !activeId} - variant="borderless" - style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} - /> - {loading ? ( + 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 }} /> + {streaming ? ( ) : ( @@ -304,9 +339,5 @@ function ChatPage() { } export default function TaskAssistant() { - return ( - - - - ) + return } diff --git a/src/services/ai-chat.ts b/src/services/ai-chat.ts index e657010..784cff2 100644 --- a/src/services/ai-chat.ts +++ b/src/services/ai-chat.ts @@ -1,22 +1,94 @@ -import { api } from './http-client' +import { getAccessToken } from './token-store' -interface ChatMessage { - role: 'user' | 'assistant' | 'system' - content: string +interface ChatMessage { 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 { + const token = getAccessToken() + const body: Record = { 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 { + 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 { content: string conversationId?: string usage?: { model?: string; inputTokens?: number; outputTokens?: number } } - export async function sendMessage( - messages: ChatMessage[], - conversationId?: string, - signal?: AbortSignal, + messages: ChatMessage[], conversationId?: string, signal?: AbortSignal, ): Promise { + const token = getAccessToken() const body: Record = { messages } if (conversationId) body.conversationId = conversationId - return api.post('/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 }