feat(agent): add history session sidebar with restore

Agent Workbench 左侧新增历史会话栏:
- 列出调用方过往会话(标题/相对时间/轮次),可折叠
- 点击恢复整段对话(拉 /sessions/{id},回填消息 + 账目 + sessionId)
- 高亮当前会话,运行中禁用切换
- 每轮结束后刷新列表
This commit is contained in:
boos
2026-06-23 20:51:31 +08:00
parent b5df7caad8
commit 54ce1119b8
+297 -84
View File
@@ -46,6 +46,18 @@ type SessionUsage = {
numTurns: number; numTurns: number;
}; };
/** A past session summary for the history rail (matches the backend `/sessions`). */
type SessionSummary = {
id: string;
title: string | null;
model: string;
total_tokens: number;
cost_usd: number;
num_turns: number;
message_count: number;
updated_at: string;
};
type SseEvent = { type: string; data?: Record<string, unknown> }; type SseEvent = { type: string; data?: Record<string, unknown> };
const MODE_LABELS: Record<PermissionMode, string> = { const MODE_LABELS: Record<PermissionMode, string> = {
@@ -83,6 +95,19 @@ async function readSse(response: Response, onEvent: (e: SseEvent) => void): Prom
function formatCost(usd: number): string { function formatCost(usd: number): string {
return `$${usd.toFixed(4)}`; return `$${usd.toFixed(4)}`;
} }
// Sessions store `updated_at` as a SQLite UTC string ("YYYY-MM-DD HH:MM:SS");
// render it as a short, human relative label for the history rail.
function formatRelative(sqliteUtc: string): string {
const ts = Date.parse(sqliteUtc.replace(' ', 'T') + 'Z');
if (Number.isNaN(ts)) return sqliteUtc;
const diffSec = Math.round((Date.now() - ts) / 1000);
if (diffSec < 60) return '刚刚';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)} 小时前`;
if (diffSec < 604800) return `${Math.floor(diffSec / 86400)} 天前`;
return new Date(ts).toLocaleDateString();
}
export default function AgentWorkbench() { export default function AgentWorkbench() {
const toast = useToast(); const toast = useToast();
const location = useLocation(); const location = useLocation();
@@ -97,6 +122,11 @@ export default function AgentWorkbench() {
const [approval, setApproval] = useState<ApprovalPrompt | null>(null); const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
const [ask, setAsk] = useState<AskPrompt | null>(null); const [ask, setAsk] = useState<AskPrompt | null>(null);
// History rail: the caller's past sessions, restorable on click.
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [showHistory, setShowHistory] = useState(true);
const [restoring, setRestoring] = useState(false);
// Lets the user stop a running turn: aborting the fetch drops the SSE stream, // Lets the user stop a running turn: aborting the fetch drops the SSE stream,
// which the backend observes and uses to cancel the agent loop. // which the backend observes and uses to cancel the agent loop.
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -111,6 +141,75 @@ export default function AgentWorkbench() {
[audience], [audience],
); );
// Pull the caller's session history for the rail (best-effort; silent on fail).
const refreshSessions = useCallback(async () => {
const token = authToken();
if (!token) return;
try {
const res = await fetch('/api/agent/sessions?limit=100', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const body = await res.json();
if (Array.isArray(body?.sessions)) setSessions(body.sessions as SessionSummary[]);
} catch {
/* history is non-critical — leave the rail as-is */
}
}, [authToken]);
useEffect(() => {
void refreshSessions();
}, [refreshSessions]);
// Restore a past session: load its transcript into the view and adopt its id
// so the next turn continues it.
const restoreSession = useCallback(
async (id: string) => {
if (running || restoring || id === sessionId) return;
const token = authToken();
if (!token) {
toast.error('未登录或会话已过期');
return;
}
setRestoring(true);
try {
const res = await fetch(`/api/agent/sessions/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const restored: ChatMessage[] = Array.isArray(body?.messages)
? (body.messages as ChatMessage[]).map((m) => ({
role: m.role,
text: m.text ?? '',
tools: Array.isArray(m.tools) ? m.tools : [],
}))
: [];
const s = body?.session;
setMessages(restored);
setSessionId(id);
setApproval(null);
setAsk(null);
setUsage(
s
? {
inputTokens: Number(s.input_tokens ?? 0),
outputTokens: Number(s.output_tokens ?? 0),
totalTokens: Number(s.total_tokens ?? 0),
costUsd: Number(s.cost_usd ?? 0),
numTurns: Number(s.num_turns ?? 0),
}
: null,
);
} catch (err) {
toast.error(`恢复会话失败: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setRestoring(false);
}
},
[authToken, running, restoring, sessionId, toast],
);
// Mutate the last assistant message in place as stream events arrive. // Mutate the last assistant message in place as stream events arrive.
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => { const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
setMessages((prev) => { setMessages((prev) => {
@@ -302,8 +401,10 @@ export default function AgentWorkbench() {
setRunning(false); setRunning(false);
setApproval(null); setApproval(null);
setAsk(null); setAsk(null);
// The turn created/updated this session's row — reflect it in the rail.
void refreshSessions();
} }
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]); }, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant, refreshSessions]);
const stop = useCallback(() => { const stop = useCallback(() => {
abortRef.current?.abort(); abortRef.current?.abort();
@@ -328,6 +429,13 @@ export default function AgentWorkbench() {
</p> </p>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={() => setShowHistory((v) => !v)}
style={{ padding: '4px 12px', borderRadius: 6 }}
title="历史会话"
>
{showHistory ? '隐藏历史' : '历史会话'}
</button>
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label> <label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
<select <select
value={mode} value={mode}
@@ -355,50 +463,110 @@ export default function AgentWorkbench() {
</div> </div>
)} )}
{/* Messages */} {/* Main area: history rail + conversation column */}
<div <div style={{ display: 'flex', gap: 12, flex: 1, minHeight: 0 }}>
ref={scrollRef} {showHistory && (
style={{ <HistoryRail
flex: 1, sessions={sessions}
overflowY: 'auto', activeId={sessionId}
border: '1px solid var(--border-color, #2a2a2a)', disabled={running || restoring}
borderRadius: 8, onSelect={(id) => void restoreSession(id)}
padding: 12, />
display: 'flex',
flexDirection: 'column',
gap: 12,
minHeight: 240,
}}
>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
</div>
)} )}
{messages.map((m, i) => ( <div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minWidth: 0 }}>
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> {/* Messages */}
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}> <div
{m.role === 'user' ? '你' : 'Agent'} ref={scrollRef}
</div> style={{
{m.text && ( flex: 1,
m.role === 'assistant' ? ( overflowY: 'auto',
<div border: '1px solid var(--border-color, #2a2a2a)',
className="agent-markdown" borderRadius: 8,
style={{ fontSize: 14, lineHeight: 1.6 }} padding: 12,
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.text) }} display: 'flex',
/> flexDirection: 'column',
) : ( gap: 12,
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div> minHeight: 240,
) }}
>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13, textAlign: 'center', marginTop: 40 }}>
输入任务,让 agent 在隔离沙箱中读写文件、执行命令。危险操作会请求你的审批。
</div>
)} )}
{m.tools.map((t) => ( {messages.map((m, i) => (
<ToolCard key={t.id} tool={t} /> <div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: m.role === 'user' ? 'var(--primary, #4a9eff)' : '#22c55e' }}>
{m.role === 'user' ? '你' : 'Agent'}
</div>
{m.text && (
m.role === 'assistant' ? (
<div
className="agent-markdown"
style={{ fontSize: 14, lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.text) }}
/>
) : (
<div style={{ whiteSpace: 'pre-wrap', fontSize: 14, lineHeight: 1.6 }}>{m.text}</div>
)
)}
{m.tools.map((t) => (
<ToolCard key={t.id} tool={t} />
))}
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && (
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
)}
</div>
))} ))}
{m.role === 'assistant' && !m.text && m.tools.length === 0 && running && i === messages.length - 1 && ( </div>
<div style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
{/* Composer */}
<div style={{ display: 'flex', gap: 8 }}>
<textarea
value={input}
disabled={running}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void send();
}
}}
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)"
rows={3}
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
/>
<button
onClick={() => void send()}
disabled={running || !input.trim()}
style={{
padding: '0 20px',
borderRadius: 8,
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)',
color: '#fff',
border: 'none',
cursor: running ? 'default' : 'pointer',
}}
>
{running ? '运行中…' : '发送'}
</button>
{running && (
<button
onClick={stop}
style={{
padding: '0 16px',
borderRadius: 8,
background: '#ef4444',
color: '#fff',
border: 'none',
cursor: 'pointer',
}}
>
停止
</button>
)} )}
</div> </div>
))} </div>
</div> </div>
{/* Approval modal */} {/* Approval modal */}
@@ -417,51 +585,96 @@ export default function AgentWorkbench() {
onSubmit={(answer) => submitAnswer(ask.requestId, answer)} onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
/> />
)} )}
</div>
);
}
{/* Composer */} function HistoryRail({
<div style={{ display: 'flex', gap: 8 }}> sessions,
<textarea activeId,
value={input} disabled,
disabled={running} onSelect,
onChange={(e) => setInput(e.target.value)} }: {
onKeyDown={(e) => { sessions: SessionSummary[];
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { activeId: string | null;
e.preventDefault(); disabled: boolean;
void send(); onSelect: (id: string) => void;
} }) {
}} return (
placeholder="描述任务… (Ctrl/Cmd + Enter 发送)" <div
rows={3} style={{
style={{ flex: 1, padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }} width: 240,
/> flexShrink: 0,
<button display: 'flex',
onClick={() => void send()} flexDirection: 'column',
disabled={running || !input.trim()} border: '1px solid var(--border-color, #2a2a2a)',
style={{ borderRadius: 8,
padding: '0 20px', overflow: 'hidden',
borderRadius: 8, }}
background: running ? 'var(--border-color, #333)' : 'var(--primary, #4a9eff)', >
color: '#fff', <div
border: 'none', style={{
cursor: running ? 'default' : 'pointer', padding: '8px 12px',
}} fontSize: 12,
> fontWeight: 600,
{running ? '运行中…' : '发送'} color: 'var(--text-secondary, #888)',
</button> borderBottom: '1px solid var(--border-color, #2a2a2a)',
{running && ( }}
<button >
onClick={stop} 历史会话 {sessions.length > 0 && `(${sessions.length})`}
style={{ </div>
padding: '0 16px', <div style={{ flex: 1, overflowY: 'auto', padding: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
borderRadius: 8, {sessions.length === 0 ? (
background: '#ef4444', <div style={{ fontSize: 12, color: 'var(--text-secondary, #888)', textAlign: 'center', padding: '20px 8px' }}>
color: '#fff', 还没有会话记录
border: 'none', </div>
cursor: 'pointer', ) : (
}} sessions.map((s) => {
> const active = s.id === activeId;
停止 return (
</button> <button
key={s.id}
onClick={() => onSelect(s.id)}
disabled={disabled && !active}
title={s.title ?? s.id}
style={{
textAlign: 'left',
padding: '8px 10px',
borderRadius: 6,
border: '1px solid transparent',
background: active ? 'var(--primary, #4a9eff)' : 'var(--surface, rgba(255,255,255,0.02))',
color: active ? '#fff' : 'inherit',
cursor: disabled && !active ? 'default' : 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 3,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{s.title?.trim() || `会话 ${s.id.slice(0, 8)}`}
</span>
<span
style={{
fontSize: 11,
color: active ? 'rgba(255,255,255,0.8)' : 'var(--text-secondary, #888)',
display: 'flex',
gap: 8,
}}
>
<span>{formatRelative(s.updated_at)}</span>
<span>· {s.num_turns} 轮</span>
</span>
</button>
);
})
)} )}
</div> </div>
</div> </div>