admin-projects/src/pages/TaskAssistant.tsx
WangDL 1bceee6bc2
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s
feat: ChatGPT-style UI + approval for sensitive ops
2026-05-22 17:31:31 +08:00

273 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect, useCallback } from 'react'
import { Input, Button, theme, Typography, App } from 'antd'
import {
SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined,
DeleteOutlined, StopOutlined, MessageOutlined, UserOutlined,
CheckOutlined, CloseOutlined,
} 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<Conversation[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [waitingApproval, setWaitingApproval] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('')
const [deleting, setDeleting] = useState(false)
const composingRef = useRef(false)
const abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const editInputRef = useRef<any>(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<Message>) => 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 (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)' }}>
{/* Sidebar */}
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRight: '1px solid ' + token.colorBorderSecondary }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid ' + token.colorBorderSecondary }}>
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}></Button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{conversations.map(conv => (
<div key={conv.id} onClick={() => 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' }}>
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 13, marginRight: 8, flexShrink: 0 }} />
{editingId === conv.id ? (
<Input ref={editInputRef} size="small" value={editTitle} onChange={e => setEditTitle(e.target.value)}
onBlur={() => saveTitle(conv.id)} onPressEnter={() => saveTitle(conv.id)}
onClick={e => e.stopPropagation()} style={{ flex: 1, fontSize: 13 }} bordered={false} />
) : (
<Text ellipsis style={{ flex: 1, fontSize: 13 }} onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
)}
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled={deleting}
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ opacity: 0.4 }} />
</div>
))}
</div>
</div>
{/* Chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '32px 0' }}>
{messages.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
<div style={{ width: 64, height: 64, borderRadius: 16, background: token.colorFillSecondary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<RobotOutlined style={{ fontSize: 28, color: token.colorPrimary }} />
</div>
<Text style={{ fontSize: 16, fontWeight: 500 }}></Text>
<Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · </Text>
</div>
) : (
<div style={{ maxWidth: 768, margin: '0 auto', width: '100%', padding: '0 24px' }}>
{messages.map(msg => (
<div key={msg.id} style={{ marginBottom: 32 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 30, height: 30, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700,
background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, color: '#fff' }}>
{msg.role === 'user' ? 'Y' : 'H'}
</div>
<Text strong style={{ fontSize: 13, color: token.colorText }}>{msg.role === 'user' ? 'You' : 'Hermes'}</Text>
{msg.streaming && !msg.content && <Text type="secondary" style={{ fontSize: 12 }}>Thinking...</Text>}
</div>
{/* Approval card */}
{msg.approval && !msg.approval.resolved && (
<div style={{ marginLeft: 40, marginBottom: 12, padding: 16, borderRadius: 12, background: token.colorWarningBg, border: '1px solid ' + token.colorWarningBorder }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: token.colorWarning, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<ToolOutlined style={{ color: '#fff', fontSize: 14 }} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}></Text>
<div style={{ marginTop: 6, padding: '8px 12px', borderRadius: 8, background: token.colorBgContainer, fontFamily: 'monospace', fontSize: 13 }}>
{msg.approval.command}
</div>
<Text type="secondary" style={{ fontSize: 12, marginTop: 6, display: 'block' }}>{msg.approval.description}</Text>
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
{msg.approval.choices.map(c => (
<Button key={c} size="small" type={c === 'deny' ? 'default' : 'primary'}
danger={c === 'deny'}
onClick={() => handleApprove(msg, c)}>
{c === 'deny' ? '拒绝' : c === 'once' ? '允许本次' : c === 'session' ? '本次对话始终允许' : c === 'always' ? '始终允许' : c}
</Button>
))}
</div>
</div>
</div>
</div>
)}
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div style={{ marginLeft: 40, marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{msg.toolCalls.map((t, i) => (
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 10px', borderRadius: 6, fontSize: 12,
background: t.done ? (t.error ? token.colorErrorBg : token.colorSuccessBg) : token.colorFillSecondary,
border: '1px solid ' + (t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary) }}>
<ToolOutlined style={{ fontSize: 11, color: t.done ? (t.error ? token.colorError : token.colorSuccess) : token.colorTextSecondary }} />
<Text style={{ fontSize: 12 }}>{t.preview || t.tool}</Text>
{t.done && <Text style={{ fontSize: 11, color: t.error ? token.colorError : token.colorSuccess }}>{t.error ? 'failed' : t.duration?.toFixed(1) + 's'}</Text>}
</div>
))}
</div>
)}
{/* Content */}
<div style={{ marginLeft: 40, maxWidth: '100%' }}>
<div style={{ fontSize: 14, lineHeight: 1.85, color: token.colorText }}>
{msg.role === 'assistant'
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : (!msg.toolCalls?.length && !msg.approval ? <Text type="secondary">Thinking...</Text> : null))
: <div style={{ padding: '10px 16px', borderRadius: 12, background: token.colorPrimary, color: '#fff', display: 'inline-block', maxWidth: '85%' }}>{msg.content}</div>}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div style={{ padding: '16px 0 24px', background: token.colorBgLayout }}>
<div style={{ maxWidth: 768, margin: '0 auto', padding: '0 24px' }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorBgContainer, borderRadius: 16, padding: '10px 10px 10px 20px', border: '1px solid ' + token.colorBorderSecondary, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<Input.TextArea value={input} onChange={e => 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 ? (
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, width: 36 }} />
) : (
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
disabled={!input.trim() || !activeId || waitingApproval} style={{ borderRadius: 10, height: 36, width: 36 }} />
)}
</div>
</div>
</div>
</div>
</div>
)
}
export default function TaskAssistant() { return <App><ChatPage /></App> }