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
+231 -18
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,6 +463,17 @@ export default function AgentWorkbench() {
</div> </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)}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, minWidth: 0 }}>
{/* Messages */} {/* Messages */}
<div <div
ref={scrollRef} ref={scrollRef}
@@ -401,23 +520,6 @@ export default function AgentWorkbench() {
))} ))}
</div> </div>
{/* Approval modal */}
{approval && (
<ApprovalModal
prompt={approval}
onApprove={() => resolveApproval(approval.requestId, true)}
onDeny={() => resolveApproval(approval.requestId, false)}
/>
)}
{/* Ask-user-question modal */}
{ask && (
<AskModal
prompt={ask}
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
/>
)}
{/* Composer */} {/* Composer */}
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<textarea <textarea
@@ -465,6 +567,117 @@ export default function AgentWorkbench() {
)} )}
</div> </div>
</div> </div>
</div>
{/* Approval modal */}
{approval && (
<ApprovalModal
prompt={approval}
onApprove={() => resolveApproval(approval.requestId, true)}
onDeny={() => resolveApproval(approval.requestId, false)}
/>
)}
{/* Ask-user-question modal */}
{ask && (
<AskModal
prompt={ask}
onSubmit={(answer) => submitAnswer(ask.requestId, answer)}
/>
)}
</div>
);
}
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>
); );
} }