feat(agent): 前端补齐 P0(AskUser 问答 UI + 停止按钮)

- AskModal: agent 调用 AskUserQuestion 时弹出问答框(多选 options +
  自由文本),提交走 POST /api/agent/answer;ask/ask_resolved SSE
  事件接入 handleEvent。
- 停止按钮: AbortController 绑定 chat fetch,abort 即 drop SSE
  stream → 后端 CancelGuard 触发 CancellationToken 取消 agent loop;
  区分 [已停止] 与 [连接中断]。
This commit is contained in:
boos
2026-06-23 16:14:39 +08:00
parent c177e0ce77
commit d8011e2959
+188 -1
View File
@@ -31,6 +31,12 @@ type ApprovalPrompt = {
risk: string; risk: string;
}; };
type AskPrompt = {
requestId: string;
question: string;
options: string[];
};
type SessionUsage = { type SessionUsage = {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
@@ -88,11 +94,16 @@ export default function AgentWorkbench() {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [usage, setUsage] = useState<SessionUsage | null>(null); const [usage, setUsage] = useState<SessionUsage | null>(null);
const [approval, setApproval] = useState<ApprovalPrompt | null>(null); const [approval, setApproval] = useState<ApprovalPrompt | null>(null);
const [ask, setAsk] = useState<AskPrompt | null>(null);
// 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);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, approval]); }, [messages, approval, ask]);
const authToken = useCallback( const authToken = useCallback(
() => getSessionToken(audience, localStorage) ?? '', () => getSessionToken(audience, localStorage) ?? '',
@@ -132,6 +143,25 @@ export default function AgentWorkbench() {
[authToken, toast], [authToken, toast],
); );
const submitAnswer = useCallback(
async (requestId: string, answer: string) => {
setAsk(null);
try {
await fetch('/api/agent/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken()}`,
},
body: JSON.stringify({ request_id: requestId, answer }),
});
} catch {
toast.error('提交回答失败');
}
},
[authToken, toast],
);
const handleEvent = useCallback( const handleEvent = useCallback(
(e: SseEvent) => { (e: SseEvent) => {
const d = e.data ?? {}; const d = e.data ?? {};
@@ -179,6 +209,19 @@ export default function AgentWorkbench() {
risk: String(d.risk ?? ''), risk: String(d.risk ?? ''),
}); });
break; break;
case 'ask':
setAsk({
requestId: String(d.request_id ?? ''),
question: String(d.question ?? ''),
options: Array.isArray(d.options)
? (d.options as unknown[]).map((o) => String(o))
: [],
});
break;
case 'ask_resolved':
// Agent moved on (answered elsewhere, or timed out) — drop the modal.
setAsk((cur) => (cur && cur.requestId === d.request_id ? null : cur));
break;
case 'result': case 'result':
setUsage({ setUsage({
inputTokens: Number(d.input_tokens ?? 0), inputTokens: Number(d.input_tokens ?? 0),
@@ -212,6 +255,7 @@ export default function AgentWorkbench() {
setRunning(true); setRunning(true);
setApproval(null); setApproval(null);
setAsk(null);
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ role: 'user', text: prompt, tools: [] }, { role: 'user', text: prompt, tools: [] },
@@ -219,6 +263,8 @@ export default function AgentWorkbench() {
]); ]);
setInput(''); setInput('');
const controller = new AbortController();
abortRef.current = controller;
try { try {
const response = await fetch('/api/agent/chat', { const response = await fetch('/api/agent/chat', {
method: 'POST', method: 'POST',
@@ -231,6 +277,7 @@ export default function AgentWorkbench() {
session_id: sessionId, session_id: sessionId,
permission_mode: mode, permission_mode: mode,
}), }),
signal: controller.signal,
}); });
if (!response.ok) { if (!response.ok) {
const detail = await response.text().catch(() => ''); const detail = await response.text().catch(() => '');
@@ -238,22 +285,36 @@ export default function AgentWorkbench() {
} }
await readSse(response, handleEvent); await readSse(response, handleEvent);
} catch (err) { } catch (err) {
if (controller.signal.aborted) {
// User pressed stop — not an error.
updateAssistant((m) => {
m.text += '\n\n[已停止]';
});
} else {
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`); toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
updateAssistant((m) => { updateAssistant((m) => {
m.text += '\n\n[连接中断]'; m.text += '\n\n[连接中断]';
}); });
}
} finally { } finally {
abortRef.current = null;
setRunning(false); setRunning(false);
setApproval(null); setApproval(null);
setAsk(null);
} }
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]); }, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
const stop = useCallback(() => {
abortRef.current?.abort();
}, []);
const newSession = useCallback(() => { const newSession = useCallback(() => {
if (running) return; if (running) return;
setMessages([]); setMessages([]);
setSessionId(null); setSessionId(null);
setUsage(null); setUsage(null);
setApproval(null); setApproval(null);
setAsk(null);
}, [running]); }, [running]);
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 12, padding: 16 }}>
@@ -340,6 +401,14 @@ export default function AgentWorkbench() {
/> />
)} )}
{/* 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
@@ -370,6 +439,21 @@ export default function AgentWorkbench() {
> >
{running ? '运行中…' : '发送'} {running ? '运行中…' : '发送'}
</button> </button>
{running && (
<button
onClick={stop}
style={{
padding: '0 16px',
borderRadius: 8,
background: '#ef4444',
color: '#fff',
border: 'none',
cursor: 'pointer',
}}
>
停止
</button>
)}
</div> </div>
</div> </div>
); );
@@ -480,3 +564,106 @@ function ApprovalModal({
</div> </div>
); );
} }
function AskModal({
prompt,
onSubmit,
}: {
prompt: AskPrompt;
onSubmit: (answer: string) => void;
}) {
const [selected, setSelected] = useState<string[]>([]);
const [freeText, setFreeText] = useState('');
const toggle = (opt: string) => {
setSelected((cur) =>
cur.includes(opt) ? cur.filter((o) => o !== opt) : [...cur, opt],
);
};
// Combine chosen options and any free-form text into one reply.
const compose = (): string => {
const parts: string[] = [];
if (selected.length > 0) parts.push(selected.join(', '));
const extra = freeText.trim();
if (extra) parts.push(extra);
return parts.join('\n');
};
const answer = compose();
const canSubmit = answer.length > 0;
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 }}>Agent 提问</h3>
<p style={{ margin: '0 0 12px', fontSize: 14, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{prompt.question}
</p>
{prompt.options.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
{prompt.options.map((opt) => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggle(opt)}
/>
<span>{opt}</span>
</label>
))}
</div>
)}
<textarea
value={freeText}
onChange={(e) => setFreeText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
e.preventDefault();
onSubmit(answer);
}
}}
placeholder={prompt.options.length > 0 ? '补充说明(可选)…' : '输入你的回答…'}
rows={3}
style={{ width: '100%', boxSizing: 'border-box', padding: 10, borderRadius: 8, resize: 'vertical', fontFamily: 'inherit', fontSize: 14 }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button
onClick={() => onSubmit(answer)}
disabled={!canSubmit}
style={{
padding: '8px 16px',
borderRadius: 8,
background: canSubmit ? 'var(--primary, #4a9eff)' : 'var(--border-color, #333)',
color: '#fff',
border: 'none',
cursor: canSubmit ? 'pointer' : 'default',
}}
>
提交回答
</button>
</div>
</div>
</div>
);
}