feat: conversation sidebar + markdown rendering + stop generation
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 1s

This commit is contained in:
WangDL 2026-05-22 10:43:28 +08:00
parent feae49ab1a
commit 63a06c1a2d
7 changed files with 1985 additions and 150 deletions

1609
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,10 @@
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.15.1" "react-markdown": "^10.1.0",
"react-router-dom": "^7.15.1",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@ -27,6 +30,7 @@
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",

View File

@ -0,0 +1,75 @@
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
import { CopyOutlined, CheckOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
function CodeBlock({ language, children }: { language: string; children: string }) {
const [copied, setCopied] = useState(false)
const code = String(children).replace(/\n$/, '')
const handleCopy = async () => {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div style={{ position: 'relative', marginBottom: 16, borderRadius: 8, overflow: 'hidden' }}>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '4px 12px', background: '#282c34', color: '#abb2bf',
fontSize: 12, fontFamily: 'monospace',
}}>
<span>{language || 'code'}</span>
<Tooltip title={copied ? '已复制' : '复制代码'}>
<span onClick={handleCopy} style={{ cursor: 'pointer', fontSize: 14 }}>
{copied ? <CheckOutlined style={{ color: '#98c379' }} /> : <CopyOutlined />}
</span>
</Tooltip>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{ margin: 0, borderRadius: '0 0 8px 8px', fontSize: 13 }}
showLineNumbers={code.split('\n').length > 3}
>
{code}
</SyntaxHighlighter>
</div>
)
}
export default function Markdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const isInline = !match && !String(children).includes('\n')
if (isInline) {
return <code className={className} {...props}>{children}</code>
}
return <CodeBlock language={match?.[1] || ''}>{String(children)}</CodeBlock>
},
pre({ children }) {
return <>{children}</>
},
table({ children }) {
return <div style={{ overflowX: 'auto', marginBottom: 16 }}><table>{children}</table></div>
},
th({ children }) {
return <th style={{ padding: '8px 12px', border: '1px solid #ddd', background: '#fafafa', fontWeight: 600, textAlign: 'left' }}>{children}</th>
},
td({ children }) {
return <td style={{ padding: '8px 12px', border: '1px solid #ddd' }}>{children}</td>
},
}}
>
{content}
</ReactMarkdown>
)
}

View File

@ -1,9 +1,14 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { Typography, Input, Button, Space, Avatar, Spin, theme } from 'antd' import { Typography, Input, Button, Space, Avatar, Spin, theme, Modal, Tooltip } from 'antd'
import { SendOutlined, RobotOutlined, UserOutlined } from '@ant-design/icons' import {
SendOutlined, RobotOutlined, UserOutlined, PlusOutlined,
DeleteOutlined, StopOutlined, MessageOutlined,
} 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 Markdown from '@/components/Markdown'
const { Title, Text } = Typography const { Text } = Typography
const { TextArea } = Input const { TextArea } = Input
interface Message { interface Message {
@ -13,17 +18,79 @@ 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 [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 abortRef = useRef<AbortController | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const { token } = theme.useToken() const { token } = theme.useToken()
// Load conversations
useEffect(() => {
listConversations().then(setConversations).catch(() => {})
}, [])
// Scroll to bottom
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [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 handleSend = async () => {
const text = input.trim() const text = input.trim()
if (!text || loading) return if (!text || loading) return
@ -34,36 +101,61 @@ export default function TaskAssistant() {
content: text, content: text,
timestamp: Date.now(), timestamp: Date.now(),
} }
setMessages(prev => [...prev, userMsg]) const newMessages = [...messages, userMsg]
setMessages(newMessages)
setInput('') setInput('')
setLoading(true) setLoading(true)
const controller = new AbortController()
abortRef.current = controller
try { try {
const reply = await sendMessage([...messages, userMsg].map(m => ({ const result = await sendMessage(
role: m.role, newMessages.map(m => ({ role: m.role, content: m.content })),
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 = { const assistantMsg: Message = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
content: reply, content: result.content,
timestamp: Date.now(), timestamp: Date.now(),
} }
setMessages(prev => [...prev, assistantMsg]) setMessages(prev => [...prev, assistantMsg])
} catch (err) {
// Refresh conversation list for updated timestamps
listConversations().then(setConversations).catch(() => {})
} catch (err: any) {
if (err.name === 'AbortError') return
const errorMsg: Message = { const errorMsg: Message = {
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]) setMessages(prev => [...prev, errorMsg])
} finally { } finally {
setLoading(false) setLoading(false)
abortRef.current = null
} }
} }
// Stop generation
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() e.preventDefault()
@ -72,38 +164,73 @@ export default function TaskAssistant() {
} }
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 112px)' }}> <div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 16 }}>
<div style={{ marginBottom: 16 }}> {/* Sidebar */}
<Title level={4} style={{ margin: 0 }}></Title> <div style={{
<Text type="secondary">AI </Text> 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> </div>
<div {/* Chat area */}
style={{ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
flex: 1, <div style={{
overflowY: 'auto', flex: 1, overflowY: 'auto', background: token.colorBgContainer,
background: token.colorBgContainer, borderRadius: token.borderRadiusLG, padding: '20px 24px', marginBottom: 12,
borderRadius: token.borderRadiusLG,
padding: '20px 24px',
marginBottom: 16,
border: `1px solid ${token.colorBorderSecondary}`, border: `1px solid ${token.colorBorderSecondary}`,
}} }}>
>
{messages.length === 0 && ( {messages.length === 0 && (
<div <div style={{
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center',
display: 'flex', justifyContent: 'center', height: '100%', color: token.colorTextQuaternary,
flexDirection: 'column', }}>
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: token.colorTextQuaternary,
}}
>
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} /> <RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<Text type="secondary" style={{ fontSize: 16 }}></Text> <Text type="secondary" style={{ fontSize: 16 }}>
{activeId ? '开始新对话' : '点击左侧「新对话」开始'}
</Text>
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}> <Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
AI Hermes Agent DeepSeek
</Text> </Text>
</div> </div>
)} )}
@ -112,14 +239,12 @@ export default function TaskAssistant() {
<div <div
key={msg.id} key={msg.id}
style={{ style={{
display: 'flex', display: 'flex', gap: 12, marginBottom: 20,
gap: 12,
marginBottom: 20,
flexDirection: msg.role === 'user' ? 'row-reverse' : 'row', flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
}} }}
> >
<Avatar <Avatar
size={36} 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,
@ -128,39 +253,29 @@ export default function TaskAssistant() {
/> />
<div <div
style={{ style={{
maxWidth: '72%', maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
padding: '10px 16px', background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter,
borderRadius: 12,
background: msg.role === 'user'
? token.colorPrimary
: token.colorFillAlter,
color: msg.role === 'user' ? '#fff' : token.colorText, color: msg.role === 'user' ? '#fff' : token.colorText,
lineHeight: 1.7, lineHeight: 1.8, wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}} }}
> >
{msg.content} {msg.role === 'assistant'
? <Markdown content={msg.content} />
: msg.content
}
</div> </div>
</div> </div>
))} ))}
{loading && ( {loading && (
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}> <div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
<Avatar <Avatar size={32} icon={<RobotOutlined />}
size={36} style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} />
icon={<RobotOutlined />} <div style={{
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }} maxWidth: '75%', padding: '12px 16px', borderRadius: 12,
/>
<div
style={{
maxWidth: '72%',
padding: '10px 16px',
borderRadius: 12,
background: token.colorFillAlter, background: token.colorFillAlter,
}} }}>
> <Spin size="small" /> <Text type="secondary">Hermes ...</Text>
<Spin size="small" /> <Text type="secondary">...</Text>
</div> </div>
</div> </div>
)} )}
@ -168,36 +283,40 @@ export default function TaskAssistant() {
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
<div {/* Input area */}
style={{ <div style={{
padding: '12px 16px', padding: '12px 16px', background: token.colorBgContainer,
background: token.colorBgContainer, borderRadius: token.borderRadiusLG, border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG, }}>
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
<TextArea <TextArea
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="输入你的问题Enter 发送Shift+Enter 换行" placeholder={activeId ? '输入消息Enter 发送Shift+Enter 换行' : '请先新建或选择对话'}
autoSize={{ minRows: 1, maxRows: 4 }} autoSize={{ minRows: 1, maxRows: 4 }}
disabled={loading} disabled={loading || !activeId}
style={{ resize: 'none' }} style={{ resize: 'none' }}
/> />
<Button {loading ? (
type="primary" <Button danger icon={<StopOutlined />} onClick={handleStop} style={{ height: 'auto' }}>
icon={<SendOutlined />}
onClick={handleSend} </Button>
loading={loading} ) : (
disabled={!input.trim()} <Button type="primary" icon={<SendOutlined />} onClick={handleSend}
style={{ height: 'auto' }} disabled={!input.trim() || !activeId} style={{ height: 'auto' }}>
>
</Button> </Button>
)}
</Space.Compact> </Space.Compact>
</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>
) )
} }

View File

@ -1,4 +1,4 @@
import { api } from './http-client' import { api } from './http-client'
interface ChatMessage { interface ChatMessage {
role: 'user' | 'assistant' | 'system' role: 'user' | 'assistant' | 'system'
@ -7,10 +7,16 @@ interface ChatMessage {
interface ChatResponse { interface ChatResponse {
content: string content: string
conversationId?: string
usage?: { model?: string; inputTokens?: number; outputTokens?: number } usage?: { model?: string; inputTokens?: number; outputTokens?: number }
} }
export async function sendMessage(messages: ChatMessage[]): Promise<string> { export async function sendMessage(
const data = await api.post<ChatResponse>('/admin-api/ai/chat', { messages }) messages: ChatMessage[],
return data.content || '(无回复内容)' conversationId?: string,
signal?: AbortSignal,
): Promise<ChatResponse> {
const body: Record<string, unknown> = { messages }
if (conversationId) body.conversationId = conversationId
return api.post<ChatResponse>('/admin-api/ai/chat', body, signal ? { signal } : undefined)
} }

View File

@ -0,0 +1,24 @@
import { api } from './http-client'
interface Conversation {
id: string
title: string
createdAt: string
updatedAt: string
}
export function listConversations(): Promise<Conversation[]> {
return api.get('/admin-api/conversations')
}
export function createConversation(title?: string): Promise<Conversation> {
return api.post('/admin-api/conversations', { title })
}
export function updateConversation(id: string, title: string): Promise<void> {
return api.patch(`/admin-api/conversations/${id}`, { title })
}
export function deleteConversation(id: string): Promise<void> {
return api.delete(`/admin-api/conversations/${id}`)
}

View File

@ -69,7 +69,8 @@ async function request<T>(
headers['Authorization'] = `Bearer ${token}` headers['Authorization'] = `Bearer ${token}`
} }
const res = await fetch(path, { ...options, headers }) const { signal, ...restOpts } = options;
const res = await fetch(path, { ...restOpts, headers, signal: signal ?? undefined })
if (res.status === 401 && !retried) { if (res.status === 401 && !retried) {
const refreshed = await tryRefresh() const refreshed = await tryRefresh()
@ -92,10 +93,11 @@ export const api = {
get<T>(path: string) { get<T>(path: string) {
return request<T>(path) return request<T>(path)
}, },
post<T>(path: string, body?: unknown) { post<T>(path: string, body?: unknown, extra?: RequestInit & { signal?: AbortSignal }) {
return request<T>(path, { return request<T>(path, {
method: 'POST', method: 'POST',
body: body != null ? JSON.stringify(body) : undefined, body: body != null ? JSON.stringify(body) : undefined,
...extra,
}) })
}, },
put<T>(path: string, body?: unknown) { put<T>(path: string, body?: unknown) {
@ -104,6 +106,12 @@ export const api = {
body: body != null ? JSON.stringify(body) : undefined, body: body != null ? JSON.stringify(body) : undefined,
}) })
}, },
patch<T>(path: string, body?: unknown) {
return request<T>(path, {
method: 'PATCH',
body: body != null ? JSON.stringify(body) : undefined,
})
},
delete<T>(path: string, body?: unknown) { delete<T>(path: string, body?: unknown) {
return request<T>(path, { return request<T>(path, {
method: 'DELETE', method: 'DELETE',