import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react"; import { Button, Dropdown, Input } from "antd"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react"; import { saveAs } from "file-saver"; import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export"; import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan"; import { nanoid } from "nanoid"; import { ModelPicker } from "@/components/model-picker"; import { FluidOrb } from "@/components/ui/fluid-orb"; import { cn } from "@/lib/utils"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation"; import { cancelAgentRun, getAgentCapabilities, getAgentProfile, getAgentRun, createAgentRun, decideAgentApproval, sendAgentInterjection, sendAgentMessage, subscribeAgentEvents, updateAgentProfile, type AgentEvent, type AgentPermissionMode, type AgentProfileScope, type AgentProfileView, type AgentReasoningMode, type AgentRun } from "@/services/api/agent"; import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation"; import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval"; import type { AgentMediaSettings } from "@/services/api/agent"; import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings"; import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory } from "@/services/api/skills"; import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations"; import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { createAgentCanvasSync } from "@/services/agent-canvas-sync"; import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime"; import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, AgentWorkingMessage, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui"; import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal"; import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings"; import { useAgentPanelLayout } from "./use-agent-panel-layout"; import "./canvas-cloud-agent.css"; type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void }; type ApprovalState = { approvalId: string; detail: Record; reason: string }; type AgentPanelView = "chat" | "history" | "settings"; export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { const theme = canvasThemes[useActiveTheme()]; const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const reducedMotion = useReducedMotion(); const [view, setView] = useState("chat"); const [run, setRun] = useState(null); const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">("connecting"); const [connectionEpoch, setConnectionEpoch] = useState(0); const [messages, setMessages] = useState([]); const [prompt, setPrompt] = useState(""); const lastPrefillPromptRef = useRef(""); const [reasoningMode, setReasoningMode] = useState("off"); const [profileView, setProfileView] = useState(null); const [profileLoading, setProfileLoading] = useState(false); const [profileSaving, setProfileSaving] = useState(false); const [profileError, setProfileError] = useState(); const profileRequestRef = useRef(0); const [skills, setSkills] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [marketSkills, setMarketSkills] = useState([]); const [skillSearch, setSkillSearch] = useState(""); const [debouncedSkillSearch, setDebouncedSkillSearch] = useState(""); const [skillsLoading, setSkillsLoading] = useState(false); const [skillHasMore, setSkillHasMore] = useState(false); const [skillPage, setSkillPage] = useState(1); const [skillCategories, setSkillCategories] = useState([]); const [skillTag, setSkillTag] = useState("all"); const [skillsOpen, setSkillsOpen] = useState(false); const [busy, setBusy] = useState(false); const [approvalSubmitting, setApprovalSubmitting] = useState(false); const [exporting, setExporting] = useState(false); const [stopping, setStopping] = useState(false); const [approval, setApproval] = useState(null); const [permissionMode, setPermissionMode] = useState("request_approval"); const [contextScope, setContextScope] = useState(["canvas"]); const [maxCredits, setMaxCredits] = useState("200"); const [maxGenerationTasks, setMaxGenerationTasks] = useState("0"); const [maxVideoSeconds, setMaxVideoSeconds] = useState("0"); const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(() => nanoid()); const [historyHydrated, setHistoryHydrated] = useState(false); const [pendingHydrated, setPendingHydrated] = useState(false); const [planMinimized, setPlanMinimized] = useState(false); const planItems = useMemo(() => latestAgentPlanItems(messages), [messages]); const planVisible = agentPlanVisible(planItems); const pendingQuestion = useMemo(() => pendingAgentQuestion(messages), [messages]); const panelLayout = useAgentPanelLayout(); const lastSeqRef = useRef(0); const canvasSyncRef = useRef | null>(null); useEffect(() => { const sync = createAgentCanvasSync({ canvasId, applyPatches: (patches) => applyAgentCanvasPatches(canvasId, patches), refresh: () => refreshCanvasAfterAgent(canvasId), onError: (cause) => setMessages((current) => appendAgentError(current, `canvas-sync-${canvasId}`, cause, "画布同步冲突")), }); canvasSyncRef.current = sync; return () => { sync.dispose(); if (canvasSyncRef.current === sync) canvasSyncRef.current = null; }; }, [canvasId]); const skillPageRequestRef = useRef(false); const approvalRequestRef = useRef(null); const pendingSubmission = useRef(null); const submissionRequestRef = useRef(false); const conversationScope = `${canvasId}:${activeConversationId}`; const currentScope = useRef(conversationScope); currentScope.current = conversationScope; const running = Boolean(run?.cleanupPending) || run?.status === "running" || run?.status === "queued" || run?.status === "waiting_approval"; const selectedModel = useMemo(() => { const textModels = selectableModelsByCapability(config, "text"); const preferred = config.textModel || config.model || ""; return textModels.includes(preferred) ? preferred : (textModels[0] || ""); }, [config]); const reasoningSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking); useEffect(() => { if (!reasoningSupported && reasoningMode !== "off") setReasoningMode("off"); }, [reasoningSupported, reasoningMode]); const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]); const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]); const status = run?.status || "idle"; const statusLabel = status === "waiting_approval" ? "等待审批" : status === "running" || status === "queued" ? "运行中" : status === "completed" ? "已完成" : status === "failed" ? "异常" : status === "cancelled" ? "已停止" : status === "rejected" ? "已拒绝" : "待命"; const statusColor = status === "failed" ? "#e66b6b" : status === "rejected" || status === "cancelled" ? theme.node.muted : status === "waiting_approval" ? "#d6a24a" : status === "running" || status === "queued" ? "#69c29b" : theme.node.muted; useEffect(() => { const value = prefillPrompt?.trim(); if (!value || value === lastPrefillPromptRef.current) return; lastPrefillPromptRef.current = value; setPrompt(value); setView("chat"); }, [prefillPrompt]); useEffect(() => { if (!open || view !== "chat") setSkillsOpen(false); }, [open, view]); const reloadProfile = useCallback(async () => { const requestId = ++profileRequestRef.current; setProfileLoading(true); setProfileError(undefined); setProfileView(null); try { const result = await getAgentProfile({ projectId: domainProjectId, canvasId }); if (profileRequestRef.current === requestId) setProfileView(result); } catch (cause) { if (profileRequestRef.current === requestId) setProfileError(cause instanceof Error ? cause.message : String(cause)); } finally { if (profileRequestRef.current === requestId) setProfileLoading(false); } }, [canvasId, domainProjectId]); useEffect(() => { void reloadProfile(); }, [reloadProfile]); const saveProfile = async (input: { scope: AgentProfileScope; projectId?: string; canvasId?: string; content: string; revision: number }) => { setProfileSaving(true); try { const result = await updateAgentProfile(input); setProfileView(result); setProfileError(undefined); return result; } finally { setProfileSaving(false); } }; useEffect(() => { const timer = window.setTimeout(() => setDebouncedSkillSearch(skillSearch.trim()), 250); return () => window.clearTimeout(timer); }, [skillSearch]); useEffect(() => { let active = true; const refresh = () => { void listAddedSkills() .then((result) => { if (!active) return; setSkills(result.skills); setMessages((current) => current.filter((message) => message.id !== "skills-load-error")); }) .catch((cause) => { if (active) setMessages((current) => appendAgentError(current, "skills-load-error", cause, "技能库读取失败")); }); }; refresh(); window.addEventListener("canvas-skills-changed", refresh); window.addEventListener("focus", refresh); return () => { active = false; window.removeEventListener("canvas-skills-changed", refresh); window.removeEventListener("focus", refresh); }; }, [open]); useEffect(() => { if (view !== "settings" && !skillsOpen) return; let active = true; setSkillsLoading(true); void listSkills({ scope: "public", search: debouncedSkillSearch || undefined, tag: skillsOpen && skillTag !== "all" ? skillTag : undefined, pageSize: 20, sort: "popular", }) .then((result) => { if (active) { setMarketSkills(result.skills); setSkillHasMore(result.hasMore); setSkillPage(result.page); if (result.categories.length > 0) setSkillCategories(result.categories); } }) .catch(() => { if (active) setMarketSkills([]); }) .finally(() => { if (active) setSkillsLoading(false); }); return () => { active = false; }; }, [view, skillsOpen, debouncedSkillSearch, skillTag]); const loadMoreSkills = async () => { if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return; skillPageRequestRef.current = true; setSkillsLoading(true); try { const result = await listSkills({ scope: "public", search: debouncedSkillSearch || undefined, tag: skillsOpen && skillTag !== "all" ? skillTag : undefined, page: skillPage + 1, pageSize: 20, sort: "popular", }); setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]); setSkillPage(result.page); setSkillHasMore(result.hasMore); } finally { skillPageRequestRef.current = false; setSkillsLoading(false); } }; useEffect(() => { let active = true; setHistoryHydrated(false); setPendingHydrated(false); pendingSubmission.current = null; setBusy(false); setConversations([]); setRun(null); setMessages([]); setApproval(null); setApprovalSubmitting(false); approvalRequestRef.current = null; setPrompt(""); void loadCloudAgentConversations(canvasId) .then(async (document) => { if (!active) return; const current = document.conversations.find((conversation) => conversation.id === document.activeId) || document.conversations[0]; setConversations(document.conversations); if (current) { setActiveConversationId(current.id); setMessages(current.messages); setRun(current.run); setPermissionMode(current.permissionMode); setSelectedSkillIds(current.skillIds || []); if (current.model) setModel(current.model); const pending = await loadCloudAgentPendingSubmission(canvasId, current.id); if (!active) return; pendingSubmission.current = pending; if (pending?.request) setPrompt(pending.request.prompt); } else { setActiveConversationId(nanoid()); pendingSubmission.current = null; } setPendingHydrated(true); }) .catch((cause) => { if (!active) return; setMessages((current) => appendAgentError(current, "history-error", cause, "对话恢复失败,已暂停发送;请重新打开对话核对")); }) .finally(() => { if (active) setHistoryHydrated(true); }); return () => { active = false; }; }, [canvasId]); useEffect(() => { if (!historyHydrated || (!messages.length && !run)) return; const now = new Date().toISOString(); setConversations((current) => { const existing = current.find((conversation) => conversation.id === activeConversationId); const next: CloudAgentConversation = { id: activeConversationId, title: cloudAgentConversationTitle(messages), messages, run, model: selectedModel || undefined, permissionMode, skillIds: selectedSkillIds, createdAt: existing?.createdAt || now, updatedAt: now, }; return [next, ...current.filter((conversation) => conversation.id !== activeConversationId)]; }); }, [activeConversationId, historyHydrated, messages, permissionMode, run, selectedModel, selectedSkillIds]); useEffect(() => { if (!historyHydrated) return; const timer = window.setTimeout(() => { void saveCloudAgentConversations(canvasId, activeConversationId, conversations); }, 180); return () => window.clearTimeout(timer); }, [activeConversationId, canvasId, conversations, historyHydrated]); useEffect(() => { if (!run?.id) return; lastSeqRef.current = 0; return subscribeAgentEvents( run.id, (event) => { // Only persisted agent events participate in the replay cursor. // Snapshot-derived UI events intentionally use seq=0. if (event.seq > 0) { if (event.seq <= lastSeqRef.current) return; if (event.seq > lastSeqRef.current + 1) canvasSyncRef.current?.reconcile(); lastSeqRef.current = event.seq; } setMessages((current) => current.filter((item) => item.id !== `stream-error-${run.id}`)); applyAgentEvent(event, setMessages, setRun, setApproval, setPrompt); canvasSyncRef.current?.receive(event); }, { after: 0, onConnectionChange: setConnectionStatus, onError: (cause) => { canvasSyncRef.current?.reconcile(); setMessages((current) => appendAgentError(current, `stream-error-${run.id}`, cause, "Agent 事件流已断开")); // The observation channel failed, not the durable run. Keep // identity and approval so reconnect/cancel/continue remain available. setConnectionStatus("disconnected"); }, }, ); }, [run?.id, connectionEpoch]); useEffect(() => { if (!run?.id || connectionStatus !== "disconnected") return; const reconnect = () => setConnectionEpoch((value) => value + 1); window.addEventListener("online", reconnect); return () => window.removeEventListener("online", reconnect); }, [run?.id, connectionStatus]); const interject = async (value: string) => { const activeRun = run; if (!value || !activeRun?.id || busy || connectionStatus !== "connected" || currentScope.current !== conversationScope || !historyHydrated) return; const scope = conversationScope; const messageId = `user-${crypto.randomUUID()}`; setBusy(true); try { await sendAgentInterjection(activeRun.id, { text: value, messageId }); if (currentScope.current !== scope) return; setPrompt(""); setMessages((current) => appendUniqueMessage(current, { id: messageId, role: "user", text: value, interjection: "sent" })); } catch (cause) { if (currentScope.current !== scope) return; const status = (cause as { status?: number }).status; setMessages((current) => appendAgentError(current, `interject-error-${activeConversationId}-${messageId}`, cause, "插话没有送达")); if (status === 409) setPrompt(value); } finally { if (currentScope.current === scope) setBusy(false); } }; const submit = async (override?: string) => { const value = (override ?? prompt).trim(); if (running) { await interject(value); return; } if (!value || busy || running || (run && connectionStatus !== "connected") || submissionRequestRef.current || !historyHydrated || !pendingHydrated || currentScope.current !== conversationScope) return; const scope = conversationScope; submissionRequestRef.current = true; setBusy(true); let accepted = false; try { const pending = pendingSubmission.current; // An ambiguous previous POST owns its body/key until reconciled. // Editing model settings or prompt must not silently create a new charge. if (pending?.request && pending.request.prompt !== value) throw new Error("上一条请求结果待确认,请先原样重试上一条消息,再发送新要求"); if (!pending?.request) { if (profileLoading) throw new Error("正在确认长期偏好快照,请稍后再发送"); if (!profileView || profileError) throw new Error("长期偏好快照尚未确认,请重新读取后再发送"); const capabilities = await getAgentCapabilities(); if (currentScope.current !== scope) return; if (!capabilities.permissionModes.includes(permissionMode)) throw new Error("当前后端不支持所选 Agent 权限,请更新后端"); if (selectedSkillIds.length && !capabilities.skills) throw new Error("当前后端尚未接入技能库"); await saveRemoteUserDataNow(); if (currentScope.current !== scope) return; const agentConfig = { ...config, model: selectedModel }; const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel); const logicalModelId = logicalModelIDForConfig(agentConfig); const input = { canvasId, prompt: value, reasoningMode: reasoningSupported ? reasoningMode : "off", profileRevision: profileView.revision, model: modelOptionName(selectedModel) || undefined, ...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}), skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])], permissionMode, contextScope, budget: { maxCredits: positiveNumber(maxCredits), maxGenerationTasks: permissionMode === "read_only" ? 0 : Number(maxGenerationTasks), maxVideoSeconds: permissionMode === "read_only" ? 0 : Number(maxVideoSeconds) }, }; const fingerprint = JSON.stringify({ scope, parent: run?.id, input }); if (pending && pending.fingerprint !== fingerprint) throw new Error("上一条请求尚未确认,请恢复原消息与设置后核对,不能覆盖原幂等记录"); const key = pending?.key || crypto.randomUUID(); const next = { fingerprint, key, request: { ...input, idempotencyKey: key }, parentRunId: run?.id, messageId: `user-${key}` }; // Persist before sending. A failed local save must not submit a request // whose recovery identity will disappear on reload. await saveCloudAgentPendingSubmission(canvasId, activeConversationId, next); if (currentScope.current !== scope) return; pendingSubmission.current = next; } const submission = pendingSubmission.current!; const request = submission.request!; const nextMessages = appendUniqueMessage(messages, { id: submission.messageId || `user-${submission.key}`, role: "user", text: request.prompt }); const now = new Date().toISOString(); const existing = conversations.find((item) => item.id === activeConversationId); // Persist a discoverable conversation before POST as well as its key; // otherwise a reload of a brand-new chat can orphan the pending record. await saveCloudAgentConversations(canvasId, activeConversationId, [{ id: activeConversationId, title: cloudAgentConversationTitle(nextMessages), messages: nextMessages, run, model: selectedModel || undefined, permissionMode, skillIds: selectedSkillIds, createdAt: existing?.createdAt || now, updatedAt: now, }, ...conversations.filter((item) => item.id !== activeConversationId)]); if (currentScope.current !== scope) return; setPrompt(""); setMessages(nextMessages); const result = submission.parentRunId ? await sendAgentMessage(submission.parentRunId, request) : await createAgentRun(request); accepted = true; if (currentScope.current === scope) setRun(result.run); await clearCloudAgentPendingSubmission(canvasId, activeConversationId); if (currentScope.current === scope) pendingSubmission.current = null; } catch (cause) { if (currentScope.current !== scope) return; if (!accepted) { setPrompt(value); const status = (cause as { status?: number }).status; if (status && [400, 401, 403, 404, 422].includes(status)) { // These admission responses explicitly rejected the write. // Transport errors and conflicts retain the pending identity. try { await clearCloudAgentPendingSubmission(canvasId, activeConversationId); pendingSubmission.current = null; } catch (storageError) { setMessages((current) => appendAgentError(current, `pending-storage-${activeConversationId}`, storageError, "提交记录更新失败")); } } } setMessages((current) => appendAgentError(current, `submit-error-${activeConversationId}`, cause, agentSubmissionErrorTitle(cause, accepted))); } finally { submissionRequestRef.current = false; if (currentScope.current === scope) setBusy(false); } }; const stop = async () => { const activeRun = run; if (!activeRun?.id || stopping) return; setStopping(true); try { await cancelAgentRun(activeRun.id); const snapshot = await getAgentRun(activeRun.id, AbortSignal.timeout(5_000)); if (currentScope.current === conversationScope) { setRun((current) => current?.id === activeRun.id ? snapshot.run : current); if (!snapshot.run.approval) setApproval(null); setConnectionEpoch((value) => value + 1); } } catch (cause) { if (currentScope.current === conversationScope) { // An ambiguous cancellation is not proof of a terminal run. Keep // the run and approval until the server confirms their state. setConnectionEpoch((value) => value + 1); setMessages((current) => appendAgentError(current, `cancel-${activeRun.id}`, cause, "取消结果未确认,正在重新核对运行状态")); } } finally { setStopping(false); } }; const submitApproval = async (decision: "approve" | "reject", mediaSettings?: AgentMediaSettings) => { if (!run || !approval || connectionStatus !== "connected" || approvalRequestRef.current === approval.approvalId) return; const runId = run.id; const approvalId = approval.approvalId; const scope = conversationScope; approvalRequestRef.current = approvalId; setApprovalSubmitting(true); try { if (decision === "approve") await saveRemoteUserDataNow(); if (currentScope.current !== scope) return; await decideAgentApproval(runId, approvalId, decision, approval.reason, AbortSignal.timeout(15_000), mediaSettings); if (currentScope.current === scope) { setApproval((current) => current?.approvalId === approvalId ? null : current); setRun((current) => current?.id === runId && current.status === "waiting_approval" && (!current.approval || current.approval.approvalId === approvalId) ? { ...current, status: decision === "reject" ? "rejected" : "running", approval: undefined } : current); } } catch (cause) { if (currentScope.current !== scope) return; // A timed-out response is ambiguous: query the durable decision before // asking the user to retry. The backend treats identical decisions idempotently. try { const snapshot = await getAgentRun(runId, AbortSignal.timeout(5_000)); const decided = snapshot.run.events?.some((event) => event.type === "approval_decided" && event.payload.approvalId === approvalId && event.payload.decision === decision && (!mediaSettings || agentApprovalMatchesSettings(event.payload.arguments, mediaSettings))); if (decided) { setApproval((current) => current?.approvalId === approvalId ? null : current); setRun(snapshot.run); return; } } catch { // Preserve the pending approval so the same decision can be retried. } setMessages((current) => appendAgentError(current, `approval-error-${Date.now()}`, cause, "审批未确认,请重试")); } finally { if (approvalRequestRef.current === approvalId) approvalRequestRef.current = null; if (currentScope.current === scope) setApprovalSubmitting(false); } }; const installSkill = async (skill: Skill) => { if (skill.isAdded) return; try { const result = await addSkill(skill.skillId); setSkills((current) => [...current.filter((item) => item.skillId !== skill.skillId), result.skill]); setMarketSkills((current) => current.map((item) => (item.skillId === skill.skillId ? result.skill : item))); setSelectedSkillIds((current) => (current.includes(skill.skillId) ? current : [...current, skill.skillId])); } catch (cause) { setMessages((current) => appendAgentError(current, `skill-error-${Date.now()}`, cause, "添加 Skill 失败")); } }; const setModel = (model: string) => { updateConfig("textModel", model); updateConfig("model", model); }; const newConversation = () => { const id = nanoid(); currentScope.current = `${canvasId}:${id}`; setPendingHydrated(true); setBusy(false); pendingSubmission.current = null; approvalRequestRef.current = null; setApprovalSubmitting(false); setActiveConversationId(id); setRun(null); setMessages([]); setPrompt(""); setApproval(null); lastSeqRef.current = 0; setView("chat"); }; const openConversation = (conversation: CloudAgentConversation) => { currentScope.current = `${canvasId}:${conversation.id}`; setPendingHydrated(false); setBusy(false); pendingSubmission.current = null; approvalRequestRef.current = null; setApprovalSubmitting(false); setActiveConversationId(conversation.id); setRun(conversation.run); setMessages(conversation.messages); setPermissionMode(conversation.permissionMode); setSelectedSkillIds(conversation.skillIds || []); setApproval(null); setPrompt(""); if (conversation.model) setModel(conversation.model); setView("chat"); void loadCloudAgentPendingSubmission(canvasId, conversation.id).then((pending) => { if (currentScope.current === `${canvasId}:${conversation.id}`) { pendingSubmission.current = pending; setPendingHydrated(true); if (pending?.request) setPrompt(pending.request.prompt); } }).catch((cause) => { if (currentScope.current === `${canvasId}:${conversation.id}`) setMessages((current) => appendAgentError(current, `pending-${conversation.id}`, cause, "待确认请求读取失败")); }); }; const deleteConversation = (id: string) => { const next = conversations.filter((item) => item.id !== id); setConversations(next); if (id === activeConversationId) newConversation(); void saveCloudAgentConversations(canvasId, id === activeConversationId ? null : activeConversationId, next); }; return ( <> {!open ? : null} {open ? ( event.stopPropagation()} >
{view === "settings" ? ( setView("chat")} onModelChange={setModel} onPermissionChange={setPermissionMode} reasoningMode={reasoningMode} onReasoningModeChange={setReasoningMode} profileView={profileView} profileLoading={profileLoading} profileSaving={profileSaving} profileError={profileError} projectId={domainProjectId} canvasId={canvasId} onReloadProfile={reloadProfile} onSaveProfile={saveProfile} onContextToggle={(value) => setContextScope((current) => (current.includes(value) ? current.filter((item) => item !== value) : [...current, value]))} onSkillSearch={setSkillSearch} onSkillToggle={(id) => setSelectedSkillIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))} onSkillInstall={installSkill} onLoadMoreSkills={loadMoreSkills} onMaxCreditsChange={setMaxCredits} onMaxGenerationTasksChange={setMaxGenerationTasks} onMaxVideoSecondsChange={setMaxVideoSeconds} /> ) : view === "history" ? ( setView("chat")} onNew={newConversation} onOpen={openConversation} onDelete={deleteConversation} /> ) : ( setView("history")} exporting={exporting} onExport={() => { if (exporting) return; setExporting(true); void (async () => { let snapshot = run; let snapshotError: string | undefined; if (run?.id) { try { snapshot = (await getAgentRun(run.id, AbortSignal.timeout(15_000))).run; } catch (cause) { snapshotError = cause instanceof Error ? cause.message : String(cause); } } const text = buildAgentDebugExport({ canvasId, conversationId: activeConversationId, messages, run: snapshot, approval, model: selectedModel, permissionMode, snapshotError }); saveAs(new Blob([text], { type: "application/json;charset=utf-8" }), `agent-debug-${activeConversationId}-${Date.now()}.json`); })().catch((cause) => setMessages((current) => appendAgentError(current, `export-${Date.now()}`, cause, "导出失败"))).finally(() => setExporting(false)); }} onSettings={() => setView("settings")} onCollapse={onCollapse} /> {run && connectionStatus !== "connected" ? (
{connectionStatus === "disconnected" ? "连接已断开,服务端任务可能仍在执行;运行记录已保留" : "正在连接并校准运行状态…"} {connectionStatus === "disconnected" ? : null}
) : null} setApproval((current) => (current ? { ...current, reason } : current))} onApprove={(settings) => void submitApproval("approve", settings)} onReject={() => void submitApproval("reject")} /> {planVisible ? setPlanMinimized((value) => !value)} /> : null} {pendingQuestion ? ( void submit(label)} /> ) : null} void submit()} onStop={run?.id && running ? stop : undefined} stopping={stopping} references={[...references, ...buildSkillMentionReferences(installedSkills)]} slashSkills={installedSkills} includeAssetLibrary={false} left={ { if (reasoningSupported) setReasoningMode(value); }} config={config} selectedModel={selectedModel} permissionMode={permissionMode} theme={theme} onModelChange={setModel} onPermissionChange={setPermissionMode} skillsOpen={skillsOpen} onSkillsOpenChange={setSkillsOpen} selectedSkillCount={selectedSkillIds.length} /> } />
)}
) : null} setSkillsOpen(false)} onCategoryChange={setSkillTag} onSearch={setSkillSearch} onToggle={(id) => setSelectedSkillIds((current) => { if (current.includes(id)) return current.filter((item) => item !== id); return [...current, id]; })} onInstall={installSkill} onLoadMore={loadMoreSkills} /> ); } function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) { return ( Agent {approvalPending ? 待审批 : null} ); } function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) { return (
Agent {statusLabel}
当前画布 · {nodeCount} 个节点
); } function AgentHistory({ conversations, activeConversationId, theme, onBack, onNew, onOpen, onDelete }: { conversations: CloudAgentConversation[]; activeConversationId: string; theme: CanvasTheme; onBack: () => void; onNew: () => void; onOpen: (conversation: CloudAgentConversation) => void; onDelete: (id: string) => void }) { return (
{conversations.length ? (
{conversations.map((conversation) => { const preview = truncateConversationPreview(conversation.messages.at(-1)?.text || "尚未发送消息"); const active = conversation.id === activeConversationId; return (
); })}
) : (
还没有历史对话
发送第一条消息后会自动保存
)}
); } function AgentConversation({ theme, messages, references, busy, approval, approvalSubmitting, nodeCount, onFocusNode, onApprovalReasonChange, onApprove, onReject, }: { theme: CanvasTheme; messages: CloudAgentChatMessage[]; references: CanvasResourceReference[]; busy: boolean; approval: ApprovalState | null; approvalSubmitting: boolean; nodeCount: number; onFocusNode?: (nodeId: string) => void; onApprovalReasonChange: (reason: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void; }) { const scrollRef = useRef(null); const contentRef = useRef(null); const followRef = useRef(true); const lastUserId = messages.findLast((item) => item.role === "user")?.id; // 自己发送时恢复跟随;阅读旧消息时不让流式输出抢走滚动位置。 useLayoutEffect(() => { followRef.current = true; }, [lastUserId]); useLayoutEffect(() => { const element = scrollRef.current; if (element && followRef.current) element.scrollTop = element.scrollHeight; }, [messages, busy, approval]); useEffect(() => { const element = scrollRef.current; const content = contentRef.current; if (!element || !content) return; const observer = new ResizeObserver(() => { if (followRef.current) element.scrollTop = element.scrollHeight; }); observer.observe(element); observer.observe(content); return () => observer.disconnect(); }, []); return (
{ const element = event.currentTarget; followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48; }}> {!messages.length ? : null}
{messages.map((item) => ( ))} {approval ? : null} {busy && !approval ? ( ) : null}
); } function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount: number }) { return (

现在想做什么?

当前画布有 {nodeCount} 个节点。Agent 可以按权限读取画布、调用已启用 Skill、整理文本节点并提交受预算约束的生成任务。

); } function ComposerControls({ reasoningMode, reasoningSupported, onReasoningModeChange, config, selectedModel, permissionMode, theme, onModelChange, onPermissionChange, skillsOpen, onSkillsOpenChange, selectedSkillCount, }: { reasoningMode: AgentReasoningMode; reasoningSupported: boolean; onReasoningModeChange: (value: AgentReasoningMode) => void; config: ReturnType; selectedModel: string; permissionMode: AgentPermissionMode; theme: CanvasTheme; onModelChange: (model: string) => void; onPermissionChange: (mode: AgentPermissionMode) => void; skillsOpen: boolean; onSkillsOpenChange: (open: boolean) => void; selectedSkillCount: number; }) { const permissionVisual = agentPermissionVisual(permissionMode); return (
{reasoningSupported ? : null}
); } const reasoningLabels: Record = { off: "直达", auto: "自动推理", deep: "深入推理" }; function reasoningModeLabel(mode: AgentReasoningMode) { return reasoningLabels[mode]; } function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentReasoningMode) => void) { return (Object.keys(reasoningLabels) as AgentReasoningMode[]).map((value) => ({ key: value, label: reasoningLabels[value], icon: value === mode ? : undefined, onClick: () => onChange(value), })); } function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void }) { const [showReason, setShowReason] = useState(Boolean(approval.reason)); const [mediaSettings, setMediaSettings] = useState(); const imageApproval = agentImageApproval(approval.detail); const action = agentApprovalPresentation(approval.detail); return (

{action.title}

等待你的确认

{action.description}

{action.items.length ? (
{action.items.map((item, index) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item} theme={theme} onFocusNode={onFocusNode} />)}
) :
无法确认具体目标,继续前请重新读取画布。
} {imageApproval ? : null} {showReason ? onReasonChange(event.target.value)} placeholder="告诉 Agent 为什么暂不执行" autoSize={{ minRows: 2, maxRows: 3 }} maxLength={2000} disabled={submitting} /> : null}
); } function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) { const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成"; const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => { if (!title) return null; const content = <>{title}{typeLabel ? {typeLabel} : null}; return id && onFocusNode ? : {content}; }; return (
{operationLabel} {item.operation === "connect_nodes" ? (
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "source")} {renderNode(item.targetNodeTitle, item.targetNodeId, undefined, "target")}
) : (
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "node")} {item.resultTitle ? <>《{item.resultTitle}》 : null}
)}
{item.fields?.length ?
修改:{item.fields.map((field) => {field})}
: null} {item.details?.length ?
{item.details.map((detail) => {detail})}
: null}
{item.summary}
); } function positiveNumber(value: string) { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : undefined; } function truncateConversationPreview(value: string, max = 96) { const compact = markdownPlainText(value); return compact.length > max ? `${compact.slice(0, max)}…` : compact || "尚未发送消息"; } function formatConversationTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; const today = new Date(); if (date.toDateString() === today.toDateString()) return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); } function applyAgentEvent(event: AgentEvent, setMessages: Dispatch>, setRun: Dispatch>, setApproval: Dispatch>, setPrompt?: Dispatch>) { const payload = event.payload || {}; const text = String(payload.text || payload.summary || payload.message || ""); if (event.type === "run_status") { const snapshotApproval = payload.approval && typeof payload.approval === "object" ? payload.approval as AgentRun["approval"] : undefined; setRun((current) => (current ? { ...current, status: String(payload.status || current.status) as AgentRun["status"], updatedAt: event.createdAt, revision: Number(payload.revision || 0), cleanupPending: Boolean(payload.cleanupPending), failureMessage: String(payload.failureMessage || ""), skills: payload.skills as AgentRun["skills"], spentCredits: Number(payload.spentCredits || 0), step: Number(payload.step || 0), approval: snapshotApproval } : current)); if (payload.failureMessage) setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage))); if (snapshotApproval && !snapshotApproval.decision && snapshotApproval.approvalId) { setApproval((current) => ({ approvalId: snapshotApproval.approvalId, detail: snapshotApproval, reason: current?.approvalId === snapshotApproval.approvalId ? current.reason : snapshotApproval.reason || "" })); } else { setApproval(null); } return; } if (event.type === "approval_decided") { setApproval(null); if (payload.decision === "reject") { setMessages((current) => appendUniqueMessage(current, { id: event.eventId, role: "system", text: text || "已拒绝本次操作,未写入画布。你可以告诉 Agent 修改方向后重新申请。", })); } return; } if (event.type === "progress_summary") { setMessages((current) => appendUniqueMessage(current, { id: event.eventId, role: "system", text: text || "Agent 正在整理执行计划" })); return; } if (event.type === "assistant_delta") { setMessages((current) => upsertTextMessage(current, String(payload.messageId || "assistant"), text, true)); return; } if (event.type === "reasoning_delta" || event.type === "reasoning_message") { const id = String(payload.messageId || `${event.runId}:reasoning`); setMessages((current) => upsertTextMessage(current, id, text, event.type === "reasoning_delta") .map((item) => item.id === id ? { ...item, reasoning: true } : item)); return; } if (event.type === "plan_updated" && Array.isArray(payload.items)) { const id = `plan-${event.runId}`; const planItems = payload.items as CloudAgentPlanItem[]; setMessages((current) => { const index = current.findIndex((entry) => entry.id === id); const message: CloudAgentChatMessage = { id, role: "tool", text: "", planItems }; if (index < 0) return [...current, message]; const next = [...current]; next[index] = message; return next; }); return; } if (event.type === "user_interjection") { if (!text) return; setMessages((current) => appendUniqueMessage(current, { id: String(payload.messageId || event.eventId), role: "user", text, interjection: "sent" })); return; } if (event.type === "user_interjection_dropped") { const messageId = String(payload.messageId || event.eventId); const reason = String(payload.reason || "本轮已结束"); setMessages((current) => { const marked = current.map((item) => item.id === messageId ? { ...item, interjection: "undelivered" as const } : item); return appendUniqueMessage(marked, { id: `interjection-dropped-${messageId}`, role: "system", text: `${reason},这条插话没有送到模型。需要的话重新发一次,它会作为新一轮。` }); }); setPrompt?.((current) => (current.trim() ? current : text)); return; } if (event.type === "user_question") { const options = Array.isArray(payload.options) ? (payload.options as Array<{ label?: unknown; detail?: unknown }>) .map((option) => ({ label: String(option?.label || "").trim(), detail: option?.detail === undefined ? undefined : String(option.detail) })) .filter((option) => option.label) : []; const question = String(payload.question || "").trim(); if (!question || options.length < 2) return; const id = `question-${event.runId}:${event.seq ?? event.eventId}`; setMessages((current) => appendUniqueMessage(current, { id, role: "assistant", text: "", question: { question, options, allowFreeform: payload.allowFreeform !== false }, })); return; } if (event.type === "assistant_message") { setMessages((current) => upsertTextMessage(current, String(payload.messageId || event.eventId), text, false)); return; } if (event.type === "assistant_snapshot") { setMessages((current) => upsertTextMessage(current, String(payload.messageId || event.eventId), text, false)); return; } if (event.type === "approval_requested") { const approvalId = String(payload.approvalId || ""); setApproval((current) => ({ approvalId, detail: payload, reason: current?.approvalId === approvalId ? current.reason : "" })); return; } if (event.type === "canvas_updated" && Array.isArray(payload.actions)) { if (payload.operation === "generate_media_submit" || payload.operation === "generate_media_complete") return; const { canvasPatch: _patch, ...detail } = payload; const id = payload.callId ? `canvas-${event.runId}-${payload.callId}` : event.eventId; setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: "画布操作已完成", detail: { ...detail, eventType: event.type } })); return; } if (event.type === "tool_completed" && payload.toolName === "canvas_apply_ops" && payload.callId) { const id = `canvas-${event.runId}-${payload.callId}`; setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: text || "画布操作已完成", detail: { ...payload, eventType: event.type } })); return; } if (event.type === "generation_task_created") { const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: "generate_media", text: text || event.type, detail: { ...payload, eventType: event.type } }; setMessages((current) => upsertMediaToolTrace(current, message)); return; } if (event.type.startsWith("tool_")) { const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: String(payload.toolName || payload.title || "工具执行"), text: text || event.type, detail: { ...payload, eventType: event.type } }; if (payload.toolName === "generate_media") { setMessages((current) => upsertMediaToolTrace(current, message)); } else { setMessages((current) => appendUniqueMessage(current, message)); } return; } if (event.type === "run_failed" || event.type === "error") setMessages((current) => appendAgentError(current, event.eventId, text || "Agent 执行失败")); } function toolDetailRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function toolDetailNodeIds(detail: unknown): Set { const payload = toolDetailRecord(detail); const ids = new Set(); for (const value of [payload.nodeId, toolDetailRecord(payload.result).nodeId]) { if (typeof value === "string" && value) ids.add(value); } if (Array.isArray(payload.actions)) { for (const action of payload.actions) { const nodeId = toolDetailRecord(action).nodeId; if (typeof nodeId === "string" && nodeId) ids.add(nodeId); } } if (typeof payload.arguments === "string") { try { const args = toolDetailRecord(JSON.parse(payload.arguments)); if (Array.isArray(args.ops)) { for (const op of args.ops) { const nodeId = toolDetailRecord(op).id; if (typeof nodeId === "string" && nodeId) ids.add(nodeId); } } } catch { // Tool arguments are diagnostic data; a malformed value must not break the event feed. } } return ids; } function toolDetailTaskIds(detail: unknown): Set { const payload = toolDetailRecord(detail); const ids = new Set(); for (const value of [payload.taskId, toolDetailRecord(payload.result).taskId]) { if (typeof value === "string" && value) ids.add(value); } return ids; } function mergeToolDetails(previous: unknown, next: unknown): Record { const previousDetail = toolDetailRecord(previous); const nextDetail = toolDetailRecord(next); return { ...previousDetail, ...nextDetail, actions: Array.isArray(nextDetail.actions) ? nextDetail.actions : previousDetail.actions, arguments: nextDetail.arguments || previousDetail.arguments, }; } function upsertMediaToolTrace(current: CloudAgentChatMessage[], message: CloudAgentChatMessage): CloudAgentChatMessage[] { const nextNodeIds = toolDetailNodeIds(message.detail); const nextTaskIds = toolDetailTaskIds(message.detail); const index = current.findIndex((item) => { if (item.role !== "tool") return false; const itemToolName = item.title || ""; if (itemToolName !== "canvas_apply_ops" && itemToolName !== "generate_media") return false; const itemNodeIds = toolDetailNodeIds(item.detail); const itemTaskIds = toolDetailTaskIds(item.detail); return [...nextNodeIds].some((id) => itemNodeIds.has(id)) || [...nextTaskIds].some((id) => itemTaskIds.has(id)); }); if (index < 0) return appendUniqueMessage(current, message); const next = [...current]; const previous = next[index]; next[index] = { ...previous, ...message, id: previous.id, detail: mergeToolDetails(previous.detail, message.detail), }; return next; } function appendUniqueMessage(current: CloudAgentChatMessage[], message: CloudAgentChatMessage) { return current.some((item) => item.id === message.id) ? current : [...current, message]; } function upsertTextMessage(current: CloudAgentChatMessage[], id: string, text: string, append: boolean): CloudAgentChatMessage[] { const index = current.findIndex((item) => item.id === id); if (index < 0) return [...current, { id, role: "assistant" as const, text, streaming: append }]; if (!append && current[index].text === text && !current[index].streaming) return current; const next = [...current]; next[index] = { ...next[index], text: append ? `${next[index].text}${text}` : text, streaming: append }; return next; } function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string) { const message = agentErrorPresentation(cause, fallback); const last = current.at(-1); if (last?.role === "error" && last.title === message.title && last.text === message.text) return current; return appendUniqueMessage(current, { id, role: "error", ...message }); } function isNotFoundError(cause: unknown) { if (!cause || typeof cause !== "object") return false; const status = "status" in cause ? (cause as { status?: unknown }).status : undefined; return status === 404 || (cause instanceof Error && /\(404\)/u.test(cause.message)); }