feat(web): 工作台 - 优化 Agent 工作区与技能库
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -233,6 +233,7 @@ export const AGENT_TOOL_ERROR_CLASS_LABELS: Record<string, string> = {
|
||||
state_conflict: "画布状态已变化",
|
||||
permission_violation: "超出本轮权限",
|
||||
upstream_failure: "上游故障",
|
||||
admission_failure: "媒体生成准入失败",
|
||||
tool_error: "工具执行失败",
|
||||
};
|
||||
|
||||
|
||||
123
web/src/pages/canvas/canvas-sync-status.css
Normal file
123
web/src/pages/canvas/canvas-sync-status.css
Normal 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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 || "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
65
web/test/agent-chat-node-links.test.tsx
Normal file
65
web/test/agent-chat-node-links.test.tsx
Normal 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");
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user