diff --git a/src/pages/TaskAssistant.tsx b/src/pages/TaskAssistant.tsx index fc82f6c..45f2c22 100644 --- a/src/pages/TaskAssistant.tsx +++ b/src/pages/TaskAssistant.tsx @@ -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(null) 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 composingRef = useRef(false) const abortRef = useRef(null) const messagesEndRef = useRef(null) const editInputRef = useRef(null) - const streamMsgRef = useRef('') 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({ - 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) - }, - }) - } + 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) + }, + }) - 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) => { + const update = (updates: Partial) => 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 (
{/* Sidebar */} -
+
{conversations.map(conv => ( -
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', - }}> +
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' }}> {editingId === conv.id ? ( - setEditTitle(e.target.value)} + setEditTitle(e.target.value)} onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)} onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} /> ) : ( { e.stopPropagation(); startEdit(conv) }}>{conv.title} )} -
))} - {conversations.length === 0 && ( -
- 暂无对话 -
- )} + {conversations.length === 0 &&
暂无对话
}
- {/* Chat area */} + {/* Chat */}
-
+
{messages.length === 0 && (
@@ -256,80 +202,65 @@ function ChatPage() { )} {messages.map(msg => ( -
- : } +
+ : } style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0 }} />
- {/* Thinking panel */} + {/* Thinking — auto expand during streaming */} {msg.thinking && ( - 思考过程, - children:
{msg.thinking}
, - }]} style={{ marginBottom: 8 }} /> + 思考过程{msg.streaming ? : null}, + children:
{msg.thinking}
, + }]} style={{ marginBottom: 8, background: 'transparent' }} /> )} - {/* Tool calls */} + {/* Tools */} {msg.toolCalls && msg.toolCalls.length > 0 && ( 工具调用 ({msg.toolCalls.length}), + key: 'tools', label: 工具调用 ({msg.toolCalls.length}), children: msg.toolCalls.map((t, i) => (
{t.name} {t.result &&
{t.result}
}
)), - }]} style={{ marginBottom: 8 }} /> + }]} style={{ marginBottom: 8, background: 'transparent' }} /> )} - {/* Message content */} -
- {msg.role === 'assistant' ? ( - msg.content ? : - ) : msg.content} + {/* Content */} +
+ {msg.role === 'assistant' + ? (msg.content + ? + : 思考中...) + : msg.content}
))} - - {/* Streaming indicator */} - {streaming && ( -
- } style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} /> -
- Hermes 执行中... -
-
- )}
{/* Input */} -
-
- 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 }} /> +
+
+ 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 ? ( - + ) : ( + disabled={!input.trim() || !activeId} style={{ borderRadius: 8, height: 34, minWidth: 72 }}>发送 )}
@@ -338,6 +269,4 @@ function ChatPage() { ) } -export default function TaskAssistant() { - return -} +export default function TaskAssistant() { return }