feat(web): 工作台 - 优化 Agent 工作区与技能库

This commit is contained in:
ddcat666
2026-09-27 08:21:08 +08:00
parent b4111a8721
commit 5df5e09c63
22 changed files with 1681 additions and 196 deletions

View File

@@ -9,6 +9,8 @@ type AIMessageMarkdownProps = {
isStreaming?: boolean;
streamingAnimation?: "word" | "char" | "none";
className?: string;
/** 仅覆盖当前调用方需要定制的 Markdown 渲染器,默认渲染行为保持不变。 */
components?: Components;
};
function buildComponents(isStreaming: boolean): Components {
@@ -37,7 +39,7 @@ function buildComponents(isStreaming: boolean): Components {
const staticComponents = buildComponents(false);
const streamingComponents = buildComponents(true);
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, streamingAnimation = "word", className = "" }: AIMessageMarkdownProps) {
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, streamingAnimation = "word", className = "", components }: AIMessageMarkdownProps) {
if (!children.trim()) return null;
return (
<Streamdown
@@ -49,7 +51,7 @@ export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isS
parseIncompleteMarkdown
skipHtml
lineNumbers={false}
components={isStreaming ? streamingComponents : staticComponents}
components={components ? { ...(isStreaming ? streamingComponents : staticComponents), ...components } : isStreaming ? streamingComponents : staticComponents}
>
{children}
</Streamdown>

View File

@@ -4,7 +4,7 @@ import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react
import { AppModal } from "@/components/ui/product/app-modal";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { Skill, SkillCategory } from "@/services/api/skills";
import type { Skill, SkillCategory, SkillLibraryCategory } from "@/services/api/skills";
type SkillLibraryTab = "enabled" | "installed" | "market";
@@ -15,6 +15,7 @@ type CanvasAgentSkillLibraryModalProps = {
marketSkills: Skill[];
selectedSkillIds: string[];
categories: SkillCategory[];
libraryCategories: SkillLibraryCategory[];
category: string;
search: string;
loading: boolean;
@@ -34,6 +35,7 @@ export function CanvasAgentSkillLibraryModal({
marketSkills,
selectedSkillIds,
categories,
libraryCategories,
category,
search,
loading,
@@ -46,36 +48,53 @@ export function CanvasAgentSkillLibraryModal({
onLoadMore,
}: CanvasAgentSkillLibraryModalProps) {
const [tab, setTab] = useState<SkillLibraryTab>("market");
const [libraryCategoryId, setLibraryCategoryId] = useState("all");
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
const selectedCount = selectedSkillIds.length;
useEffect(() => {
if (open && !wasOpenRef.current) setTab("market");
if (open && !wasOpenRef.current) {
setTab("market");
setLibraryCategoryId("all");
onCategoryChange("all");
}
wasOpenRef.current = open;
}, [open]);
}, [onCategoryChange, open]);
useEffect(() => {
listRef.current?.scrollTo({ top: 0 });
}, [category, search, tab]);
}, [category, libraryCategoryId, search, tab]);
const librarySource = useMemo(() => tab === "enabled"
? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId))
: installedSkills, [installedSkills, selectedSkillIds, tab]);
const libraryCategoryCounts = useMemo(() => {
const counts = new Map<string, number>();
librarySource.forEach((skill) => {
const categoryId = skill.libraryCategoryId || "__uncategorized__";
counts.set(categoryId, (counts.get(categoryId) || 0) + 1);
});
return counts;
}, [librarySource]);
const visibleSkills = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase("zh-CN");
const source = tab === "enabled"
? installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId))
: tab === "installed"
? installedSkills
: marketSkills;
const source = tab === "market" ? marketSkills : librarySource;
return source.filter((skill) => {
if (category !== "all" && skill.tag !== category) return false;
if (tab === "market" && category !== "all" && skill.tag !== category) return false;
if (tab !== "market") {
if (libraryCategoryId === "__uncategorized__" && skill.libraryCategoryId) return false;
if (libraryCategoryId !== "all" && libraryCategoryId !== "__uncategorized__" && skill.libraryCategoryId !== libraryCategoryId) return false;
}
if (!keyword) return true;
return `${skill.skillName} ${skill.description || ""} ${skill.effectiveUser?.name || ""}`
return `${skill.skillName} ${skill.description || ""} ${skill.instruction || ""} ${skill.extraInfo || ""} ${skill.effectiveUser?.name || ""} ${categories.find((item) => item.value === skill.tag)?.label || ""}`
.toLocaleLowerCase("zh-CN")
.includes(keyword);
});
}, [category, installedSkills, marketSkills, search, selectedSkillIds, tab]);
}, [categories, category, libraryCategoryId, librarySource, marketSkills, search, tab]);
useEffect(() => {
const target = loadMoreRef.current;
@@ -95,14 +114,32 @@ export function CanvasAgentSkillLibraryModal({
return [...unique.values()];
}, [categories]);
const marketplaceCategoryTotal = useMemo(
() => categoryItems.reduce((total, item) => total + (item.count || 0), 0),
[categoryItems],
);
const emptyText = tab === "enabled"
? "本轮还没有启用技能,可在“我的技能”中选择"
? selectedCount === 0 ? "本轮还没有启用技能,可在“我的技能”中选择" : "没有匹配的已启用技能"
: tab === "installed"
? "还没有匹配的已加入技能,可前往“全部”添加"
: "没有匹配的公开技能,换个关键词或分类试试";
const changeTab = (value: SkillLibraryTab) => {
setTab(value);
setLibraryCategoryId("all");
onCategoryChange("all");
};
const selectMarketplaceCategory = (value: string) => {
setTab("market");
setLibraryCategoryId("all");
onCategoryChange(value);
};
const selectLibraryCategory = (value: string) => {
setTab((current) => current === "enabled" ? "enabled" : "installed");
setLibraryCategoryId(value);
onCategoryChange("all");
};
@@ -144,20 +181,34 @@ export function CanvasAgentSkillLibraryModal({
className="canvas-agent-skill-library-search"
/>
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
<SkillTab active={tab === "market" && category === "all"} label="全部" onClick={() => changeTab("market")} />
<SkillTab active={tab === "installed"} label="我的技能" count={installedSkills.length} onClick={() => changeTab("installed")} />
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => changeTab("enabled")} />
{categoryItems.map((item) => (
<button
key={item.value}
type="button"
className={`canvas-agent-skill-library-tab ${tab === "market" && category === item.value ? "is-active" : ""}`}
aria-pressed={tab === "market" && category === item.value}
onClick={() => { setTab("market"); onCategoryChange(item.value); }}
>
{item.label}
</button>
))}
<div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">我的技能分类</div>
<SkillTab active={tab !== "market" && libraryCategoryId === "all"} label="全部分类" count={librarySource.length} onClick={() => selectLibraryCategory("all")} />
<SkillTab active={tab !== "market" && libraryCategoryId === "__uncategorized__"} label="未分类" count={libraryCategoryCounts.get("__uncategorized__") || 0} onClick={() => selectLibraryCategory("__uncategorized__")} />
{libraryCategories.map((item) => (
<SkillTab
key={item.id}
active={tab !== "market" && libraryCategoryId === item.id}
label={item.name}
count={libraryCategoryCounts.get(item.id) || 0}
onClick={() => selectLibraryCategory(item.id)}
/>
))}
</div>
<div className="canvas-agent-skill-library-category-group">
<div className="canvas-agent-skill-library-category-heading">技能广场分类</div>
<SkillTab active={tab === "market" && category === "all"} label="全部技能" count={marketplaceCategoryTotal} onClick={() => changeTab("market")} />
{categoryItems.map((item) => (
<SkillTab
key={item.value}
active={tab === "market" && category === item.value}
label={item.label}
count={item.count}
onClick={() => selectMarketplaceCategory(item.value)}
/>
))}
</div>
</nav>
</aside>
@@ -199,7 +250,7 @@ export function CanvasAgentSkillLibraryModal({
</div>
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
<span>{tab === "enabled" ? `${visibleSkills.length} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
{tab === "market" && hasMore ? (
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
@@ -233,6 +284,7 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
const cover = skill.showcaseMedia?.find((item) => item.type === "image" && item.showcaseUrl)
|| skill.showcaseMedia?.find((item) => item.showcaseUrl);
const coverUrl = cover?.showcaseUrl;
const hasCover = Boolean(coverUrl && !coverFailed);
const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他";
const author = skill.effectiveUser?.name || "影策创作者";
const addedCount = formatSkillCount(skill.addedCount || 0);
@@ -250,21 +302,20 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
};
return (
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`}>
<div className="canvas-agent-skill-card-cover">
{coverUrl && !coverFailed ? (
cover?.type === "video"
? <video src={coverUrl} muted playsInline preload="metadata" onError={() => setCoverFailed(true)} />
: <img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
) : (
<div className="canvas-agent-skill-card-placeholder" aria-hidden="true">
<Sparkles className="size-7" />
</div>
)}
<span className="canvas-agent-skill-card-category">{categoryLabel}</span>
{selected ? <span className="canvas-agent-skill-card-selected" aria-label="本轮已启用"><Check className="size-4" aria-hidden="true" /></span> : null}
</div>
<div className="canvas-agent-skill-card-body">
<article className={`canvas-agent-skill-card ${hasCover ? "" : "is-text-only"} ${selected ? "is-selected" : ""}`}>
{hasCover ? (
<div className="canvas-agent-skill-card-cover">
{cover?.type === "video" ? (
<video src={coverUrl} muted playsInline preload="metadata" onError={() => setCoverFailed(true)} />
) : (
<img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
)}
<span className="canvas-agent-skill-card-category">{categoryLabel}</span>
{selected ? <span className="canvas-agent-skill-card-selected" aria-label="本轮已启用"><Check className="size-4" aria-hidden="true" /></span> : null}
</div>
) : null}
<div className={`canvas-agent-skill-card-body ${hasCover ? "" : "is-text-only"}`}>
{!hasCover ? <span className="canvas-agent-skill-card-category is-inline">{categoryLabel}</span> : null}
<div className="canvas-agent-skill-card-title-row">
<h3 title={skill.skillName}>{skill.skillName}</h3>
<div className="canvas-agent-skill-card-action">

View File

@@ -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<string, string> = {
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(`(?<![A-Za-z0-9_-])(?:\\x60(${nodeIdPattern})\\x60|(${nodeIdPattern}))(?![A-Za-z0-9_-])`, "gu");
const newNodePrefixPattern = /(新节点|节点|新建节点)\s*ID(?=\s*[::])/gu;
let inFence = false;
return text.split(/(\r?\n)/u).map((part) => {
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 <a {...props} href={href} className={`ai-message-markdown-link ${className || ""}`.trim()} target="_blank" rel="noreferrer">{children}</a>;
}
const reference = referenceByNodeId.get(nodeId);
const title = reference?.title || reference?.label || "画布节点";
const previewUrl = reference?.previewUrl;
return (
<a
{...props}
href={href}
className="agent-message-node-link"
data-agent-node-id={nodeId}
aria-label={`定位到画布中的${title}`}
title={`定位到画布中的${title}`}
onClick={(event) => {
event.preventDefault();
onFocusNode?.(nodeId);
}}
>
{previewUrl ? <img className="agent-message-node-link-preview" src={previewUrl} alt="" loading="lazy" /> : <span className="agent-message-node-link-icon" aria-hidden="true"><CircleDot className="size-3.5" /></span>}
<span className="agent-message-node-link-copy">
<span className="agent-message-node-link-kind">{agentNodeTypeLabel(nodeId, reference)}</span>
<span className="agent-message-node-link-title">{reference?.title || reference?.label || children}</span>
</span>
</a>
);
},
};
}
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 <AgentReasoningFeed items={[item]} theme={theme} />;
}
if (isSystem) {
return (
<div className="agent-status-message flex items-start gap-2 text-xs">
<div className="agent-status-message agent-status-message--system flex items-start gap-2 text-xs">
<AgentTimelineMarker theme={theme} tone="muted" icon={<Sparkles className="size-3" />} />
<div className="min-w-0 flex-1 py-0.5 leading-5" style={{ color: theme.node.muted }}>
{item.text}
@@ -163,17 +296,17 @@ export function AgentChatMessage({
}
if (item.role === "error") {
return (
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="error" icon={<CircleAlert className="size-3.5" />} />
<div className={`agent-status-message agent-status-message--${errorTone} flex items-start gap-2`}>
<AgentTimelineMarker theme={theme} tone={errorTone} icon={errorTone === "warning" ? <CircleAlert className="size-3.5" /> : <CircleAlert className="size-3.5" />} />
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
<div className="font-medium" style={{ color }}>
{item.title || "Agent 暂时无法继续"}
<div className="agent-error-title font-medium" style={{ color }}>
{item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续")}
</div>
<div className="mt-0.5 whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
<div className="agent-error-detail whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
{item.text}
</div>
{item.meta ? (
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<div className="agent-error-meta text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
{item.meta}
</div>
) : null}
@@ -188,6 +321,11 @@ export function AgentChatMessage({
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? (
<span className="agent-message-icon agent-message-icon--assistant" aria-hidden="true">
<MessageCircle className="size-4" />
</span>
) : null}
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-full flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
@@ -198,8 +336,8 @@ export function AgentChatMessage({
</span>
) : null}
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none">
{displayedText}
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none" components={markdownComponents}>
{agentMarkdownText}
</AIMessageMarkdown>
) : (
<AgentMessageText text={item.text} references={references} />
@@ -207,6 +345,11 @@ export function AgentChatMessage({
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[var(--fs-label)] opacity-45">{item.meta}</div> : null}
</div>
{isUser ? (
<span className="agent-message-icon agent-message-icon--user" aria-hidden="true">
<UserRound className="size-4" />
</span>
) : null}
</div>
);
}
@@ -229,6 +372,9 @@ export function AgentReasoningFeed({
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${streaming ? " is-streaming" : ""}`}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className={`agent-reasoning-icon${streaming ? " is-live" : ""}`} aria-hidden="true">
<BrainCircuit className="size-4" />
</span>
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`}</span>
@@ -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)}
>
<span className="agent-operation-icon" aria-hidden="true">
{categoryIcon}
</span>
<span className="agent-operation-kind">
<span>{categoryLabel}</span>
</span>
@@ -679,7 +829,12 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<div className="flex items-start gap-2 px-3 pt-2.5">
<HelpCircle className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{question.question}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] opacity-55">
{question.round && question.maxRounds ? `确认 ${question.round}/${question.maxRounds}` : "需要确认"}
</div>
<div className="text-xs font-semibold leading-5">{question.question}</div>
</div>
</div>
<div className="flex flex-wrap gap-2 px-3 pb-2 pt-2">
{question.options.map((option) => (
@@ -701,6 +856,21 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
{option.detail ? <span className="mt-0.5 block break-words text-[10px] leading-4 opacity-60">{option.detail}</span> : null}
</button>
))}
<button
type="button"
disabled={disabled}
title="使用安全默认方案继续"
className="max-w-full rounded-md border-0 px-3 py-1.5 text-left text-xs font-medium transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAnswer("直接开始");
}}
>
按默认方案开始
</button>
</div>
<div className="px-3 pb-2 text-[10px] opacity-50">{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}</div>
</div>
@@ -1248,8 +1418,8 @@ export function AgentPanelTabs<T extends string>({
* 的模型品牌 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 (
<span className="agent-timeline-marker relative" aria-hidden="true">
<span className="relative grid size-5 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>

View File

@@ -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<SkillCategory[]>([]);
const [libraryCategories, setLibraryCategories] = useState<SkillLibraryCategory[]>([]);
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<SetStateAction
if (terminal) {
setMessages((current) => 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<SetStateAction
id,
role: "assistant",
text: "",
question: { question, options, allowFreeform: payload.allowFreeform !== false },
question: { question, options, allowFreeform: payload.allowFreeform !== false, round: Number.isFinite(Number(payload.round)) ? Number(payload.round) : undefined, maxRounds: Number.isFinite(Number(payload.maxRounds)) ? Number(payload.maxRounds) : undefined },
}));
return;
}
@@ -1549,6 +1582,26 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
setMessages((current) => 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<string, unknown> : {};
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) {

View File

@@ -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);

View File

@@ -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<DirectorScene[]>([]);
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
<IconButton label="重做" disabled={!future.length} onClick={redo}><Redo2 className="size-4" /></IconButton>
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
{/* 一级模式切换:小屏也必须可达,因此不加 max-lg:hidden。 */}
<nav className="director-mode-switch" aria-label="导演台模式">
<button
type="button"
className={`director-mode-switch-button ${quickMode ? "is-active" : ""}`}
aria-pressed={quickMode}
title="用镜头优先的最短路径完成摆位、姿态和预演"
onClick={() => setQuickMode(true)}
>
快速镜头
</button>
<button
type="button"
className={`director-mode-switch-button ${!quickMode ? "is-active" : ""}`}
aria-pressed={!quickMode}
title="打开完整的摆场、姿态、动画和摄影机工具"
onClick={() => setQuickMode(false)}
>
高级工作台
</button>
{!quickMode ? <nav className="director-mode-switch" aria-label="导演台模式">
{DIRECTOR_MODES.map((item) => (
<button
key={item.mode}
@@ -722,7 +807,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
{item.label}
</button>
))}
</nav>
</nav> : null}
<div className="ml-auto flex items-center gap-2">
<span
aria-live="polite"
@@ -741,6 +826,48 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
</div>
</header>
{quickMode ? <div className="grid min-h-0 flex-1 grid-cols-[minmax(220px,280px)_minmax(0,1fr)_minmax(260px,320px)] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold">镜头对象</div>
<div className="text-[var(--fs-tiny)] opacity-55">先解决站位和关系,不先建复杂模型</div>
</div>
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} onClick={addQuickActor}>演员</Button>
</div>
<div className="space-y-1">
{quickActors.map((actor) => <button key={actor.id} type="button" className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left text-sm transition-colors ${quickSelectedActor?.id === actor.id ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={() => setSelectedObjectId(actor.id)}>
<span className="flex size-7 items-center justify-center rounded-full text-white" style={{ background: actor.color }}><UserRound className="size-3.5" /></span>
<span className="min-w-0 flex-1 truncate">{actor.name}</span>
<span className="text-[var(--fs-tiny)] opacity-45">{actor.pose ? directorPoseLabel(actor.pose) : "站立"}</span>
</button>)}
</div>
{!quickActors.length ? <div className="rounded-lg border border-dashed p-4 text-center text-xs opacity-60">还没有演员<br />先加一个人偶,再用镜头完成预演</div> : null}
<div className="mt-5 rounded-lg border p-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 flex items-center gap-2 text-sm font-medium"><MousePointer2 className="size-4" />快速落位</div>
<p className="mb-3 text-xs leading-5 opacity-60">直接点击视口地面即可落位;也可以先移动鼠标,再用按钮落到当前指针位置。无需输入 XYZ。</p>
<Button block size="small" disabled={!quickSelectedActor} onClick={placeQuickActor}>按当前指针落位</Button>
</div>
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={quickSelectedActor?.id || null} selectedBone={null} transformMode="translate" renderMode="clay" playhead={0} playing={false} showMotionPaths={false} viewMode={viewMode} onViewModeChange={setViewMode} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onGroundClick={handleQuickGroundClick} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2 rounded-md bg-black/35 px-2.5 py-1.5 text-xs font-medium text-white/80"><WandSparkles className="size-3.5" />{activeShot.name} · {activeCamera?.name || "无摄影机"}</div>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-lg border border-white/10 bg-black/55 p-1.5 text-white shadow-lg">
<span className="px-2 text-xs text-white/65">白模预演</span>
<Button size="small" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>生成预演</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用镜头</Button>
</div>
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-4 flex items-center justify-between"><div><div className="text-sm font-semibold">镜头卡</div><div className="text-[var(--fs-tiny)] opacity-55">用语义参数替代工程参数</div></div><Camera className="size-4 opacity-45" /></div>
<div className="mb-4 grid grid-cols-3 gap-1.5">{(["wide", "full", "medium", "close_up", "extreme_close_up"] as DirectorShotSize[]).map((size) => <button key={size} type="button" className={`rounded-md border px-2 py-2 text-xs ${activeShot.shotSize === size ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => selectQuickShotSize(size)}>{({ wide: "全景", full: "全身", medium: "中景", close_up: "近景", extreme_close_up: "特写" } as Record<string, string>)[size]}</button>)}</div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">姿态</div><div className="grid grid-cols-3 gap-1.5">{quickPoseOptions.map((option) => <button key={option.value} type="button" disabled={!quickSelectedActor} className={`rounded-md border px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-35 ${quickSelectedActor?.pose === option.value ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => applyQuickPose(option.value)}>{option.label}</button>)}</div></div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">运镜</div><Select className="w-full" size="small" value={activeShot.cameraMove} options={[{ value: "static", label: "固定" }, { value: "push_in", label: "推进" }, { value: "pull_out", label: "拉远" }, { value: "pan_left", label: "左摇" }, { value: "pan_right", label: "右摇" }, { value: "handheld", label: "手持" }]} onChange={selectQuickCameraMove} /></div>
<div className="space-y-2"><Button block size="small" icon={<Focus className="size-3.5" />} onClick={alignCameraToView}>用当前视图设机位</Button><Button block size="small" onClick={applyCameraMove}>生成运镜</Button></div>
<div className="mt-5 rounded-lg border p-3 text-xs leading-5 opacity-70" style={{ borderColor: theme.toolbar.border }}><div className="mb-1 font-medium opacity-100">给视频模型的参考</div>白模只负责镜头、动作和空间关系;生成真实人体比例,不沿用白模轮廓。</div>
</aside>
</div> : null}
{!quickMode ? <>
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1fr)_292px] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<PanelTitle title="场景对象" action={<AddMenuButton label="添加场景对象" items={addObjectMenuItems} />} />
@@ -780,6 +907,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
{/* 时间轴只属于动画模式:其他模式下它不渲染,Auto Key 与录制入口一并消失。 */}
{capabilities.timeline ? <DirectorSequencer scene={draft} shot={activeShot} camera={activeCamera} objects={draft.objects} selectedObjectId={selectedObjectId} selectedBone={selectedBone} playhead={playhead} playing={playing} autoKey={autoKey} height={sequencerHeight} visible={sequencerVisible} onPlayToggle={() => setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> : null}
</> : null}
</div>
);
}

View File

@@ -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<DirectorOrbitControls>(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。

View File

@@ -233,6 +233,7 @@ export const AGENT_TOOL_ERROR_CLASS_LABELS: Record<string, string> = {
state_conflict: "画布状态已变化",
permission_violation: "超出本轮权限",
upstream_failure: "上游故障",
admission_failure: "媒体生成准入失败",
tool_error: "工具执行失败",
};

View File

@@ -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; }
}

View File

@@ -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<void>; 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<unknown>) => {
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: <CloudOff className="canvas-sync-modal-icon" aria-hidden="true" />,
content: (
<div className="canvas-sync-confirm-copy">
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看或下载,不会直接丢失。</p>
</div>
),
okText: "保留草稿并加载",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () =>
run(async () => {
await onLoadLatest();
setStatusOpen(false);
message.success("云端版本已加载,本地内容已保留为草稿");
}),
});
};
return (
<>
<Popover
trigger="click"
placement="bottom"
open={statusOpen}
onOpenChange={setStatusOpen}
content={
<div className="max-w-80 space-y-3" data-canvas-no-zoom>
<p role="status" className="text-sm">
{progress?.message || (phase === "done" ? "画布已同步到云端" : "尚未确认云端保存,请保留本地内容")}
</p>
{conflict ? <p className="text-xs text-muted-foreground">此画布的自动提交已暂停。加载最新版前会保留本地草稿,可下载后从画布列表导入为副本。</p> : null}
<div className="flex flex-wrap gap-2">
<Button size="small" loading={busy} onClick={() => void run(onLoadLatest)}>
加载云端最新版本
</Button>
<Popover
trigger="click"
placement="bottom"
open={statusOpen}
onOpenChange={setStatusOpen}
arrow={false}
classNames={{ root: "canvas-sync-popover", container: "canvas-sync-popover-surface", content: "canvas-sync-popover-content" }}
content={
<div className={`canvas-sync-panel canvas-sync-panel--${tone}`} data-canvas-no-zoom>
<div className="canvas-sync-panel__header">
<span className="canvas-sync-panel__status-icon" aria-hidden="true">
{saving ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff /> : <CloudCheck />}
</span>
<div className="canvas-sync-panel__heading">
<span className="canvas-sync-panel__eyebrow">云端同步</span>
<strong>{label}</strong>
</div>
</div>
<p className="canvas-sync-panel__description" role="status">{description}</p>
{conflict ? (
<div className="canvas-sync-panel__callout">
<strong>先保护本地,再决定版本</strong>
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
</div>
) : error ? (
<div className="canvas-sync-panel__callout">
<strong>{retryScheduled ? "本地内容仍在,正在等待重试" : "本地内容仍在,未覆盖云端"}</strong>
<span>
{retryScheduled
? "网络恢复后会自动重试;也可以点击“立即重试”。"
: "请处理上面的错误后点击“立即重试”;在此之前本地内容不会被覆盖。"}
</span>
</div>
) : null}
<div className="canvas-sync-panel__actions">
{error ? (
<Button
type="primary"
block
icon={<RefreshCw className="size-3.5" />}
loading={busy}
onClick={() => void run(() => retryRemoteUserDataSync(projectId)).then(() => setStatusOpen(false)).catch(() => undefined)}
>
立即重试
</Button>
) : null}
<Button
block
icon={<CloudOff className="size-3.5" />}
disabled={busy || saving}
onClick={confirmLoadLatest}
>
加载云端版
</Button>
<div className="canvas-sync-panel__secondary-actions">
<Button
type="text"
size="small"
disabled={busy}
icon={<Download className="size-3.5" />}
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)
}
>
下载当前内容
下载本地副本
</Button>
{onOpenVersions ? (
<Button
type="text"
size="small"
disabled={busy}
icon={<FileClock className="size-3.5" />}
onClick={() => {
setStatusOpen(false);
onOpenVersions();
@@ -70,19 +152,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
) : null}
</div>
</div>
}
</div>
}
>
<Button
type="text"
size="small"
className={`canvas-sync-status-trigger canvas-sync-status-trigger--${tone}`}
aria-label={`画布保存状态:${label}`}
aria-expanded={statusOpen}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : conflict || error ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<Button
type="text"
size="small"
danger={failed}
aria-label={`画布保存状态:${label}`}
icon={saving ? <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" /> : failed ? <CloudOff className="size-3.5" /> : <CloudCheck className="size-3.5" />}
>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
</>
<span className="canvas-sync-status-label text-xs">{label}</span>
</Button>
</Popover>
);
}

View File

@@ -2828,7 +2828,17 @@ function InfiniteCanvasPage() {
<div className={versions.open ? "hidden" : "contents"}>
<CanvasCloudAgentPanel canvasId={projectId} domainProjectId={currentProject?.projectId} nodeCount={nodes.length} selectedNodeIds={Array.from(selectedNodeIds)} references={agentMentionReferences} prefillPrompt={agentPrefillPrompt} open={assistantOpen} onOpen={openAgent} onCollapse={closeAgent} onFocusNode={(nodeId) => {
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);
}} />
</div>

View File

@@ -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<SkillLibraryCategoryList | null>(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<Skill[]>([]);
@@ -71,14 +80,33 @@ export default function SkillsPage() {
const [editorOpen, setEditorOpen] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(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) => (
<SkillCard
key={skill.skillId}
skill={skill}
categories={categories}
libraryCategories={libraryCategories}
canCategorize={isLibraryScope}
loading={mutatingID === skill.skillId}
style={{ animationDelay: `${Math.min(index, 5) * 30}ms` }}
onOpen={() => 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 ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : isLibraryScope && skills.length ? (
<div className="library-grid skill-library-grid">
{skills.map(renderSkillCard)}
</div>
) : !isLibraryScope && groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading">
<h2 id={`skill-category-${group.value}`} className="flex items-center gap-2 text-base font-semibold text-foreground/75">
<span className="skill-group-icon"><GroupIcon /></span>
{group.label}
</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="library-grid skill-library-grid">
{group.skills.map(renderSkillCard)}
</div>
</section>
);
})}
</div>
) : (
<WorkspaceState
compact
className="min-h-[188px]"
icon="skills"
title={filtersActive ? "没有找到匹配技能" : scope === "created" ? "还没有创建技能" : scope === "public" ? "技能广场还是空的" : "这里还没有技能"}
description={filtersActive ? "换个关键词或分类试试。" : scope === "favorites" ? "收藏的公开技能会显示在这里。" : scope === "mine" ? "从技能广场加入后会显示在这里。" : "创建并公开第一个技能,其他用户就能直接加入使用。"}
action={filtersActive
? <Button onClick={resetFilters}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
);
return (
<>
<WorkspacePage className="library-page skills-library-page" grid>
@@ -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<HTMLButtonElement>('[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); }}
>
<Icon className="size-4" />
<span>{option.label}</span>
@@ -245,49 +406,129 @@ export default function SkillsPage() {
</div>
<CollectionToolbar active={filtersActive} onReset={resetFilters}>
<Input className="min-w-0 sm:!w-56" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
<Select aria-label="技能分类" className="w-28" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
<Select aria-label="技能排序" className="w-24" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
</CollectionToolbar>
</div>
{loading && !skills.length ? <SkillSkeleton /> : loadError ? <WorkspaceErrorState compact description={loadError} onRetry={reload} /> : groupedSkills.length ? (
<div key={`${scope}-${page}`} className="skills-scope-panel">
{groupedSkills.map((group) => {
const GroupIcon = categoryIconOf(group.value);
return (
<section key={group.value} data-category={group.value} aria-labelledby={`skill-category-${group.value}`}>
<div className="skill-section-heading">
<h2 id={`skill-category-${group.value}`} className="flex items-center gap-2 text-base font-semibold text-foreground/75">
<span className="skill-group-icon"><GroupIcon /></span>
{group.label}
</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
<div className="skills-library-layout">
<aside className="skills-library-sidebar" aria-label="技能分类筛选">
<section className="skills-category-section">
<div className="skills-library-sidebar-heading">
<div>
<strong>技能广场分类</strong>
<span>筛选广场公开技能</span>
</div>
</div>
<nav className="skills-library-category-list" aria-label="技能广场分类">
<button type="button" className={`skills-library-category-item${scope === "public" && tag === "all" ? " is-active" : ""}`} aria-pressed={scope === "public" && tag === "all"} onClick={() => selectMarketplaceCategory("all")}>
<span><Sparkles className="size-4" aria-hidden="true" />全部技能</span>
<span>{marketplaceCategoryTotal}</span>
</button>
{categories.map((category) => {
const Icon = categoryIconOf(category.value);
const active = scope === "public" && tag === category.value;
return (
<button key={category.value} type="button" className={`skills-library-category-item${active ? " is-active" : ""}`} aria-pressed={active} onClick={() => selectMarketplaceCategory(category.value)}>
<span><Icon className="size-4" aria-hidden="true" />{category.label}</span>
<span>{category.count ?? "—"}</span>
</button>
);
})}
</nav>
</section>
<section className="skills-category-section">
<div className="skills-library-sidebar-heading">
<div>
<strong>我的技能分类</strong>
</div>
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => { setCategoryName(""); setCategoryScope("personal"); setCategoryEditorOpen(true); }}>新建</Button>
</div>
{libraryCategoryError ? <div className="skills-library-category-error" role="alert">{libraryCategoryError}<Button type="link" size="small" onClick={reload}>重试</Button></div> : null}
<nav className="skills-library-category-list" aria-label="我的技能分类">
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === "all" ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === "all"} onClick={() => selectLibraryCategory("all")}>
<span><Library className="size-4" aria-hidden="true" />全部技能</span>
<span>{libraryCategoryList?.totalCount ?? "—"}</span>
</button>
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === "__uncategorized__" ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === "__uncategorized__"} onClick={() => selectLibraryCategory("__uncategorized__")}>
<span><FolderInput className="size-4" aria-hidden="true" />未分类</span>
<span>{libraryCategoryList?.uncategorizedCount ?? "—"}</span>
</button>
{libraryCategories.map((category) => (
<div key={category.id} className={`skills-library-category-row${isLibraryScope && libraryCategoryId === category.id ? " is-active" : ""}`}>
<button type="button" className={`skills-library-category-item${isLibraryScope && libraryCategoryId === category.id ? " is-active" : ""}`} aria-pressed={isLibraryScope && libraryCategoryId === category.id} onClick={() => selectLibraryCategory(category.id)}>
<span><FolderInput className="size-4" aria-hidden="true" />{category.name}</span>
<span>{category.count}</span>
</button>
{category.scope === "personal" || isAdmin ? (
<button type="button" className="skills-library-category-delete" aria-label={`删除分类${category.name}`} title="删除分类" onClick={() => confirmDeleteLibraryCategory(category)}>
<Trash2 className="size-3.5" aria-hidden="true" />
</button>
) : null}
</div>
<div className="library-grid skill-library-grid">
{group.skills.map((skill, index) => <SkillCard key={skill.skillId} skill={skill} categories={categories} loading={mutatingID === skill.skillId} style={{ animationDelay: `${Math.min(index, 5) * 30}ms` }} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
);
})}
</div>
) : (
<WorkspaceState
compact
className="min-h-[188px]"
icon="skills"
title={filtersActive ? "没有找到匹配技能" : scope === "created" ? "还没有创建技能" : scope === "public" ? "技能广场还是空的" : "这里还没有技能"}
description={filtersActive ? "换个关键词或分类试试。" : scope === "favorites" ? "收藏的公开技能会显示在这里。" : scope === "mine" ? "从技能广场加入后会显示在这里。" : "创建并公开第一个技能,其他用户就能直接加入使用。"}
action={filtersActive
? <Button onClick={() => { setSearch(""); setTag("all"); setSort("popular"); setPage(1); }}>清除筛选</Button>
: (scope === "created" || scope === "public")
? <Button type="primary" icon={<Plus className="size-4" />} onClick={() => setInstallOpen(true)}>安装技能</Button>
: undefined}
/>
)}
))}
</nav>
</section>
</aside>
<div className="skills-library-results">{skillContent}</div>
</div>
<PaginationBar current={page} pageSize={pageSize} total={total} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</WorkspacePage>
<AppModal
open={categoryEditorOpen}
title={(
<div className="skills-library-category-title">
<span className="skills-library-category-title-icon"><FolderPlus className="size-4" aria-hidden="true" /></span>
<span>
<small>技能库整理</small>
<strong>新建分类</strong>
</span>
</div>
)}
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
>
<div className="skills-library-category-editor">
<div className="skills-library-category-field">
<label htmlFor="skills-library-category-name">分类名称</label>
<Input id="skills-library-category-name" autoFocus maxLength={64} showCount value={categoryName} onChange={(event) => setCategoryName(event.target.value)} onPressEnter={() => void saveLibraryCategory()} placeholder="例如:角色设定、分镜、宣发" />
<span className="skills-library-category-field-hint">最多 64 个字符;名称不能与当前可用分类重名。</span>
</div>
{isAdmin ? (
<div className="skills-library-category-scope">
<span className="skills-library-category-field-label">分类范围</span>
<div className="skills-library-category-scope-options" role="group" aria-label="分类范围">
<button type="button" className={`skills-library-category-scope-option${categoryScope === "personal" ? " is-active" : ""}`} aria-pressed={categoryScope === "personal"} onClick={() => setCategoryScope("personal")}>
<span className="skills-library-category-scope-icon"><UserRound className="size-4" aria-hidden="true" /></span>
<span className="skills-library-category-scope-copy"><strong>个人分类</strong><small>仅你自己可见和使用</small></span>
{categoryScope === "personal" ? <Check className="skills-library-category-scope-check size-4" aria-hidden="true" /> : null}
</button>
<button type="button" className={`skills-library-category-scope-option${categoryScope === "platform" ? " is-active" : ""}`} aria-pressed={categoryScope === "platform"} onClick={() => setCategoryScope("platform")}>
<span className="skills-library-category-scope-icon"><Globe2 className="size-4" aria-hidden="true" /></span>
<span className="skills-library-category-scope-copy"><strong>平台分类</strong><small>所有用户可见、可分配</small></span>
{categoryScope === "platform" ? <Check className="skills-library-category-scope-check size-4" aria-hidden="true" /> : null}
</button>
</div>
</div>
) : null}
<div className={`skills-library-category-note${categoryScope === "platform" && isAdmin ? " is-platform" : ""}`}>
<Sparkles className="size-4" aria-hidden="true" />
<p>{categoryScope === "platform" && isAdmin
? "平台分类对所有用户可见、可分配,并可用于各自的技能库和画布 Agent;不会替换技能广场分类。"
: "个人分类只影响你的技能库和画布 Agent。短剧影视、电商营销是技能广场的共享分类,可在左侧“技能广场分类”中筛选。"}</p>
</div>
</div>
</AppModal>
<SkillDetailModal skill={activeSkill} loading={detailLoading} mutating={Boolean(activeSkill && mutatingID === activeSkill.skillId)} categories={categories} onClose={() => setActiveSkill(null)} onAdd={(skill) => void toggleAdded(skill)} onLike={(skill) => void toggleLiked(skill)} onEdit={(skill) => void openEditor(skill)} onSync={(skill) => void synchronizeSkill(skill)} />
<SkillInstallModal open={installOpen} onClose={() => setInstallOpen(false)} onInstalled={(skill) => { setInstallOpen(false); setActiveSkill(skill); reload(); }} onManualCreate={() => { setInstallOpen(false); void openEditor(); }} />
<SkillEditorDrawer open={editorOpen} skill={editingSkill} onClose={() => 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 (
<article style={style} className={`product-collection-card library-card library-card-surface skill-library-card group${skill.isAdded ? " is-added" : ""}`}>
<div className="skill-card-top">
@@ -304,6 +547,25 @@ function SkillCard({ skill, categories, loading, style, onOpen, onAdd, onLike, o
<button type="button" className="skill-card-title-button" onClick={onOpen}>
<h3>{skill.skillName}</h3>
</button>
{canCategorize ? (
<Tooltip title={`技能库分类:${libraryCategoryLabel}`}>
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "__uncategorized__", label: "未分类" },
...libraryCategories.map((category) => ({ key: category.id, label: category.name })),
],
onClick: ({ key }) => onSetLibraryCategory(key === "__uncategorized__" ? "" : key),
}}
>
<button type="button" aria-label={`设置技能分类,当前为${libraryCategoryLabel}`} className="skill-card-category-picker" disabled={loading}>
<FolderInput className="size-4" aria-hidden="true" />
<span>{libraryCategoryLabel}</span>
</button>
</Dropdown>
</Tooltip>
) : null}
{skill.isOwner ? (
<Dropdown
trigger={["click"]}

View File

@@ -80,7 +80,9 @@ function unwrapTransportError(error: unknown): never {
code,
reason: error.response?.data?.reason,
details: error.response?.data?.details,
retryable: isRetryableStatus(status) || isRetryableStatus(code),
// 没有 response 的 AxiosError 代表请求没有拿到 HTTP 响应(断网、连接重置、DNS、超时等)。
// 这类错误不能靠 status 判断,但对保存来说通常是暂时性的,应交给上层有限退避重试。
retryable: status === undefined || isRetryableStatus(status) || isRetryableStatus(code),
retryAfterMs: retryAfterMilliseconds(error.response?.headers),
cause: error,
});

View File

@@ -26,7 +26,7 @@ export type Skill = {
contentHash: string;
fileCount: number;
totalBytes: number;
sourceType: "builtin" | "markdown" | "zip" | "github" | string;
sourceType: "builtin" | "markdown" | "zip" | string;
sourceUrl: string;
sourceRef: string;
sourceSubdir: string;
@@ -55,12 +55,32 @@ export type Skill = {
extraInfo: string;
isAdded: boolean;
isOwner: boolean;
libraryCategoryId?: string;
};
/** /skills/added 只返回运行时目录需要的引用字段,不携带编辑器和同步详情。 */
export type AddedSkillReference = Pick<Skill, "skillId" | "skillName" | "description" | "versionId" | "version" | "tag" | "isLike" | "isAdded" | "isOwner">;
export type AddedSkillReference = Pick<Skill, "skillId" | "skillName" | "description" | "versionId" | "version" | "tag" | "isLike" | "isAdded" | "isOwner" | "libraryCategoryId">;
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<SkillList>(`/skills?${params.toString()}`);
}
export function listSkillLibraryCategories(scope: "mine" | "created" = "mine") {
return http.get<SkillLibraryCategoryList>("/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 || "",
};
}

View File

@@ -26,6 +26,7 @@ let remoteUserDataPhase: RemoteUserDataPhase = "inactive";
let syncTimer: number | null = null;
let syncPromise: Promise<void> | null = null;
let syncQueued = false;
let syncRetryAttempt = 0;
let remoteOperationTail: Promise<void> = Promise.resolve();
let subscriptionsInstalled = false;
let acknowledgedAssets = new Map<string, Asset>();
@@ -35,6 +36,7 @@ let sessionEpoch = 0;
const verifiedProjects = new Set<string>();
const verifiedAssets = new Set<string>();
const remoteProjectLoadPromises = new Map<string, Promise<CanvasProject | undefined>>();
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;
}
}
/**

View File

@@ -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;

View File

@@ -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; }

View File

@@ -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</a>");
});
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(
<AgentChatMessage
item={assistantMessage("新节点 ID:video-1790385749473-q4157")}
theme={canvasThemes.dark}
references={[videoReference]}
onFocusNode={() => {}}
/>,
);
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");
});

View File

@@ -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(<AgentReasoningFeed items={messages.slice(0, 2)} theme={canvasThemes.light} />);
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(<AgentChatMessage item={{ id: "a1", role: "assistant", text: "已完成。" }} theme={canvasThemes.light} />);
expect(assistant).toContain('class="agent-message-icon agent-message-icon--assistant"');
expect(assistant).toContain("lucide-message-circle");
const user = renderToStaticMarkup(<AgentChatMessage item={{ id: "u1", role: "user", text: "继续处理" }} theme={canvasThemes.light} />);
expect(user).toContain('class="agent-message-icon agent-message-icon--user"');
expect(user).toContain("lucide-user-round");
const live = renderToStaticMarkup(<AgentReasoningFeed items={[{ id: "r1", role: "assistant", text: "正在整理", reasoning: true, streaming: true }]} theme={canvasThemes.light} />);
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(<AgentOperationFeed items={[steps[0]]} theme={canvasThemes.light} />);
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");

View File

@@ -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();

View File

@@ -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 () => ({

View File

@@ -23,6 +23,7 @@ let remote = new Map<string, CanvasProject>();
let requests: Array<{ method: string; id: string; project?: CanvasProject }> = [];
let beforePut: (() => Promise<void>) | 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<void>((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;