admin-projects/src/pages/TaskAssistant.tsx

323 lines
11 KiB
TypeScript
Raw Normal View History

import { useState, useRef, useEffect, useCallback } from 'react'
import { Typography, Input, Button, Space, Avatar, Spin, theme, Modal, Tooltip } from 'antd'
import {
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
DeleteOutlined, StopOutlined, MessageOutlined,
} from '@ant-design/icons'
import { sendMessage } from '@/services/ai-chat'
import { listConversations, createConversation, deleteConversation } from '@/services/conversation-api'
import Markdown from '@/components/Markdown'
const { Text } = Typography
const { TextArea } = Input
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}
interface Conversation {
id: string
title: string
updatedAt: string
}
export default function TaskAssistant() {
const [conversations, setConversations] = useState<Conversation[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const { token } = theme.useToken()
// Load conversations
useEffect(() => {
listConversations().then(setConversations).catch(() => {})
}, [])
// Scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Switch conversation
const switchConversation = useCallback((id: string) => {
if (loading) {
abortRef.current?.abort()
setLoading(false)
}
setActiveId(id)
setMessages([])
setInput('')
}, [loading])
// New conversation
const handleNew = async () => {
if (loading) {
abortRef.current?.abort()
setLoading(false)
}
try {
const conv = await createConversation()
setConversations(prev => [conv, ...prev])
setActiveId(conv.id)
setMessages([])
setInput('')
} catch { /* ignore */ }
}
// Delete conversation
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation()
Modal.confirm({
title: '删除对话',
content: '确定要删除这个对话吗?',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
await deleteConversation(id).catch(() => {})
setConversations(prev => prev.filter(c => c.id !== id))
if (activeId === id) {
setActiveId(null)
setMessages([])
}
},
})
}
// Send message
const handleSend = async () => {
const text = input.trim()
if (!text || loading) return
const userMsg: Message = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: Date.now(),
}
const newMessages = [...messages, userMsg]
setMessages(newMessages)
setInput('')
setLoading(true)
const controller = new AbortController()
abortRef.current = controller
try {
const result = await sendMessage(
newMessages.map(m => ({ role: m.role, content: m.content })),
activeId ?? undefined,
controller.signal,
)
// Use returned conversationId if this was auto-created
if (result.conversationId && !activeId) {
setActiveId(result.conversationId)
const conv = await createConversation(
newMessages[newMessages.length - 1]?.content?.slice(0, 30)
).catch(() => null)
if (conv) setConversations(prev => [conv, ...prev])
}
const assistantMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: result.content,
timestamp: Date.now(),
}
setMessages(prev => [...prev, assistantMsg])
// Refresh conversation list for updated timestamps
listConversations().then(setConversations).catch(() => {})
} catch (err: any) {
if (err.name === 'AbortError') return
const errorMsg: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '请求失败:' + (err instanceof Error ? err.message : '未知错误'),
timestamp: Date.now(),
}
setMessages(prev => [...prev, errorMsg])
} finally {
setLoading(false)
abortRef.current = null
}
}
// Stop generation
const handleStop = () => {
abortRef.current?.abort()
setLoading(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 16 }}>
{/* Sidebar */}
<div style={{
width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column',
background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
border: `1px solid ${token.colorBorderSecondary}`, overflow: 'hidden',
}}>
<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: 4 }}>
{conversations.map(conv => (
<div
key={conv.id}
onClick={() => switchConversation(conv.id)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', margin: '2px 0', borderRadius: 8, cursor: 'pointer',
background: activeId === conv.id ? token.colorFillSecondary : 'transparent',
transition: 'background 0.2s',
}}
onMouseEnter={e => (e.currentTarget.style.background = token.colorFillSecondary)}
onMouseLeave={e => {
if (activeId !== conv.id) e.currentTarget.style.background = 'transparent'
}}
>
<Space style={{ overflow: 'hidden', flex: 1 }}>
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14 }} />
<Text ellipsis style={{ fontSize: 13, maxWidth: 160 }}>{conv.title}</Text>
</Space>
<Tooltip title="删除">
<DeleteOutlined
onClick={e => handleDelete(conv.id, e)}
style={{ fontSize: 12, color: token.colorTextQuaternary, opacity: 0, transition: 'opacity 0.2s' }}
className="conv-delete-icon"
/>
</Tooltip>
</div>
))}
{conversations.length === 0 && (
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
<Text type="secondary" style={{ fontSize: 13 }}></Text>
</div>
)}
</div>
</div>
{/* Chat area */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{
flex: 1, overflowY: 'auto', background: token.colorBgContainer,
borderRadius: token.borderRadiusLG, padding: '20px 24px', marginBottom: 12,
border: `1px solid ${token.colorBorderSecondary}`,
}}>
{messages.length === 0 && (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '100%', color: token.colorTextQuaternary,
}}>
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<Text type="secondary" style={{ fontSize: 16 }}>
{activeId ? '开始新对话' : '点击左侧「新对话」开始'}
</Text>
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
Hermes Agent DeepSeek
</Text>
</div>
)}
{messages.map(msg => (
<div
key={msg.id}
style={{
display: 'flex', gap: 12, marginBottom: 20,
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
}}
>
<Avatar
size={32}
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
style={{
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess,
flexShrink: 0,
}}
/>
<div
style={{
maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter,
color: msg.role === 'user' ? '#fff' : token.colorText,
lineHeight: 1.8, wordBreak: 'break-word',
}}
>
{msg.role === 'assistant'
? <Markdown content={msg.content} />
: msg.content
}
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
<Avatar size={32} icon={<RobotOutlined />}
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
<div style={{
maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
background: token.colorFillAlter,
}}>
<Spin size="small" /> <Text type="secondary">Hermes ...</Text>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div style={{
padding: '12px 16px', background: token.colorBgContainer,
borderRadius: token.borderRadiusLG, border: `1px solid ${token.colorBorderSecondary}`,
}}>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={activeId ? '输入消息Enter 发送Shift+Enter 换行' : '请先新建或选择对话'}
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={loading || !activeId}
style={{ resize: 'none' }}
/>
{loading ? (
<Button danger icon={<StopOutlined />} onClick={handleStop} style={{ height: 'auto' }}>
</Button>
) : (
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
disabled={!input.trim() || !activeId} style={{ height: 'auto' }}>
</Button>
)}
</Space.Compact>
</div>
</div>
{/* CSS for delete icon hover */}
<style>{`
.conv-delete-icon { opacity: 0; }
div:hover > .conv-delete-icon { opacity: 1 !important; }
`}</style>
</div>
)
}