feat(agents): 完成云智能体工作台与交互流程
This commit is contained in:
1 parent
46fbae9dec
commit
64a3cf4934
26 files changed
+1955
-49
No files matched your search
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user