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:
@@ -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 />} />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user