feat(agents): 完善恢复费用与云资源交互
This commit is contained in:
1 parent
f8f39f39de
commit
303f26294e
24 files changed
+1170
-127
No files matched your search
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user