diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx
index 90f969666..daef34c12 100644
--- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx
+++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx
@@ -4,13 +4,14 @@ import { Tooltip } from "@/components/ui/base/tooltip";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
-import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, Wrench, X, XCircle } from "lucide-react";
+import { ArrowLeft, ArrowUp, AtSign, Bookmark, BrainCircuit, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, MessageCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingGlow } from "@/components/ai/working-indicator";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
+import type { Components } from "streamdown";
import type { Skill, SkillPreset } from "@/services/api/skills";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation";
@@ -31,12 +32,15 @@ export type CloudAgentUserQuestion = {
question: string;
options: Array<{ label: string; detail?: string }>;
allowFreeform?: boolean;
+ round?: number;
+ maxRounds?: number;
};
export type CloudAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
title?: string;
text: string;
+ errorSeverity?: "warning" | "error";
streaming?: boolean;
reasoning?: boolean;
/**
@@ -114,6 +118,132 @@ function clampAgentPromptHeight(height: number) {
return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height)));
}
+const AGENT_NODE_LINK_PREFIX = "#agent-node:";
+const AGENT_NODE_TYPE_LABELS: Record
= {
+ image: "图片节点",
+ video: "视频节点",
+ audio: "音频节点",
+ text: "文本节点",
+ script: "脚本节点",
+ drawing: "绘图节点",
+ config: "工作流节点",
+ frame: "画框节点",
+ markdown: "Markdown 节点",
+ svg: "SVG 节点",
+ html: "HTML 节点",
+ panorama: "全景节点",
+ compare: "对比节点",
+ chart: "图表节点",
+ colorgrade: "调色节点",
+ "media-conversion": "转码节点",
+ "batch-table": "批量表节点",
+};
+const AGENT_NODE_TYPE_PREFIXES = Object.keys(AGENT_NODE_TYPE_LABELS).sort((left, right) => right.length - left.length);
+const BUILTIN_AGENT_NODE_ID_PATTERN = "(?:image|video|audio|text|script|drawing|config|frame|markdown|svg|html|panorama|compare|chart|colorgrade|media-conversion|batch-table)-[A-Za-z0-9][A-Za-z0-9_-]*";
+
+function escapeAgentNodePattern(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
+}
+
+function agentNodeTypeLabel(nodeId: string, reference?: CanvasResourceReference) {
+ const sourceType = String(reference?.sourceType || "");
+ if (sourceType && AGENT_NODE_TYPE_LABELS[sourceType]) return AGENT_NODE_TYPE_LABELS[sourceType];
+ const prefix = AGENT_NODE_TYPE_PREFIXES.find((candidate) => nodeId.startsWith(`${candidate}-`));
+ return (prefix && AGENT_NODE_TYPE_LABELS[prefix]) || (reference?.kind ? `${reference.kind} 节点` : "画布节点");
+}
+
+function agentNodeLinkLabel(nodeId: string, reference?: CanvasResourceReference) {
+ const kindLabel = agentNodeTypeLabel(nodeId, reference);
+ const title = (reference?.title || reference?.label || "").replace(/\s+/gu, " ").trim();
+ return title ? `${kindLabel} · ${title}` : kindLabel;
+}
+
+function escapeAgentMarkdownLinkText(value: string) {
+ return value.replace(/[\\[\]]/gu, "\\$&");
+}
+
+function createAgentNodeLink(nodeId: string, reference?: CanvasResourceReference) {
+ return `[${escapeAgentMarkdownLinkText(agentNodeLinkLabel(nodeId, reference))}](${AGENT_NODE_LINK_PREFIX}${encodeURIComponent(nodeId)})`;
+}
+
+/**
+ * Replace internal canvas node IDs in assistant Markdown with semantic links.
+ * Fenced code is left untouched, while the historical "新节点 ID" wording is
+ * shortened so the assistant exposes a node card instead of an implementation ID.
+ */
+export function rewriteAgentNodeLinks(text: string, references: CanvasResourceReference[] = []) {
+ if (!text) return text;
+
+ const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference]));
+ const exactNodeIds = [...referenceByNodeId.keys()].sort((left, right) => right.length - left.length).map(escapeAgentNodePattern);
+ const nodeIdPattern = exactNodeIds.length ? `(?:${exactNodeIds.join("|")}|${BUILTIN_AGENT_NODE_ID_PATTERN})` : BUILTIN_AGENT_NODE_ID_PATTERN;
+ const nodeTokenPattern = new RegExp(`(? {
+ if (/^\r?\n$/u.test(part)) return part;
+ const fence = /^\s{0,3}(`{3,}|~{3,})/u.test(part);
+ if (fence) {
+ inFence = !inFence;
+ return part;
+ }
+ if (inFence) return part;
+
+ const normalized = part.replace(newNodePrefixPattern, "$1");
+ return normalized.replace(nodeTokenPattern, (match, backtickedId: string | undefined, plainId: string | undefined) => {
+ const nodeId = backtickedId || plainId;
+ return nodeId ? createAgentNodeLink(nodeId, referenceByNodeId.get(nodeId)) : match;
+ });
+ }).join("");
+}
+
+function agentNodeIdFromHref(href?: string) {
+ if (!href?.startsWith(AGENT_NODE_LINK_PREFIX)) return null;
+ try {
+ const nodeId = decodeURIComponent(href.slice(AGENT_NODE_LINK_PREFIX.length));
+ return nodeId || null;
+ } catch {
+ return null;
+ }
+}
+
+function createAgentMessageMarkdownComponents(references: CanvasResourceReference[], onFocusNode?: (nodeId: string) => void): Components {
+ const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference]));
+ return {
+ a: ({ children, href, className, ...props }) => {
+ const nodeId = agentNodeIdFromHref(href);
+ if (!nodeId) {
+ return {children};
+ }
+
+ const reference = referenceByNodeId.get(nodeId);
+ const title = reference?.title || reference?.label || "画布节点";
+ const previewUrl = reference?.previewUrl;
+ return (
+ {
+ event.preventDefault();
+ onFocusNode?.(nodeId);
+ }}
+ >
+ {previewUrl ?
: }
+
+ {agentNodeTypeLabel(nodeId, reference)}
+ {reference?.title || reference?.label || children}
+
+
+ );
+ },
+ };
+}
+
export function AgentChatMessage({
item,
theme,
@@ -138,13 +268,16 @@ export function AgentChatMessage({
const isUser = item.role === "user";
const isSystem = item.role === "system";
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
- const color = item.role === "error" ? "#ef4444" : theme.node.text;
+ const markdownComponents = useMemo(() => createAgentMessageMarkdownComponents(references, onFocusNode), [onFocusNode, references]);
+ const agentMarkdownText = rewriteAgentNodeLinks(displayedText, references);
+ const errorTone = item.errorSeverity === "warning" ? "warning" : "error";
+ const color = item.role === "error" ? (errorTone === "warning" ? "#b45309" : "#ef4444") : theme.node.text;
if (item.reasoning) {
return ;
}
if (isSystem) {
return (
-
+
} />
{item.text}
@@ -163,17 +296,17 @@ export function AgentChatMessage({
}
if (item.role === "error") {
return (
-
-
} />
+
+
:
} />
-
- {item.title || "Agent 暂时无法继续"}
+
+ {item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续")}
-
+
{item.text}
{item.meta ? (
-
+
{item.meta}
) : null}
@@ -188,6 +321,11 @@ export function AgentChatMessage({
}
return (
+ {!isUser ? (
+
+
+
+ ) : null}
{item.interjection ? (
) : null}
{item.role === "assistant" ? (
-
- {displayedText}
+
+ {agentMarkdownText}
) : (
@@ -207,6 +345,11 @@ export function AgentChatMessage({
{item.attachments?.length ? : null}
{item.meta ? {item.meta}
: null}
+ {isUser ? (
+
+
+
+ ) : null}
);
}
@@ -229,6 +372,9 @@ export function AgentReasoningFeed({
+
+
+
{streaming ? "模型正在思考" : "模型思考"}
{streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`}
@@ -580,6 +726,7 @@ export function AgentOperationFeed({
if (!latest) return null;
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
+ const categoryIcon = agentToolCategoryIcon(category);
const label = agentOperationSegmentLabel(items);
// 只看最新一步的状态。前面的参数错误如果已被自动纠正,不应让整段
// 操作流继续保持红色并默认展开,否则用户会误以为最终动作仍然失败。
@@ -597,6 +744,9 @@ export function AgentOperationFeed({
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
+
+ {categoryIcon}
+
{categoryLabel}
@@ -679,7 +829,12 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
-
{question.question}
+
+
+ {question.round && question.maxRounds ? `确认 ${question.round}/${question.maxRounds}` : "需要确认"}
+
+
{question.question}
+
{question.options.map((option) => (
@@ -701,6 +856,21 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
{option.detail ? {option.detail} : null}
))}
+
{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}
@@ -1248,8 +1418,8 @@ export function AgentPanelTabs({
* 的模型品牌 glyph —— 那个圆环标志会被读成"这条是模型/OpenAI 出品",与它实际表达的
* "这是谁的一行"无关。
*/
-function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "error"; icon: ReactNode }) {
- const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
+function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "warning" | "error"; icon: ReactNode }) {
+ const color = tone === "error" ? "#ef4444" : tone === "warning" ? "#b45309" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx
index 21ce7bdff..8add1934f 100644
--- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx
+++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx
@@ -22,7 +22,7 @@ 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, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillPreset } from "@/services/api/skills";
+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";
@@ -77,6 +77,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
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);
@@ -335,7 +336,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
setMarketSkills(result.skills);
setSkillHasMore(result.hasMore);
setSkillPage(result.page);
- if (result.categories.length > 0) setSkillCategories(result.categories);
+ setSkillCategories(result.categories);
}
})
.catch(() => {
@@ -349,6 +350,15 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
};
}, [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;
@@ -363,6 +373,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
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 {
@@ -682,6 +693,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const newConversation = () => {
const id = nanoid();
+ const now = new Date().toISOString();
+ const inheritedSkillIds = [...selectedSkillIds];
currentScope.current = `${canvasId}:${id}`;
presetApplyingRef.current = null;
setPresetApplyingId("");
@@ -693,10 +706,27 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
setActiveConversationId(id);
setRun(null);
setMessages([]);
- setSelectedSkillIds([]);
+ // 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");
};
@@ -932,6 +962,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
categories={skillCategories}
+ libraryCategories={libraryCategories}
category={skillTag}
search={skillSearch}
loading={skillsLoading}
@@ -1446,7 +1477,9 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch current.map((message) => message.id === `plan-${event.runId}` && message.planItems?.length ? { ...message, planTerminal: true, streaming: false } : message));
}
- if (payload.failureMessage) setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage)));
+ if (terminal && nextStatus === "failed" && payload.failureMessage) {
+ setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage), "Agent 执行失败", { severity: "error" }));
+ }
if (snapshotApproval && !snapshotApproval.decision && snapshotApproval.approvalId) {
setApproval((current) => ({ approvalId: snapshotApproval.approvalId, detail: snapshotApproval, reason: current?.approvalId === snapshotApproval.approvalId ? current.reason : snapshotApproval.reason || "" }));
} else {
@@ -1520,7 +1553,7 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch mergeAgentToolRetry(current, message));
if (event.type === "tool_failed") return;
}
+ if (event.type === "tool_completed" && payload.toolName === "director_preview") {
+ const result = payload.result && typeof payload.result === "object" ? payload.result as Record : {};
+ window.dispatchEvent(new CustomEvent("director:preview-requested", {
+ detail: {
+ sceneId: String(result.sceneId || ""),
+ shotId: String(result.shotId || ""),
+ duration: Number(result.duration || 0),
+ fps: Number(result.fps || 0),
+ previewRequestId: String(result.previewRequestId || payload.callId || ""),
+ },
+ }));
+ setMessages((current) => appendUniqueMessage(current, {
+ id: event.eventId,
+ role: "tool",
+ title: "director_preview",
+ text: text || "已请求导演台生成白模预演",
+ detail: { ...payload, 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 } }));
@@ -1657,11 +1710,11 @@ function upsertTextMessage(current: CloudAgentChatMessage[], id: string, text: s
return next;
}
-function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string) {
+function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string, options?: { severity?: CloudAgentChatMessage["errorSeverity"] }) {
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 });
+ return appendUniqueMessage(current, { id, role: "error", ...message, ...(options?.severity ? { errorSeverity: options.severity } : {}) });
}
function isNotFoundError(cause: unknown) {
diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css
index edc26ef5e..7a13e2631 100644
--- a/web/src/components/canvas/canvas-cloud-agent.css
+++ b/web/src/components/canvas/canvas-cloud-agent.css
@@ -384,8 +384,8 @@
}
.agent-conversation {
- padding: var(--space-3) var(--space-5) var(--space-5);
- scroll-padding-block: var(--space-5);
+ padding: 10px var(--space-4) var(--space-4);
+ scroll-padding-block: var(--space-4);
}
.agent-conversation-messages {
@@ -396,12 +396,56 @@
}
.agent-conversation-messages > * + * {
- margin-top: var(--space-4);
+ margin-top: 9px;
}
/* 状态行的图标只是语义标记,不应把正文向右推成另一条内容轴。 */
.agent-status-message {
min-width: 0;
+ line-height: 1.45;
+}
+
+.agent-status-message--system {
+ opacity: 0.78;
+}
+
+.agent-status-message--warning {
+ margin-inline: -4px;
+ border: 1px solid color-mix(in srgb, #f59e0b 26%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, #fef3c7 72%, transparent);
+ padding: 8px 10px;
+}
+
+.agent-status-message--error {
+ margin-inline: -4px;
+ border: 1px solid color-mix(in srgb, #ef4444 16%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, #ef4444 5%, transparent);
+ padding: 8px 10px;
+}
+
+.agent-status-message--warning .agent-timeline-marker,
+.agent-status-message--error .agent-timeline-marker {
+ align-self: flex-start;
+ padding-top: 1px;
+}
+
+.agent-error-title {
+ font-size: 13px;
+ line-height: 18px;
+}
+
+.agent-error-detail {
+ margin-top: 2px;
+ font-size: 12px;
+ line-height: 17px;
+}
+
+.agent-error-meta {
+ margin-top: 3px;
+ opacity: 0.72;
+ line-height: 16px;
}
.agent-message-user {
@@ -409,6 +453,15 @@
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
}
+.agent-message-body .ai-message-markdown-paragraph {
+ margin: 0.32em 0;
+}
+
+.agent-message-body .ai-message-markdown-list {
+ margin-top: 0.35em;
+ margin-bottom: 0.35em;
+}
+
.agent-welcome {
display: flex;
min-height: 100%;
@@ -788,14 +841,94 @@
line-height: 1;
}
+.agent-message-node-link {
+ display: inline-flex;
+ max-width: min(100%, 300px);
+ align-items: center;
+ gap: 7px;
+ margin: 1px 3px;
+ border: 1px solid color-mix(in srgb, var(--workspace-accent) 28%, transparent);
+ border-radius: 9px;
+ background: color-mix(in srgb, var(--workspace-accent) 9%, transparent);
+ padding: 3px 9px 3px 4px;
+ color: var(--foreground);
+ line-height: 1.15;
+ text-align: left;
+ text-decoration: none;
+ vertical-align: middle;
+ transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease;
+}
+
+.agent-message-node-link:hover {
+ border-color: color-mix(in srgb, var(--workspace-accent) 50%, transparent);
+ background: color-mix(in srgb, var(--workspace-accent) 16%, transparent);
+ color: var(--foreground);
+ text-decoration: none;
+ transform: translateY(-1px);
+}
+
+.agent-message-node-link:focus-visible {
+ outline: 2px solid var(--agent-focus-ring, var(--workspace-accent));
+ outline-offset: 2px;
+}
+
+.agent-message-node-link-preview,
+.agent-message-node-link-icon {
+ display: grid;
+ width: 25px;
+ height: 25px;
+ flex: 0 0 auto;
+ place-items: center;
+ overflow: hidden;
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--workspace-accent) 16%, transparent);
+ color: var(--workspace-accent);
+}
+
+.agent-message-node-link-preview {
+ object-fit: cover;
+}
+
+.agent-message-node-link-copy {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.agent-message-node-link-kind,
+.agent-message-node-link-title {
+ display: block;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.agent-message-node-link-kind {
+ color: color-mix(in srgb, var(--foreground) 58%, transparent);
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.agent-message-node-link-title {
+ color: var(--foreground);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .agent-message-node-link { transition: none; }
+}
+
.agent-tool-row {
/* 日志是事件流,不是连续卡片;只用间距和状态图标建立层次。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
- min-height: 36px;
+ min-height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
- padding: 5px 0;
+ padding: 2px 0;
transition: transform 160ms ease;
}
@@ -831,11 +964,11 @@
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
.agent-tool-row--plain {
- min-height: 28px;
+ min-height: 24px;
border-color: transparent;
border-radius: 9px;
background: transparent;
- padding: 3px 0;
+ padding: 1px 0;
}
.agent-tool-row--read {
@@ -861,10 +994,10 @@
.agent-tool-header {
display: flex;
min-width: 0;
- align-items: center;
+ align-items: baseline;
flex-wrap: wrap;
- gap: 6px;
- line-height: 18px;
+ gap: 4px 6px;
+ line-height: 17px;
}
.agent-tool-category {
@@ -978,19 +1111,37 @@
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
+.agent-operation-feed[data-agent-category="read"] {
+ --agent-tool-accent: #2563eb;
+}
+
+.agent-operation-feed[data-agent-category="create"] {
+ --agent-tool-accent: #d97706;
+}
+
+.agent-operation-feed[data-agent-category="operate"] {
+ --agent-tool-accent: #0f766e;
+}
+
+.agent-operation-feed[data-agent-category="other"] {
+ --agent-tool-accent: #64748b;
+}
+
.agent-operation-toggle {
display: flex;
+ width: 100%;
min-width: 0;
max-width: 100%;
align-items: center;
- gap: 6px;
+ justify-content: flex-start;
+ gap: 5px;
border: 0;
- border-radius: 9px;
+ border-radius: 8px;
background: transparent;
- padding: 5px 0;
+ padding: 3px 0;
color: color-mix(in srgb, var(--agent-ink, currentColor) 72%, transparent);
font-size: var(--fs-caption);
- line-height: 18px;
+ line-height: 17px;
text-align: left;
cursor: pointer;
transition:
@@ -1004,9 +1155,27 @@
padding-inline: 0;
}
+.agent-operation-icon {
+ display: inline-flex;
+ width: 16px;
+ height: 18px;
+ flex: 0 0 16px;
+ align-items: center;
+ justify-content: center;
+ color: var(--agent-tool-accent);
+}
+
+.agent-operation-icon svg {
+ width: 14px;
+ height: 14px;
+}
+
+.agent-operation-feed.is-failed .agent-operation-icon {
+ color: #dc2626;
+}
+
.agent-operation-kind {
flex: 0 0 auto;
- color: color-mix(in srgb, var(--agent-tool-accent) 86%, var(--foreground));
font-size: var(--fs-caption);
font-weight: 600;
white-space: nowrap;
@@ -1087,7 +1256,13 @@
}
.agent-operation-list > * + * {
- margin-top: var(--space-1);
+ margin-top: 3px;
+}
+
+.agent-operation-feed.is-failed .agent-operation-toggle {
+ border-radius: 8px;
+ background: color-mix(in srgb, #dc2626 5%, transparent);
+ padding-inline: 6px;
}
/*
@@ -1344,11 +1519,26 @@
justify-content: center;
}
+.agent-message-icon {
+ display: inline-flex;
+ width: 20px;
+ height: 24px;
+ flex: 0 0 20px;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 3px;
+ color: var(--agent-accent, var(--foreground));
+}
+
+.agent-message-icon--user {
+ opacity: 0.76;
+}
+
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
border: 0;
- border-radius: 10px;
+ border-radius: 0;
background: transparent;
box-shadow: none;
transition: background-color 160ms ease;
@@ -1391,6 +1581,10 @@
color: color-mix(in srgb, var(--agent-reasoning-accent) 82%, var(--foreground));
}
+.agent-reasoning-icon.is-live {
+ animation: agent-reasoning-icon-pulse 1.8s ease-in-out infinite;
+}
+
.agent-reasoning-copy {
display: flex;
min-width: 0;
@@ -1474,7 +1668,7 @@
gap: 0;
overflow: auto;
border-top: 0;
- padding: 4px 0 10px;
+ padding: 2px 0 7px;
scrollbar-gutter: stable;
}
@@ -1487,6 +1681,11 @@
overflow-wrap: anywhere;
}
+@keyframes agent-reasoning-icon-pulse {
+ 0%, 100% { opacity: .55; transform: rotate(-8deg) scale(.9); }
+ 50% { opacity: 1; transform: rotate(8deg) scale(1.08); }
+}
+
@keyframes agent-reasoning-pulse {
0%, 100% { opacity: .55; transform: scale(.82); }
50% { opacity: 1; transform: scale(1); }
@@ -1496,7 +1695,8 @@
.agent-reasoning-card,
.agent-reasoning-chevron { transition: none; }
- .agent-reasoning-status-dot { animation: none; }
+ .agent-reasoning-status-dot,
+ .agent-reasoning-icon.is-live { animation: none; }
}
.agent-working-indicator {
@@ -1898,6 +2098,25 @@
overscroll-behavior: contain;
}
+.canvas-agent-skill-library-category-group {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.canvas-agent-skill-library-category-group + .canvas-agent-skill-library-category-group {
+ margin-top: var(--space-4);
+ border-top: 1px solid var(--border);
+ padding-top: var(--space-4);
+}
+
+.canvas-agent-skill-library-category-heading {
+ padding: 0 var(--space-3) var(--space-1);
+ color: var(--muted-foreground);
+ font-size: var(--fs-caption);
+ font-weight: 650;
+ letter-spacing: 0.04em;
+}
+
.canvas-agent-skill-library-tab {
display: flex;
min-height: var(--space-12);
@@ -2099,7 +2318,25 @@
min-width: 0;
flex-direction: column;
gap: var(--space-2);
- padding-top: var(--space-3);
+ padding: var(--space-4) var(--space-2) var(--space-2);
+}
+
+.canvas-agent-skill-card-body.is-text-only {
+ min-height: 204px;
+ border-radius: var(--r-2xl);
+ background: var(--library-surface);
+ padding: var(--space-5);
+}
+
+.canvas-agent-skill-card-category.is-inline {
+ position: static;
+ display: inline-flex;
+ width: fit-content;
+ max-width: 100%;
+ align-self: flex-start;
+ background: var(--library-surface-hover);
+ color: var(--muted-foreground);
+ backdrop-filter: none;
}
.canvas-agent-skill-card-title-row {
@@ -2111,6 +2348,7 @@
}
.canvas-agent-skill-card-title-row h3 {
+ display: -webkit-box;
min-width: 0;
overflow: hidden;
margin: 0;
@@ -2119,7 +2357,9 @@
font-weight: 650;
line-height: 1.5;
text-overflow: ellipsis;
- white-space: nowrap;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow-wrap: anywhere;
}
.canvas-agent-skill-card-body > p {
@@ -2131,10 +2371,14 @@
font-size: var(--fs-body);
line-height: 1.65;
-webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
+ -webkit-line-clamp: 3;
overflow-wrap: anywhere;
}
+.canvas-agent-skill-card-body.is-text-only > p {
+ -webkit-line-clamp: 5;
+}
+
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
@@ -2250,11 +2494,32 @@
.canvas-agent-skill-library-categories {
flex-direction: row;
- gap: var(--space-1);
+ gap: var(--space-3);
overflow-x: auto;
overscroll-behavior-x: contain;
}
+ .canvas-agent-skill-library-category-group {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: var(--space-1);
+ }
+
+ .canvas-agent-skill-library-category-group + .canvas-agent-skill-library-category-group {
+ margin-top: 0;
+ border-top: 0;
+ border-left: 1px solid var(--border);
+ padding-top: 0;
+ padding-left: var(--space-3);
+ }
+
+ .canvas-agent-skill-library-category-heading {
+ flex: 0 0 auto;
+ padding: 0 var(--space-2);
+ font-size: var(--fs-caption);
+ }
+
.canvas-agent-skill-library-tab {
min-height: var(--space-10);
gap: var(--space-2);
diff --git a/web/src/components/canvas/director/canvas-director-workbench.tsx b/web/src/components/canvas/director/canvas-director-workbench.tsx
index 0733e6a5f..9a4daceb2 100644
--- a/web/src/components/canvas/director/canvas-director-workbench.tsx
+++ b/web/src/components/canvas/director/canvas-director-workbench.tsx
@@ -1,7 +1,7 @@
import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Slider } from "antd";
import { Switch } from "@/components/ui/base/switch";
import type { MenuProps } from "antd";
-import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
+import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, MousePointer2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, WandSparkles, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { nanoid } from "nanoid";
import { Euler, Quaternion } from "three";
@@ -17,7 +17,7 @@ import { advanceDirectorPlayhead, resolveDirectorCameraAlignment, resolveDirecto
import { createDirectorTransaction, installDirectorTerminalListeners, type DirectorTransaction } from "@/lib/canvas/director/director-gesture-transaction";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { DIRECTOR_MODES, directorModeCapabilities, type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes";
-import { resolveDirectorPlacement, resolveDirectorPlacementAnchor } from "@/lib/canvas/director/director-placement";
+import { resolveDirectorPlacement, resolveDirectorPlacementAnchor, type DirectorGroundPoint } from "@/lib/canvas/director/director-placement";
import { isDirectorOutputSnapshotCurrent, shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session";
import { blocksDirectorShortcut, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorShortcutAction } from "@/lib/canvas/director/director-shortcuts";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel, interpolateDirectorTransform, removeDirectorSceneKeyframe, setDirectorSceneKeyframeEasing, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene";
@@ -42,6 +42,8 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
const [future, setFuture] = useState([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = useState(false);
+ // 默认进入镜头优先的快速工作流,完整的摆场/姿态/动画能力仍保留在高级模式。
+ const [quickMode, setQuickMode] = useState(true);
const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0);
const mode = useDirectorWorkbenchStore((state) => state.mode);
const viewMode = useDirectorWorkbenchStore((state) => state.viewMode);
@@ -692,6 +694,71 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
}
};
+ const quickActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
+ const quickSelectedActor = quickActors.find((item) => item.id === selectedObjectId) || quickActors[0] || null;
+ const handleQuickGroundClick = useCallback((point: DirectorGroundPoint) => {
+ if (!quickSelectedActor) {
+ message.info("先添加或选择一个演员");
+ return;
+ }
+ setSelectedObjectId(quickSelectedActor.id);
+ handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, {
+ ...quickSelectedActor.transform,
+ position: [point.x, quickSelectedActor.transform.position[1], point.z],
+ });
+ message.success("演员已落位");
+ }, [handleObjectTransform, message, quickSelectedActor]);
+ const quickPoseOptions: Array<{ value: DirectorPose; label: string }> = [
+ { value: "stand", label: "站立" },
+ { value: "sit", label: "坐下" },
+ { value: "walk", label: "走动" },
+ { value: "wave", label: "挥手" },
+ { value: "think", label: "思考" },
+ { value: "fight", label: "对抗" },
+ ];
+ const applyQuickPose = (pose: DirectorPose) => {
+ if (!quickSelectedActor) {
+ message.info("先添加或选择一个演员");
+ return;
+ }
+ setSelectedObjectId(quickSelectedActor.id);
+ updateObject(quickSelectedActor.id, { pose, activeMotionClipId: undefined, boneOverrides: {} });
+ };
+ const placeQuickActor = () => {
+ if (!quickSelectedActor) {
+ message.info("先添加或选择一个演员");
+ return;
+ }
+ const intent = viewportRef.current?.readPlacementIntent();
+ const point = intent?.pointer || intent?.orbitTarget;
+ if (!point) {
+ message.info("先在视口里移动鼠标到地面,或旋转视图后再落位");
+ return;
+ }
+ setSelectedObjectId(quickSelectedActor.id);
+ handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, { ...quickSelectedActor.transform, position: [point.x, quickSelectedActor.transform.position[1], point.z] });
+ };
+ const addQuickActor = () => {
+ addActor();
+ message.success("演员已加入;直接点击视口地面即可落位");
+ };
+ const selectQuickShotSize = (shotSize: DirectorShotSize) => {
+ if (activeShot) updateShot(activeShot.id, { shotSize });
+ };
+ const selectQuickCameraMove = (move: DirectorCameraMove) => {
+ if (activeShot) updateShot(activeShot.id, { cameraMove: move });
+ };
+
+ useEffect(() => {
+ const onPreviewRequested = (event: Event) => {
+ const detail = (event as CustomEvent<{ sceneId?: string; shotId?: string; duration?: number; fps?: number }>).detail;
+ if (!detail || detail.sceneId !== draft?.id || (detail.shotId && detail.shotId !== activeShot?.id)) return;
+ void exportClayVideo();
+ };
+ window.addEventListener("director:preview-requested", onPreviewRequested);
+ return () => window.removeEventListener("director:preview-requested", onPreviewRequested);
+ }, [activeShot?.id, draft?.id, exportClayVideo]);
+
if (!open || !draft || !activeShot) return null;
return (
@@ -704,7 +771,25 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
{/* 一级模式切换:小屏也必须可达,因此不加 max-lg:hidden。 */}
- : null}
+ {quickMode ?
+
+
+
+ {activeShot.name} · {activeCamera?.name || "无摄影机"}
+
+ 白模预演
+ } loading={recording} onClick={() => void exportClayVideo()}>生成预演
+ } loading={saving} onClick={() => void applyToCanvas()}>应用镜头
+
+
+
+
: null}
+ {!quickMode ? <>
);
}
diff --git a/web/src/components/canvas/director/director-viewport.tsx b/web/src/components/canvas/director/director-viewport.tsx
index 32fcf625f..f6a7ab573 100644
--- a/web/src/components/canvas/director/director-viewport.tsx
+++ b/web/src/components/canvas/director/director-viewport.tsx
@@ -45,6 +45,8 @@ type DirectorViewportProps = {
onViewModeChange?: (mode: DirectorViewMode) => void;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
+ /** 快速镜头模式下,点击地面直接落位;不提供则保持高级工作台原有行为。 */
+ onGroundClick?: (point: DirectorGroundPoint) => void;
onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void;
onBoneTransform: (id: string, bone: string, rotation: DirectorQuat) => void;
onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void;
@@ -288,7 +290,7 @@ function DirectorViewportNotice({ title, description, actionLabel, onAction, var
);
}
-function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) {
+function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onGroundClick, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) {
const { gl, camera, scene: threeScene, invalidate, set, size } = useThree();
const orbitRef = useRef(null);
const [transforming, setTransforming] = useState(false);
@@ -355,25 +357,51 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
const groundPlane = new Plane(new Vector3(0, 1, 0), 0);
const hit = new Vector3();
const ndc = new Vector2();
+ let pointerDown: { id: number; x: number; y: number; button: number } | null = null;
- const onPointerMove = (event: PointerEvent) => {
+ const resolveGroundPoint = (event: PointerEvent) => {
const bounds = canvasElement.getBoundingClientRect();
- if (bounds.width <= 0 || bounds.height <= 0) return;
+ if (bounds.width <= 0 || bounds.height <= 0) return null;
ndc.set(((event.clientX - bounds.left) / bounds.width) * 2 - 1, -((event.clientY - bounds.top) / bounds.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
- // 射线与地面平行或背离时 intersectPlane 返回 null:保留上一个合法点,不写非法值。
- if (!raycaster.ray.intersectPlane(groundPlane, hit)) return;
- const point = finiteDirectorGroundPoint(hit.x, hit.z);
+ if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
+ return finiteDirectorGroundPoint(hit.x, hit.z);
+ };
+
+ const onPointerMove = (event: PointerEvent) => {
+ const point = resolveGroundPoint(event);
+ // 射线与地面平行或背离时保留上一个合法点,不写非法值。
if (point) onGroundPoint(canvasElement, point);
};
+ const onPointerDown = (event: PointerEvent) => {
+ if (!onGroundClick || event.button !== 0 || event.isPrimary === false) return;
+ pointerDown = { id: event.pointerId, x: event.clientX, y: event.clientY, button: event.button };
+ };
+
+ const onPointerUp = (event: PointerEvent) => {
+ const start = pointerDown;
+ pointerDown = null;
+ if (!start || start.id !== event.pointerId || start.button !== 0 || event.button !== 0) return;
+ // OrbitControls 旋转、平移或缩放结束时也会收到 pointerup,移动阈值避免误落位。
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
+ if (distance > 5) return;
+ const point = resolveGroundPoint(event);
+ if (point) onGroundClick?.(point);
+ };
+
canvasElement.addEventListener("pointermove", onPointerMove, { passive: true });
+ canvasElement.addEventListener("pointerdown", onPointerDown, { passive: true });
+ canvasElement.addEventListener("pointerup", onPointerUp, { passive: true });
return () => {
canvasElement.removeEventListener("pointermove", onPointerMove);
+ canvasElement.removeEventListener("pointerdown", onPointerDown);
+ canvasElement.removeEventListener("pointerup", onPointerUp);
+ pointerDown = null;
// 这个 renderer 的 canvas 卸载后,它记录的地面点不得再被读到。
onGroundPoint(canvasElement, null);
};
- }, [camera, gl, onGroundPoint]);
+ }, [camera, gl, onGroundClick, onGroundPoint]);
// OrbitControls 的真实 target 只能从实例读;activeCamera.target 只是初始 prop。
// 挂载期登记一次即可:drei 重建实例会连带重跑本 effect。
diff --git a/web/src/lib/canvas/agent-tool-presentation.ts b/web/src/lib/canvas/agent-tool-presentation.ts
index a1448a33d..dfdafe517 100644
--- a/web/src/lib/canvas/agent-tool-presentation.ts
+++ b/web/src/lib/canvas/agent-tool-presentation.ts
@@ -233,6 +233,7 @@ export const AGENT_TOOL_ERROR_CLASS_LABELS: Record = {
state_conflict: "画布状态已变化",
permission_violation: "超出本轮权限",
upstream_failure: "上游故障",
+ admission_failure: "媒体生成准入失败",
tool_error: "工具执行失败",
};
diff --git a/web/src/pages/canvas/canvas-sync-status.css b/web/src/pages/canvas/canvas-sync-status.css
new file mode 100644
index 000000000..67d723bb4
--- /dev/null
+++ b/web/src/pages/canvas/canvas-sync-status.css
@@ -0,0 +1,123 @@
+.canvas-sync-status-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 30px;
+ padding-inline: 9px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ color: var(--user-ink-muted, var(--muted-foreground));
+ transition: border-color 160ms ease, background 160ms ease, color 160ms ease;
+}
+
+.canvas-sync-status-trigger:hover,
+.canvas-sync-status-trigger[aria-expanded="true"] {
+ background: var(--user-surface-hover, var(--accent));
+ color: var(--user-ink, var(--foreground));
+}
+
+.canvas-sync-status-trigger--done { color: var(--status-success, #3f8f68); }
+.canvas-sync-status-trigger--pending { color: var(--status-warning, #b7791f); }
+.canvas-sync-status-trigger--error,
+.canvas-sync-status-trigger--conflict { color: var(--status-error, var(--destructive)); }
+
+.canvas-sync-popover .ant-popover-inner {
+ overflow: hidden;
+ padding: 0;
+ border: 1px solid var(--user-border, var(--border));
+ border-radius: var(--user-radius-card, 16px);
+ background: var(--user-surface-raised, var(--popover));
+ box-shadow: 0 18px 48px color-mix(in srgb, var(--user-ink, var(--foreground)) 16%, transparent);
+}
+
+.canvas-sync-popover .ant-popover-inner-content { padding: 0; }
+
+.canvas-sync-panel {
+ width: min(340px, calc(100vw - 32px));
+ color: var(--user-ink, var(--foreground));
+}
+
+.canvas-sync-panel__header {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 16px 17px 12px;
+}
+
+.canvas-sync-panel__status-icon {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 34px;
+ place-items: center;
+ border-radius: 11px;
+ background: color-mix(in srgb, var(--user-ink-muted, var(--muted-foreground)) 11%, transparent);
+ color: var(--user-ink-muted, var(--muted-foreground));
+}
+
+.canvas-sync-panel__status-icon svg { width: 17px; height: 17px; }
+.canvas-sync-panel--done .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-success, #3f8f68) 13%, transparent); color: var(--status-success, #3f8f68); }
+.canvas-sync-panel--pending .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-warning, #b7791f) 14%, transparent); color: var(--status-warning, #b7791f); }
+.canvas-sync-panel--error .canvas-sync-panel__status-icon,
+.canvas-sync-panel--conflict .canvas-sync-panel__status-icon { background: color-mix(in srgb, var(--status-error, var(--destructive)) 13%, transparent); color: var(--status-error, var(--destructive)); }
+
+.canvas-sync-panel__heading { min-width: 0; }
+.canvas-sync-panel__eyebrow {
+ display: block;
+ margin-bottom: 2px;
+ color: var(--user-ink-soft, var(--muted-foreground));
+ font-size: 11px;
+ letter-spacing: .04em;
+}
+.canvas-sync-panel__heading strong { display: block; font-size: 14px; font-weight: 650; }
+
+.canvas-sync-panel__description {
+ margin: 0;
+ padding: 0 17px 13px;
+ color: var(--user-ink-muted, var(--muted-foreground));
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.canvas-sync-panel__callout {
+ display: grid;
+ gap: 3px;
+ margin: 0 17px 13px;
+ padding: 10px 11px;
+ border: 1px solid color-mix(in srgb, var(--status-warning, #b7791f) 25%, var(--user-border, var(--border)));
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--status-warning, #b7791f) 8%, var(--user-surface, var(--background)));
+ color: var(--user-ink-muted, var(--muted-foreground));
+ font-size: 11px;
+ line-height: 1.55;
+}
+.canvas-sync-panel--error .canvas-sync-panel__callout { border-color: color-mix(in srgb, var(--status-error, var(--destructive)) 25%, var(--user-border, var(--border))); background: color-mix(in srgb, var(--status-error, var(--destructive)) 7%, var(--user-surface, var(--background))); }
+.canvas-sync-panel__callout strong { color: var(--user-ink, var(--foreground)); font-size: 12px; }
+
+.canvas-sync-panel__actions {
+ display: grid;
+ gap: 8px;
+ padding: 0 17px 15px;
+}
+.canvas-sync-panel__actions .ant-btn { border-radius: 9px; }
+.canvas-sync-panel__secondary-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+ margin: 1px -5px -3px;
+}
+.canvas-sync-panel__secondary-actions .ant-btn {
+ min-width: 0;
+ padding-inline: 6px;
+ color: var(--user-ink-muted, var(--muted-foreground));
+ font-size: 11px;
+}
+.canvas-sync-panel__secondary-actions .ant-btn:hover { color: var(--user-ink, var(--foreground)); background: var(--user-surface-hover, var(--accent)); }
+
+.canvas-sync-confirm-copy p { margin: 0 0 8px; line-height: 1.65; }
+.canvas-sync-confirm-copy p:last-child { margin-bottom: 0; color: var(--muted-foreground); font-size: 12px; }
+.canvas-sync-modal-icon { color: var(--status-warning, #b7791f); }
+
+@media (prefers-reduced-motion: reduce) {
+ .canvas-sync-status-trigger { transition: none; }
+}
diff --git a/web/src/pages/canvas/canvas-sync-status.tsx b/web/src/pages/canvas/canvas-sync-status.tsx
index 13db6aff3..5bab6bd58 100644
--- a/web/src/pages/canvas/canvas-sync-status.tsx
+++ b/web/src/pages/canvas/canvas-sync-status.tsx
@@ -1,65 +1,147 @@
import { App, Button, Dropdown, Popover } from "antd";
-import { CloudCheck, CloudOff, LoaderCircle } from "lucide-react";
+import { CloudCheck, CloudOff, Download, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
import { useState } from "react";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { readAllCanvasSyncDrafts, readCanvasSyncDrafts, type CanvasSyncDraft } from "@/services/canvas-sync-drafts";
import { getActiveUserScope } from "@/lib/user-scope";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
+import { retryRemoteUserDataSync } from "@/services/user-data-sync";
+import "./canvas-sync-status.css";
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise; onOpenVersions?: () => void }) {
- const { message } = App.useApp();
+ const { message, modal } = App.useApp();
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
const [busy, setBusy] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const phase = progress?.phase;
const conflict = phase === "conflict";
- const failed = phase === "error" || conflict || !phase;
+ const error = phase === "error";
+ const retryScheduled = error && progress?.message?.includes("自动重试");
const saving = phase === "pending" || phase === "saving" || phase === "uploading";
- const label = conflict ? "版本冲突 · 未同步" : !phase ? "尚未同步" : failed ? "云端未保存" : saving ? "正在保存" : "已保存";
+ const tone = conflict ? "conflict" : error ? "error" : saving ? "pending" : phase === "done" ? "done" : "idle";
+ const label = conflict ? "需要处理冲突" : error ? "云端保存失败" : saving ? "正在同步" : phase === "done" ? "已保存到云端" : "尚未同步";
+ const description = conflict
+ ? "本地修改没有被覆盖,已保存为本地草稿。"
+ : error
+ ? progress?.message || "本地内容仍然保留,云端暂未确认。"
+ : saving
+ ? progress?.message || "正在把本地修改同步到云端。"
+ : phase === "done"
+ ? progress?.message || "当前画布与云端版本一致。"
+ : "本地内容会先保存到浏览器,再尝试同步到云端。";
+
const run = async (operation: () => Promise) => {
setBusy(true);
try {
await operation();
- } catch (error) {
- message.error(error instanceof Error ? error.message : "操作失败,请重试");
+ } catch (cause) {
+ message.error(cause instanceof Error ? cause.message : "操作失败,请重试");
+ throw cause;
} finally {
setBusy(false);
}
};
+ const confirmLoadLatest = () => {
+ modal.confirm({
+ title: "加载云端最新版本?",
+ icon: ,
+ content: (
+
+
这不是合并操作:当前编辑器内容会替换为云端版本。
+
系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。
+
+ ),
+ okText: "保留草稿并加载",
+ cancelText: "继续编辑",
+ okButtonProps: { danger: true },
+ onOk: () =>
+ run(async () => {
+ await onLoadLatest();
+ setStatusOpen(false);
+ message.success("云端版本已加载,本地内容已保留为草稿");
+ }),
+ });
+ };
+
return (
- <>
-
-
- {progress?.message || (phase === "done" ? "画布已同步到云端" : "尚未确认云端保存,请保留本地内容")}
-
- {conflict ? 此画布的自动提交已暂停。加载最新版前会保留本地草稿,可下载后从画布列表导入为副本。
: null}
-
-
+
+
+
+ {saving ? : conflict || error ? : }
+
+
+ 云端同步
+ {label}
+
+
+ {description}
+ {conflict ? (
+
+ 先保护本地,再决定版本
+ {progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。
+
+ ) : error ? (
+
+ {retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}
+
+ {retryScheduled
+ ? "网络恢复后会自动重试;也可以点击“立即重试”。"
+ : "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
+
+
+ ) : null}
+
+ {error ? (
}
+ loading={busy}
+ onClick={() => void run(() => retryRemoteUserDataSync(projectId)).then(() => setStatusOpen(false)).catch(() => undefined)}
+ >
+ 立即重试
+
+ ) : null}
+
}
+ disabled={busy || saving}
+ onClick={confirmLoadLatest}
+ >
+ 加载云端版
+
+
+ }
onClick={() =>
void run(async () => {
const project = useCanvasStore.getState().openProject(projectId);
- if (project) await exportCanvasProjects([project], `${project.title}-本地草稿`);
- })
+ if (project) await exportCanvasProjects([project], `${project.title}-本地副本`);
+ message.success("当前内容已下载,可作为新画布导入");
+ }).catch(() => undefined)
}
>
- 下载当前内容
+ 下载本地副本
{onOpenVersions ? (
}
onClick={() => {
setStatusOpen(false);
onOpenVersions();
@@ -70,19 +152,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
) : null}
- }
+
+ }
+ >
+ : conflict || error ? : }
>
- : failed ? : }
- >
- {label}
-
-
- >
+ {label}
+
+
);
}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index f3e37c513..8087d780c 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -2828,7 +2828,17 @@ function InfiniteCanvasPage() {
{
- if (!nodesRef.current.some((node) => node.id === nodeId)) { message.info("该节点已删除或尚未同步到画布"); return; }
+ const currentNodes = nodesRef.current;
+ const target = currentNodes.find((node) => node.id === nodeId);
+ if (!target) { message.info("该节点已删除或尚未同步到画布"); return; }
+
+ const parent = target.parentId ? currentNodes.find((node) => node.id === target.parentId) : null;
+ if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
+
+ const batchRootId = target.metadata?.batchRootId;
+ const batchRoot = batchRootId ? currentNodes.find((node) => node.id === batchRootId) : null;
+ if (batchRoot && isHiddenBatchChild(target, currentNodes) && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
+
focusCanvasNode(nodeId);
}} />
diff --git a/web/src/pages/skills/index.tsx b/web/src/pages/skills/index.tsx
index 568918734..5110dc6b4 100644
--- a/web/src/pages/skills/index.tsx
+++ b/web/src/pages/skills/index.tsx
@@ -1,8 +1,9 @@
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
+import { AppModal } from "@/components/ui/product/app-modal";
import { App, Button, Dropdown, Input } from "antd";
import { Tooltip } from "@/components/ui/base/tooltip";
-import { Boxes, Check, Clapperboard, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, UserRound } from "lucide-react";
+import { Boxes, Check, Clapperboard, FolderInput, FolderPlus, Globe2, Heart, Library, LoaderCircle, Megaphone, MoreHorizontal, Palette, Plus, Puzzle, Search, ShoppingBag, Sparkles, Trash2, UserRound } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
@@ -13,7 +14,8 @@ import { fallbackSkillCategories, formatSkillCount, groupSkills, skillCategoryLa
import { SkillDetailModal } from "@/pages/skills/skill-detail-drawer";
import { SkillEditorDrawer } from "@/pages/skills/skill-editor-drawer";
import { SkillInstallModal } from "@/pages/skills/skill-install-modal";
-import { addSkill, deleteSkill, getSkill, likeSkill, listSkills, removeSkill, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillScope, type SkillSort } from "@/services/api/skills";
+import { addSkill, createSkillLibraryCategory, deleteSkill, deleteSkillLibraryCategory, getSkill, likeSkill, listSkillLibraryCategories, listSkills, removeSkill, setSkillLibraryCategory, syncSkill, unlikeSkill, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillLibraryCategoryList, type SkillScope, type SkillSort } from "@/services/api/skills";
+import { useUserStore } from "@/stores/use-user-store";
import { Select } from "@/components/ui/base/select";
const scopeOptions = [
@@ -46,6 +48,13 @@ export default function SkillsPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [tag, setTag] = useState("all");
+ const [libraryCategoryId, setLibraryCategoryId] = useState("all");
+ const [libraryCategoryList, setLibraryCategoryList] = useState(null);
+ const [libraryCategoryError, setLibraryCategoryError] = useState("");
+ const [categoryEditorOpen, setCategoryEditorOpen] = useState(false);
+ const [categoryName, setCategoryName] = useState("");
+ const [categoryScope, setCategoryScope] = useState<"personal" | "platform">("personal");
+ const [categorySaving, setCategorySaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [skills, setSkills] = useState([]);
@@ -71,14 +80,33 @@ export default function SkillsPage() {
const [editorOpen, setEditorOpen] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState(null);
+ const isAdmin = useUserStore((state) => state.user?.role === "admin");
+ const isLibraryScope = scope === "mine" || scope === "created";
+ const libraryCategoryScope = scope === "created" ? "created" : "mine";
const reload = useCallback(() => setReloadKey((value) => value + 1), []);
+ const selectMarketplaceCategory = (categoryId: string) => {
+ setScope("public");
+ setTag(categoryId);
+ setLibraryCategoryId("all");
+ setPage(1);
+ };
+ const selectLibraryCategory = (categoryId: string) => {
+ setScope((current) => current === "created" ? "created" : "mine");
+ setLibraryCategoryId(categoryId);
+ setPage(1);
+ };
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadError("");
- listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: tag === "all" ? undefined : tag })
+ const libraryFilter = isLibraryScope
+ ? libraryCategoryId === "__uncategorized__"
+ ? { libraryUncategorized: true }
+ : libraryCategoryId !== "all" ? { libraryCategoryId } : {}
+ : {};
+ listSkills({ page, pageSize, scope, sort, search: debouncedSearch || undefined, tag: !isLibraryScope && tag !== "all" ? tag : undefined, ...libraryFilter })
.then((result) => {
if (cancelled) return;
setSkills(result.skills);
@@ -98,11 +126,25 @@ export default function SkillsPage() {
return () => {
cancelled = true;
};
- }, [debouncedSearch, page, pageSize, reloadKey, scope, sort, tag]);
+ }, [debouncedSearch, isLibraryScope, libraryCategoryId, page, pageSize, reloadKey, scope, sort, tag]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLibraryCategoryError("");
+ listSkillLibraryCategories(libraryCategoryScope)
+ .then((result) => { if (!cancelled) setLibraryCategoryList(result); })
+ .catch((error) => {
+ if (!cancelled) {
+ setLibraryCategoryList(null);
+ setLibraryCategoryError(error instanceof Error ? error.message : "技能库分类加载失败");
+ }
+ });
+ return () => { cancelled = true; };
+ }, [libraryCategoryScope, reloadKey]);
const groupedSkills = useMemo(() => groupSkills(skills, categories), [categories, skills]);
- const filtersActive = Boolean(search || tag !== "all" || sort !== "popular");
- const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }, []);
+ const filtersActive = Boolean(search || (!isLibraryScope && tag !== "all") || (isLibraryScope && libraryCategoryId !== "all") || sort !== "popular");
+ const resetFilters = useCallback(() => { setSearch(""); setTag("all"); setLibraryCategoryId("all"); setSort("popular"); setPage(1); }, []);
const openSkill = async (skill: Skill) => {
setActiveSkill(skill);
@@ -147,7 +189,7 @@ export default function SkillsPage() {
const result = skill.isAdded ? await removeSkill(skill.skillId) : await addSkill(skill.skillId);
patchSkill(result.skill);
message.success(result.skill.isAdded ? "已加入我的技能" : "已从我的技能移除");
- if (scope === "mine" && !result.skill.isAdded) reload();
+ if (scope === "mine") reload();
} catch (error) {
message.error(error instanceof Error ? error.message : "技能状态更新失败");
} finally {
@@ -204,6 +246,124 @@ export default function SkillsPage() {
});
};
+ const saveLibraryCategory = async () => {
+ const name = categoryName.trim();
+ if (!name) {
+ message.error("请输入分类名称");
+ return;
+ }
+ setCategorySaving(true);
+ try {
+ await createSkillLibraryCategory({ name, scope: isAdmin ? categoryScope : "personal" });
+ message.success("技能库分类已创建");
+ setCategoryEditorOpen(false);
+ setCategoryName("");
+ setLibraryCategoryId("all");
+ setPage(1);
+ reload();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "分类创建失败");
+ } finally {
+ setCategorySaving(false);
+ }
+ };
+
+ const confirmDeleteLibraryCategory = (category: SkillLibraryCategory) => {
+ modal.confirm({
+ title: `删除分类“${category.name}”?`,
+ content: "删除分类只会解除技能与该分类的关联,不会删除技能本身。",
+ okText: "删除分类",
+ okButtonProps: { danger: true },
+ cancelText: "取消",
+ onOk: async () => {
+ try {
+ await deleteSkillLibraryCategory(category.id);
+ if (libraryCategoryId === category.id) setLibraryCategoryId("all");
+ message.success("分类已删除,技能仍保留在技能库中");
+ reload();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "分类删除失败");
+ throw error;
+ }
+ },
+ });
+ };
+
+ const assignLibraryCategory = async (skill: Skill, categoryId: string) => {
+ setMutatingID(skill.skillId);
+ try {
+ const result = await setSkillLibraryCategory(skill.skillId, categoryId);
+ patchSkill(result.skill);
+ message.success(categoryId ? "技能已归类" : "已取消技能归类");
+ reload();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "技能归类失败");
+ } finally {
+ setMutatingID("");
+ }
+ };
+
+ const libraryCategories = libraryCategoryList?.categories ?? [];
+ const marketplaceCategoryTotal = categories.every((category) => category.count !== undefined)
+ ? categories.reduce((count, category) => count + (category.count ?? 0), 0)
+ : "—";
+ const renderSkillCard = (skill: Skill, index: number) => (
+ void openSkill(skill)}
+ onAdd={() => void toggleAdded(skill)}
+ onLike={() => void toggleLiked(skill)}
+ onEdit={() => void openEditor(skill)}
+ onDelete={() => confirmDelete(skill)}
+ onSetLibraryCategory={(categoryId) => void assignLibraryCategory(skill, categoryId)}
+ />
+ );
+
+ const skillContent = loading && !skills.length ? : loadError ? : isLibraryScope && skills.length ? (
+
+ {skills.map(renderSkillCard)}
+
+ ) : !isLibraryScope && groupedSkills.length ? (
+
+ {groupedSkills.map((group) => {
+ const GroupIcon = categoryIconOf(group.value);
+ return (
+
+
+
+
+ {group.label}
+
+ {group.skills.length} 个
+
+
+ {group.skills.map(renderSkillCard)}
+
+
+ );
+ })}
+
+ ) : (
+ 清除筛选
+ : (scope === "created" || scope === "public")
+ ? } onClick={() => setInstallOpen(true)}>安装技能
+ : undefined}
+ />
+ );
+
return (
<>
@@ -217,6 +377,7 @@ export default function SkillsPage() {
const current = scopeOptions.findIndex((option) => option.value === scope);
const next = event.key === "Home" ? 0 : event.key === "End" ? scopeOptions.length - 1 : (current + (event.key === "ArrowRight" ? 1 : -1) + scopeOptions.length) % scopeOptions.length;
setScope(scopeOptions[next].value as SkillScope);
+ setLibraryCategoryId("all");
setPage(1);
tabsRef.current?.querySelectorAll('[role="tab"]')[next]?.focus();
}}>
@@ -233,7 +394,7 @@ export default function SkillsPage() {
tabIndex={active ? 0 : -1}
aria-selected={active}
className={`skills-tab${active ? " is-active" : ""}`}
- onClick={() => { setScope(option.value as SkillScope); setPage(1); }}
+ onClick={() => { setScope(option.value as SkillScope); setLibraryCategoryId("all"); setPage(1); }}
>
{option.label}
@@ -245,49 +406,129 @@ export default function SkillsPage() {
} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
-
- {loading && !skills.length ?
: loadError ?
: groupedSkills.length ? (
-
- {groupedSkills.map((group) => {
- const GroupIcon = categoryIconOf(group.value);
- return (
-
-
-
-
- {group.label}
-
-
{group.skills.length} 个
+
+
- ) : (
-
{ setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选
- : (scope === "created" || scope === "public")
- ? } onClick={() => setInstallOpen(true)}>安装技能
- : undefined}
- />
- )}
+ ))}
+
+
+
+ {skillContent}
+
{ setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
+
+
+
+ 技能库整理
+ 新建分类
+
+
+ )}
+ className="library-modal skills-library-category-modal"
+ width="min(480px, calc(100vw - 32px))"
+ centered
+ okText="创建分类"
+ cancelText="取消"
+ okButtonProps={{ disabled: !categoryName.trim() }}
+ confirmLoading={categorySaving}
+ onOk={() => void saveLibraryCategory()}
+ onCancel={() => setCategoryEditorOpen(false)}
+ destroyOnHidden
+ >
+
+
+
+ setCategoryName(event.target.value)} onPressEnter={() => void saveLibraryCategory()} placeholder="例如:角色设定、分镜、宣发" />
+ 最多 64 个字符;名称不能与当前可用分类重名。
+
+ {isAdmin ? (
+
+
分类范围
+
+
+
+
+
+ ) : null}
+
+
+
{categoryScope === "platform" && isAdmin
+ ? "平台分类对所有用户可见、可分配,并可用于各自的技能库和画布 Agent;不会替换技能广场分类。"
+ : "个人分类只影响你的技能库和画布 Agent。短剧影视、电商营销是技能广场的共享分类,可在左侧“技能广场分类”中筛选。"}
+
+
+
setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} />
setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} />
setEditorOpen(false)} onSaved={(skill) => { setEditorOpen(false); setEditingSkill(null); setActiveSkill(skill); reload(); }} />
@@ -295,8 +536,10 @@ export default function SkillsPage() {
);
}
-function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; categories: SkillCategory[]; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
+function SkillCard({ skill, categories, libraryCategories, canCategorize, loading, style, onOpen, onAdd, onLike, onEdit, onDelete, onSetLibraryCategory }: { skill: Skill; categories: SkillCategory[]; libraryCategories: SkillLibraryCategory[]; canCategorize: boolean; loading: boolean; style?: CSSProperties; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void; onSetLibraryCategory: (categoryId: string) => void }) {
const CategoryIcon = categoryIconOf(skill.tag);
+ const currentLibraryCategory = libraryCategories.find((category) => category.id === skill.libraryCategoryId);
+ const libraryCategoryLabel = currentLibraryCategory?.name || (skill.libraryCategoryId ? "已归类" : "未分类");
return (
@@ -304,6 +547,25 @@ function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, o
+ {canCategorize ? (
+
+ ({ key: category.id, label: category.name })),
+ ],
+ onClick: ({ key }) => onSetLibraryCategory(key === "__uncategorized__" ? "" : key),
+ }}
+ >
+
+
+
+ ) : null}
{skill.isOwner ? (
;
+export type AddedSkillReference = Pick;
-export type SkillCategory = { value: string; label: string };
+export type SkillCategory = { value: string; label: string; count?: number };
+
+
+export type SkillLibraryCategory = {
+ id: string;
+ name: string;
+ scope: "personal" | "platform";
+ count: number;
+};
+
+export type SkillLibraryCategoryList = {
+ categories: SkillLibraryCategory[];
+ totalCount: number;
+ uncategorizedCount: number;
+};
+
+export type SkillLibraryCategoryMutationInput = {
+ name: string;
+ scope?: "personal" | "platform";
+};
/**
* 场景预设:平台只读目录(GET /skills/presets,随二进制内置)。
@@ -95,6 +115,8 @@ export type ListSkillsInput = {
sort?: SkillSort;
search?: string;
tag?: string;
+ libraryCategoryId?: string;
+ libraryUncategorized?: boolean;
};
export type SkillMutationInput = {
@@ -158,6 +180,23 @@ export function listSkills(input: ListSkillsInput = {}) {
return http.get(`/skills?${params.toString()}`);
}
+
+export function listSkillLibraryCategories(scope: "mine" | "created" = "mine") {
+ return http.get("/skills/library-categories", { params: { scope } });
+}
+
+export function createSkillLibraryCategory(input: SkillLibraryCategoryMutationInput) {
+ return http.post<{ category: SkillLibraryCategory }>("/skills/library-categories", input).finally(invalidateAddedSkillsCache);
+}
+
+export function deleteSkillLibraryCategory(id: string) {
+ return http.delete<{ deleted: boolean }>(`/skills/library-categories/${encodeURIComponent(id)}`).finally(invalidateAddedSkillsCache);
+}
+
+export function setSkillLibraryCategory(skillId: string, categoryId: string) {
+ return http.patch<{ skill: Skill }>(`/skills/${encodeURIComponent(skillId)}/library-category`, { categoryId }).finally(invalidateAddedSkillsCache);
+}
+
export function getSkill(id: string) {
return http.get<{ skill: Skill }>(`/skills/${encodeURIComponent(id)}`);
}
@@ -237,6 +276,7 @@ function normalizeAddedSkillReference(skill: AddedSkillReference): Skill {
extraInfo: "",
isAdded: skill.isAdded,
isOwner: skill.isOwner,
+ libraryCategoryId: skill.libraryCategoryId || "",
};
}
diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts
index 3fd25ca54..b385f19a7 100644
--- a/web/src/services/user-data-sync.ts
+++ b/web/src/services/user-data-sync.ts
@@ -26,6 +26,7 @@ let remoteUserDataPhase: RemoteUserDataPhase = "inactive";
let syncTimer: number | null = null;
let syncPromise: Promise | null = null;
let syncQueued = false;
+let syncRetryAttempt = 0;
let remoteOperationTail: Promise = Promise.resolve();
let subscriptionsInstalled = false;
let acknowledgedAssets = new Map();
@@ -35,6 +36,7 @@ let sessionEpoch = 0;
const verifiedProjects = new Set();
const verifiedAssets = new Set();
const remoteProjectLoadPromises = new Map>();
+const REMOTE_SYNC_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
export async function initializeRemoteUserDataSession(userId: string) {
await withRemoteUserDataSyncExclusive(async () => {
@@ -373,6 +375,7 @@ export function resetRemoteUserDataSync() {
window.clearTimeout(syncTimer);
syncTimer = null;
}
+ syncRetryAttempt = 0;
syncQueued = false;
useSyncProgressStore.getState().clearAll();
}
@@ -409,6 +412,66 @@ export function scheduleRemoteUserDataSync() {
}, 1200);
}
+function isRetryableRemoteSyncError(error: unknown) {
+ if (error instanceof ApiError) return error.retryable;
+ if (error instanceof DOMException && error.name === "AbortError") return false;
+ if (typeof error === "object" && error !== null && "permanent" in error) {
+ return (error as { permanent?: unknown }).permanent !== true;
+ }
+ // Plain errors from validation/local storage should remain visible instead of
+ // being retried indefinitely. Transport failures are normalized as ApiError.
+ return false;
+}
+
+function updateRetryProgress(delayMs: number) {
+ const seconds = Math.max(1, Math.ceil(delayMs / 1_000));
+ const progress = useSyncProgressStore.getState();
+ for (const item of Object.values(progress.syncingProjects)) {
+ if (item.phase !== "error") continue;
+ progress.setProjectProgress(item.projectId, {
+ message: `${item.message || "云端同步失败"};${seconds} 秒后自动重试`,
+ });
+ }
+}
+
+function stopRetryProgress() {
+ const progress = useSyncProgressStore.getState();
+ for (const item of Object.values(progress.syncingProjects)) {
+ if (item.phase !== "error" || !item.message?.includes("自动重试")) continue;
+ progress.setProjectProgress(item.projectId, { message: "云端同步失败,请点击立即重试" });
+ }
+}
+
+function scheduleRemoteUserDataRetry(error: unknown) {
+ if (!isRetryableRemoteSyncError(error) || !activeRemoteUserId || remoteUserDataPhase !== "ready") return false;
+ if (syncRetryAttempt >= REMOTE_SYNC_RETRY_DELAYS_MS.length) {
+ stopRetryProgress();
+ return false;
+ }
+ const delayMs = Math.max(
+ REMOTE_SYNC_RETRY_DELAYS_MS[syncRetryAttempt],
+ error instanceof ApiError ? error.retryAfterMs || 0 : 0,
+ );
+ syncRetryAttempt += 1;
+ if (syncTimer) window.clearTimeout(syncTimer);
+ updateRetryProgress(delayMs);
+ syncTimer = window.setTimeout(() => {
+ syncTimer = null;
+ void saveRemoteUserDataNow().catch((retryError) => console.warn("云端自动重试失败", retryError));
+ }, delayMs);
+ return true;
+}
+
+/** 用户主动点击重试时重新开始退避窗口,不影响本地已保存的内容。 */
+export function retryRemoteUserDataSync(projectId?: string) {
+ syncRetryAttempt = 0;
+ if (syncTimer) {
+ window.clearTimeout(syncTimer);
+ syncTimer = null;
+ }
+ return saveRemoteUserDataNow(projectId);
+}
+
export function formatLocalSavedRemotePending(localAction: string, error: unknown): string {
const detail = error instanceof Error && error.message.trim() ? error.message.trim() : "未知错误";
if (error instanceof ApiError && error.reason === "canvas_history_resources_missing") {
@@ -616,13 +679,23 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止旧会话保存");
await drainRemoteUserDataChanges(options);
});
+ let syncFailed = false;
+ let syncError: unknown;
try {
await syncPromise;
assertNoConflict();
+ syncRetryAttempt = 0;
+ } catch (error) {
+ syncFailed = true;
+ syncError = error;
} finally {
syncPromise = null;
if (syncQueued) scheduleRemoteUserDataSync();
}
+ if (syncFailed) {
+ scheduleRemoteUserDataRetry(syncError);
+ throw syncError;
+ }
}
/**
diff --git a/web/src/styles/unified-buttons.css b/web/src/styles/unified-buttons.css
index e07248ce9..f383fb6ec 100644
--- a/web/src/styles/unified-buttons.css
+++ b/web/src/styles/unified-buttons.css
@@ -11,13 +11,6 @@
box-shadow: none !important;
}
-/* 带文字的按钮不要贴着左边缘。纯图标按钮保持正方形,不额外加水平内边距。
- :has 只能识别 svg 是直接唯一子元素的情况;图标被动画 span、角标、sr-only 等
- wrapper 包住时检测不到,这类按钮用 data-icon-only 显式声明豁免。 */
-:where(button:not(.ant-btn-icon-only):not([data-icon-only]), .ant-btn:not(.ant-btn-icon-only):not([data-icon-only])):not(:has(> svg:only-child)) {
- padding-inline: max(12px, var(--space-3));
-}
-
.ant-select.app-unified-select[data-input-modality="keyboard"]:not(.ant-select-open) {
outline: none !important;
box-shadow: none !important;
diff --git a/web/src/styles/workspace-product.css b/web/src/styles/workspace-product.css
index 77ad3e9cd..8aa5801b9 100644
--- a/web/src/styles/workspace-product.css
+++ b/web/src/styles/workspace-product.css
@@ -94,7 +94,7 @@
.app-product-workspace .product-collection-card .project-library-heading strong { font-size: 15px; }
.app-product-workspace .product-collection-card :is(.project-library-subtitle, .project-library-progress, .project-library-stats, .library-card-meta, .library-card-description) { color: var(--user-ink-muted); }
.app-product-workspace .product-collection-card .project-library-cover-scrim { background: linear-gradient(transparent 65%, rgba(0, 0, 0, .12)); }
-.app-product-workspace .skill-library-grid.library-grid { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
+.app-product-workspace .skill-library-grid.library-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); }
.app-product-workspace .skill-library-card.product-collection-card { min-height: 220px; padding: var(--space-4); }
.app-product-workspace .skill-library-card .skill-card-top { align-items: center; gap: var(--space-3); }
.app-product-workspace .skill-library-card .skill-card-icon { width: 36px; height: 36px; flex-shrink: 0; margin: 0; border: 0; background: color-mix(in srgb, var(--category-accent, var(--user-ink-muted)) 12%, var(--user-surface)); color: var(--category-accent, var(--user-ink-muted)); }
@@ -419,6 +419,79 @@
.app-product-workspace .skills-browse-bar .collection-toolbar { margin: 0; max-width: 100%; }
.app-product-workspace .skills-scope-panel { display: grid; gap: 28px; padding: 0; animation: none; }
+.app-product-workspace .skills-library-layout { display: grid; grid-template-columns: minmax(208px, 236px) minmax(0, 1fr); align-items: start; gap: 24px; }
+.app-product-workspace .skills-library-sidebar { position: sticky; top: 20px; display: grid; gap: 16px; align-self: start; padding: 16px; border: 1px solid color-mix(in srgb, var(--user-ink-muted) 18%, transparent); border-radius: var(--r-xl); background: var(--user-surface); }
+.app-product-workspace .skills-category-section { display: grid; min-width: 0; gap: 10px; }
+.app-product-workspace .skills-category-section + .skills-category-section { border-top: 1px solid color-mix(in srgb, var(--user-ink-muted) 16%, transparent); padding-top: 16px; }
+.app-product-workspace .skills-library-sidebar-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
+.app-product-workspace .skills-library-sidebar-heading > div { display: grid; gap: 2px; }
+.app-product-workspace .skills-library-sidebar-heading strong { font-size: var(--fs-body); font-weight: 650; }
+.app-product-workspace .skills-library-sidebar-heading span { color: var(--user-ink-muted); font-size: var(--fs-label); }
+.app-product-workspace .skills-library-category-list { display: grid; gap: 4px; }
+.app-product-workspace .skills-library-category-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 2px; }
+.app-product-workspace .skills-library-category-item { display: flex; min-width: 0; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; gap: var(--space-2); padding: 7px 9px; border: 0; border-radius: var(--r-lg); background: transparent; color: var(--user-ink-muted); text-align: left; transition: background var(--product-enter) var(--product-ease), color var(--product-enter) var(--product-ease); }
+.app-product-workspace .skills-library-category-item > span:first-child { display: flex; min-width: 0; align-items: center; gap: var(--space-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.app-product-workspace .skills-library-category-item > span:last-child { flex: 0 0 auto; font-size: var(--fs-label); font-variant-numeric: tabular-nums; }
+.app-product-workspace .skills-library-category-item:hover,
+.app-product-workspace .skills-library-category-item.is-active,
+.app-product-workspace .skills-library-category-row.is-active .skills-library-category-item { background: color-mix(in srgb, var(--user-ink-muted) 12%, var(--user-surface)); color: inherit; }
+.app-product-workspace .skills-library-category-item:focus-visible,
+.app-product-workspace .skills-library-category-delete:focus-visible { outline: 2px solid color-mix(in srgb, var(--user-accent) 62%, transparent); outline-offset: 2px; }
+.app-product-workspace .skills-library-sidebar-heading > .ant-btn { flex: 0 0 auto; }
+.app-product-workspace .skills-library-category-delete { display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: var(--r-lg); background: transparent; color: var(--user-ink-muted); opacity: .68; }
+.app-product-workspace .skills-library-category-delete:hover { background: color-mix(in srgb, var(--user-ink-muted) 12%, var(--user-surface)); opacity: 1; }
+.app-product-workspace .skills-library-category-error { color: var(--user-ink-muted); font-size: var(--fs-label); }
+.skills-library-category-modal .ant-modal-container { overflow: hidden; border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); border-radius: var(--r-xl); background: var(--popover); box-shadow: var(--elevation-overlay); }
+.skills-library-category-modal .ant-modal-header { margin: 0; padding: 20px 22px 8px; }
+.skills-library-category-modal .ant-modal-title { font-size: var(--fs-body); font-weight: 600; letter-spacing: 0; }
+.skills-library-category-modal .ant-modal-body { padding: 10px 22px 18px; }
+.skills-library-category-modal .ant-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin: 0; padding: 0 22px 20px; }
+.skills-library-category-modal .ant-modal-footer .ant-btn { min-width: 84px; height: 38px; margin-inline-start: 0; }
+.skills-library-category-modal .ant-modal-footer .ant-btn-primary { box-shadow: none; font-weight: 600; }
+.skills-library-category-modal .ant-modal-close { top: 14px; right: 14px; }
+.skills-library-category-title { display: flex; align-items: center; gap: 12px; }
+.skills-library-category-title-icon { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--user-accent) 18%, transparent); border-radius: var(--r-lg); background: color-mix(in srgb, var(--user-accent) 10%, var(--user-surface)); color: var(--user-accent); }
+.skills-library-category-title > span:last-child { display: grid; gap: 2px; }
+.skills-library-category-title small { color: var(--user-ink-muted); font-size: var(--fs-label); font-weight: 560; letter-spacing: .04em; }
+.skills-library-category-title strong { color: var(--user-ink); font-size: var(--fs-heading-lg); font-weight: 650; line-height: 1.25; }
+.skills-library-category-editor { display: grid; gap: 16px; }
+.skills-library-category-field { display: grid; gap: 8px; }
+.skills-library-category-field > label,
+.skills-library-category-field-label { color: var(--user-ink); font-size: var(--fs-label); font-weight: 620; }
+.skills-library-category-field .ant-input-affix-wrapper { min-height: 44px; padding-inline: 12px; border-radius: var(--r-md); background: var(--user-surface-muted); box-shadow: none; }
+.skills-library-category-field .ant-input { background: transparent; }
+.skills-library-category-field-hint { color: var(--user-ink-muted); font-size: var(--fs-caption); line-height: 1.45; }
+.skills-library-category-scope { display: grid; gap: 8px; }
+.skills-library-category-scope-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+.skills-library-category-scope-option { display: grid; grid-template-columns: 34px minmax(0, 1fr) 16px; min-width: 0; min-height: 72px; align-items: center; gap: 9px; border: 1px solid color-mix(in srgb, var(--user-ink-muted) 18%, transparent); border-radius: var(--r-lg); padding: 10px; background: var(--user-surface-muted); color: var(--user-ink-muted); text-align: left; transition: border-color var(--product-state) var(--product-ease), background var(--product-state) var(--product-ease), color var(--product-state) var(--product-ease); }
+.skills-library-category-scope-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: var(--r-md); background: color-mix(in srgb, var(--user-ink-muted) 8%, var(--user-surface)); }
+.skills-library-category-scope-copy { display: grid; min-width: 0; gap: 3px; }
+.skills-library-category-scope-copy strong { color: var(--user-ink); font-size: var(--fs-label); font-weight: 620; }
+.skills-library-category-scope-copy small { color: var(--user-ink-muted); font-size: var(--fs-caption); line-height: 1.4; }
+.skills-library-category-scope-check { color: var(--user-accent); }
+.skills-library-category-scope-option:hover,
+.skills-library-category-scope-option.is-active { border-color: color-mix(in srgb, var(--user-accent) 48%, transparent); background: color-mix(in srgb, var(--user-accent) 7%, var(--user-surface)); }
+.skills-library-category-scope-option.is-active .skills-library-category-scope-icon { background: color-mix(in srgb, var(--user-accent) 12%, var(--user-surface)); color: var(--user-accent); }
+.skills-library-category-scope-option:focus-visible { outline: 2px solid color-mix(in srgb, var(--user-accent) 62%, transparent); outline-offset: 2px; }
+.skills-library-category-note { display: flex; align-items: flex-start; gap: 8px; border: 1px solid color-mix(in srgb, var(--user-ink-muted) 12%, transparent); border-radius: var(--r-md); padding: 10px 11px; background: color-mix(in srgb, var(--user-ink-muted) 5%, var(--user-surface)); color: var(--user-accent); }
+.skills-library-category-note.is-platform { border-color: color-mix(in srgb, var(--user-accent) 20%, transparent); background: color-mix(in srgb, var(--user-accent) 6%, var(--user-surface)); }
+.skills-library-category-note > svg { flex: 0 0 auto; margin-top: 1px; }
+.skills-library-category-note p { margin: 0; color: var(--user-ink-muted); font-size: var(--fs-caption); line-height: 1.5; }
+.app-product-workspace .skills-library-results { min-width: 0; }
+.app-product-workspace .skill-card-category-picker { display: flex; max-width: 132px; min-width: 0; align-items: center; gap: 5px; overflow: hidden; border: 0; border-radius: var(--r-lg); padding: 5px 7px; background: color-mix(in srgb, var(--user-ink-muted) 9%, var(--user-surface)); color: var(--user-ink-muted); font-size: var(--fs-label); }
+.app-product-workspace .skill-card-category-picker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.app-product-workspace .skill-card-category-picker:hover:not(:disabled) { color: inherit; }
+@media (max-width: 720px) {
+ .app-product-workspace .skills-library-layout { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
+ .app-product-workspace .skills-library-sidebar { position: static; gap: var(--space-3); padding: var(--space-3); }
+ .app-product-workspace .skills-library-category-list { display: flex; overflow-x: auto; padding-bottom: 2px; }
+ .app-product-workspace .skills-library-category-item { width: max-content; flex: 0 0 auto; }
+ .app-product-workspace .skills-library-category-row { display: flex; flex: 0 0 auto; }
+ .skills-library-category-modal .ant-modal-header { padding: 18px 18px 6px; }
+ .skills-library-category-modal .ant-modal-body { padding: 10px 18px 16px; }
+ .skills-library-category-modal .ant-modal-footer { padding: 0 18px 18px; }
+ .skills-library-category-scope-options { grid-template-columns: minmax(0, 1fr); }
+}
.app-product-workspace .skill-section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.app-product-workspace .skill-section-heading h2 { margin: 0; }
.app-product-workspace .skill-group-icon { border: 0; color: var(--category-accent, var(--user-ink-muted)); background: transparent; }
diff --git a/web/test/agent-chat-node-links.test.tsx b/web/test/agent-chat-node-links.test.tsx
new file mode 100644
index 000000000..a65e489df
--- /dev/null
+++ b/web/test/agent-chat-node-links.test.tsx
@@ -0,0 +1,65 @@
+import { expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { AgentChatMessage, rewriteAgentNodeLinks, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
+import { canvasThemes } from "@/lib/canvas-theme";
+
+const videoReference = {
+ id: "video-1790385749473-q4157",
+ nodeId: "video-1790385749473-q4157",
+ kind: "video" as const,
+ label: "片头镜头",
+ title: "片头镜头",
+ sourceType: "video" as const,
+ previewUrl: "data:image/png;base64,preview",
+};
+
+function assistantMessage(text: string): CloudAgentChatMessage {
+ return { id: "assistant-1", role: "assistant", text };
+}
+
+test("rewrites generated node IDs into semantic Markdown links", () => {
+ const text = rewriteAgentNodeLinks("新节点 ID:video-1790385749473-q4157", [videoReference]);
+
+ expect(text).toContain("新节点:[视频节点 · 片头镜头](#agent-node:video-1790385749473-q4157)");
+ expect(text).not.toContain("新节点 ID");
+ expect(text).not.toContain("video-1790385749473-q4157");
+});
+
+test("does not mistake Agent task IDs for canvas nodes", () => {
+ const text = "任务 ag293f6d7-55b6-4a25-8c4f-8c6b2c4d9b10 已创建";
+ expect(rewriteAgentNodeLinks(text)).toBe(text);
+});
+
+test("falls back to a node type label and URL-encodes exact reference IDs", () => {
+ const nodeId = "video-custom:take-1";
+ const text = rewriteAgentNodeLinks(nodeId, [{ ...videoReference, id: nodeId, nodeId, title: "自定义镜头", label: "自定义镜头" }]);
+
+ expect(text).toContain("[视频节点 · 自定义镜头](#agent-node:video-custom%3Atake-1)");
+ expect(rewriteAgentNodeLinks("video-123")).toContain("[视频节点](#agent-node:video-123)");
+});
+
+test("leaves fenced code blocks untouched", () => {
+ const text = "```text\nvideo-1790385749473-q4157\n```\nvideo-1790385749473-q4157";
+ const rewritten = rewriteAgentNodeLinks(text, [videoReference]);
+
+ expect(rewritten).toContain("```text\nvideo-1790385749473-q4157\n```");
+ expect(rewritten).toContain("[视频节点 · 片头镜头](#agent-node:video-1790385749473-q4157)");
+});
+
+test("renders an agent node link as a titled inline node card", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ />,
+ );
+ const visibleText = html.replace(/<[^>]+>/gu, "");
+
+ expect(html).toContain('class="agent-message-node-link"');
+ expect(html).toContain('data-agent-node-id="video-1790385749473-q4157"');
+ expect(html).toContain("视频节点");
+ expect(html).toContain("片头镜头");
+ expect(visibleText).not.toContain("video-1790385749473-q4157");
+});
diff --git a/web/test/agent-operation-feed-view.test.tsx b/web/test/agent-operation-feed-view.test.tsx
index 299d5c68f..01f94c4a6 100644
--- a/web/test/agent-operation-feed-view.test.tsx
+++ b/web/test/agent-operation-feed-view.test.tsx
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
-import { AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
+import { AgentChatMessage, AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -22,6 +22,9 @@ test("consecutive reasoning messages share one collapsed entry", () => {
const html = renderToStaticMarkup();
expect(html).toContain("2 段 · 点击查看");
expect(html).toContain('class="agent-reasoning-card"');
+ expect(html).toContain('class="agent-reasoning-icon"');
+ expect(html).toContain("lucide-brain-circuit");
+ expect(html).not.toContain("is-live");
expect(html).not.toContain(" open");
});
@@ -35,9 +38,25 @@ test("operations fold into one line that reports only the latest action", () =>
expect(html).not.toContain("agent-operation-list");
expect(html).toContain("2 步");
expect(html).toContain("读取信息");
+ expect(html).toContain('class="agent-operation-icon"');
+ expect(html).toContain("lucide-list");
}
});
+test("messages and live reasoning expose their visual markers", () => {
+ const assistant = renderToStaticMarkup();
+ expect(assistant).toContain('class="agent-message-icon agent-message-icon--assistant"');
+ expect(assistant).toContain("lucide-message-circle");
+
+ const user = renderToStaticMarkup();
+ expect(user).toContain('class="agent-message-icon agent-message-icon--user"');
+ expect(user).toContain("lucide-user-round");
+
+ const live = renderToStaticMarkup();
+ expect(live).toContain('class="agent-reasoning-icon is-live"');
+ expect(live).toContain("模型正在思考");
+});
+
test("the folded line names the tier it is reporting", () => {
const read = renderToStaticMarkup();
expect(read).toContain("读取清单");
@@ -111,6 +130,7 @@ test("style contract: 正文 / 工具调用 / 模型思考三档不再制造左
expect(reasoning).not.toContain("margin-left");
expect(reasoning).not.toContain("border-left");
expect(block(".agent-reasoning-summary")).toContain("padding: 6px 0");
+ expect(block(".agent-reasoning-icon.is-live")).toContain("animation: agent-reasoning-icon-pulse");
// ④ 操作流是轻量文本收据,不再绘制竖线或额外左缩进。
const feed = block(".agent-operation-feed");
diff --git a/web/test/agent-tool-presentation.test.ts b/web/test/agent-tool-presentation.test.ts
index c82fd723f..3e000fef0 100644
--- a/web/test/agent-tool-presentation.test.ts
+++ b/web/test/agent-tool-presentation.test.ts
@@ -125,6 +125,7 @@ describe("Agent tool error classification", () => {
expect(agentToolErrorClassLabel({ result: { errorClass: "state_conflict" } })).toBe("画布状态已变化");
expect(agentToolErrorClassLabel({ errorClass: "permission_violation", errorClassLabel: "超出本轮权限" })).toBe("超出本轮权限");
expect(agentToolErrorClassLabel({ result: { errorClass: "upstream_failure" } })).toBe("上游故障");
+ expect(agentToolErrorClassLabel({ errorClass: "admission_failure" })).toBe("媒体生成准入失败");
expect(agentToolErrorClassLabel({ errorClass: "invalid_model_output" })).toBe("模型输出问题");
expect(agentToolErrorClassLabel({ errorClass: "unknown_new_class" })).toBe("工具执行失败");
expect(agentToolErrorClassLabel({ text: "没有归类" })).toBeUndefined();
diff --git a/web/test/api-request.test.ts b/web/test/api-request.test.ts
index 72a663eb6..a8838b1cc 100644
--- a/web/test/api-request.test.ts
+++ b/web/test/api-request.test.ts
@@ -43,6 +43,19 @@ describe("backend API request error semantics", () => {
expect(thrown).toMatchObject({ status: 502, message: "后端服务暂时不可用,请稍后重试", retryable: true });
});
+ test("marks transport failures without an HTTP response as retryable", async () => {
+ const axiosError = {
+ isAxiosError: true,
+ code: "ERR_NETWORK",
+ message: "Network Error",
+ };
+ const thrown = await request(Promise.reject(axiosError)).catch((error) => error);
+
+ expect(thrown).toBeInstanceOf(ApiError);
+ expect(thrown).toMatchObject({ message: "Network Error", retryable: true });
+ expect(thrown.cause).toBe(axiosError);
+ });
+
test("http.get unwraps the same backend envelope", async () => {
const original = apiClient.request;
apiClient.request = (async () => ({
diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts
index 1dd63916d..1b214d8fa 100644
--- a/web/test/canvas-remote-revision.test.ts
+++ b/web/test/canvas-remote-revision.test.ts
@@ -23,6 +23,7 @@ let remote = new Map();
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
let beforePut: (() => Promise) | undefined;
let deleteFailureId: string | undefined;
+let temporaryPutFailures = 0;
function canvas(id = "canvas"): CanvasProject {
return {
@@ -54,6 +55,7 @@ beforeEach(async () => {
autoSave = undefined;
beforePut = undefined;
deleteFailureId = undefined;
+ temporaryPutFailures = 0;
remote = new Map([
["canvas", canvas()],
["other", canvas("other")],
@@ -108,7 +110,10 @@ beforeEach(async () => {
await beforePut?.();
const project = body.project as CanvasProject;
const current = remote.get(id);
- if (project.revision !== (current?.revision ?? 0)) status = 409;
+ if (temporaryPutFailures > 0) {
+ temporaryPutFailures -= 1;
+ status = 503;
+ } else if (project.revision !== (current?.revision ?? 0)) status = 409;
else {
const saved = { ...structuredClone(project), revision: project.revision! + 1 };
remote.set(id, saved);
@@ -123,6 +128,14 @@ beforeEach(async () => {
await syncRemoteUserData(scope);
});
+async function waitForSyncState(predicate: () => boolean) {
+ const deadline = Date.now() + 1_000;
+ while (!predicate()) {
+ if (Date.now() >= deadline) throw new Error("同步测试状态等待超时");
+ await new Promise((resolve) => globalThis.setTimeout(resolve, 0));
+ }
+}
+
afterEach(async () => {
resetRemoteUserDataSync();
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
@@ -245,6 +258,22 @@ test("a conflict preserves drafts, stops retries and does not block another canv
expect(useSyncProgressStore.getState().syncingProjects.canvas.draftCount).toBe(2);
});
+test("transient cloud failures schedule a bounded automatic retry", async () => {
+ temporaryPutFailures = 1;
+ useCanvasStore.getState().renameProject("canvas", "retry me");
+ expect(autoSave).toBeDefined();
+
+ autoSave?.();
+ await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1);
+ expect(autoSave).toBeDefined();
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试");
+
+ autoSave?.();
+ await waitForSyncState(() => remote.get("canvas")?.title === "retry me");
+ expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2);
+ expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done");
+});
+
test("edits during a save retain the right ancestor revision and are saved next", async () => {
let release!: () => void;
let started!: () => void;