feat: conversation sidebar + markdown rendering + stop generation
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 1s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 1s
This commit is contained in:
parent
feae49ab1a
commit
63a06c1a2d
1609
package-lock.json
generated
1609
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
75
src/components/Markdown.tsx
Normal file
75
src/components/Markdown.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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,132 +164,159 @@ 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',
|
||||||
</div>
|
background: token.colorBgContainer, borderRadius: token.borderRadiusLG,
|
||||||
|
border: `1px solid ${token.colorBorderSecondary}`, overflow: 'hidden',
|
||||||
<div
|
}}>
|
||||||
style={{
|
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
flex: 1,
|
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}>
|
||||||
overflowY: 'auto',
|
新对话
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadiusLG,
|
|
||||||
padding: '20px 24px',
|
|
||||||
marginBottom: 16,
|
|
||||||
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 }}>有什么我可以帮助你的?</Text>
|
|
||||||
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>
|
|
||||||
输入你的问题,AI 助手将为你解答
|
|
||||||
</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={36}
|
|
||||||
icon={msg.role === 'user' ? <UserOutlined /> : <RobotOutlined />}
|
|
||||||
style={{
|
|
||||||
backgroundColor: msg.role === 'user' ? token.colorPrimary : token.colorSuccess,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '72%',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: msg.role === 'user'
|
|
||||||
? token.colorPrimary
|
|
||||||
: token.colorFillAlter,
|
|
||||||
color: msg.role === 'user' ? '#fff' : token.colorText,
|
|
||||||
lineHeight: 1.7,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
|
||||||
<Avatar
|
|
||||||
size={36}
|
|
||||||
icon={<RobotOutlined />}
|
|
||||||
style={{ backgroundColor: token.colorSuccess, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '72%',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: token.colorFillAlter,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spin size="small" /> <Text type="secondary">思考中...</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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="输入你的问题,Enter 发送,Shift+Enter 换行"
|
|
||||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
|
||||||
disabled={loading}
|
|
||||||
style={{ resize: 'none' }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
onClick={handleSend}
|
|
||||||
loading={loading}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
发送
|
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
24
src/services/conversation-api.ts
Normal file
24
src/services/conversation-api.ts
Normal 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}`)
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user