feat(agent): add Agent Workbench frontend panel

A minimal chat UI for the server-side agent, wired to the SSE engine:

- Streams assistant text incrementally and renders each tool call as a card
  showing the tool name, input and (color-coded) output or denial reason.
- Surfaces interactive approval prompts: when the agent requests a risky
  tool, a modal shows the tool and its arguments and posts the user's
  decision to /api/agent/approve, unblocking the loop.
- Permission-mode selector (standard review / accept edits / full access),
  new-session control, and a live session usage bar (turns, tokens, cost).

Registered as /agent-workbench (admin) and /user/agent-workbench (user) in
the SPA router and sidebar. Typecheck and vite build pass.
This commit is contained in:
boos
2026-06-22 06:19:05 +08:00
parent 2506713fad
commit f0a76fd82c
2 changed files with 487 additions and 0 deletions
+5
View File
@@ -42,6 +42,7 @@ const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js')); const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js')); const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const AgentWorkbench = lazy(() => import('./pages/AgentWorkbench.js'));
const PictureAgent = lazy(() => import('./pages/PictureAgent.js')); const PictureAgent = lazy(() => import('./pages/PictureAgent.js'));
const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js')); const DigitalHuman = lazy(() => import('./pages/DigitalHuman.js'));
const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js')); const MoneyPrinter = lazy(() => import('./pages/MoneyPrinter.js'));
@@ -626,6 +627,7 @@ export const sidebarGroups: SidebarGroup[] = [
{ to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman }, { to: '/digital-human', label: '短视频工厂/数字人', icon: navIcons.digitalHuman },
{ to: '/workflows', label: '工作流', icon: navIcons.workflow }, { to: '/workflows', label: '工作流', icon: navIcons.workflow },
{ to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant }, { to: '/comfyui-agent', label: '视频助手', icon: navIcons.assistant },
{ to: '/agent-workbench', label: 'AI Agent', icon: navIcons.assistant },
{ to: '/workspace', label: '工作空间', icon: navIcons.workspace }, { to: '/workspace', label: '工作空间', icon: navIcons.workspace },
], ],
}, },
@@ -658,6 +660,7 @@ export const userSidebarGroups = [
{ to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> }, { to: '/user/digital-human', label: '短视频工厂/数字人', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 12a4 4 0 100-8 4 4 0 000 8z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 21a8 8 0 0116 0M17 9l3 2v4l-3 2M7 9l-3 2v4l3 2" /></svg> },
{ to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> }, { to: '/user/workflows', label: '工作流', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> }, { to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/user/agent-workbench', label: 'AI Agent', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
{ to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> }, { to: '/user/workspace', label: '工作空间', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 7a2 2 0 012-2h4l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H6a2 2 0 01-2-2V7z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 13h8M8 16h5" /></svg> },
], ],
}, },
@@ -1667,6 +1670,7 @@ function AppShell() {
<Route path="/workflows" element={<Workflows />} /> <Route path="/workflows" element={<Workflows />} />
<Route path="/video-agent" element={<Navigate to="/workflows" replace />} /> <Route path="/video-agent" element={<Navigate to="/workflows" replace />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/agent-workbench" element={<AgentWorkbench />} />
<Route path="/workspace" element={<Workspace />} /> <Route path="/workspace" element={<Workspace />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
@@ -1690,6 +1694,7 @@ function AppShell() {
<Route path="/user/workflows" element={<Workflows />} /> <Route path="/user/workflows" element={<Workflows />} />
<Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} /> <Route path="/user/video-agent" element={<Navigate to="/user/workflows" replace />} />
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/user/agent-workbench" element={<AgentWorkbench />} />
<Route path="/user/workspace" element={<Workspace />} /> <Route path="/user/workspace" element={<Workspace />} />
<Route path="/user/about" element={<About />} /> <Route path="/user/about" element={<About />} />
+482
View File
@@ -0,0 +1,482 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
// Agent Workbench — a minimal chat UI over POST /api/agent/chat (SSE).
// Streams assistant text, renders tool-call cards, surfaces interactive
// approval prompts for risky tools, and shows running session token/cost.
type PermissionMode = 'default' | 'accept_edits' | 'full_access';
type ToolCall = {
id: string;
name: string;
input: unknown;
result?: string;
isError?: boolean;
denied?: string;
};
type ChatMessage = {
role: 'user' | 'assistant';
text: string;
tools: ToolCall[];
};
type ApprovalPrompt = {
requestId: string;
name: string;
input: unknown;
risk: string;
};
type SessionUsage = {
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
numTurns: number;
};
type SseEvent = { type: string; data?: Record<string, unknown> };
const MODE_LABELS: Record<PermissionMode, string> = {
default: '标准审核',
accept_edits: '自动接受编辑',
full_access: '完全放行',
};
/** Read a `data:`-framed SSE stream, invoking `onEvent` per JSON line. */
async function readSse(response: Response, onEvent: (e: SseEvent) => void): Promise<void> {
const reader = response.body?.getReader();
if (!reader) throw new Error('浏览器无法读取流式响应');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (!data) continue;
try {
onEvent(JSON.parse(data) as SseEvent);
} catch {
/* ignore malformed frame */
}
}
}
}
function formatCost(usd: number): string {
return `$${usd.toFixed(4)}`;
}
export default function AgentWorkbench() {
const toast = useToast();
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [running, setRunning] = useState(false);
const [mode, setMode] = useState<PermissionMode>('default');
const [sessionId, setSessionId] = useState<string | null>(null);
const [usage, setUsage] = useState<SessionUsage | null>(null);
const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, approval]);
const authToken = useCallback(
() => getSessionToken(audience, localStorage) ?? '',
[audience],
);
// Mutate the last assistant message in place as stream events arrive.
const updateAssistant = useCallback((mut: (m: ChatMessage) => void) => {
setMessages((prev) => {
if (prev.length === 0) return prev;
const next = prev.slice();
const last = { ...next[next.length - 1] };
if (last.role !== 'assistant') return prev;
last.tools = last.tools.slice();
mut(last);
next[next.length - 1] = last;
return next;
});
}, []);
const resolveApproval = useCallback(
async (requestId: string, approved: boolean) => {
setApproval(null);
try {
await fetch('/api/agent/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken()}`,
},
body: JSON.stringify({ request_id: requestId, approved }),
});
} catch {
toast.error('提交审批失败');
}
},
[authToken, toast],
);
const handleEvent = useCallback(
(e: SseEvent) => {
const d = e.data ?? {};
switch (e.type) {
case 'session':
if (typeof d.session_id === 'string') setSessionId(d.session_id);
break;
case 'text':
if (typeof d.text === 'string') {
const chunk = d.text;
updateAssistant((m) => {
m.text += chunk;
});
}
break;
case 'tool_use':
updateAssistant((m) => {
m.tools.push({
id: String(d.id ?? ''),
name: String(d.name ?? ''),
input: d.input,
});
});
break;
case 'tool_result':
updateAssistant((m) => {
const tc = m.tools.find((t) => t.id === d.tool_use_id);
if (tc) {
tc.result = String(d.content ?? '');
tc.isError = Boolean(d.is_error);
}
});
break;
case 'tool_denied':
updateAssistant((m) => {
const tc = m.tools.find((t) => t.name === d.name && !t.result && !t.denied);
if (tc) tc.denied = String(d.reason ?? '已拒绝');
});
break;
case 'approval_request':
setApproval({
requestId: String(d.request_id ?? ''),
name: String(d.name ?? ''),
input: d.input,
risk: String(d.risk ?? ''),
});
break;
case 'result':
setUsage({
inputTokens: Number(d.input_tokens ?? 0),
outputTokens: Number(d.output_tokens ?? 0),
totalTokens: Number(d.total_tokens ?? 0),
costUsd: Number(d.cost_usd ?? 0),
numTurns: Number(d.num_turns ?? 0),
});
break;
case 'error':
toast.error(String(d.message ?? '执行出错'));
updateAssistant((m) => {
m.text += `\n\n[错误: ${String(d.message ?? '')}]`;
});
break;
default:
break;
}
},
[toast, updateAssistant],
);
const send = useCallback(async () => {
const prompt = input.trim();
if (!prompt || running) return;
const token = authToken();
if (!token) {
toast.error('未登录或会话已过期');
return;
}
setRunning(true);
setApproval(null);
setMessages((prev) => [
...prev,
{ role: 'user', text: prompt, tools: [] },
{ role: 'assistant', text: '', tools: [] },
]);
setInput('');
try {
const response = await fetch('/api/agent/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
message: prompt,
session_id: sessionId,
permission_mode: mode,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(detail || `HTTP ${response.status}`);
}
await readSse(response, handleEvent);
} catch (err) {
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
updateAssistant((m) => {
m.text += '\n\n[连接中断]';
});
} finally {
setRunning(false);
setApproval(null);
}
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
const newSession = useCallback(() => {
if (running) return;
setMessages([]);
setSessionId(null);
setUsage(null);
setApproval(null);
}, [running]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>AI Agent 工作台</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--text-secondary, #888)' }}>
服务端内置 agent,可读写文件 / 执行命令(沙箱隔离 + 权限审批)
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ fontSize: 12, color: 'var(--text-secondary, #888)' }}>权限模式</label>
<select
value={mode}
disabled={running}
onChange={(e) => setMode(e.target.value as PermissionMode)}
style={{ padding: '4px 8px', borderRadius: 6 }}
>
{(Object.keys(MODE_LABELS) as PermissionMode[]).map((m) => (
<option key={m} value={m}>{MODE_LABELS[m]}</option>
))}
</select>
<button onClick={newSession} disabled={running} style={{ padding: '4px 12px', borderRadius: 6 }}>
新会话
</button>
</div>
</div>
{/* Usage bar */}
{usage && (
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-secondary, #888)', flexWrap: 'wrap' }}>
<span>会话: <code>{sessionId?.slice(0, 8) ?? '—'}</code></span>
<span>轮次: {usage.numTurns}</span>
<span>tokens: {usage.totalTokens} (in {usage.inputTokens} / out {usage.outputTokens})</span>
<span>成本: {formatCost(usage.costUsd)}</span>
</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>
)}
{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 && (
<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>
))}
</div>
{/* Approval modal */}
{approval && (
<ApprovalModal
prompt={approval}
onApprove={() => resolveApproval(approval.requestId, true)}
onDeny={() => resolveApproval(approval.requestId, false)}
/>
)}
{/* 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>
</div>
</div>
);
}
function ToolCard({ tool }: { tool: ToolCall }) {
const border = tool.denied
? '#f59e0b'
: tool.isError
? '#ef4444'
: tool.result
? '#22c55e'
: 'var(--border-color, #2a2a2a)';
return (
<div style={{ border: `1px solid ${border}`, borderRadius: 8, padding: 10, fontSize: 12, background: 'var(--surface, rgba(255,255,255,0.02))' }}>
<div style={{ fontWeight: 600, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>🔧 {tool.name}</span>
{!tool.result && !tool.denied && <span style={{ color: 'var(--text-secondary, #888)' }}>· 运行中</span>}
{tool.denied && <span style={{ color: '#f59e0b' }}>· 已拒绝</span>}
</div>
<pre style={{ margin: '4px 0', whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--text-secondary, #aaa)' }}>
{typeof tool.input === 'string' ? tool.input : JSON.stringify(tool.input, null, 2)}
</pre>
{tool.denied && <div style={{ color: '#f59e0b' }}>{tool.denied}</div>}
{tool.result && (
<pre
style={{
margin: '6px 0 0',
padding: 8,
borderRadius: 6,
background: 'rgba(0,0,0,0.25)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 200,
overflowY: 'auto',
color: tool.isError ? '#ef4444' : 'inherit',
}}
>
{tool.result}
</pre>
)}
</div>
);
}
function ApprovalModal({
prompt,
onApprove,
onDeny,
}: {
prompt: ApprovalPrompt;
onApprove: () => void;
onDeny: () => void;
}) {
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
style={{
background: 'var(--bg, #1a1a1a)',
border: '1px solid var(--border-color, #333)',
borderRadius: 12,
padding: 20,
width: 'min(520px, 92vw)',
maxHeight: '80vh',
overflowY: 'auto',
}}
>
<h3 style={{ margin: '0 0 8px', fontSize: 16 }}>需要审批:{prompt.name}</h3>
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-secondary, #888)' }}>
Agent 请求执行一个 <strong>{prompt.risk}</strong> 级别的工具。请确认参数后决定是否允许。
</p>
<pre
style={{
background: 'rgba(0,0,0,0.3)',
borderRadius: 8,
padding: 12,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 280,
overflowY: 'auto',
}}
>
{typeof prompt.input === 'string' ? prompt.input : JSON.stringify(prompt.input, null, 2)}
</pre>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button onClick={onDeny} style={{ padding: '8px 16px', borderRadius: 8 }}>
拒绝
</button>
<button
onClick={onApprove}
style={{ padding: '8px 16px', borderRadius: 8, background: '#22c55e', color: '#fff', border: 'none' }}
>
允许执行
</button>
</div>
</div>
</div>
);
}