import { useState, useRef, useEffect, useCallback } from 'react' import { Input, Button, Avatar, Spin, theme, Modal, Tooltip, Typography } from 'antd' import { SendOutlined, RobotOutlined, UserOutlined, PlusOutlined, DeleteOutlined, StopOutlined, MessageOutlined, } from '@ant-design/icons' import { sendMessage } from '@/services/ai-chat' import { listConversations, createConversation, deleteConversation, getMessages, updateConversation, type Conversation, } from '@/services/conversation-api' import Markdown from '@/components/Markdown' const { Text } = Typography interface Message { id: string role: 'user' | 'assistant' content: string timestamp: number } export default function TaskAssistant() { const [conversations, setConversations] = useState([]) const [activeId, setActiveId] = useState(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') const [loading, setLoading] = useState(false) const [editingId, setEditingId] = useState(null) const [editTitle, setEditTitle] = useState('') const abortRef = useRef(null) const messagesEndRef = useRef(null) const editInputRef = useRef(null) const { token } = theme.useToken() // Load conversations const loadConversations = useCallback(async () => { try { setConversations(await listConversations()) } catch { /* */ } }, []) useEffect(() => { loadConversations() }, [loadConversations]) // Scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) // Load messages when switching conversation const switchConversation = useCallback(async (id: string) => { if (loading) { abortRef.current?.abort(); setLoading(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 { /* */ } }, [loading]) // New conversation const handleNew = async () => { if (loading) { abortRef.current?.abort(); setLoading(false) } try { const conv = await createConversation() setConversations(prev => [conv, ...prev]) setActiveId(conv.id) setMessages([]) setInput('') } catch { /* */ } } // Delete conversation const handleDelete = async (id: string) => { Modal.confirm({ title: '删除对话', content: '确定要删除这个对话吗?', okText: '删除', okType: 'danger', cancelText: '取消', onOk: async () => { await deleteConversation(id).catch(() => {}) setConversations(prev => prev.filter(c => c.id !== id)) if (activeId === id) { setActiveId(null); setMessages([]) } }, }) } // Start editing title const startEdit = (conv: Conversation) => { setEditingId(conv.id) setEditTitle(conv.title) setTimeout(() => editInputRef.current?.focus(), 50) } // Save title 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)) } setEditingId(null) } // Send message 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) setInput('') setLoading(true) const controller = new AbortController() abortRef.current = controller try { const result = await sendMessage( newMessages.map(m => ({ role: m.role, content: m.content })), activeId ?? undefined, controller.signal, ) const convId = result.conversationId || activeId if (convId && convId !== activeId) { setActiveId(convId) loadConversations() } const assistantMsg: Message = { id: (Date.now() + 1).toString(), role: 'assistant', content: result.content, timestamp: Date.now(), } setMessages(prev => [...prev, assistantMsg]) 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(), }]) } finally { setLoading(false) abortRef.current = null } } const handleStop = () => { abortRef.current?.abort(); setLoading(false) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } } 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', }}> {editingId === conv.id ? ( 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} )} { e.stopPropagation(); handleDelete(conv.id) }} style={{ fontSize: 12, color: token.colorTextQuaternary, marginLeft: 4, cursor: 'pointer' }} />
))} {conversations.length === 0 && (
暂无对话
双击标题可重命名
)}
{/* Chat area */}
{/* Messages */}
{messages.length === 0 && (
{activeId ? '开始新对话' : '点击左侧「新对话」开始'} Hermes Agent 通过 DeepSeek 提供回答
)} {messages.map(msg => (
: } style={{ backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, flexShrink: 0, }} />
{msg.role === 'assistant' ? : msg.content}
))} {loading && (
} style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
Hermes 思考中...
)}
{/* Input area — redesigned */}
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 ? ( ) : ( )}
) }