2026-05-22 10:43:28 +08:00
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
2026-05-22 11:16:33 +08:00
|
|
|
|
import { Input, Button, Avatar, Spin, theme, Typography, App } from 'antd'
|
2026-05-22 10:43:28 +08:00
|
|
|
|
import {
|
|
|
|
|
|
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
|
|
|
|
|
|
DeleteOutlined, StopOutlined, MessageOutlined,
|
|
|
|
|
|
} from '@ant-design/icons'
|
2026-05-22 00:38:56 +08:00
|
|
|
|
import { sendMessage } from '@/services/ai-chat'
|
2026-05-22 11:07:58 +08:00
|
|
|
|
import {
|
|
|
|
|
|
listConversations, createConversation, deleteConversation,
|
|
|
|
|
|
getMessages, updateConversation, type Conversation,
|
|
|
|
|
|
} from '@/services/conversation-api'
|
2026-05-22 10:43:28 +08:00
|
|
|
|
import Markdown from '@/components/Markdown'
|
2026-05-22 00:38:56 +08:00
|
|
|
|
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const { Text } = Typography
|
2026-05-22 00:38:56 +08:00
|
|
|
|
|
|
|
|
|
|
interface Message {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
role: 'user' | 'assistant'
|
|
|
|
|
|
content: string
|
|
|
|
|
|
timestamp: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:16:33 +08:00
|
|
|
|
function ChatPage() {
|
|
|
|
|
|
const { modal } = App.useApp()
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
|
|
|
|
|
const [activeId, setActiveId] = useState<string | null>(null)
|
2026-05-22 00:38:56 +08:00
|
|
|
|
const [messages, setMessages] = useState<Message[]>([])
|
|
|
|
|
|
const [input, setInput] = useState('')
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
|
|
|
|
const [editTitle, setEditTitle] = useState('')
|
2026-05-22 11:16:33 +08:00
|
|
|
|
const [deleting, setDeleting] = useState(false)
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const abortRef = useRef<AbortController | null>(null)
|
2026-05-22 00:38:56 +08:00
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const editInputRef = useRef<any>(null)
|
2026-05-22 00:38:56 +08:00
|
|
|
|
const { token } = theme.useToken()
|
|
|
|
|
|
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const loadConversations = useCallback(async () => {
|
|
|
|
|
|
try { setConversations(await listConversations()) } catch { /* */ }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}, [])
|
2026-05-22 11:07:58 +08:00
|
|
|
|
useEffect(() => { loadConversations() }, [loadConversations])
|
2026-05-22 10:43:28 +08:00
|
|
|
|
|
2026-05-22 00:38:56 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
|
|
}, [messages])
|
|
|
|
|
|
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const switchConversation = useCallback(async (id: string) => {
|
|
|
|
|
|
if (loading) { abortRef.current?.abort(); setLoading(false) }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
setActiveId(id)
|
|
|
|
|
|
setMessages([])
|
2026-05-22 11:07:58 +08:00
|
|
|
|
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 { /* */ }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}, [loading])
|
|
|
|
|
|
|
|
|
|
|
|
const handleNew = async () => {
|
2026-05-22 11:07:58 +08:00
|
|
|
|
if (loading) { abortRef.current?.abort(); setLoading(false) }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const conv = await createConversation()
|
|
|
|
|
|
setConversations(prev => [conv, ...prev])
|
|
|
|
|
|
setActiveId(conv.id)
|
|
|
|
|
|
setMessages([])
|
|
|
|
|
|
setInput('')
|
2026-05-22 11:07:58 +08:00
|
|
|
|
} catch { /* */ }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:16:33 +08:00
|
|
|
|
const handleDelete = (id: string) => {
|
|
|
|
|
|
modal.confirm({
|
|
|
|
|
|
title: '删除对话',
|
|
|
|
|
|
content: '确定要删除这个对话吗?',
|
|
|
|
|
|
okText: '删除',
|
|
|
|
|
|
okType: 'danger',
|
|
|
|
|
|
cancelText: '取消',
|
2026-05-22 10:43:28 +08:00
|
|
|
|
onOk: async () => {
|
2026-05-22 11:16:33 +08:00
|
|
|
|
setDeleting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteConversation(id)
|
|
|
|
|
|
setConversations(prev => prev.filter(c => c.id !== id))
|
|
|
|
|
|
if (activeId === id) { setActiveId(null); setMessages([]) }
|
|
|
|
|
|
} catch { /* */ }
|
|
|
|
|
|
setDeleting(false)
|
2026-05-22 10:43:28 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const startEdit = (conv: Conversation) => {
|
|
|
|
|
|
setEditingId(conv.id)
|
|
|
|
|
|
setEditTitle(conv.title)
|
|
|
|
|
|
setTimeout(() => editInputRef.current?.focus(), 50)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const saveTitle = async (id: string) => {
|
|
|
|
|
|
const title = editTitle.trim()
|
|
|
|
|
|
if (title && title !== conversations.find(c => c.id === id)?.title) {
|
|
|
|
|
|
await updateConversation(id, title).catch(() => {})
|
|
|
|
|
|
setConversations(prev => prev.map(c => c.id === id ? { ...c, title } : c))
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 00:38:56 +08:00
|
|
|
|
const handleSend = async () => {
|
|
|
|
|
|
const text = input.trim()
|
|
|
|
|
|
if (!text || loading) return
|
|
|
|
|
|
|
|
|
|
|
|
const userMsg: Message = {
|
2026-05-22 11:07:58 +08:00
|
|
|
|
id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now(),
|
2026-05-22 00:38:56 +08:00
|
|
|
|
}
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const newMessages = [...messages, userMsg]
|
|
|
|
|
|
setMessages(newMessages)
|
2026-05-22 00:38:56 +08:00
|
|
|
|
setInput('')
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
abortRef.current = controller
|
|
|
|
|
|
|
2026-05-22 00:38:56 +08:00
|
|
|
|
try {
|
2026-05-22 10:43:28 +08:00
|
|
|
|
const result = await sendMessage(
|
|
|
|
|
|
newMessages.map(m => ({ role: m.role, content: m.content })),
|
2026-05-22 11:07:58 +08:00
|
|
|
|
activeId ?? undefined, controller.signal,
|
2026-05-22 10:43:28 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const convId = result.conversationId || activeId
|
|
|
|
|
|
if (convId && convId !== activeId) {
|
|
|
|
|
|
setActiveId(convId)
|
|
|
|
|
|
loadConversations()
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}
|
2026-05-22 00:38:56 +08:00
|
|
|
|
|
2026-05-22 11:16:33 +08:00
|
|
|
|
setMessages(prev => [...prev, {
|
2026-05-22 11:07:58 +08:00
|
|
|
|
id: (Date.now() + 1).toString(), role: 'assistant',
|
|
|
|
|
|
content: result.content, timestamp: Date.now(),
|
2026-05-22 11:16:33 +08:00
|
|
|
|
}])
|
2026-05-22 11:07:58 +08:00
|
|
|
|
loadConversations()
|
2026-05-22 10:43:28 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
if (err.name === 'AbortError') return
|
2026-05-22 11:07:58 +08:00
|
|
|
|
setMessages(prev => [...prev, {
|
|
|
|
|
|
id: (Date.now() + 1).toString(), role: 'assistant',
|
2026-05-22 10:43:28 +08:00
|
|
|
|
content: '请求失败:' + (err instanceof Error ? err.message : '未知错误'),
|
2026-05-22 00:38:56 +08:00
|
|
|
|
timestamp: Date.now(),
|
2026-05-22 11:07:58 +08:00
|
|
|
|
}])
|
2026-05-22 00:38:56 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
2026-05-22 10:43:28 +08:00
|
|
|
|
abortRef.current = null
|
2026-05-22 00:38:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:07:58 +08:00
|
|
|
|
const handleStop = () => { abortRef.current?.abort(); setLoading(false) }
|
2026-05-22 10:43:28 +08:00
|
|
|
|
|
2026-05-22 00:38:56 +08:00
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
2026-05-22 11:07:58 +08:00
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
2026-05-22 00:38:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column',
|
|
|
|
|
|
background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
|
|
|
|
|
|
border: `1px solid ${token.colorBorderSecondary}`, overflow: 'hidden',
|
|
|
|
|
|
}}>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div style={{ padding: 12, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
|
|
|
|
|
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}>新对话</Button>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
</div>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px' }}>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
{conversations.map(conv => (
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div key={conv.id}
|
|
|
|
|
|
onClick={() => activeId !== conv.id && switchConversation(conv.id)}
|
2026-05-22 00:38:56 +08:00
|
|
|
|
style={{
|
2026-05-22 10:43:28 +08:00
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
2026-05-22 11:07:58 +08:00
|
|
|
|
padding: '8px 10px', marginBottom: 2, borderRadius: 8, cursor: 'pointer',
|
2026-05-22 10:43:28 +08:00
|
|
|
|
background: activeId === conv.id ? token.colorFillSecondary : 'transparent',
|
2026-05-22 11:07:58 +08:00
|
|
|
|
}}>
|
|
|
|
|
|
<MessageOutlined style={{ color: token.colorTextQuaternary, fontSize: 14, 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 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-05-22 11:16:33 +08:00
|
|
|
|
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
|
|
|
|
|
|
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>
|
|
|
|
|
|
{conv.title}
|
|
|
|
|
|
</Text>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
)}
|
2026-05-22 11:16:33 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="text" size="small" danger
|
|
|
|
|
|
icon={<DeleteOutlined />}
|
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
|
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }}
|
|
|
|
|
|
style={{ marginLeft: 4, flexShrink: 0 }}
|
|
|
|
|
|
/>
|
2026-05-22 00:38:56 +08:00
|
|
|
|
</div>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
))}
|
|
|
|
|
|
{conversations.length === 0 && (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: 24, color: token.colorTextQuaternary }}>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>暂无对话</Text>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<br />
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 12, marginTop: 4 }}>双击标题可重命名</Text>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
flex: 1, overflowY: 'auto', background: token.colorBgContainer,
|
2026-05-22 11:07:58 +08:00
|
|
|
|
borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`,
|
|
|
|
|
|
padding: '20px 24px',
|
|
|
|
|
|
border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none',
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}}>
|
|
|
|
|
|
{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 => (
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div key={msg.id} style={{
|
|
|
|
|
|
display: 'flex', gap: 12, marginBottom: 20,
|
|
|
|
|
|
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<Avatar size={32}
|
2026-05-22 10:43:28 +08:00
|
|
|
|
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<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}
|
2026-05-22 10:43:28 +08:00
|
|
|
|
</div>
|
2026-05-22 00:38:56 +08:00
|
|
|
|
</div>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
))}
|
2026-05-22 00:38:56 +08:00
|
|
|
|
|
2026-05-22 10:43:28 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{
|
2026-05-22 11:16:33 +08:00
|
|
|
|
background: token.colorBgContainer,
|
2026-05-22 11:07:58 +08:00
|
|
|
|
border: `1px solid ${token.colorBorderSecondary}`,
|
|
|
|
|
|
borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`,
|
|
|
|
|
|
padding: '16px 20px',
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}}>
|
2026-05-22 11:07:58 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex', alignItems: 'flex-end', gap: 10,
|
|
|
|
|
|
background: token.colorFillTertiary, borderRadius: 12,
|
|
|
|
|
|
padding: '8px 8px 8px 14px', border: `1px solid ${token.colorBorderSecondary}`,
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<Input.TextArea
|
2026-05-22 10:43:28 +08:00
|
|
|
|
value={input}
|
|
|
|
|
|
onChange={e => setInput(e.target.value)}
|
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
|
placeholder={activeId ? '输入消息,Enter 发送,Shift+Enter 换行' : '请先新建或选择对话'}
|
2026-05-22 11:07:58 +08:00
|
|
|
|
autoSize={{ minRows: 1, maxRows: 5 }}
|
2026-05-22 10:43:28 +08:00
|
|
|
|
disabled={loading || !activeId}
|
2026-05-22 11:07:58 +08:00
|
|
|
|
variant="borderless"
|
|
|
|
|
|
style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }}
|
2026-05-22 10:43:28 +08:00
|
|
|
|
/>
|
|
|
|
|
|
{loading ? (
|
2026-05-22 11:16:33 +08:00
|
|
|
|
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop}
|
|
|
|
|
|
style={{ borderRadius: 8, height: 34, minWidth: 72 }}>停止</Button>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
) : (
|
2026-05-22 11:16:33 +08:00
|
|
|
|
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
|
2026-05-22 11:07:58 +08:00
|
|
|
|
disabled={!input.trim() || !activeId}
|
2026-05-22 11:16:33 +08:00
|
|
|
|
style={{ borderRadius: 8, height: 34, minWidth: 72 }}>发送</Button>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
)}
|
2026-05-22 11:07:58 +08:00
|
|
|
|
</div>
|
2026-05-22 10:43:28 +08:00
|
|
|
|
</div>
|
2026-05-22 00:38:56 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
2026-05-22 10:43:28 +08:00
|
|
|
|
}
|
2026-05-22 11:16:33 +08:00
|
|
|
|
|
|
|
|
|
|
export default function TaskAssistant() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<App>
|
|
|
|
|
|
<ChatPage />
|
|
|
|
|
|
</App>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|