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;
};
/** 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> };
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 {
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() {
const toast = useToast();
const location = useLocation();
@@ -97,6 +122,11 @@ export default function AgentWorkbench() {
const [approval, setApproval] = useState<ApprovalPrompt | 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,
// which the backend observes and uses to cancel the agent loop.
const abortRef = useRef<AbortController | null>(null);
@@ -111,6 +141,75 @@ export default function AgentWorkbench() {
[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.
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
setMessages((prev) => {
@@ -302,8 +401,10 @@ export default function AgentWorkbench() {
setRunning(false);
setApproval(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(() => {
abortRef.current?.abort();
@@ -328,6 +429,13 @@ export default function AgentWorkbench() {
</p>
</div>
<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>
<select
value={mode}
@@ -355,50 +463,110 @@ export default function AgentWorkbench() {
</div>
)}
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
border: '1px solid var(--border-color, #2a2a2a)',
borderRadius: 8,
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>
{/* Main area: history rail + conversation column */}
<div style={{ display: 'flex', gap: 12, flex: 1, minHeight: 0 }}>
{showHistory && (
<HistoryRail
sessions={sessions}
activeId={sessionId}
disabled={running || restoring}
onSelect={(id) => void restoreSession(id)}
/>
)}
{messages.map((m, i) => (
<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>
)
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minWidth: 0 }}>
{/* Messages */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
border: '1px solid var(--border-color, #2a2a2a)',
borderRadius: 8,
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>
)}
{m.tools.map((t) => (
<ToolCard key={t.id} tool={t} />
{messages.map((m, i) => (
<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 style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>思考中…</div>
</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>
{/* Approval modal */}
@@ -417,51 +585,96 @@ export default function AgentWorkbench() {
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
/>
)}
</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>
function HistoryRail({
sessions,
activeId,
disabled,
onSelect,
}: {
sessions: SessionSummary[];
activeId: string | null;
disabled: boolean;
onSelect: (id: string) => void;
}) {
return (
<div
style={{
width: 240,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--border-color, #2a2a2a)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<div
style={{
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
color: 'var(--text-secondary, #888)',
borderBottom: '1px solid var(--border-color, #2a2a2a)',
}}
>
历史会话 {sessions.length > 0 && `(${sessions.length})`}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{sessions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--text-secondary, #888)', textAlign: 'center', padding: '20px 8px' }}>
还没有会话记录
</div>
) : (
sessions.map((s) => {
const active = s.id === activeId;
return (
<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>