feat: ChatGPT-style UI + approval for sensitive ops
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s

This commit is contained in:
WangDL 2026-05-22 17:31:31 +08:00
parent 3b7a765c40
commit 1bceee6bc2
2 changed files with 90 additions and 43 deletions

View File

@ -3,8 +3,9 @@ import { Input, Button, theme, Typography, App } from 'antd'
import { import {
SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined, SendOutlined, RobotOutlined, PlusOutlined, ToolOutlined,
DeleteOutlined, StopOutlined, MessageOutlined, UserOutlined, DeleteOutlined, StopOutlined, MessageOutlined, UserOutlined,
CheckOutlined, CloseOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { streamChat, type StreamEvent } from '@/services/ai-chat' import { streamChat, resolveApproval, type StreamEvent } from '@/services/ai-chat'
import { import {
listConversations, createConversation, deleteConversation, listConversations, createConversation, deleteConversation,
getMessages, updateConversation, type Conversation, getMessages, updateConversation, type Conversation,
@ -17,15 +18,17 @@ interface Message {
id: string; role: 'user' | 'assistant'; content: string; timestamp: number id: string; role: 'user' | 'assistant'; content: string; timestamp: number
streaming?: boolean streaming?: boolean
toolCalls?: { tool: string; preview?: string; done: boolean; duration?: number; error?: 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() { function ChatPage() {
const { modal } = App.useApp() const { modal, message } = App.useApp()
const [conversations, setConversations] = useState<Conversation[]>([]) const [conversations, setConversations] = useState<Conversation[]>([])
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false) const [streaming, setStreaming] = useState(false)
const [waitingApproval, setWaitingApproval] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@ -41,7 +44,7 @@ function ChatPage() {
useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations]) useEffect(() => { if (!activeId && conversations.length > 0) switchConversation(conversations[0].id) }, [conversations])
const switchConversation = useCallback(async (id: string) => { const switchConversation = useCallback(async (id: string) => {
if (streaming) { abortRef.current?.abort(); setStreaming(false) } if (streaming) { abortRef.current?.abort(); setStreaming(false); setWaitingApproval(false) }
setActiveId(id); setMessages([]) 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]) }, [streaming])
@ -67,6 +70,13 @@ function ChatPage() {
setEditingId(null) 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 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 userMsg: Message = { id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now() }
@ -93,12 +103,13 @@ function ChatPage() {
break break
case 'tool.completed': { case 'tool.completed': {
const i = currentTools.findIndex((t: any) => t.tool === event.tool && !t.done) const i = currentTools.findIndex((t: any) => t.tool === event.tool && !t.done)
if (i >= 0) { if (i >= 0) { currentTools[i] = { ...currentTools[i], done: true, duration: event.duration, error: event.error }; update({ toolCalls: [...currentTools] }) }
currentTools[i] = { ...currentTools[i], done: true, duration: event.duration, error: event.error }
update({ toolCalls: [...currentTools] })
}
break break
} }
case 'approval.request':
setWaitingApproval(true)
update({ approval: { command: event.command, description: event.description, choices: event.choices, runId: event.runId } })
break
case 'message.delta': case 'message.delta':
currentContent += event.delta || '' currentContent += event.delta || ''
update({ content: currentContent, streaming: true }) update({ content: currentContent, streaming: true })
@ -120,13 +131,13 @@ function ChatPage() {
} catch (err: any) { } catch (err: any) {
if (err.name !== 'AbortError') update({ content: 'Error: ' + err.message, streaming: false }) if (err.name !== 'AbortError') update({ content: 'Error: ' + err.message, streaming: false })
else update({ streaming: false }) else update({ streaming: false })
} finally { setStreaming(false); abortRef.current = null } } finally { setStreaming(false); setWaitingApproval(false); abortRef.current = null }
} }
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) } const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
return ( return (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 0 }}> <div style={{ display: 'flex', height: 'calc(100vh - 112px)' }}>
{/* Sidebar */} {/* Sidebar */}
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRight: '1px solid ' + token.colorBorderSecondary }}> <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 }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid ' + token.colorBorderSecondary }}>
@ -152,54 +163,80 @@ function ChatPage() {
</div> </div>
</div> </div>
{/* Chat */} {/* Chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '32px 0' }}>
{messages.length === 0 ? ( {messages.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}> <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' }}> <div style={{ width: 64, height: 64, borderRadius: 16, background: token.colorFillSecondary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<RobotOutlined style={{ fontSize: 28, color: token.colorPrimary }} /> <RobotOutlined style={{ fontSize: 28, color: token.colorPrimary }} />
</div> </div>
<Text style={{ fontSize: 16, fontWeight: 500, color: token.colorText }}></Text> <Text style={{ fontSize: 16, fontWeight: 500 }}></Text>
<Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · DeepSeek xhigh · </Text> <Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · </Text>
</div> </div>
) : ( ) : (
<div style={{ maxWidth: 800, margin: '0 auto' }}> <div style={{ maxWidth: 768, margin: '0 auto', width: '100%', padding: '0 24px' }}>
{messages.map(msg => ( {messages.map(msg => (
<div key={msg.id} style={{ marginBottom: 24 }}> <div key={msg.id} style={{ marginBottom: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> {/* Header */}
<div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess }}> <div style={{ width: 30, height: 30, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, fontWeight: 700,
{msg.role === 'user' ? <UserOutlined style={{ color: '#fff', fontSize: 14 }} /> : <RobotOutlined style={{ color: '#fff', fontSize: 14 }} />} background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess, color: '#fff' }}>
{msg.role === 'user' ? 'Y' : 'H'}
</div> </div>
<Text strong style={{ fontSize: 13 }}>{msg.role === 'user' ? '' : 'Hermes'}</Text> <Text strong style={{ fontSize: 13, color: token.colorText }}>{msg.role === 'user' ? 'You' : 'Hermes'}</Text>
{msg.streaming && <Text type="secondary" style={{ fontSize: 11 }}>...</Text>} {msg.streaming && !msg.content && <Text type="secondary" style={{ fontSize: 12 }}>Thinking...</Text>}
</div> </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 && ( {msg.toolCalls && msg.toolCalls.length > 0 && (
<div style={{ marginBottom: 8, marginLeft: 36, display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ marginLeft: 40, marginBottom: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{msg.toolCalls.map((t, i) => ( {msg.toolCalls.map((t, i) => (
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, <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, background: t.done ? (t.error ? token.colorErrorBg : token.colorSuccessBg) : token.colorFillSecondary,
border: '1px solid ' + (t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary), border: '1px solid ' + (t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary) }}>
fontSize: 12, maxWidth: 'fit-content' }}> <ToolOutlined style={{ fontSize: 11, color: t.done ? (t.error ? token.colorError : token.colorSuccess) : token.colorTextSecondary }} />
<ToolOutlined style={{ color: t.done ? (t.error ? token.colorError : token.colorSuccess) : token.colorTextSecondary }} />
<Text style={{ fontSize: 12 }}>{t.preview || t.tool}</Text> <Text style={{ fontSize: 12 }}>{t.preview || t.tool}</Text>
{t.done {t.done && <Text style={{ fontSize: 11, color: t.error ? token.colorError : token.colorSuccess }}>{t.error ? 'failed' : t.duration?.toFixed(1) + 's'}</Text>}
? <Text style={{ fontSize: 11, color: t.error ? token.colorError : token.colorSuccess }}>{t.error ? '失败' : (t.duration ? t.duration.toFixed(1) + 's' : '')}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}></Text>}
</div> </div>
))} ))}
</div> </div>
)} )}
<div style={{ marginLeft: 36, padding: '12px 16px', borderRadius: 12, lineHeight: 1.85, fontSize: 14, {/* Content */}
background: msg.role === 'user' ? token.colorPrimary : token.colorBgContainer, <div style={{ marginLeft: 40, maxWidth: '100%' }}>
color: msg.role === 'user' ? '#fff' : token.colorText, <div style={{ fontSize: 14, lineHeight: 1.85, color: token.colorText }}>
border: msg.role === 'assistant' ? '1px solid ' + token.colorBorderSecondary : 'none' }}>
{msg.role === 'assistant' {msg.role === 'assistant'
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : <Text type="secondary">...</Text>) ? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : (!msg.toolCalls?.length && !msg.approval ? <Text type="secondary">Thinking...</Text> : null))
: msg.content} : <div style={{ padding: '10px 16px', borderRadius: 12, background: token.colorPrimary, color: '#fff', display: 'inline-block', maxWidth: '85%' }}>{msg.content}</div>}
</div>
</div> </div>
</div> </div>
))} ))}
@ -208,20 +245,21 @@ function ChatPage() {
)} )}
</div> </div>
<div style={{ padding: '16px 32px 20px', background: token.colorBgContainer, borderTop: '1px solid ' + token.colorBorderSecondary }}> {/* Input */}
<div style={{ maxWidth: 800, margin: '0 auto' }}> <div style={{ padding: '16px 0 24px', background: token.colorBgLayout }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorFillTertiary, borderRadius: 16, padding: '10px 10px 10px 18px', border: '1px solid ' + token.colorBorderSecondary }}> <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)} <Input.TextArea value={input} onChange={e => setInput(e.target.value)}
onCompositionStart={() => { composingRef.current = true }} onCompositionEnd={() => { composingRef.current = false }} onCompositionStart={() => { composingRef.current = true }} onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={e => { if (composingRef.current) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }} onKeyDown={e => { if (composingRef.current) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
placeholder={activeId ? '输入消息Enter 发送 / Shift+Enter 换行' : '请先新建或选择对话'} placeholder={activeId ? 'Message Hermes...' : 'Select or create a conversation'}
autoSize={{ minRows: 1, maxRows: 5 }} disabled={streaming || !activeId} autoSize={{ minRows: 1, maxRows: 6 }} disabled={streaming || !activeId || waitingApproval}
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} /> variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
{streaming ? ( {streaming ? (
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}></Button> <Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, width: 36 }} />
) : ( ) : (
<Button type="primary" icon={<SendOutlined />} onClick={handleSend} <Button type="primary" icon={<SendOutlined />} onClick={handleSend}
disabled={!input.trim() || !activeId} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}></Button> disabled={!input.trim() || !activeId || waitingApproval} style={{ borderRadius: 10, height: 36, width: 36 }} />
)} )}
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ export type StreamEvent =
| { event: 'message.delta'; delta: string; runId?: string } | { event: 'message.delta'; delta: string; runId?: string }
| { event: 'tool.started'; tool: string; preview?: string; runId?: string } | { event: 'tool.started'; tool: string; preview?: string; runId?: string }
| { event: 'tool.completed'; tool: string; duration?: number; error?: boolean; runId?: string } | { event: 'tool.completed'; tool: string; duration?: number; error?: boolean; runId?: string }
| { event: 'approval.request'; command: string; description: string; choices: string[]; runId: string }
| { event: 'run.completed'; output?: string; usage?: any; runId?: string } | { event: 'run.completed'; output?: string; usage?: any; runId?: string }
| { event: 'done'; conversationId?: string } | { event: 'done'; conversationId?: string }
| { event: 'stopped' } | { event: 'stopped' }
@ -91,3 +92,11 @@ export async function sendMessage(
if (!json.success) throw new Error(json.message || 'Chat failed') if (!json.success) throw new Error(json.message || 'Chat failed')
return json.data return json.data
} }
export async function resolveApproval(runId: string, choice: string): Promise<void> {
await fetch('/admin-api/ai/chat/stop', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (getAccessToken() || '') },
body: JSON.stringify({ runId, action: choice }),
})
}