feat(agents): 完善恢复费用与云资源交互

This commit is contained in:
brother7 committed 2026-09-10 22:29:24 +08:00
1 parent f8f39f39de
commit 303f26294e
24 files changed
+1170 -127

No files matched your search

+77 -18
View File
@@ -3,7 +3,7 @@ import { 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 } from '../../../shared/cloud-agents';
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
import { CloudFiles } from './CloudFiles';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -18,12 +18,13 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching',
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, initialThread }: { slug: string; previewRevision?: number; initialThread?: CloudThread }) {
export function CloudChat({ slug, previewRevision, initialThread, initialThreadId, onSchedule }: { slug: string; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
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);
@@ -40,6 +41,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
const generation = useRef(0);
const alive = useRef(true);
const bottom = useRef<HTMLDivElement>(null);
const entry = useRef({ initialThread, initialThreadId });
const mode = previewRevision === undefined ? 'published' : 'preview';
const readHistory = useCallback(async (id: string, expectedGeneration = generation.current) => {
let page = await cloudAgentsApi.call('history', { thread_id: id });
@@ -49,8 +51,10 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
}
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: page.run!.status, unread: false } : item));
? { ...item, run_id: page.run!.agent_run_id, status: !active(page.run!.status) && page.queued_requests?.length ? 'queued' : page.run!.status, unread: false } : item));
if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined);
}, []);
useEffect(() => {
@@ -58,19 +62,31 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
const g = ++generation.current;
const open = async () => {
try {
if (previewRevision !== undefined) {
if (initialThread) await readHistory(initialThread.thread_id, g);
return;
}
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);
const items = page.threads.filter(t => t.mode === mode && (mode !== 'preview' || t.draft_revision === previewRevision));
setThreads(items);
const latest = initialThread ?? items[0];
const pointer = recent?.slug === slug && recent.mode === mode
&& (mode !== 'preview' || recent.draft_revision === previewRevision) ? 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[0]);
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
&& (mode !== 'preview' || t.draft_revision === previewRevision));
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) {
setThread(latest); setClientId(latest.client_thread_id ?? latest.thread_id);
await readHistory(latest.thread_id, g);
@@ -80,7 +96,15 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
};
void open();
return () => { alive.current = false; generation.current++; };
}, [slug, mode, previewRevision, initialThread, readHistory]);
}, [slug, mode, previewRevision, readHistory]);
useEffect(() => {
if (loading) 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]);
useEffect(() => {
if (!request || request.run_id || !active(request.status)) return;
@@ -102,8 +126,19 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
return () => { live = false; window.clearInterval(timer); };
}, [request]);
const runId = run?.agent_run_id ?? request?.run_id;
const running = active(run?.status ?? request?.status);
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;
@@ -158,7 +193,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
};
const send = async () => {
if (busy || running || (!intent && !query.trim())) return;
if (busy || running || thread?.archived || (!intent && !query.trim())) return;
const input = intent ?? {
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
attachment_file_ids: attachmentIds,
@@ -179,7 +214,7 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
const choose = async (next: CloudThread | null) => {
const g = ++generation.current;
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
setRequest(null); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery('');
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); setQuery('');
setAttachmentIds([]);
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
};
@@ -212,15 +247,39 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
}}>{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 && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
{interrupt && runId && !thread?.archived && <CloudApproval key={runId} interrupt={interrupt} runId={runId} onResumed={nextRun => {
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
}} />}
<div ref={bottom} />
</div>
{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>}
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading} ensureThread={async () => {
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,
@@ -231,11 +290,11 @@ export function CloudChat({ slug, previewRevision, initialThread }: { slug: stri
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
<form className="rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
<Textarea aria-label="消息" className="min-h-20 resize-none border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
value={query} disabled={busy || Boolean(intent) || loading} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)} />
<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 || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && !query.trim())}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
</div>
</form>
</section></CloudPendingContext.Provider>;