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:
@@ -31,6 +31,12 @@ type ApprovalPrompt = {
|
||||
risk: string;
|
||||
};
|
||||
|
||||
type AskPrompt = {
|
||||
requestId: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
};
|
||||
|
||||
type SessionUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -88,11 +94,16 @@ export default function AgentWorkbench() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [usage, setUsage] = useState<SessionUsage | 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);
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages, approval]);
|
||||
}, [messages, approval, ask]);
|
||||
|
||||
const authToken = useCallback(
|
||||
() => getSessionToken(audience, localStorage) ?? '',
|
||||
@@ -132,6 +143,25 @@ export default function AgentWorkbench() {
|
||||
[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(
|
||||
(e: SseEvent) => {
|
||||
const d = e.data ?? {};
|
||||
@@ -179,6 +209,19 @@ export default function AgentWorkbench() {
|
||||
risk: String(d.risk ?? ''),
|
||||
});
|
||||
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':
|
||||
setUsage({
|
||||
inputTokens: Number(d.input_tokens ?? 0),
|
||||
@@ -212,6 +255,7 @@ export default function AgentWorkbench() {
|
||||
|
||||
setRunning(true);
|
||||
setApproval(null);
|
||||
setAsk(null);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: 'user', text: prompt, tools: [] },
|
||||
@@ -219,6 +263,8 @@ export default function AgentWorkbench() {
|
||||
]);
|
||||
setInput('');
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const response = await fetch('/api/agent/chat', {
|
||||
method: 'POST',
|
||||
@@ -231,6 +277,7 @@ export default function AgentWorkbench() {
|
||||
session_id: sessionId,
|
||||
permission_mode: mode,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
@@ -238,22 +285,36 @@ export default function AgentWorkbench() {
|
||||
}
|
||||
await readSse(response, handleEvent);
|
||||
} catch (err) {
|
||||
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
updateAssistant((m) => {
|
||||
m.text += '\n\n[连接中断]';
|
||||
});
|
||||
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)}`);
|
||||
updateAssistant((m) => {
|
||||
m.text += '\n\n[连接中断]';
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setRunning(false);
|
||||
setApproval(null);
|
||||
setAsk(null);
|
||||
}
|
||||
}, [authToken, handleEvent, input, mode, running, sessionId, toast, updateAssistant]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (running) return;
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
setUsage(null);
|
||||
setApproval(null);
|
||||
setAsk(null);
|
||||
}, [running]);
|
||||
return (
|
||||
<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 */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<textarea
|
||||
@@ -370,6 +439,21 @@ export default function AgentWorkbench() {
|
||||
>
|
||||
{running ? '运行中…' : '发送'}
|
||||
</button>
|
||||
{running && (
|
||||
<button
|
||||
onClick={stop}
|
||||
style={{
|
||||
padding: '0 16px',
|
||||
borderRadius: 8,
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -480,3 +564,106 @@ function ApprovalModal({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user