admin-projects/src/pages/TaskAssistant.tsx
WangDL b2c2fd3805
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 4s
redesign: clean modern Agent chat UI
2026-05-22 17:23:31 +08:00

213 lines
14 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,
} 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
toolCalls?: { tool: string; preview?: string; done: boolean; duration?: number; error?: boolean }[]
}
function ChatPage() {
const { modal } = 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 [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) }
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 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 '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 (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 0 }}>
{/* 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 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
{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, color: token.colorText }}></Text>
<Text type="secondary" style={{ fontSize: 13 }}>Hermes Agent · DeepSeek xhigh · </Text>
</div>
) : (
<div style={{ maxWidth: 800, margin: '0 auto' }}>
{messages.map(msg => (
<div key={msg.id} style={{ marginBottom: 24 }}>
{/* Role label */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: msg.role === 'user' ? token.colorPrimary : token.colorSuccess }}>
{msg.role === 'user' ? <UserOutlined style={{ color: '#fff', fontSize: 14 }} /> : <RobotOutlined style={{ color: '#fff', fontSize: 14 }} />}
</div>
<Text strong style={{ fontSize: 13 }}>{msg.role === 'user' ? '你' : 'Hermes'}</Text>
{msg.streaming && <Text type="secondary" style={{ fontSize: 11 }}>...</Text>}
</div>
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div style={{ marginBottom: 8, marginLeft: 36, display: 'flex', flexDirection: 'column', gap: 4 }}>
{msg.toolCalls.map((t, i) => (
<div key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8,
background: t.done ? (t.error ? token.colorErrorBg : token.colorSuccessBg) : token.colorFillSecondary,
border: `1px solid ${t.done ? (t.error ? token.colorErrorBorder : token.colorSuccessBorder) : token.colorBorderSecondary}`,
fontSize: 12, maxWidth: 'fit-content' }}>
<ToolOutlined style={{ 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 ? '失败' : `${t.duration?.toFixed(1)}s`}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}></Text>}
</div>
))}
</div>
)}
{/* Content */}
<div style={{ marginLeft: 36, padding: '12px 16px', borderRadius: 12, lineHeight: 1.85, fontSize: 14,
background: msg.role === 'user' ? token.colorPrimary : token.colorBgContainer,
color: msg.role === 'user' ? '#fff' : token.colorText,
border: msg.role === 'assistant' ? `1px solid ${token.colorBorderSecondary}` : 'none',
boxShadow: msg.role === 'assistant' ? 'none' : undefined }}>
{msg.role === 'assistant'
? (msg.content ? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} /> : <Text type="secondary">...</Text>)
: msg.content}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div style={{ padding: '16px 32px 20px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, background: token.colorFillTertiary, borderRadius: 16, padding: '10px 10px 10px 18px', border: `1px solid ${token.colorBorderSecondary}` }}>
<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 ? '输入消息Enter 发送 / Shift+Enter 换行' : '请先新建或选择对话'}
autoSize={{ minRows: 1, maxRows: 5 }} disabled={streaming || !activeId}
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
{streaming ? (
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}></Button>
) : (
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
disabled={!input.trim() || !activeId} style={{ borderRadius: 10, height: 36, minWidth: 72, fontWeight: 500 }}></Button>
)}
</div>
</div>
</div>
</div>
</div>
)
}
export default function TaskAssistant() { return <App><ChatPage /></App> }