restore TaskAssistant + rename iframe to Hermes 设置
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 6s
This commit is contained in:
parent
f80e48eaf8
commit
865d8e329a
@ -12,12 +12,15 @@ import AdminLayout from './layouts/AdminLayout'
|
|||||||
const Login = lazy(() => import('./pages/Login'))
|
const Login = lazy(() => import('./pages/Login'))
|
||||||
const KnowledgeBasesPage = lazy(() => import('./pages/KnowledgeBases'))
|
const KnowledgeBasesPage = lazy(() => import('./pages/KnowledgeBases'))
|
||||||
const BillingPage = lazy(() => import('./pages/Billing'))
|
const BillingPage = lazy(() => import('./pages/Billing'))
|
||||||
|
const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
|
||||||
|
const HermesSettings = lazy(() => import('./pages/HermesSettings'))
|
||||||
const GiteaEmbed = lazy(() => import('./pages/GiteaEmbed'))
|
const GiteaEmbed = lazy(() => import('./pages/GiteaEmbed'))
|
||||||
const ServersPage = lazy(() => import("./pages/Servers"))
|
const ServersPage = lazy(() => import("./pages/Servers"))
|
||||||
const AuditLogPage = lazy(() => import("./pages/AuditLog"))
|
const AuditLogPage = lazy(() => import("./pages/AuditLog"))
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||||
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
const UserManagement = lazy(() => import('./pages/UserManagement'))
|
||||||
const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
|
const TaskAssistant = lazy(() => import('./pages/TaskAssistant'))
|
||||||
|
const HermesSettings = lazy(() => import('./pages/HermesSettings'))
|
||||||
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
const Placeholder = lazy(() => import('./pages/Placeholder'))
|
||||||
const ForbiddenPage = lazy(() => import('./pages/403'))
|
const ForbiddenPage = lazy(() => import('./pages/403'))
|
||||||
const NotFoundPage = lazy(() => import('./pages/404'))
|
const NotFoundPage = lazy(() => import('./pages/404'))
|
||||||
@ -45,7 +48,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="assistant" element={<TaskAssistant />} />
|
<Route path="assistant"
|
||||||
|
element={<Suspense fallback={<PageLoading />}><TaskAssistant /></Suspense>}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="hermes" element={<TaskAssistant />} />
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="users"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export interface AdminMenuItem {
|
|||||||
export const adminMenuItems: AdminMenuItem[] = [
|
export const adminMenuItems: AdminMenuItem[] = [
|
||||||
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
{ path: '/', name: '总览', icon: <DashboardOutlined /> },
|
||||||
{ path: '/assistant', name: '任务助理', icon: <RobotOutlined /> },
|
{ path: '/assistant', name: '任务助理', icon: <RobotOutlined /> },
|
||||||
|
{ path: '/hermes', name: 'Hermes 设置', icon: <RobotOutlined />, requiredRole: 'SUPER_ADMIN' },
|
||||||
{ path: '/users', name: '用户管理', icon: <UserOutlined />, children: [
|
{ path: '/users', name: '用户管理', icon: <UserOutlined />, children: [
|
||||||
{ path: '/users/admins', name: '管理员', requiredRole: 'SUPER_ADMIN' },
|
{ path: '/users/admins', name: '管理员', requiredRole: 'SUPER_ADMIN' },
|
||||||
{ path: '/users/members', name: '普通用户' },
|
{ path: '/users/members', name: '普通用户' },
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { AdminRole } from '@/types/admin'
|
|||||||
const breadcrumbMap: Record<string, string> = {
|
const breadcrumbMap: Record<string, string> = {
|
||||||
'/': '总览',
|
'/': '总览',
|
||||||
'/assistant': '任务助理',
|
'/assistant': '任务助理',
|
||||||
|
'/hermes': 'Hermes 设置',
|
||||||
'/users': '用户管理',
|
'/users': '用户管理',
|
||||||
'/users/admins': '管理员',
|
'/users/admins': '管理员',
|
||||||
'/users/members': '普通用户',
|
'/users/members': '普通用户',
|
||||||
|
|||||||
11
src/pages/HermesSettings.tsx
Normal file
11
src/pages/HermesSettings.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export default function HermesSettings() {
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: 'calc(100vh - 112px)', overflow: 'hidden' }}>
|
||||||
|
<iframe
|
||||||
|
src="https://hermes.admin.longde.cloud"
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||||
|
title="Hermes Agent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,11 +1,228 @@
|
|||||||
export default function TaskAssistant() {
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { Input, Button, theme, Typography, App } from 'antd'
|
||||||
|
import {
|
||||||
|
SendOutlined, RobotOutlined, PlusOutlined,
|
||||||
|
DeleteOutlined, StopOutlined, MessageOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { streamChat, type StreamEvent } from '@/services/ai-chat'
|
||||||
|
import {
|
||||||
|
listConversations, createConversation, deleteConversation,
|
||||||
|
getMessages, updateConversation, type Conversation,
|
||||||
|
} from '@/services/conversation-api'
|
||||||
|
import Markdown from '@/components/Markdown'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
streaming?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatPage() {
|
||||||
|
const { modal } = App.useApp()
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([])
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [streaming, setStreaming] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editTitle, setEditTitle] = useState('')
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const composingRef = useRef(false)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const editInputRef = useRef<any>(null)
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async () => {
|
||||||
|
try { setConversations(await listConversations()) } catch { /* */ }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadConversations() }, [loadConversations])
|
||||||
|
|
||||||
|
// Auto-select first conversation on load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeId && conversations.length > 0) {
|
||||||
|
switchConversation(conversations[0].id)
|
||||||
|
}
|
||||||
|
}, [conversations, activeId])
|
||||||
|
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
|
||||||
|
|
||||||
|
const switchConversation = useCallback(async (id: string) => {
|
||||||
|
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
||||||
|
setActiveId(id); setMessages([])
|
||||||
|
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 { /* */ }
|
||||||
|
}, [streaming])
|
||||||
|
|
||||||
|
const handleNew = async () => {
|
||||||
|
if (streaming) { abortRef.current?.abort(); setStreaming(false) }
|
||||||
|
try {
|
||||||
|
const conv = await createConversation()
|
||||||
|
setConversations(prev => [conv, ...prev])
|
||||||
|
setActiveId(conv.id); setMessages([]); setInput('')
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => modal.confirm({
|
||||||
|
title: '删除对话', content: '确定?', okText: '删除', okType: 'danger', cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
setDeleting(true)
|
||||||
|
try { await deleteConversation(id); setConversations(prev => prev.filter(c => c.id !== id)); if (activeId === id) { setActiveId(null); setMessages([]) } } catch { /* */ }
|
||||||
|
setDeleting(false)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text || streaming) return
|
||||||
|
setInput('')
|
||||||
|
|
||||||
|
const userMsg: Message = { id: Date.now().toString(), role: 'user', content: text, timestamp: Date.now() }
|
||||||
|
const prevMessages = [...messages, userMsg]
|
||||||
|
setMessages(prevMessages)
|
||||||
|
setStreaming(true)
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
const streamMsgId = (Date.now() + 1).toString()
|
||||||
|
const streamMsg: Message = { id: streamMsgId, role: 'assistant', content: '', timestamp: Date.now(), streaming: true }
|
||||||
|
setMessages(prev => [...prev, streamMsg])
|
||||||
|
|
||||||
|
let currentContent = ''
|
||||||
|
let completedConvId: string | undefined
|
||||||
|
|
||||||
|
const update = (updates: Partial<Message>) =>
|
||||||
|
setMessages(prev => prev.map(m => m.id === streamMsgId ? { ...m, ...updates } : m))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamChat(
|
||||||
|
prevMessages.map(m => ({ role: m.role, content: m.content })),
|
||||||
|
activeId,
|
||||||
|
(event: StreamEvent) => {
|
||||||
|
switch (event.event) {
|
||||||
|
case 'meta':
|
||||||
|
completedConvId = event.conversationId
|
||||||
|
if (event.conversationId && event.conversationId !== activeId) { setActiveId(event.conversationId); loadConversations() }
|
||||||
|
break
|
||||||
|
case 'message.delta':
|
||||||
|
currentContent += event.delta || ''
|
||||||
|
update({ content: currentContent, streaming: true })
|
||||||
|
break
|
||||||
|
case 'run.completed':
|
||||||
|
if (event.output) currentContent = event.output
|
||||||
|
update({ content: currentContent, streaming: false })
|
||||||
|
break
|
||||||
|
case 'done':
|
||||||
|
completedConvId = event.conversationId || completedConvId; update({ streaming: false })
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
update({ content: `❌ ${event.error}`, streaming: false })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
if (completedConvId) loadConversations()
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== 'AbortError') update({ content: `❌ ${err.message}`, streaming: false })
|
||||||
|
else update({ streaming: false })
|
||||||
|
} finally {
|
||||||
|
setStreaming(false); abortRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = () => { abortRef.current?.abort(); setStreaming(false) }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', height: 'calc(100vh - 112px)', overflow: 'hidden' }}>
|
<div style={{ display: 'flex', height: 'calc(100vh - 112px)', gap: 12 }}>
|
||||||
<iframe
|
<div style={{ width: 260, flexShrink: 0, display: 'flex', flexDirection: 'column', background: token.colorBgContainer, borderRadius: token.borderRadiusLG, border: `1px solid ${token.colorBorderSecondary}`, overflow: 'hidden' }}>
|
||||||
src="https://hermes.admin.longde.cloud"
|
<div style={{ padding: 12, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
<Button type="primary" icon={<PlusOutlined />} block onClick={handleNew}>新对话</Button>
|
||||||
title="Hermes Agent"
|
</div>
|
||||||
/>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px' }}>
|
||||||
|
{conversations.map(conv => (
|
||||||
|
<div key={conv.id} onClick={() => activeId !== conv.id && switchConversation(conv.id)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', marginBottom: 2, borderRadius: 8, cursor: 'pointer', background: activeId === conv.id ? token.colorFillSecondary : 'transparent' }}>
|
||||||
|
<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 }} />
|
||||||
|
) : (
|
||||||
|
<Text ellipsis style={{ flex: 1, fontSize: 13, lineHeight: '28px' }}
|
||||||
|
onDoubleClick={e => { e.stopPropagation(); startEdit(conv) }}>{conv.title}</Text>
|
||||||
|
)}
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled={deleting}
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(conv.id) }} style={{ marginLeft: 4, flexShrink: 0 }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{conversations.length === 0 && <div style={{ textAlign: 'center', padding: 24 }}><Text type="secondary" style={{ fontSize: 13 }}>暂无对话</Text></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', background: token.colorBgContainer, borderRadius: `${token.borderRadiusLG}px ${token.borderRadiusLG}px 0 0`, padding: '20px 24px', border: `1px solid ${token.colorBorderSecondary}`, borderBottom: 'none' }}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
|
<RobotOutlined style={{ fontSize: 48, marginBottom: 16, color: token.colorTextQuaternary }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 16 }}>{activeId ? '开始新对话' : '点击「新对话」开始'}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13, marginTop: 8 }}>Hermes Agent · 流式响应 · xhigh 推理</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} style={{ display: 'flex', gap: 12, marginBottom: 20, flexDirection: msg.role === 'user' ? 'row-reverse' : 'row' }}>
|
||||||
|
<div style={{ maxWidth: '75%', minWidth: 0, padding: '12px 16px', borderRadius: 12, lineHeight: 1.8, wordBreak: 'break-word', background: msg.role === 'user' ? token.colorPrimary : token.colorFillAlter, color: msg.role === 'user' ? '#fff' : token.colorText }}>
|
||||||
|
{msg.role === 'assistant'
|
||||||
|
? (msg.content
|
||||||
|
? <Markdown content={msg.content + (msg.streaming ? '▊' : '')} />
|
||||||
|
: <Text type="secondary" style={{ fontSize: 13 }}>思考中...</Text>)
|
||||||
|
: msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: token.colorBgContainer, border: `1px solid ${token.colorBorderSecondary}`, borderRadius: `0 0 ${token.borderRadiusLG}px ${token.borderRadiusLG}px`, padding: '16px 20px' }}>
|
||||||
|
<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 value={input} onChange={e => setInput(e.target.value)}
|
||||||
|
onCompositionStart={() => { composingRef.current = true }}
|
||||||
|
onCompositionEnd={() => { composingRef.current = false }}
|
||||||
|
onKeyDown={e => { if (composingRef.current) return; if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
|
||||||
|
placeholder={activeId ? '输入消息,Enter 发送' : '请先新建或选择对话'}
|
||||||
|
autoSize={{ minRows: 1, maxRows: 5 }} disabled={streaming || !activeId}
|
||||||
|
variant="borderless" style={{ flex: 1, resize: 'none', padding: '4px 0', fontSize: 14 }} />
|
||||||
|
{streaming ? (
|
||||||
|
<Button danger type="primary" icon={<StopOutlined />} onClick={handleStop} style={{ borderRadius: 8, height: 34, minWidth: 72 }}>停止</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="primary" icon={<SendOutlined />} onClick={handleSend}
|
||||||
|
disabled={!input.trim() || !activeId} style={{ borderRadius: 8, height: 34, minWidth: 72 }}>发送</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TaskAssistant() { return <App><ChatPage /></App> }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user