feat(chat): refactor chat page layout with secondary agent list sidebar

- 聊天页移至 /chat 路由,不再强制依赖 URL id
- 左侧增加二级侧边栏:展示用户所有 Agent 列表
- 当未选择 Agent 时显示空白状态占位
- 历史会话迁移至顶部栏『历史对话』Drawer 内
main
sp mac bookpro 2605 2026-05-24 19:13:40 +08:00
parent e474b4578d
commit cf4791b34d
4 changed files with 183 additions and 185 deletions

View File

@ -18,19 +18,7 @@ import { useAuth } from './store/auth';
import { AgentAPI } from './api'; import { AgentAPI } from './api';
function HomeRedirect() { function HomeRedirect() {
const navigate = useNavigate(); return <Navigate to="/chat" replace />;
useEffect(() => {
AgentAPI.list().then(list => {
if (list.length > 0) {
navigate(`/agents/${list[0].id}/chat`, { replace: true });
} else {
navigate('/agents', { replace: true });
}
}).catch(() => {
navigate('/agents', { replace: true });
});
}, [navigate]);
return <div className="flex items-center justify-center h-full"><Spin size="large" /></div>;
} }
export default function App() { export default function App() {
@ -54,10 +42,12 @@ export default function App() {
const mainContent = ( const mainContent = (
<Routes> <Routes>
<Route path="/" element={<HomeRedirect />} /> <Route path="/" element={<HomeRedirect />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/chat/:id" element={<ChatPage />} />
<Route path="/agents" element={<AgentList />} /> <Route path="/agents" element={<AgentList />} />
<Route path="/agents/new" element={<AgentEditor />} /> <Route path="/agents/new" element={<AgentEditor />} />
<Route path="/agents/:id" element={<AgentEditor />} /> <Route path="/agents/:id" element={<AgentEditor />} />
<Route path="/agents/:id/chat" element={<ChatPage />} /> <Route path="/agents/:id/chat" element={<Navigate to="/chat" replace />} />
<Route path="/marketplace" element={<MarketplacePage />} /> <Route path="/marketplace" element={<MarketplacePage />} />
<Route path="/teams" element={<TeamsPage />} /> <Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} /> <Route path="/prompts" element={<PromptLibraryPage />} />

View File

@ -28,7 +28,7 @@ const NAV_GROUPS: Array<{
{ {
label: '工作台', label: '工作台',
items: [ items: [
{ to: '/', icon: <MessageOutlined />, label: '聊天', end: true }, { to: '/chat', icon: <MessageOutlined />, label: '聊天' },
{ to: '/agents', icon: <RobotOutlined />, label: '我的智能体' }, { to: '/agents', icon: <RobotOutlined />, label: '我的智能体' },
{ to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' } { to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
] ]

View File

@ -226,9 +226,9 @@ export default function AgentList() {
</Space> </Space>
<div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}> <div style={{ display: 'flex', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
<Link to={`/agents/${a.id}/chat`} style={{ flex: 1 }}> <Link to={`/chat/${a.id}`} style={{ flex: 1 }}>
<Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}> <Button type="primary" block icon={<MessageOutlined />} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
</Button> </Button>
</Link> </Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}> <Link to={`/agents/${a.id}`} style={{ flex: 1 }}>

View File

@ -46,6 +46,8 @@ export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null); const [highlightId, setHighlightId] = useState<string | null>(null);
const [sessionRefresh, setSessionRefresh] = useState(0); const [sessionRefresh, setSessionRefresh] = useState(0);
const [agentList, setAgentList] = useState<Agent[]>([]);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false); const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
const [tplDrawerOpen, setTplDrawerOpen] = useState(false); const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
@ -85,11 +87,24 @@ export default function ChatPage() {
}; };
const loadAgent = async () => { const loadAgent = async () => {
if (!id) return; if (!id) {
setAgent(null);
setMessages([]);
return;
}
const a = await AgentAPI.detail(id); const a = await AgentAPI.detail(id);
setAgent(a); setAgent(a);
}; };
const loadAgentList = async () => {
try {
const list = await AgentAPI.list();
setAgentList(list);
} catch (e) {
// ignore
}
};
const loadProviders = async () => { const loadProviders = async () => {
try { try {
const list = await LLMProviderAPI.list(); const list = await LLMProviderAPI.list();
@ -117,6 +132,10 @@ export default function ChatPage() {
}); });
}; };
useEffect(() => {
loadAgentList();
}, []);
useEffect(() => { useEffect(() => {
loadAgent(); loadAgent();
loadProviders(); loadProviders();
@ -125,9 +144,10 @@ export default function ChatPage() {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
if (!id) return;
loadMessages(); loadMessages();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, highlightId]); }, [sessionId, highlightId, id]);
/** 把附件文本拼成 system 注入字符串 */ /** 把附件文本拼成 system 注入字符串 */
const buildAttachmentsText = () => { const buildAttachmentsText = () => {
@ -395,11 +415,9 @@ export default function ChatPage() {
(activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) || (activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) ||
(agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认'); (agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认');
if (!agent) return null;
return ( return (
<div className="chat-shell"> <div className="chat-shell">
{/* 1. 二级侧边栏:会话列表 */} {/* 1. 二级侧边栏:智能体列表 */}
<aside <aside
className="chat-side" className="chat-side"
style={{ style={{
@ -407,143 +425,109 @@ export default function ChatPage() {
flexDirection: 'column', flexDirection: 'column',
gap: 0, gap: 0,
background: 'var(--color-surface)', background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)' borderRight: '1px solid var(--color-border)',
width: 260
}} }}
> >
<div <div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
style={{ <Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button>
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 20,
padding: '8px 0 16px',
borderBottom: '1px solid var(--color-border)'
}}
>
<div
className="avatar"
style={{
width: 42,
height: 42,
borderRadius: '50%',
background: agent.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 18,
overflow: 'hidden',
boxShadow: 'var(--shadow-sm)'
}}
>
{isImageUrl(agent.avatar) ? (
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(agent.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--color-text)' }}>{agent.name}</div>
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{agent.description || '智能体'}
</div>
</div>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{/* 会话切换 */} {agentList.map((a) => {
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> const isActive = a.id === id;
{id && ( return (
<SessionSidebar <div
agentId={id} key={a.id}
activeSessionId={sessionId} onClick={() => navigate(`/chat/${a.id}`)}
onChange={(sid, opts) => { style={{
setSessionId(sid); display: 'flex',
setHighlightId(opts?.highlightMessageId || null); alignItems: 'center',
}} gap: 12,
refreshTick={sessionRefresh} padding: '10px 16px',
/> cursor: 'pointer',
)} background: isActive ? 'var(--color-surface-2)' : 'transparent',
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
transition: 'background 0.2s'
}}
>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: a.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 14,
overflow: 'hidden'
}}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: isActive ? 600 : 500, fontSize: 14, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.name}
</div>
</div>
</div>
);
})}
</div> </div>
<Divider style={{ margin: '12px 0', borderColor: 'var(--color-border)' }} />
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<div
style={{
padding: '10px 12px',
background: 'var(--color-surface-2)',
borderRadius: 12,
border: '1px solid var(--color-border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8
}}
>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)', fontWeight: 500 }}>
</span>
<Switch size="small" checked={useStream} onChange={setUseStream} />
</div>
<Button
block
size="middle"
icon={<SettingOutlined />}
onClick={() => setParamsDrawerOpen(true)}
style={{ borderRadius: 10, height: 38 }}
>
</Button>
<Button
block
size="middle"
icon={<ApiOutlined />}
onClick={() => setMcpDrawerOpen(true)}
style={{ borderRadius: 10, height: 38 }}
>
MCP
</Button>
<Button
block
size="middle"
icon={<EditOutlined />}
onClick={() => navigate(`/agents/${id}`)}
style={{ borderRadius: 10, height: 38 }}
>
Agent
</Button>
<Popconfirm title="清空当前会话所有消息?" onConfirm={handleClear}>
<Button danger block size="middle" icon={<DeleteOutlined />} style={{ borderRadius: 10, height: 38 }}>
</Button>
</Popconfirm>
</Space>
</aside> </aside>
{/* 2. 主聊天区 */} {/* 2. 主聊天区 */}
<section className="chat-main"> <section className="chat-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
{/* Header */} {!agent ? (
<div className="chat-header"> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}> <Empty description="请在左侧选择一个智能体开始对话" />
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{agent.model || '默认模型'} · T={agent.temperature}
</div>
</div> </div>
</div> ) : (
<>
{/* Header */}
<div className="chat-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 24px' }}>
<div>
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
{agent.model || '默认模型'} · T={agent.temperature}
</div>
</div>
<Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
<Switch size="small" checked={useStream} onChange={setUseStream} />
</div>
<Button size="small" onClick={() => setHistoryDrawerOpen(true)}></Button>
<Dropdown
menu={{
items: [
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) },
{ key: 'edit', label: '管理 Agent', icon: <EditOutlined />, onClick: () => navigate(`/agents/${id}`) },
{ type: 'divider' },
{ key: 'clear', label: '清空对话', icon: <DeleteOutlined />, danger: true, onClick: () => {
Modal.confirm({
title: '清空当前会话所有消息?',
onOk: handleClear
});
}
}
]
}}
>
<Button size="small"> <DownOutlined /></Button>
</Dropdown>
</Space>
</div>
{/* Body */} {/* Body */}
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
<div className="messages-container"> <div className="messages-container">
{messages.length === 0 && !streaming.active ? ( {messages.length === 0 && !streaming.active ? (
<div style={{ textAlign: 'center', marginTop: 120 }}> <div style={{ textAlign: 'center', marginTop: 120 }}>
@ -780,6 +764,8 @@ export default function ChatPage() {
AI AI
</div> </div>
</div> </div>
</>
)}
</section> </section>
{/* ---- 抽屉组件保持不变 ---- */} {/* ---- 抽屉组件保持不变 ---- */}
@ -893,6 +879,28 @@ export default function ChatPage() {
🔄 🔄
</Button> </Button>
</Drawer> </Drawer>
<Drawer
title="历史对话"
placement="right"
width={320}
onClose={() => setHistoryDrawerOpen(false)}
open={historyDrawerOpen}
bodyStyle={{ padding: 0 }}
>
{id && (
<SessionSidebar
agentId={id}
activeSessionId={sessionId}
onChange={(sid, opts) => {
setSessionId(sid);
setHighlightId(opts?.highlightMessageId || null);
setHistoryDrawerOpen(false);
}}
refreshTick={sessionRefresh}
/>
)}
</Drawer>
</div> </div>
); );
} }