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';
function HomeRedirect() {
const navigate = useNavigate();
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>;
return <Navigate to="/chat" replace />;
}
export default function App() {
@ -54,10 +42,12 @@ export default function App() {
const mainContent = (
<Routes>
<Route path="/" element={<HomeRedirect />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/chat/:id" element={<ChatPage />} />
<Route path="/agents" element={<AgentList />} />
<Route path="/agents/new" 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="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} />

View File

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

View File

@ -226,10 +226,10 @@ export default function AgentList() {
</Space>
<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>
</Button>
</Link>
<Link to={`/agents/${a.id}`} style={{ flex: 1 }}>
<Button block icon={<EditOutlined />} style={{ borderRadius: 12, height: 40 }}>

View File

@ -46,6 +46,8 @@ export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null);
const [sessionRefresh, setSessionRefresh] = useState(0);
const [agentList, setAgentList] = useState<Agent[]>([]);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
@ -84,11 +86,24 @@ export default function ChatPage() {
});
};
const loadAgent = async () => {
if (!id) return;
const a = await AgentAPI.detail(id);
setAgent(a);
};
const loadAgent = async () => {
if (!id) {
setAgent(null);
setMessages([]);
return;
}
const a = await AgentAPI.detail(id);
setAgent(a);
};
const loadAgentList = async () => {
try {
const list = await AgentAPI.list();
setAgentList(list);
} catch (e) {
// ignore
}
};
const loadProviders = async () => {
try {
@ -117,17 +132,22 @@ export default function ChatPage() {
});
};
useEffect(() => {
loadAgent();
useEffect(() => {
loadAgentList();
}, []);
useEffect(() => {
loadAgent();
loadProviders();
return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
loadMessages();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, highlightId]);
return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!id) return;
loadMessages();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, highlightId, id]);
/** 把附件文本拼成 system 注入字符串 */
const buildAttachmentsText = () => {
@ -394,12 +414,10 @@ export default function ChatPage() {
activeModelOption?.providerName ||
(activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) ||
(agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认');
if (!agent) return null;
return (
<div className="chat-shell">
{/* 1. 二级侧边栏:会话列表 */}
return (
<div className="chat-shell">
{/* 1. 二级侧边栏:智能体列表 */}
<aside
className="chat-side"
style={{
@ -407,143 +425,109 @@ export default function ChatPage() {
flexDirection: 'column',
gap: 0,
background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)'
borderRight: '1px solid var(--color-border)',
width: 260
}}
>
<div
style={{
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 style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{agentList.map((a) => {
const isActive = a.id === id;
return (
<div
key={a.id}
onClick={() => navigate(`/chat/${a.id}`)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
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>
</aside>
{/* 2. 主聊天区 */}
<section className="chat-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" />
</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>
</div>
</div>
{/* 会话切换 */}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{id && (
<SessionSidebar
agentId={id}
activeSessionId={sessionId}
onChange={(sid, opts) => {
setSessionId(sid);
setHighlightId(opts?.highlightMessageId || null);
}}
refreshTick={sessionRefresh}
/>
)}
</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>
{/* 2. 主聊天区 */}
<section className="chat-main">
{/* Header */}
<div className="chat-header">
<div style={{ textAlign: 'center' }}>
<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>
{/* Body */}
<div ref={bodyRef} className="chat-body">
{/* Body */}
<div ref={bodyRef} className="chat-body">
<div className="messages-container">
{messages.length === 0 && !streaming.active ? (
<div style={{ textAlign: 'center', marginTop: 120 }}>
@ -778,12 +762,14 @@ export default function ChatPage() {
</div>
<div style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 8 }}>
AI
</div>
</div>
</section>
{/* ---- 抽屉组件保持不变 ---- */}
{id && (
</div>
</div>
</>
)}
</section>
{/* ---- 抽屉组件保持不变 ---- */}
{id && (
<McpResourcesDrawer
agentId={id}
open={mcpDrawerOpen}
@ -889,12 +875,34 @@ export default function ChatPage() {
/>
</div>
<Button block onClick={() => setOverrides({})}>
🔄
</Button>
</Drawer>
</div>
);
<Button block onClick={() => setOverrides({})}>
🔄
</Button>
</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>
);
}
/** 输入栏子组件 */