import { useState, useRef, useEffect, useCallback } from 'react' import { Input, Button, theme, Typography, App } from 'antd' import { SendOutlined, RobotOutlined, PlusOutlined, DeleteOutlined, StopOutlined, MessageOutlined, } from '@ant-design/icons' import { streamChat, type StreamEvent } 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 streaming?: boolean } function ChatPage() { const { modal } = App.useApp() const [conversations, setConversations] = useState([]) const [activeId, setActiveId] = useState(null) const [messages, setMessages] = useState([]) const [input, setInput] = useState('') 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 { token } = theme.useToken() 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]) 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 { /* */ } }, [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 { /* */ } } 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 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) } const handleSend = async () => { 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 streamMsgId = (Date.now() + 1).toString() const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), streaming: true } setMessages(prev => [...prev, streamMsg]) let currentContent = '' let completedConvId: string | undefined const update = (updates: Partial) => setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : 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 '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 } } const handleStop = () => { abortRef.current?.abort(); setStreaming(false) } return (
{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} )}
))} {conversations.length === 0 &&
暂无对话
}
{messages.length === 0 && (
{activeId ? '开始新对话' : '点击「新对话」开始'} Hermes Agent · 流式响应 · xhigh 推理
)} {messages.map(msg => (
{msg.role === 'assistant' ? (msg.content ? : 思考中...) : msg.content}
))}
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 ? ( ) : ( )}
) } export default function TaskAssistant() { return }