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;
|
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) {
|
||||||
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
if (controller.signal.aborted) {
|
||||||
updateAssistant((m) => {
|
// User pressed stop — not an error.
|
||||||
m.text += '\n\n[连接中断]';
|
updateAssistant((m) => {
|
||||||
});
|
m.text += '\n\n[已停止]';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(`请求失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
updateAssistant((m) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user