feat(agents): 完成云智能体工作台与交互流程

This commit is contained in:
brother7 committed 2026-09-10 19:25:12 +08:00
1 parent 46fbae9dec
commit 64a3cf4934
26 files changed
+1955 -49

No files matched your search

+293
View File
@@ -0,0 +1,293 @@
import { useCallback, useEffect, useRef, useState } from 'react';
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 { CloudFiles } from './CloudFiles';
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, initialThread }: { slug: string; previewRevision?: number; initialThread?: CloudThread }) {
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 [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 bottom = useRef<HTMLDivElement>(null);
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);
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));
if (page.run) void cloudAgentsApi.call('viewed', { thread_id: id, run_id: page.run.agent_run_id }).catch(() => undefined);
}, []);
useEffect(() => {
alive.current = true;
const g = ++generation.current;
const open = async () => {
try {
if (previewRevision !== undefined) {
if (initialThread) await readHistory(initialThread.thread_id, g);
return;
}
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 latest = initialThread ?? items[0];
if (latest) {
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, previewRevision, initialThread, readHistory]);
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]);
const runId = run?.agent_run_id ?? request?.run_id;
const running = active(run?.status ?? 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(() => { bottom.current?.scrollIntoView?.({ block: 'end' }); }, [history?.messages.length, liveText, interrupt]);
const rememberThread = (next: CloudThread) => {
setThread(next);
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
};
const send = async () => {
if (busy || running || (!intent && !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 }),
};
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, 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) => {
const g = ++generation.current;
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
setRequest(null); 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); } }
};
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="flex min-h-[480px] flex-col rounded-xl bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
<div className="flex min-h-12 items-center justify-between gap-3 border-b border-border pb-3">
<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>
<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>
</div>
{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.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
</select>}
<div className="my-4 max-h-[55vh] min-h-56 flex-1 space-y-5 overflow-y-auto 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>}
{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 => {
setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null);
}} />}
<div ref={bottom} />
</div>
{error && <p role="alert" className="mb-3 text-sm text-destructive">{error}</p>}
<CloudFiles key={clientId} threadId={thread?.thread_id} selected={attachmentIds} onSelected={setAttachmentIds}
onBusy={setBusy} disabled={busy || running || Boolean(intent) || loading} 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,
status: 'idle', updated_at: new Date().toISOString(), run_id: null, unread: false });
return created.thread_id;
}} />
{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 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)} />
<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>}
</div>
</form>
</section></CloudPendingContext.Provider>;
}
function CloudApproval({ interrupt, runId, onResumed }: { interrupt: CloudInterrupt; runId: string; onResumed: (run: CloudRun) => 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 {
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>;
}