feat(agent): add history session sidebar with restore
Agent Workbench 左侧新增历史会话栏:
- 列出调用方过往会话(标题/相对时间/轮次),可折叠
- 点击恢复整段对话(拉 /sessions/{id},回填消息 + 账目 + sessionId)
- 高亮当前会话,运行中禁用切换
- 每轮结束后刷新列表
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user