442 lines
33 KiB
TypeScript
442 lines
33 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { ArrowUpRight, History, MessageCircle, MessageSquarePlus, Send, Sparkles, 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 { CloudAvatar } from './CloudAvatar';
|
|
import './desktop-workspace.css';
|
|
import './agent-chat.css';
|
|
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
|
import { agentExamples } from './agent-guidance';
|
|
|
|
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,
|
|
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
|
slug: string; assistantName?: string; assistantAvatarUrl?: string | null; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
|
examples?: readonly string[]; hasUnsavedChanges?: boolean; saveDisabled?: boolean; onSaveForPreview?: () => Promise<number | undefined>;
|
|
}) {
|
|
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 [previousTrial, setPreviousTrial] = useState<{ history: CloudHistory; revision?: number } | 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 && (hasUnsavedChanges || 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, revision = latestRevision) => {
|
|
following.current = true;
|
|
if (next) setPreviousTrial(null);
|
|
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision);
|
|
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 trialPending = running || queued.length > 0 || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending;
|
|
const restartTrial = async (saveFirst: boolean) => {
|
|
if (busy || loading || trialPending || (saveFirst ? saveDisabled || !onSaveForPreview : !previewReady)) return;
|
|
setBusy(true);
|
|
try {
|
|
const revision = saveFirst ? await onSaveForPreview!() : latestRevision;
|
|
if (!alive.current || revision === undefined) return;
|
|
if (history?.messages.length) setPreviousTrial({ history, revision: previewRevision });
|
|
const lastQuestion = history?.messages.filter(item => item.role === 'user').at(-1)?.content;
|
|
await choose(null, true, revision);
|
|
if (!query.trim() && lastQuestion) setQuery(lastQuestion);
|
|
} catch (e) { if (alive.current) setError(message(e)); }
|
|
finally { if (alive.current) setBusy(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 agent-chat-styled" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
|
<div className="agent-chat-toolbar"><div className="agent-chat-toolbar-row">
|
|
<div className="agent-chat-toolbar-heading"><span className="agent-chat-toolbar-icon" aria-hidden="true"><MessageCircle /></span><div className="min-w-0"><p className="agent-chat-toolbar-title">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}</p>
|
|
{previewRevision !== undefined && <p className="mt-1 text-xs text-muted-foreground">当前试用 · 修订 {previewRevision}</p>}
|
|
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div></div>
|
|
<div className="agent-chat-toolbar-actions"><Button variant="ghost" aria-label="查看历史对话" title="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History aria-hidden="true" className="h-4 w-4" /><span>历史对话</span></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 aria-hidden="true" className="h-4 w-4" /><span>新对话</span></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="agent-chat-history-select"
|
|
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 || hasUnsavedChanges) && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-sm leading-6">
|
|
<p>{hasUnsavedChanges ? '要求改好了?保存后再试一次,看看回答有什么不同。' : '要求已保存,重新试用就会使用最新修改。'}</p>
|
|
<Button title="保留上次试用和未发送文字,不会自动发送消息" className="min-h-10" variant="outline" disabled={busy || loading || trialPending || (hasUnsavedChanges ? saveDisabled : !previewReady)}
|
|
onClick={() => void restartTrial(hasUnsavedChanges)}>{hasUnsavedChanges ? '保存并重新试用' : '用最新草稿试用'}</Button>
|
|
{trialPending && <p className="text-xs 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="agent-chat-transcript">
|
|
{loading ? <div role="status" className="agent-chat-loading"><MessageCircle aria-hidden="true" /><p>正在读取对话…</p></div>
|
|
: !history?.messages.length && !liveText && <div className="agent-chat-welcome">
|
|
<div className="agent-chat-welcome-portrait"><CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-welcome-avatar" /><Sparkles aria-hidden="true" className="agent-chat-welcome-spark" /></div>
|
|
<p className="agent-chat-welcome-name">{assistantName}</p>
|
|
<h2>{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}</h2>
|
|
<p className="agent-chat-welcome-description">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
|
|
<div className="agent-chat-examples">{examples.map((example, index) => <Button key={example} type="button" variant="outline"
|
|
className="agent-chat-example" data-tone={index % 3}
|
|
disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived)} onClick={() => setQuery(example)}><span>{example}</span><ArrowUpRight aria-hidden="true" /></Button>)}</div>
|
|
<p className="agent-chat-example-note">点选只会填入输入框,由你决定何时发送。</p>
|
|
</div>}
|
|
{previousTrial && <details className="rounded-lg border border-border p-3 text-sm">
|
|
<summary className="min-h-10 cursor-pointer content-center font-medium">对比上一次试用 · 修订 {previousTrial.revision}</summary>
|
|
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
|
|
<TrialMessages history={previousTrial.history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />
|
|
</details>}
|
|
{history && <TrialMessages history={history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
|
|
{liveText && <article className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
|
|
<CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />
|
|
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p><div className="agent-chat-message-body whitespace-pre-wrap">{liveText}</div></div>
|
|
</article>}
|
|
{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" onSubmit={e => { e.preventDefault(); void send(); }}>
|
|
<Textarea aria-label="消息" className="agent-chat-input resize-y border-0 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="agent-chat-composer-footer">
|
|
<div className="agent-chat-composer-meta"><p role="status" className="agent-chat-status" data-active={running}>{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p><span className="agent-chat-keyboard-hint">Enter 发送 · Shift + Enter 换行</span></div>
|
|
{running ? <Button className="agent-chat-stop" size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
|
: <Button className="agent-chat-send" size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
|
</div>
|
|
</form>
|
|
</section></CloudPendingContext.Provider>;
|
|
}
|
|
|
|
function TrialMessages({ history, onError, assistantName = '助手', assistantAvatarUrl }: {
|
|
history: CloudHistory; onError: (error: string) => void; assistantName?: string; assistantAvatarUrl?: string | null;
|
|
}) {
|
|
return history.messages.map(item => <article key={item.id} className="agent-chat-message" data-role={item.role === 'user' ? 'user' : 'assistant'}>
|
|
{item.role !== 'user' && <CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />}
|
|
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{item.role === 'user' ? '你' : assistantName}</p>
|
|
<div className="agent-chat-message-body prose prose-sm max-w-none break-words"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
|
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
|
|
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(history.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => onError(message(e)))}>{children}</button>
|
|
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
|
}}>{item.content}</ReactMarkdown></div></div>
|
|
</article>);
|
|
}
|
|
|
|
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 ? <>
|
|
<p>助手想执行下面{actions.length > 1 ? '这些操作' : '这个操作'}。看清内容后,再决定是否允许。</p>
|
|
{actions.map((action, i) => {
|
|
const detail = object(action);
|
|
const args = object(detail.args ?? detail.arguments);
|
|
const labels: Record<string, string> = { query: '要查找的内容', url: '网页地址', path: '位置', file_path: '文件位置', content: '内容', command: '要运行的命令' };
|
|
const names: Record<string, string> = { execute: '运行命令', ls: '查看文件列表' };
|
|
return <div key={i} className="space-y-2 rounded-lg border border-amber-200 p-3">
|
|
<p className="break-words font-medium">{actions.length > 1 ? (i + 1) + '. ' : ''}{typeof detail.description === 'string' && detail.description ? detail.description : typeof detail.name === 'string' ? names[detail.name] ?? detail.name : '待确认的操作'}</p>
|
|
{Object.entries(args).map(([key, value]) => <div key={key} className="text-sm"><p className="text-muted-foreground">{labels[key] ?? key}</p>
|
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words font-sans leading-6">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>)}
|
|
<details><summary className="min-h-10 cursor-pointer content-center text-xs text-muted-foreground">查看技术详情</summary><pre className="max-h-40 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(action, null, 2)}</pre></details>
|
|
</div>;
|
|
})}
|
|
<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>;
|
|
}
|