Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-cloud-agent-panel.tsx

1357 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, 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 { markdownPlainText } from "@/lib/markdown-plain-text";
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 { useAppearanceStore } from "@/stores/use-appearance-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 { AgentWelcome } from "./canvas-agent-welcome";
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<string, unknown>; 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<AgentPanelView>("chat");
const [run, setRun] = useState<AgentRun | null>(null);
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "connected" | "reconnecting" | "disconnected">("connecting");
const [connectionEpoch, setConnectionEpoch] = useState(0);
const [messages, setMessages] = useState<CloudAgentChatMessage[]>([]);
const [prompt, setPrompt] = useState("");
const lastPrefillPromptRef = useRef("");
const [reasoningMode, setReasoningMode] = useState<AgentReasoningMode>("off");
const [profileView, setProfileView] = useState<AgentProfileView | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [profileError, setProfileError] = useState<string>();
const profileRequestRef = useRef(0);
const [skills, setSkills] = useState<Skill[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [marketSkills, setMarketSkills] = useState<Skill[]>([]);
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<SkillCategory[]>([]);
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<ApprovalState | null>(null);
const [permissionMode, setPermissionMode] = useState<AgentPermissionMode>("request_approval");
const [contextScope, setContextScope] = useState<AgentContextKey[]>(["canvas"]);
const [maxCredits, setMaxCredits] = useState("200");
const [maxGenerationTasks, setMaxGenerationTasks] = useState("0");
const [maxVideoSeconds, setMaxVideoSeconds] = useState("0");
const [conversations, setConversations] = useState<CloudAgentConversation[]>([]);
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<ReturnType<typeof createAgentCanvasSync> | 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<string | null>(null);
const pendingSubmission = useRef<CloudAgentPendingSubmission | null>(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 ? <AgentLauncher theme={theme} statusColor={statusColor} approvalPending={Boolean(approval)} reducedMotion={Boolean(reducedMotion)} onOpen={onOpen} /> : null}
<AnimatePresence>
{open ? (
<motion.aside
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 0.975 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
transition={{ duration: reducedMotion ? 0 : 0.26, ease: [0.16, 1, 0.3, 1] }}
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden"
style={{ ...panelLayout.style, "--agent-surface-base": theme.node.panel, "--agent-ink": theme.node.text, "--agent-accent": theme.accent.primary, "--agent-shadow-color": theme.spatial.shadow } as CSSProperties & Record<`--${string}`, string>}
aria-label="Agent 工作台"
data-canvas-no-zoom
data-canvas-wheel-scroll
{...panelLayout.pointerHandlers}
onWheel={(event) => event.stopPropagation()}
>
<div data-agent-resize="north" className="absolute inset-x-5 top-0 z-10 hidden h-2 cursor-n-resize touch-none sm:block" />
<div data-agent-resize="west" className="absolute bottom-5 left-0 top-5 z-10 hidden w-2 cursor-w-resize touch-none sm:block" />
<button
type="button"
aria-label="调整 Agent 面板大小"
title="拖动调整宽高,也可用方向键调整"
data-agent-resize="northwest"
className="agent-panel-resize-corner absolute left-2 top-2 z-10 hidden size-5 cursor-nw-resize touch-none place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:grid"
onKeyDown={panelLayout.onResizeKeyDown}
style={{ color: theme.node.muted }}
>
<MoveDiagonal2 className="size-3" aria-hidden="true" />
</button>
<AnimatePresence mode="wait" initial={false}>
{view === "settings" ? (
<motion.div key="settings" className="flex min-h-0 flex-1" initial={{ opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 18 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
<CanvasCloudAgentSettings
theme={theme}
config={config}
selectedModel={selectedModel}
permissionMode={permissionMode}
contextScope={contextScope}
nodeCount={nodeCount}
installedSkills={installedSkills}
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
skillSearch={skillSearch}
skillsLoading={skillsLoading}
skillHasMore={skillHasMore}
maxCredits={maxCredits}
maxGenerationTasks={maxGenerationTasks}
maxVideoSeconds={maxVideoSeconds}
onBack={() => 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}
/>
</motion.div>
) : view === "history" ? (
<motion.div key="history" className="flex min-h-0 flex-1" initial={{ opacity: 0, x: 18 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 18 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
<AgentHistory
conversations={conversations}
activeConversationId={activeConversationId}
theme={theme}
onBack={() => setView("chat")}
onNew={newConversation}
onOpen={openConversation}
onDelete={deleteConversation}
/>
</motion.div>
) : (
<motion.div key="chat" className="flex min-h-0 flex-1 flex-col" initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
<AgentHeader
theme={theme}
hasMessages={messages.length > 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")}
onCollapse={onCollapse}
/>
{run && connectionStatus !== "connected" ? (
<div role="status" className="flex items-center justify-between gap-2 px-5 py-2 text-xs" style={{ color: theme.node.muted }}>
<span>{connectionStatus === "disconnected" ? "连接已断开,服务端任务可能仍在执行;运行记录已保留" : "正在连接并校准运行状态…"}</span>
{connectionStatus === "disconnected" ? <Button size="small" onClick={() => setConnectionEpoch((value) => value + 1)}>重新连接</Button> : null}
</div>
) : null}
<AgentConversation
key={activeConversationId}
theme={theme}
messages={messages}
onFocusNode={onFocusNode}
references={[...references, ...buildSkillMentionReferences(installedSkills)]}
busy={busy || running}
approval={approval}
nodeCount={nodeCount}
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
onChooseSkill={() => 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 ? <AgentPlanBar items={planItems} theme={theme} minimized={planMinimized} onToggle={() => setPlanMinimized((value) => !value)} /> : null}
{pendingQuestion ? (
<AgentQuestionBar
question={pendingQuestion}
theme={theme}
disabled={approvalSubmitting || connectionStatus !== "connected"}
onAnswer={(label) => void submit(label)}
/>
) : null}
<AgentChatComposer
prompt={prompt}
disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
sending={busy}
running={running}
placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills"}
theme={theme}
onPromptChange={setPrompt}
onSubmit={() => void submit()}
onStop={run?.id && running ? stop : undefined}
stopping={stopping}
references={[...references, ...buildSkillMentionReferences(installedSkills)]}
slashSkills={installedSkills}
includeAssetLibrary={false}
left={
<ComposerControls
reasoningMode={reasoningSupported ? reasoningMode : "off"}
reasoningSupported={reasoningSupported}
onReasoningModeChange={(value) => { if (reasoningSupported) setReasoningMode(value); }}
config={config}
selectedModel={selectedModel}
permissionMode={permissionMode}
theme={theme}
onModelChange={setModel}
onPermissionChange={setPermissionMode}
skillsOpen={skillsOpen}
onSkillsOpenChange={setSkillsOpen}
selectedSkillCount={selectedSkillIds.length}
/>
}
/>
</motion.div>
)}
</AnimatePresence>
</motion.aside>
) : null}
</AnimatePresence>
<CanvasAgentSkillLibraryModal
open={skillsOpen}
theme={theme}
installedSkills={installedSkills}
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
categories={skillCategories}
category={skillTag}
search={skillSearch}
loading={skillsLoading}
hasMore={skillHasMore}
onClose={() => 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 (
<motion.button
type="button"
aria-label="打开云端 Agent"
title={approvalPending ? "Agent 等待你的审批" : "打开 Agent 助手"}
className="canvas-agent-launcher fixed bottom-5 right-5 z-[var(--z-modal-overlay)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/35"
style={{ color: theme.node.text, "--canvas-agent-launcher-shadow": theme.spatial.shadow } as CSSProperties}
data-canvas-no-zoom
onClick={onOpen}
whileHover={reducedMotion ? undefined : { y: -3, scale: 1.035 }}
whileTap={reducedMotion ? undefined : { scale: 0.96 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<FluidOrb size={62} color="#7164f6" />
<span className="canvas-agent-launcher-label">Agent</span>
<span className={cn("canvas-agent-launcher-status", approvalPending && "is-pending")} style={{ "--canvas-agent-status-color": statusColor } as CSSProperties} />
{approvalPending ? <span className="canvas-agent-launcher-badge">待审批</span> : null}
</motion.button>
);
}
function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
return (
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="agent-panel-title">{hasMessages ? "画布助手" : "新对话"}</span>
<span className="agent-panel-status flex items-center gap-1" style={{ color: statusColor }}>
<CircleDot className="size-3" />
{statusLabel}
</span>
</div>
<div className="agent-panel-context">Agent · 当前画布 {nodeCount} 个节点</div>
</div>
<div className="agent-header-actions flex items-center gap-0.5" style={{ color: theme.node.muted }}>
<Button type="text" shape="circle" icon={<Download className="size-4" />} loading={exporting} onClick={onExport} aria-label="导出 Agent 调试记录" title="导出对话、工具参数、审批和错误(分享前请检查隐私)" />
<Button type="text" shape="circle" icon={<MessageSquarePlus className="size-4" />} onClick={onNew} aria-label="新建对话" title="新建对话" />
<Button type="text" shape="circle" icon={<History className="size-4" />} onClick={onHistory} aria-label="历史对话" title="历史对话" />
<Button type="text" shape="circle" icon={<Settings2 className="size-4" />} onClick={onSettings} aria-label="Agent 设置" title="Agent 设置" />
<Button type="text" shape="circle" icon={<X className="size-4" />} onClick={onCollapse} aria-label="收起 Agent" title="收起" />
</div>
</header>
);
}
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 (
<div className="canvas-agent-history-root flex min-h-0 min-w-0 flex-1 flex-col">
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={onBack} aria-label="返回对话" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">历史对话</div>
<div className="mt-0.5 text-[11px] opacity-40">保存在当前账号与画布下</div>
</div>
<Button type="text" shape="circle" icon={<MessageSquarePlus className="size-4" />} onClick={onNew} aria-label="新建对话" title="新建对话" />
</header>
<div className="canvas-agent-history-scroll thin-scrollbar min-h-0 min-w-0 flex-1 overflow-y-auto px-3 py-3">
{conversations.length ? (
<div className="space-y-1">
{conversations.map((conversation) => {
const preview = truncateConversationPreview(conversation.messages.at(-1)?.text || "尚未发送消息");
const active = conversation.id === activeConversationId;
return (
<div key={conversation.id} className="canvas-agent-history-item group flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors" style={{ background: active ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}>
<button type="button" className="canvas-agent-history-open flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => onOpen(conversation)} aria-current={active ? "page" : undefined}>
<span className="grid size-8 shrink-0 place-items-center rounded-full" style={{ background: theme.node.fill, color: theme.node.muted }}><Clock3 className="size-3.5" /></span>
<span className="canvas-agent-history-text min-w-0 flex-1">
<span className="canvas-agent-history-title block truncate text-[13px] font-medium">{conversation.title}</span>
<span className="canvas-agent-history-preview mt-0.5 block text-[11px] opacity-40" title={preview}>{preview}</span>
</span>
<span className="canvas-agent-history-time shrink-0 text-[10px] opacity-35">{formatConversationTime(conversation.updatedAt)}</span>
</button>
<Button type="text" size="small" danger className="canvas-agent-history-delete !h-7 !px-2 !text-xs !opacity-80" icon={<Trash2 className="size-3.5" />} onClick={() => onDelete(conversation.id)} aria-label={`删除对话 ${conversation.title}`} title="删除对话">删除</Button>
</div>
);
})}
</div>
) : (
<div className="flex h-full min-h-72 flex-col items-center justify-center text-center">
<History className="size-5 opacity-30" />
<div className="mt-3 text-sm font-medium">还没有历史对话</div>
<div className="mt-1 text-xs opacity-40">发送第一条消息后会自动保存</div>
</div>
)}
</div>
</div>
);
}
function AgentConversation({
theme,
messages,
references,
busy,
approval,
approvalSubmitting,
nodeCount,
onChooseSkill,
onDraftPrompt,
onFocusNode,
onApprovalReasonChange,
onApprove,
onReject,
}: {
theme: CanvasTheme;
messages: CloudAgentChatMessage[];
references: CanvasResourceReference[];
busy: boolean;
approval: ApprovalState | null;
approvalSubmitting: boolean;
nodeCount: number;
onChooseSkill: () => void;
onDraftPrompt: (prompt: string) => void;
onFocusNode?: (nodeId: string) => void;
onApprovalReasonChange: (reason: string) => void;
onApprove: (settings?: AgentMediaSettings) => void;
onReject: () => void;
}) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(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 (
<div ref={scrollRef} data-agent-conversation className="agent-conversation thin-scrollbar min-h-0 flex-1 overflow-y-auto" onScroll={(event) => {
const element = event.currentTarget;
followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
}}>
{!messages.length ? <AgentWelcome brandName={brandName} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
<div ref={contentRef} className="agent-conversation-messages">
{messages.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
))}
{approval ? <ApprovalCard key={approval.approvalId} approval={approval} theme={theme} submitting={approvalSubmitting} onFocusNode={onFocusNode} onReasonChange={onApprovalReasonChange} onApprove={onApprove} onReject={onReject} /> : null}
{busy && !approval ? (
<AgentWorkingMessage theme={theme} label="正在处理当前画布" />
) : null}
</div>
</div>
);
}
function ComposerControls({
reasoningMode,
reasoningSupported,
onReasoningModeChange,
config,
selectedModel,
permissionMode,
theme,
onModelChange,
onPermissionChange,
skillsOpen,
onSkillsOpenChange,
selectedSkillCount,
}: {
reasoningMode: AgentReasoningMode;
reasoningSupported: boolean;
onReasoningModeChange: (value: AgentReasoningMode) => void;
config: ReturnType<typeof useEffectiveConfig>;
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);
const PermissionIcon = permissionVisual.icon;
return (
<div className="flex min-w-0 flex-wrap items-center gap-0.5">
<ModelPicker
config={config}
value={selectedModel}
capability="text"
onChange={onModelChange}
variant="creation"
className="!h-8 !min-w-0 !w-52 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
popoverClassName="agent-model-picker-popover"
showSelectedPrice={false}
showOptionPrices
placeholder="选择文本模型"
/>
{reasoningSupported ? <Dropdown trigger={["click"]} placement="topLeft" menu={{ items: reasoningMenuItems(reasoningMode, onReasoningModeChange) }}>
<button type="button" aria-label="选择 Agent 推理模式" title="推理模式:只用于规划和工具选择" className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25" style={{ color: reasoningMode === "off" ? theme.node.muted : theme.accent.primary, background: reasoningMode === "off" ? "transparent" : theme.node.fill }}>
<Sparkles className="size-3.5" />{reasoningModeLabel(reasoningMode)}
</button>
</Dropdown> : null}
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: theme.node.muted, background: "transparent" }}
aria-label={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
title={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
>
<PermissionIcon className="size-3.5" style={{ color: permissionVisual.color }} aria-hidden="true" />
</button>
</Dropdown>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: selectedSkillCount ? theme.accent.primary : theme.node.muted, background: skillsOpen ? theme.node.fill : "transparent" }}
aria-label={`打开 Skills 技能库${selectedSkillCount ? `,已启用 ${selectedSkillCount} 个` : ""}`}
aria-expanded={skillsOpen}
aria-haspopup="dialog"
title="打开 Skills 技能库"
onClick={() => onSkillsOpenChange(true)}
>
<Sparkles className="size-3.5" />
<span className="max-w-28 truncate">Skills({selectedSkillCount})</span>
</button>
</div>
);
}
const reasoningLabels: Record<AgentReasoningMode, string> = { 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 ? <Check className="size-3.5" /> : 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<AgentMediaSettings>();
const imageApproval = agentImageApproval(approval.detail);
const action = agentApprovalPresentation(approval.detail);
return (
<section className="canvas-agent-approval-card" aria-label={action.title}>
<div className="canvas-agent-approval-header">
<span className="canvas-agent-approval-icon" aria-hidden="true"><ShieldCheck className="size-4" /></span>
<h3>{action.title}</h3>
<span className="canvas-agent-approval-badge">等待你的确认</span>
</div>
<p className="canvas-agent-approval-description" style={{ color: theme.node.muted }}>{action.description}</p>
{action.items.length ? (
<div className="canvas-agent-approval-items" aria-label="涉及节点">
{action.items.map((item, index) => <ApprovalPreviewItemView key={`${item.operation}-${item.nodeId || item.nodeTitle || index}-${index}`} item={imageApproval ? { ...item, details: item.details?.filter((detail) => !/^(模型|画幅|质量)[::]/.test(detail)) } : item} theme={theme} onFocusNode={onFocusNode} />)}
</div>
) : <div className="canvas-agent-approval-empty" style={{ color: theme.node.muted }}>无法确认具体目标,继续前请重新读取画布。</div>}
{imageApproval ? <CanvasAgentImageApprovalSettings initial={imageApproval} value={mediaSettings} onChange={setMediaSettings} theme={theme} disabled={submitting} /> : null}
<button type="button" className="canvas-agent-approval-reason-toggle" aria-expanded={showReason} onClick={() => setShowReason((value) => !value)} disabled={submitting}>
{showReason ? "收起拒绝理由" : "填写拒绝理由(可选)"}
</button>
{showReason ? <Input.TextArea className="canvas-agent-approval-reason" value={approval.reason} onChange={(event) => onReasonChange(event.target.value)} placeholder="告诉 Agent 为什么暂不执行" autoSize={{ minRows: 2, maxRows: 3 }} maxLength={2000} disabled={submitting} /> : null}
<div className="canvas-agent-approval-actions">
<button type="button" className="canvas-agent-approval-reject" disabled={submitting} onClick={onReject}>暂不执行</button>
<button type="button" className="canvas-agent-approval-approve" disabled={submitting} onClick={() => onApprove(mediaSettings)}>
{submitting ? <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" aria-hidden="true" /> : <Check className="size-4" aria-hidden="true" />}
{submitting ? "正在提交" : "同意执行"}
</button>
</div>
</section>
);
}
function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType<typeof agentApprovalPresentation>["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 = <><span className="canvas-agent-approval-node-title">{title}</span>{typeLabel ? <span className="canvas-agent-approval-node-type">{typeLabel}</span> : null}</>;
return id && onFocusNode ? <button type="button" className="canvas-agent-approval-node canvas-agent-approval-node-button" onClick={() => onFocusNode(id)} title="定位到画布节点">{content}</button> : <span className={`canvas-agent-approval-node canvas-agent-approval-node-${role}`}>{content}</span>;
};
return (
<article className="canvas-agent-approval-item">
<div className="canvas-agent-approval-item-main">
<span className={`canvas-agent-approval-operation canvas-agent-approval-operation-${item.operation}`}>{operationLabel}</span>
{item.operation === "connect_nodes" ? (
<div className="canvas-agent-approval-connection">
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "source")}
<span className="canvas-agent-approval-arrow" aria-hidden="true">→</span>
{renderNode(item.targetNodeTitle, item.targetNodeId, undefined, "target")}
</div>
) : (
<div className="canvas-agent-approval-node-summary">
{renderNode(item.nodeTitle, item.nodeId, item.nodeTypeLabel, "node")}
{item.resultTitle ? <><span className="canvas-agent-approval-change-arrow" aria-hidden="true">改为</span><span className="canvas-agent-approval-result-title">《{item.resultTitle}》</span></> : null}
</div>
)}
</div>
{item.fields?.length ? <div className="canvas-agent-approval-field-list">修改:{item.fields.map((field) => <span key={field}>{field}</span>)}</div> : null}
{item.details?.length ? <div className="canvas-agent-approval-detail-list">{item.details.map((detail) => <span key={detail}>{detail}</span>)}</div> : null}
<div className="canvas-agent-approval-summary">{item.summary}</div>
</article>
);
}
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<SetStateAction<CloudAgentChatMessage[]>>, setRun: Dispatch<SetStateAction<AgentRun | null>>, setApproval: Dispatch<SetStateAction<ApprovalState | null>>, setPrompt?: Dispatch<SetStateAction<string>>) {
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function toolDetailNodeIds(detail: unknown): Set<string> {
const payload = toolDetailRecord(detail);
const ids = new Set<string>();
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<string> {
const payload = toolDetailRecord(detail);
const ids = new Set<string>();
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<string, unknown> {
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));
}