import { useState, useRef, useEffect, useCallback } from 'react' import { Input, Button, theme, Typography, App } from 'antd' import { SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined, DeleteOutlined, StopOutlined, MessageOutlined, } from '@ant-design/icons' import { streamChat, resolveApproval, 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 toolCalls?: { tool: string; preview?: string; done: boolean; duration?: number; error?: boolean }[] approval?: { command: string; description: string; choices: string[]; runId: string; resolved?: boolean } } function ChatPage() { const { modal, message } = 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 [waitingApproval, setWaitingApproval] = 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]) 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); setWaitingApproval(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: '删除对话', 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 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 handleApprove = async (approvalMsg: Message, choice: string) => { setMessages(prev => prev.map(m => m.id === approvalMsg.id ? { ...m, approval: { ...m.approval!, resolved: true } } : m)) setWaitingApproval(false) await resolveApproval(approvalMsg.approval!.runId, choice) message.success(choice === 'deny' ? '已拒绝' : '已批准') } 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 = '' const currentTools: any[] = [] let completedConvId: string | undefined 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 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 'approval.request': setWaitingApproval(true) update({ approval: { command: event.command, description: event.description, choices: event.choices, runId: event.runId } }) 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: 'Error: ' + event.error, streaming: false }) break } }, controller.signal) if (completedConvId) loadConversations() } catch (err: any) { if (err.name !== 'AbortError') update({ content: 'Error: ' + err.message, streaming: false }) else update({ streaming: false }) } finally { setStreaming(false); setWaitingApproval(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', 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 }} bordered={false} /> ) : ( { e.stopPropagation(); startEdit(conv) }}>{conv.title} )}
))}
{/* Chat area */}
{messages.length === 0 ? (
有什么可以帮你的? Hermes Agent · 可执行任务、操作文件
) : (
{messages.map(msg => (
{/* Header */}
{msg.role === 'user' ? 'Y' : 'H'}
{msg.role === 'user' ? 'You' : 'Hermes'} {msg.streaming && !msg.content && Thinking...}
{/* Approval card */} {msg.approval && !msg.approval.resolved && (
需要确认操作
{msg.approval.command}
{msg.approval.description}
{msg.approval.choices.map(c => ( ))}
)} {/* Tool calls */} {msg.toolCalls && msg.toolCalls.length > 0 && (
{msg.toolCalls.map((t, i) => (
{t.preview || t.tool} {t.done && {t.error ? 'failed' : t.duration?.toFixed(1) + 's'}}
))}
)} {/* Content */}
{msg.role === 'assistant' ? (msg.content ? : (!msg.toolCalls?.length && !msg.approval ? Thinking... : null)) :
{msg.content}
}
))}
)}
{/* 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 ? 'Message Hermes...' : 'Select or create a conversation'} autoSize={{ minRows: 1, maxRows: 6 }} disabled={streaming || !activeId || waitingApproval} variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} /> {streaming ? (
) } export default function TaskAssistant() { return }