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 => v && typeof v === 'object' ? v as Record : {}; 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; }) { const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision); const [showHistory, setShowHistory] = useState(false); const [threads, setThreads] = useState([]); const [thread, setThread] = useState(initialThread ?? null); const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID()); const [history, setHistory] = useState(null); const [previousTrial, setPreviousTrial] = useState<{ history: CloudHistory; revision?: number } | null>(null); const [request, setRequest] = useState(null); const [queued, setQueued] = useState([]); const [run, setRun] = useState(null); const [query, setQuery] = useState(''); const [intent, setIntent] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [liveText, setLiveText] = useState(''); const [progress, setProgress] = useState(''); const [interrupt, setInterrupt] = useState(null); const [loading, setLoading] = useState(true); const [leaving, setLeaving] = useState(false); const [attachmentIds, setAttachmentIds] = useState([]); 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(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) => { 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

{previewRevision === undefined ? '和助手聊一聊' : '试一试'}

{previewRevision !== undefined &&

当前试用 · 修订 {previewRevision}

}

由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}

{headerAction}
{showHistory && threads.length === 0 &&

还没有历史对话

} {showHistory && threads.length > 0 && } {(stalePreview || hasUnsavedChanges) &&

{hasUnsavedChanges ? '要求改好了?保存后再试一次,看看回答有什么不同。' : '要求已保存,重新试用就会使用最新修改。'}

{trialPending &&

先完成当前试用或确认操作,并取消附件选择,再重新试用。

} {!previewReady &&

当前草稿尚未选择模型。选择模型并保存后,可开始新试用。

}
}
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?
: !history?.messages.length && !liveText &&

{assistantName}

{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}

{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}

{examples.map((example, index) => )}

点选只会填入输入框,由你决定何时发送。

} {previousTrial &&
对比上一次试用 · 修订 {previousTrial.revision}

这是修改前的回答。用同一个问题再问一次,更容易看出变化。

} {history && } {liveText &&

{assistantName}

{liveText}
} {onSchedule && history?.schedule_proposals?.map(proposal =>

自动任务建议 · {proposal.name}

{proposal.prompt}

{proposal.cron_expression} · {proposal.timezone} · 尚未启用

)} {run?.error && run.status === 'failed' &&

{run.error.message}

} {interrupt && runId && !thread?.archived && { setInterrupt(null); setLiveText(''); setRun(nextRun); setRequest(null); }} />} {queued.length > 0 &&

还有 {queued.length} 条请求排队中,关闭客户端后仍会继续。

{queued.map((item, index) =>
排队 {index + 1} · 版本 {item.version}
)}
} {error &&

{error}

} {thread?.archived &&
此对话已归档,恢复后可继续发送消息。
}
{ 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; }} />
{leaving &&
放弃未发送的内容并新建对话?
}
{ e.preventDefault(); void send(); }}>