feat: message persistence + inline title edit + redesigned input + fix delete
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 8s

This commit is contained in:
WangDL 2026-05-22 11:07:58 +08:00
parent 12ef1f40fb
commit 7438323e96
2 changed files with 137 additions and 134 deletions

View File

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

View File

@ -1,16 +1,27 @@
import { api } from './http-client' import { api } from './http-client'
interface Conversation { export interface Conversation {
id: string id: string
title: string title: string
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
export interface MessageRecord {
id: string
role: 'user' | 'assistant'
content: string
createdAt: string
}
export function listConversations(): Promise<Conversation[]> { export function listConversations(): Promise<Conversation[]> {
return api.get('/admin-api/conversations') return api.get('/admin-api/conversations')
} }
export function getMessages(conversationId: string): Promise<MessageRecord[]> {
return api.get(`/admin-api/conversations/${conversationId}/messages`)
}
export function createConversation(title?: string): Promise<Conversation> { export function createConversation(title?: string): Promise<Conversation> {
return api.post('/admin-api/conversations', { title }) return api.post('/admin-api/conversations', { title })
} }