381 lines
27 KiB
TypeScript
381 lines
27 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { History, MessageSquarePlus, Send, Square } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
|
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
|
|
import { CloudFiles } from './CloudFiles';
|
|
import './desktop-workspace.css';
|
|
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
|
|
|
export const cloudStatus = (status: string) => ({
|
|
idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中',
|
|
running: '运行中', cancel_requested: '正在停止', cancelled: '已停止', completed: '已完成',
|
|
failed: '失败', interrupted: '等待你的确认', submitted: '已提交', rejected: '未执行',
|
|
}[status] ?? status);
|
|
const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status));
|
|
const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作';
|
|
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
|
|
|
|
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true }: { slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
|
|
const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
const [threads, setThreads] = useState<CloudThread[]>([]);
|
|
const [thread, setThread] = useState<CloudThread | null>(initialThread ?? null);
|
|
const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID());
|
|
const [history, setHistory] = useState<CloudHistory | null>(null);
|
|
const [request, setRequest] = useState<CloudRequest | null>(null);
|
|
const [queued, setQueued] = useState<CloudRequest[]>([]);
|
|
const [run, setRun] = useState<CloudRun | null>(null);
|
|
const [query, setQuery] = useState('');
|
|
const [intent, setIntent] = useState<CloudPrompt | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [liveText, setLiveText] = useState('');
|
|
const [progress, setProgress] = useState('');
|
|
const [interrupt, setInterrupt] = useState<CloudInterrupt | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [leaving, setLeaving] = useState(false);
|
|
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
|
const childrenPending = useCloudPendingState();
|
|
usePendingCloudInput(busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending);
|
|
const generation = useRef(0);
|
|
const alive = useRef(true);
|
|
const transcript = useRef<HTMLDivElement>(null);
|
|
const following = useRef(true);
|
|
const entry = useRef({ initialThread, initialThreadId, revision: previewRevision });
|
|
const mode = previewRevision === undefined ? 'published' : 'preview';
|
|
const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => {
|
|
let page = await cloudAgentsApi.call('history', { thread_id: id });
|
|
while (page.next_offset !== null) {
|
|
const more = await cloudAgentsApi.call('history', { thread_id: id, offset: page.next_offset });
|
|
page = { ...more, messages: [...page.messages, ...more.messages] };
|
|
}
|
|
if (!alive.current || expectedGeneration !== generation.current) return;
|
|
setHistory(page); setRun(page.run); setInterrupt(page.run?.interrupt ?? null);
|
|
setQueued(page.queued_requests ?? []);
|
|
setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null);
|
|
if (page.run) setThreads(current => current.map(item => item.thread_id === id
|
|
? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item));
|
|
}, []);
|
|
useEffect(() => {
|
|
alive.current = true;
|
|
const g = ++generation.current;
|
|
const open = async () => {
|
|
try {
|
|
const { recent } = await cloudAgentsApi.recovery();
|
|
let page = await cloudAgentsApi.call('threads', { slug });
|
|
while (page.next_offset !== null) {
|
|
const more = await cloudAgentsApi.call('threads', { slug, offset: page.next_offset });
|
|
page = { ...more, threads: [...page.threads, ...more.threads] };
|
|
}
|
|
if (!alive.current || g !== generation.current) return;
|
|
const items = page.threads.filter(t => t.mode === mode);
|
|
setThreads(items);
|
|
const pointer = recent?.slug === slug && recent.mode === mode ? recent : null;
|
|
const wanted = entry.current.initialThreadId ?? pointer?.thread_id;
|
|
let latest = entry.current.initialThread ?? (wanted ? items.find(t => t.thread_id === wanted) : pointer ? undefined : items.find(t => mode !== 'preview' || t.draft_revision === entry.current.revision));
|
|
if (wanted && !latest) {
|
|
let archived = await cloudAgentsApi.call('threads', { slug, archived: true });
|
|
for (;;) {
|
|
latest = archived.threads.find(t => t.thread_id === wanted && t.mode === mode);
|
|
if (latest || archived.next_offset === null) break;
|
|
archived = await cloudAgentsApi.call('threads', { slug, archived: true, offset: archived.next_offset });
|
|
}
|
|
if (!alive.current || g !== generation.current) return;
|
|
if (latest) setThreads([...items, latest]);
|
|
}
|
|
if (wanted && !latest) setError('上次对话已不可用,可选择其他对话或开始新对话');
|
|
if (latest) {
|
|
if (mode === 'preview') setPreviewRevision(latest.draft_revision ?? entry.current.revision);
|
|
setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id);
|
|
await readHistory(latest.thread_id, g);
|
|
}
|
|
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
|
|
finally { if (alive.current && g === generation.current) setLoading(false); }
|
|
};
|
|
void open();
|
|
return () => { alive.current = false; generation.current++; };
|
|
}, [slug, mode, readHistory]);
|
|
|
|
useEffect(() => {
|
|
if (loading || !activeView) return;
|
|
let live = true;
|
|
void cloudAgentsApi.remember({ slug, mode, thread_id: thread?.thread_id, draft_revision: previewRevision })
|
|
.catch(e => { if (live) setError(message(e)); });
|
|
return () => { live = false; };
|
|
}, [slug, mode, thread?.thread_id, previewRevision, loading, activeView]);
|
|
|
|
useEffect(() => {
|
|
if (activeView && thread && run) void cloudAgentsApi.call('viewed', { thread_id: thread.thread_id, run_id: run.agent_run_id }).catch(() => undefined);
|
|
}, [activeView, thread, run]);
|
|
|
|
useEffect(() => {
|
|
if (!request || request.run_id || !active(request.status)) return;
|
|
let live = true;
|
|
const poll = async () => {
|
|
try {
|
|
const next = await cloudAgentsApi.call('request', { request_id: request.request_id });
|
|
if (live) {
|
|
setRequest(next);
|
|
if (next.run_id) {
|
|
const acceptedRun = await cloudAgentsApi.call('run', { run_id: next.run_id });
|
|
if (live) setRun(acceptedRun);
|
|
}
|
|
}
|
|
} catch (e) { if (live) setError(message(e)); }
|
|
};
|
|
const timer = window.setInterval(() => void poll(), 1800);
|
|
void poll();
|
|
return () => { live = false; window.clearInterval(timer); };
|
|
}, [request]);
|
|
|
|
useEffect(() => {
|
|
if (!queued.length || !thread) return;
|
|
const g = generation.current;
|
|
const timer = window.setInterval(() => {
|
|
void readHistory(thread.thread_id, g).catch(e => {
|
|
if (alive.current && generation.current === g) setError(message(e));
|
|
});
|
|
}, 2000);
|
|
return () => window.clearInterval(timer);
|
|
}, [queued.length, thread, readHistory]);
|
|
|
|
const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id;
|
|
const running = active(run?.status) || active(request?.status);
|
|
useEffect(() => {
|
|
if (!runId || !running) return;
|
|
let live = true;
|
|
let source: EventSource | undefined;
|
|
const g = generation.current;
|
|
const accept = (event: MessageEvent<string>) => {
|
|
if (!live || g !== generation.current) return;
|
|
try {
|
|
const envelope = object(JSON.parse(event.data));
|
|
const payload = object(envelope.payload);
|
|
const chunk = object(payload.chunk);
|
|
const targetThread = thread?.thread_id ?? request?.thread_id;
|
|
if (envelope.thread_id && targetThread && envelope.thread_id !== targetThread) return;
|
|
for (const item of Array.isArray(payload.items) ? payload.items : [chunk]) {
|
|
const semantic = object(object(item).stream_event);
|
|
if (semantic.type === 'message_delta' && typeof semantic.content === 'string') setLiveText(v => v + semantic.content);
|
|
if (semantic.type === 'tool_call' && typeof semantic.name === 'string') setProgress('正在使用 ' + semantic.name);
|
|
}
|
|
if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt);
|
|
if (event.type === 'end') {
|
|
source?.close();
|
|
setLiveText(''); setProgress('');
|
|
const id = thread?.thread_id ?? request?.thread_id;
|
|
if (id) void readHistory(id, g).catch(e => { if (live) setError(message(e)); });
|
|
}
|
|
} catch { setError('运行事件读取失败,正在重新读取状态'); }
|
|
};
|
|
cloudAgentsApi.events(runId).then(stream => {
|
|
if (!live) { stream.close(); return; }
|
|
source = stream;
|
|
for (const name of ['metadata', 'messages', 'custom', 'interrupt', 'end']) stream.addEventListener(name, accept as EventListener);
|
|
stream.onerror = () => { if (live) setProgress('连接中断,正在重连…'); };
|
|
stream.onopen = () => { if (live) setProgress(''); };
|
|
}).catch(e => { if (live) setError(message(e)); });
|
|
// Durable state is also read after an event stream expires or its terminal event was missed.
|
|
const timer = window.setInterval(() => {
|
|
cloudAgentsApi.call('run', { run_id: runId }).then(next => {
|
|
if (!live || g !== generation.current) return;
|
|
setRun(next);
|
|
if (!active(next.status)) {
|
|
source?.close(); setLiveText(''); setProgress(''); setInterrupt(next.interrupt ?? null);
|
|
void readHistory(next.thread_id, g).catch(e => { if (live) setError(message(e)); });
|
|
}
|
|
}).catch(e => { if (live) setError(message(e)); });
|
|
}, 5000);
|
|
return () => { live = false; source?.close(); window.clearInterval(timer); };
|
|
}, [runId, running, thread?.thread_id, request?.thread_id, readHistory]);
|
|
useEffect(() => { if (following.current && transcript.current) transcript.current.scrollTop = transcript.current.scrollHeight; }, [history?.messages.length, liveText, interrupt]);
|
|
const stalePreview = previewRevision !== undefined && latestRevision !== undefined && previewRevision !== latestRevision;
|
|
|
|
const rememberThread = (next: CloudThread) => {
|
|
setThread(next);
|
|
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
|
|
};
|
|
const send = async () => {
|
|
if (busy || running || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))) return;
|
|
const input = intent ?? {
|
|
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
|
|
attachment_file_ids: attachmentIds,
|
|
...(previewRevision === undefined ? {} : { expected_revision: previewRevision }),
|
|
};
|
|
following.current = true;
|
|
setBusy(true); setIntent(input); setError('');
|
|
const g = generation.current;
|
|
try {
|
|
const accepted = await cloudAgentsApi.call(previewRevision === undefined ? 'submit' : 'preview', { slug, ...input });
|
|
if (!alive.current || g !== generation.current) return;
|
|
setRequest(accepted); setIntent(null); setQuery(''); setLiveText(''); setAttachmentIds([]);
|
|
rememberThread({ thread_id: accepted.thread_id, client_thread_id: clientId, agent_slug: slug, title: input.query.slice(0, 80),
|
|
mode, draft_revision: previewRevision, updated_at: new Date().toISOString(), run_id: accepted.run_id, status: accepted.status, unread: false });
|
|
await readHistory(accepted.thread_id, g);
|
|
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
|
|
finally { if (alive.current && g === generation.current) setBusy(false); }
|
|
};
|
|
const choose = async (next: CloudThread | null, keepQuery = false) => {
|
|
following.current = true;
|
|
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? latestRevision);
|
|
const g = ++generation.current;
|
|
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
|
|
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery('');
|
|
setAttachmentIds([]);
|
|
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
|
|
};
|
|
const cancel = async () => {
|
|
setBusy(true); setError('');
|
|
try {
|
|
if (request) await cloudAgentsApi.call('cancelRequest', { request_id: request.request_id });
|
|
else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId });
|
|
} catch (e) { setError(message(e)); } finally { setBusy(false); }
|
|
};
|
|
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
|
<div className="agent-chat-toolbar"><div className="flex min-h-12 shrink-0 items-center justify-between gap-3 border-b border-border py-2">
|
|
<div className="min-w-0 text-sm">{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision}
|
|
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div>
|
|
<div className="flex shrink-0"><Button variant="ghost" size="icon" aria-label="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History className="h-4 w-4" /></Button>
|
|
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>{headerAction}</div>
|
|
</div>
|
|
{showHistory && threads.length === 0 && <p className="py-2 text-xs text-muted-foreground">还没有历史对话</p>}
|
|
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
|
|
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
|
|
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.mode === 'preview' ? `修订 ${t.draft_revision ?? previewRevision} · ` : ''}{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
|
|
</select>}
|
|
{stalePreview && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-xs leading-5">
|
|
<p>配置已更新到修订 {latestRevision},当前试用仍保留修订 {previewRevision}。</p>
|
|
<Button title="保留未发送文字,已有对话可从历史中查看" size="sm" variant="outline" disabled={!previewReady || busy || loading || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending}
|
|
onClick={() => void choose(null, true)}>用最新草稿试用</Button>
|
|
{(Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending) && <p className="mt-1 text-muted-foreground">请先完成本次重试或确认,并取消附件选择,再开始新试用。</p>}
|
|
{!previewReady && <p className="text-muted-foreground">当前草稿尚未选择模型。选择模型并保存后,可开始新试用。</p>}
|
|
</div>}
|
|
</div>
|
|
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
|
|
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="space-y-5 py-5 pr-2">
|
|
{loading ? <p role="status" className="text-sm text-muted-foreground">正在读取对话…</p>
|
|
: !history?.messages.length && !liveText && <div className="py-12 text-center text-sm leading-7 text-muted-foreground">告诉它你想完成什么。<br />关闭客户端后,已提交的任务仍在云端继续。</div>}
|
|
{history?.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
|
|
<p className="mb-2 text-xs text-muted-foreground">{item.role === 'user' ? '你' : '智能体'}</p>
|
|
<div className="prose prose-sm max-w-none break-words dark:prose-invert"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
|
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) && thread
|
|
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(thread.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => setError(message(e)))}>{children}</button>
|
|
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
|
}}>{item.content}</ReactMarkdown></div>
|
|
</article>)}
|
|
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
|
{onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm">
|
|
<h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p>
|
|
<p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
|
|
<Button variant="outline" disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onClick={() => onSchedule(proposal)}>查看并配置自动任务</Button>
|
|
</article>)}
|
|
{run?.error && run.status === 'failed' && <p role="alert" className="text-sm text-destructive">{run.error.message}</p>}
|
|
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
|
|
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
|
|
}} />}
|
|
|
|
{queued.length > 0 && <div className="mb-3 space-y-2 rounded-lg bg-muted/40 p-3 text-sm">
|
|
<p>还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。</p>
|
|
{queued.map((item, index) => <div key={item.request_id} className="flex items-center justify-between">
|
|
<span>排队 {index + 1} · 版本 {item.version}</span>
|
|
<Button variant="ghost" size="sm" disabled={busy} onClick={async () => {
|
|
setBusy(true);
|
|
try { await cloudAgentsApi.call('cancelRequest', { request_id: item.request_id }); await readHistory(item.thread_id); }
|
|
catch (e) { setError(message(e)); } finally { setBusy(false); }
|
|
}}>取消排队</Button>
|
|
</div>)}
|
|
</div>}
|
|
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
|
|
{thread?.archived && <div className="mb-3 flex items-center justify-between gap-3 rounded-lg bg-muted/40 p-3 text-sm">
|
|
<span>此对话已归档,恢复后可继续发送消息。</span>
|
|
<Button variant="outline" disabled={busy} onClick={async () => {
|
|
setBusy(true); setError('');
|
|
try { await cloudAgentsApi.call('archiveThread', { thread_id: thread.thread_id, archived: false }); rememberThread({ ...thread, archived: false }); }
|
|
catch (e) { setError(message(e)); } finally { setBusy(false); }
|
|
}}>恢复对话</Button>
|
|
</div>}
|
|
</div></div>
|
|
<div className="agent-chat-files"><CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
|
|
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading || Boolean(thread?.archived)} ensureThread={async () => {
|
|
if (thread) return thread.thread_id;
|
|
const created = await cloudAgentsApi.call('createThread', { slug, thread_id: clientId, preview: previewRevision !== undefined, expected_revision: previewRevision });
|
|
rememberThread({ thread_id: created.thread_id, client_thread_id: clientId, agent_slug: slug, title: '新对话', mode, draft_revision: previewRevision,
|
|
status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false });
|
|
return created.thread_id;
|
|
}} /></div>
|
|
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span>放弃未发送的内容并新建对话?</span>
|
|
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
|
|
<form data-testid="cloud-chat-composer" className="agent-chat-composer rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
|
|
<Textarea aria-label="消息" className="h-20 min-h-20 max-h-40 resize-y border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
|
|
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); void send(); } }} />
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
|
|
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
|
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
|
</div>
|
|
</form>
|
|
</section></CloudPendingContext.Provider>;
|
|
}
|
|
|
|
export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: {
|
|
interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void;
|
|
submitDecision?: (input: { operation_id: string; decision: Record<string, unknown> }) => Promise<void>;
|
|
}) {
|
|
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
|
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
|
const [error, setError] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [intent, setIntent] = useState<{ operation_id: string; decision: Record<string, unknown> } | null>(null);
|
|
const actions = Array.isArray(interrupt.approval?.action_requests) ? interrupt.approval.action_requests : [];
|
|
usePendingCloudInput(busy || Boolean(intent) || Object.values(answers).some(value => value.length > 0) || Object.values(freeText).some(value => value.trim()));
|
|
const questions = (interrupt.questions ?? []).map((item, index) => {
|
|
const q = object(item);
|
|
return {
|
|
id: String(q.question_id ?? 'q-' + (index + 1)), text: String(q.question ?? ''),
|
|
multiple: q.multi_select === true, allowOther: q.allow_other !== false,
|
|
options: (Array.isArray(q.options) ? q.options : []).map(value => typeof value === 'string'
|
|
? { value, label: value } : { value: String(object(value).value ?? object(value).label ?? ''), label: String(object(value).label ?? object(value).value ?? '') }),
|
|
};
|
|
});
|
|
const complete = questions.length > 0 && questions.every(q => (freeText[q.id]?.trim() && q.allowOther) || answers[q.id]?.length);
|
|
const submit = async (decision: Record<string, unknown>) => {
|
|
const input = intent ?? { operation_id: crypto.randomUUID(), decision };
|
|
setIntent(input); setBusy(true); setError('');
|
|
try {
|
|
if (submitDecision) await submitDecision(input);
|
|
else {
|
|
const result = await cloudAgentsApi.call('resume', { run_id: runId, ...input });
|
|
onResumed?.(await cloudAgentsApi.call('run', { run_id: result.run_id }));
|
|
}
|
|
}
|
|
catch(e) { setError(message(e)); } finally { setBusy(false); }
|
|
};
|
|
return <div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
|
<h3 className="font-medium">需要你的确认</h3>
|
|
{actions.length ? <>{actions.map((action, i) => <pre key={i} className="max-h-36 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(action, null, 2)}</pre>)}
|
|
<div className="flex gap-2"><Button disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'approve' })) })}>允许执行</Button>
|
|
<Button variant="outline" disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'reject', message: '用户拒绝执行' })) })}>拒绝</Button></div></>
|
|
: <>{questions.map(q => <fieldset key={q.id} disabled={busy || Boolean(intent)} className="space-y-2">
|
|
<legend className="mb-2 font-medium">{q.text}</legend>
|
|
{q.options.map(option => <label key={option.value} className="flex min-h-10 items-center gap-2">
|
|
<input type={q.multiple ? 'checkbox' : 'radio'} name={q.id} checked={answers[q.id]?.includes(option.value) ?? false}
|
|
onChange={e => { setFreeText(v => ({ ...v, [q.id]: '' })); setAnswers(v => ({ ...v, [q.id]: q.multiple
|
|
? e.target.checked ? [...(v[q.id] ?? []), option.value] : (v[q.id] ?? []).filter(item => item !== option.value) : [option.value] })); }} />
|
|
{option.label}</label>)}
|
|
{q.allowOther && <Textarea aria-label={q.text + ':补充回答'} placeholder="也可以填写自己的回答" value={freeText[q.id] ?? ''}
|
|
onChange={e => setFreeText(v => ({ ...v, [q.id]: e.target.value }))} />}
|
|
</fieldset>)}
|
|
<Button disabled={busy || Boolean(intent) || !complete} onClick={() => void submit(Object.fromEntries(questions.map(q => [
|
|
q.id, freeText[q.id]?.trim() ? { type: 'other', text: freeText[q.id].trim(), selected: answers[q.id] ?? [] }
|
|
: q.multiple ? answers[q.id] : answers[q.id]?.[0],
|
|
])))}>提交回答</Button></>}
|
|
{intent && !busy && <Button variant="outline" onClick={() => void submit(intent.decision)}>重试本次确认</Button>}
|
|
{error && <p role="alert" className="text-destructive">{error}</p>}
|
|
</div>;
|
|
}
|