diff --git a/src/pages/TaskAssistant.tsx b/src/pages/TaskAssistant.tsx index 9778052..dd31794 100644 --- a/src/pages/TaskAssistant.tsx +++ b/src/pages/TaskAssistant.tsx @@ -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,205 +35,173 @@ function ChatPage() { const editInputRef = useRef(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) => - setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m)) + const update = (u: Partial) => 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) => { - 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 - } - }, - 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 '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) 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 ( -
-
-
+
+ {/* 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' }}> - + style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 2, + background: activeId === conv.id ? token.colorFillSecondary : 'transparent' }}> + {editingId === conv.id ? ( 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} /> ) : ( - { e.stopPropagation(); startEdit(conv) }}>{conv.title} + { e.stopPropagation(); startEdit(conv) }}>{conv.title} )}
))} - {conversations.length === 0 &&
暂无对话
}
-
-
- {messages.length === 0 && ( -
- - {activeId ? '开始新对话' : '点击「新对话」开始'} - Hermes Agent · 流式响应 · xhigh 推理 + {/* Chat */} +
+ {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ +
+ 有什么可以帮你的? + Hermes Agent · DeepSeek xhigh 推理 · 可执行任务 +
+ ) : ( +
+ {messages.map(msg => ( +
+ {/* Role label */} +
+
+ {msg.role === 'user' ? : } +
+ {msg.role === 'user' ? '你' : 'Hermes'} + {msg.streaming && 执行中...} +
+ + {/* Tool calls */} + {msg.toolCalls && msg.toolCalls.length > 0 && ( +
+ {msg.toolCalls.map((t, i) => ( +
+ + {t.preview || t.tool} + {t.done + ? {t.error ? '失败' : `${t.duration?.toFixed(1)}s`} + : 执行中} +
+ ))} +
+ )} + + {/* Content */} +
+ {msg.role === 'assistant' + ? (msg.content ? : 思考中...) + : msg.content} +
+
+ ))} +
)} - - {messages.map(msg => ( -
-
- {msg.role === 'assistant' - ? (msg.content - ? - : 思考中...) - : msg.content} -
- {msg.toolCalls && msg.toolCalls.length > 0 && ( -
- {msg.toolCalls.map((t, i) => ( - - {t.preview || t.tool}{t.done ? (t.error ? ' ❌' : ' ✓') : ' ···'} - - ))} -
- )} -
- ))} -
-
-
- 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 发送' : '请先新建或选择对话'} - autoSize={{ minRows: 1, maxRows: 5 }} disabled={streaming || !activeId} - variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} /> - {streaming ? ( - - ) : ( - - )} + {/* Input */} +
+
+
+ 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 ? ( + + ) : ( + + )} +