import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react"; import { Button, Dropdown, Input, Popover } from "antd"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, RotateCcw, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react"; import { saveAs } from "file-saver"; import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export"; import { markdownPlainText } from "@/lib/markdown-plain-text"; import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry"; import { agentPlanVisible, latestAgentPlanItems, latestAgentPlanTerminal, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan"; import { emptyAgentContextUsage, presentAgentContextUsage, reduceAgentContextUsage, type AgentContextPhase, type AgentContextUsage, type AgentContextUsageView } from "@/lib/canvas/agent-context-usage"; import { nanoid } from "nanoid"; import { ModelPicker } from "@/components/model-picker"; import { FluidOrb } from "@/components/ui/fluid-orb"; import { cn } from "@/lib/utils"; 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 { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed"; 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, listSkillLibraryCategories, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillPreset } 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 { useAppearanceStore } from "@/stores/use-appearance-store"; import { useUserStore } from "@/stores/use-user-store"; import { getActiveUserScope } from "@/lib/user-scope"; import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync"; import { createAgentCanvasSync } from "@/services/agent-canvas-sync"; import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime"; import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentReasoningFeed, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, 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 { useAgentLauncherPosition } from "./use-agent-launcher-position"; import { AgentWelcome } from "./canvas-agent-welcome"; import { DEFAULT_CANVAS_APPEARANCE, agentCopy } from "@/lib/canvas/agent-appearance"; import { live2DModelURL } from "@/services/api/appearance"; import { Live2DAvatar } from "./live2d-avatar"; import "./canvas-cloud-agent.css"; type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; selectedNodeIds: string[]; 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, selectedNodeIds, references, open, prefillPrompt, onOpen, onCollapse, onFocusNode }: CloudAgentPanelProps) { const userId = useUserStore((state) => state.user?.id); const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; 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 [contextUsage, setContextUsage] = useState(() => emptyAgentContextUsage("")); 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 [libraryCategories, setLibraryCategories] = 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 planTerminal = useMemo(() => latestAgentPlanTerminal(messages), [messages]); const planVisible = agentPlanVisible(planItems); const pendingQuestion = useMemo(() => pendingAgentQuestion(messages), [messages]); const [scenePresets, setScenePresets] = useState([]); const [presetApplyingId, setPresetApplyingId] = useState(""); const presetApplyingRef = useRef(null); 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 installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]); const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]); const installedSkillIds = useMemo(() => new Set(installedSkills.map((skill) => skill.skillId)), [installedSkills]); const [createdSkills, setCreatedSkills] = useState([]); // 用户切换后重新加载,旧请求不得把其他账号的数据写回当前面板。 useEffect(() => { let active = true; presetApplyingRef.current = null; setPresetApplyingId(""); setScenePresets([]); listSkillPresets() .then((result) => { if (active) setScenePresets(result.presets || []); }) .catch(() => { if (active) setScenePresets([]); }); return () => { active = false; }; }, [userId]); // 用户自建技能也要能出现在推荐里:官方种子库与剧典走「已装」,自建走 scope=created。 useEffect(() => { let active = true; setCreatedSkills([]); listSkills({ scope: "created", pageSize: 50 }) .then((result) => { if (active) setCreatedSkills(result.skills || []); }) .catch((cause) => { if (active) setMessages((current) => appendAgentError(current, "created-skills-error", cause, "自建技能读取失败")); }); return () => { active = false; }; }, [userId]); // 场景分桶:把「常用 / 推荐配方 / 场景技能」收进同一个维度,一级只显示分类。 // 常用度:自建 > 已收藏 > 已装,同级按市场热度降序。 const sceneBuckets = useMemo(() => { const merged = new Map(); for (const skill of [...installedSkills, ...createdSkills]) { if (!merged.has(skill.skillId)) merged.set(skill.skillId, skill); } const rank = (skill: Skill) => (skill.isOwner ? 0 : skill.isLike ? 1 : 2); const frequent = [...merged.values()].sort((a, b) => rank(a) - rank(b) || (b.addedCount || 0) - (a.addedCount || 0)); const pool = frequent.slice(0, 24); const sceneOf = (skill: Skill) => skill.tag || "others"; return AGENT_SCENE_DEFS.map((definition) => ({ key: definition.key, label: definition.label, presets: definition.key === "frequent" ? [] : scenePresets.filter((preset) => preset.scene === definition.key), skills: definition.key === "frequent" ? frequent.slice(0, 8) : pool.filter((skill) => sceneOf(skill) === definition.key), })); }, [installedSkills, createdSkills, scenePresets]); const applyScenePreset = useCallback( async (preset: SkillPreset) => { if (running || busy || presetApplyingRef.current || !historyHydrated || !pendingHydrated) return; const scope = conversationScope; const account = getActiveUserScope(); const token = crypto.randomUUID(); const isCurrent = () => presetApplyingRef.current === token && currentScope.current === scope && getActiveUserScope() === account; const missing = preset.skillIds.filter((id) => !installedSkillIds.has(id)); presetApplyingRef.current = token; setPresetApplyingId(preset.presetId); try { // 安装是持久写操作;任何失败都不能谎称整个配方已挂载。 for (const id of missing) { if (!isCurrent()) return; await addSkill(id); if (!isCurrent()) return; } const refreshed = await listAddedSkills(); if (!isCurrent()) return; if (preset.skillIds.some((id) => !refreshed.skills.some((skill) => skill.skillId === id && skill.isAdded))) { throw new Error("技能库未确认全部预设技能已安装,请刷新后重试"); } setSkills(refreshed.skills); setSelectedSkillIds(preset.skillIds); setMessages((current) => appendUniqueMessage(current, { id: `preset-${preset.presetId}-${Date.now()}`, role: "system", text: `已按「${preset.name}」挂上 ${preset.skillIds.length} 个技能${missing.length ? `(新装 ${missing.length} 个)` : ""}。${preset.rationale}`, }), ); } catch (cause) { if (isCurrent()) setMessages((current) => appendAgentError(current, `preset-${preset.presetId}`, cause, `「${preset.name}」挂载失败(已安装的技能仍在技能库中)`)); } finally { if (presetApplyingRef.current === token) { presetApplyingRef.current = null; setPresetApplyingId(""); } } }, [busy, conversationScope, historyHydrated, installedSkillIds, pendingHydrated, running], ); // 单个技能(含用户自建)挂载到本会话;未装的先补装,已挂的不重复追加。 const applySingleSkill = useCallback( async (skill: Skill) => { if (running || busy || presetApplyingRef.current || !historyHydrated || !pendingHydrated) return; const scope = conversationScope; const account = getActiveUserScope(); const token = crypto.randomUUID(); const isCurrent = () => presetApplyingRef.current === token && currentScope.current === scope && getActiveUserScope() === account; presetApplyingRef.current = token; setPresetApplyingId(skill.skillId); try { if (!installedSkillIds.has(skill.skillId)) { await addSkill(skill.skillId); if (!isCurrent()) return; } const refreshed = await listAddedSkills(); if (!isCurrent()) return; if (!refreshed.skills.some((item) => item.skillId === skill.skillId && item.isAdded)) { throw new Error("技能库未确认该技能已安装,请刷新后重试"); } setSkills(refreshed.skills); setSelectedSkillIds((current) => (current.includes(skill.skillId) ? current : [...current, skill.skillId])); setMessages((current) => appendUniqueMessage(current, { id: `skill-${skill.skillId}-${Date.now()}`, role: "system", text: `已把「${skill.skillName}」挂到本会话。用哪张卡交给 Agent 按任务检索。`, }), ); } catch (cause) { if (isCurrent()) setMessages((current) => appendAgentError(current, `skill-${skill.skillId}`, cause, `「${skill.skillName}」挂载失败`)); } finally { if (presetApplyingRef.current === token) { presetApplyingRef.current = null; setPresetApplyingId(""); } } }, [busy, conversationScope, historyHydrated, installedSkillIds, pendingHydrated, running], ); 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; setSkills([]); 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, userId]); 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); setSkillCategories(result.categories); } }) .catch(() => { if (active) setMarketSkills([]); }) .finally(() => { if (active) setSkillsLoading(false); }); return () => { active = false; }; }, [view, skillsOpen, debouncedSkillSearch, skillTag]); useEffect(() => { if (view !== "settings" && !skillsOpen) return; let active = true; void listSkillLibraryCategories("mine") .then((result) => { if (active) setLibraryCategories(result.categories); }) .catch(() => { if (active) setLibraryCategories([]); }); return () => { active = false; }; }, [skillsOpen, userId, view]); 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))]); setSkillCategories(result.categories); 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); presetApplyingRef.current = null; setPresetApplyingId(""); setConversations([]); setRun(null); setMessages([]); setSelectedSkillIds([]); 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, userId]); 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}`)); setContextUsage((current) => reduceAgentContextUsage(current, event)); 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, 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)])], focusNodeIds: selectedNodeIds.length <= 8 ? selectedNodeIds : [], 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 formAnswer = parseCloudAgentFormAnswer(request.prompt); const nextMessages = appendUniqueMessage(messages, { id: submission.messageId || `user-${submission.key}`, role: "user", text: formAnswer ? "已确认创作方向" : request.prompt, ...(formAnswer ? { formAnswer } : {}), }); 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(); const now = new Date().toISOString(); const inheritedSkillIds = [...selectedSkillIds]; currentScope.current = `${canvasId}:${id}`; presetApplyingRef.current = null; setPresetApplyingId(""); setPendingHydrated(true); setBusy(false); pendingSubmission.current = null; approvalRequestRef.current = null; setApprovalSubmitting(false); setActiveConversationId(id); setRun(null); setMessages([]); // Skills are a user-selected Agent workspace setting. Keep them when // starting a fresh conversation so the skill picker does not appear to // lose the skills the user just enabled. setSelectedSkillIds(inheritedSkillIds); setPrompt(""); setApproval(null); lastSeqRef.current = 0; // Persist the blank conversation immediately. Otherwise the active id // can point at no conversation after a reload, losing the inherited // skill selection before the first message is sent. setConversations((current) => [ { id, title: "新对话", messages: [], run: null, model: selectedModel || undefined, permissionMode, skillIds: inheritedSkillIds, createdAt: now, updatedAt: now, }, ...current.filter((conversation) => conversation.messages.length > 0 || conversation.run), ]); setView("chat"); }; const openConversation = (conversation: CloudAgentConversation) => { currentScope.current = `${canvasId}:${conversation.id}`; presetApplyingRef.current = null; setPresetApplyingId(""); 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 ? ( } aria-label="Agent 工作台" data-canvas-no-zoom data-canvas-wheel-scroll {...panelLayout.pointerHandlers} onWheel={(event) => 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} /> ) : ( 0} statusLabel={statusLabel} statusColor={statusColor} nodeCount={nodeCount} onNew={newConversation} onHistory={() => 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")} onResetLayout={panelLayout.reset} onCollapse={onCollapse} /> {run && connectionStatus !== "connected" ? (
{connectionStatus === "disconnected" ? "连接已断开,服务端任务可能仍在执行;运行记录已保留" : "正在连接并校准运行状态…"} {connectionStatus === "disconnected" ? ( ) : null}
) : null} setSkillsOpen(true)} onDraftPrompt={(draft) => setPrompt((current) => (current.trim() ? `${current}\n\n${draft}` : draft))} onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))} onApprove={(settings) => void submitApproval("approve", settings)} onReject={() => void submitApproval("reject")} /> {planVisible ? ( setPlanMinimized((value) => !value)} /> ) : null} {historyHydrated && !messages.some((message) => message.role === "user" || message.role === "assistant") && !run ? ( void applyScenePreset(preset)} onPickSkill={(skill) => void applySingleSkill(skill)} /> ) : null} {pendingQuestion ? void submit(label)} /> : null} void submit()} onStop={run?.id && running ? stop : undefined} stopping={stopping} references={[...references, ...buildSkillMentionReferences(installedSkills)]} slashSkills={installedSkills} includeAssetLibrary={false} submitAccessory={} left={ } />
)}
) : 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 }) { const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry); const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); useEffect(() => { const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, []); const height = live ? Math.max(40, Math.min(appearance.avatarHeight, viewport.height - 60, (viewport.width - 40) / 0.75)) : 76; const width = live ? Math.round(height * 0.75) : 76; const { position, dragging, handlers } = useAgentLauncherPosition(onOpen, width, height); return ( {live ? ( } /> ) : ( )} {appearance.launcherLabel ? {appearance.launcherLabel} : null} {approvalPending ? 待审批 : null} ); } function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onResetLayout, onCollapse, onExport, exporting, }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onResetLayout: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean; }) { const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; return (
{agentCopy(appearance.panelTitle, appearance.agentName)} {hasMessages ? "" : " · 新对话"} {statusLabel}
{appearance.agentName} · 当前画布 {nodeCount} 个节点
); } const CONTEXT_PHASE_LABEL: Record = { idle: "尚未测量", unknown: "窗口未知", ok: "上下文充足", watch: "接近压缩", compress: "即将压缩", compacting: "正在压缩", stale: "压缩后待刷新", }; function formatContextCount(tokens: number | undefined) { if (tokens === undefined) return "—"; if (tokens >= 1_000_000) return `${Math.round(tokens / 100_000) / 10}M`; if (tokens >= 1_000) return `${Math.round(tokens / 100) / 10}K`; return Math.round(tokens).toLocaleString("zh-CN"); } function formatContextBytes(bytes: number | undefined) { if (bytes === undefined) return "—"; if (bytes >= 1_000_000) return `${Math.round(bytes / 100_000) / 10} MB`; if (bytes >= 1_000) return `${Math.round(bytes / 100) / 10} KB`; return `${Math.round(bytes).toLocaleString("zh-CN")} 字节`; } function AgentContextRing({ view }: { view: AgentContextUsageView }) { const [open, setOpen] = useState(false); const marker = view.compactRatio && view.compactRatio > 0 && view.compactRatio < 1 ? view.compactRatio : undefined; const percent = view.ratio === undefined ? view.label : `${Math.round(view.ratio * 100)}%`; const meterLabel = view.ratio === undefined ? "—" : percent; const used = formatContextCount(view.inputTokens); const budget = formatContextCount(view.usableTokens); const remaining = formatContextCount(view.remainingTokens); const protocolBytes = formatContextBytes(view.protocolBytes); const usedRatio = view.ratio === undefined ? 0 : Math.max(0, Math.min(1, view.ratio)); const phaseLabel = CONTEXT_PHASE_LABEL[view.phase]; const sourceLabel = view.tokenSource === "provider" ? "模型实测校准" : view.estimate ? "本地估算" : "未测量"; const usageHeading = view.ratio !== undefined ? `上下文已用 ${percent}` : view.phase === "idle" ? "上下文用量" : view.phase === "unknown" ? "上下文窗口未知" : `上下文${view.label}`; return ( trigger.closest(".canvas-agent-panel") ?? document.body} content={
下一次请求 · 上下文窗口占用
{usageHeading} {view.phase !== "ok" ? {phaseLabel} : null}
{view.remainingTokens !== undefined && view.usableTokens !== undefined ? ( <> {used} / {budget} Token 剩余 {remaining} ) : ( <> {used} Token )}
上下文窗口占用 {percent}
{marker ?