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

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

View File

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

View File

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

View File

@@ -4,13 +4,14 @@ import { Tooltip } from "@/components/ui/base/tooltip";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, Wrench, X, XCircle } from "lucide-react";
import { ArrowLeft, ArrowUp, AtSign, Bookmark, BrainCircuit, CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Clapperboard, Eye, HelpCircle, ImagePlus, Layers3, List, ListChecks, LoaderCircle, MessageCircle, Palette, Pencil, Plus, RotateCcw, Shapes, Share2, ShoppingBag, Sparkles, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingGlow } from "@/components/ai/working-indicator";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Components } from "streamdown";
import type { Skill, SkillPreset } from "@/services/api/skills";
import { buildSkillMentionReferences } from "@/services/skill-runtime";
import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation";
@@ -31,12 +32,15 @@ export type CloudAgentUserQuestion = {
question: string;
options: Array<{ label: string; detail?: string }>;
allowFreeform?: boolean;
round?: number;
maxRounds?: number;
};
export type CloudAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
title?: string;
text: string;
errorSeverity?: "warning" | "error";
streaming?: boolean;
reasoning?: boolean;
/**
@@ -114,6 +118,132 @@ function clampAgentPromptHeight(height: number) {
return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height)));
}
const AGENT_NODE_LINK_PREFIX = "#agent-node:";
const AGENT_NODE_TYPE_LABELS: Record<string, string> = {
image: "图片节点",
video: "视频节点",
audio: "音频节点",
text: "文本节点",
script: "脚本节点",
drawing: "绘图节点",
config: "工作流节点",
frame: "画框节点",
markdown: "Markdown 节点",
svg: "SVG 节点",
html: "HTML 节点",
panorama: "全景节点",
compare: "对比节点",
chart: "图表节点",
colorgrade: "调色节点",
"media-conversion": "转码节点",
"batch-table": "批量表节点",
};
const AGENT_NODE_TYPE_PREFIXES = Object.keys(AGENT_NODE_TYPE_LABELS).sort((left, right) => right.length - left.length);
const BUILTIN_AGENT_NODE_ID_PATTERN = "(?:image|video|audio|text|script|drawing|config|frame|markdown|svg|html|panorama|compare|chart|colorgrade|media-conversion|batch-table)-[A-Za-z0-9][A-Za-z0-9_-]*";
function escapeAgentNodePattern(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
function agentNodeTypeLabel(nodeId: string, reference?: CanvasResourceReference) {
const sourceType = String(reference?.sourceType || "");
if (sourceType && AGENT_NODE_TYPE_LABELS[sourceType]) return AGENT_NODE_TYPE_LABELS[sourceType];
const prefix = AGENT_NODE_TYPE_PREFIXES.find((candidate) => nodeId.startsWith(`${candidate}-`));
return (prefix && AGENT_NODE_TYPE_LABELS[prefix]) || (reference?.kind ? `${reference.kind} 节点` : "画布节点");
}
function agentNodeLinkLabel(nodeId: string, reference?: CanvasResourceReference) {
const kindLabel = agentNodeTypeLabel(nodeId, reference);
const title = (reference?.title || reference?.label || "").replace(/\s+/gu, " ").trim();
return title ? `${kindLabel} · ${title}` : kindLabel;
}
function escapeAgentMarkdownLinkText(value: string) {
return value.replace(/[\\[\]]/gu, "\\$&");
}
function createAgentNodeLink(nodeId: string, reference?: CanvasResourceReference) {
return `[${escapeAgentMarkdownLinkText(agentNodeLinkLabel(nodeId, reference))}](${AGENT_NODE_LINK_PREFIX}${encodeURIComponent(nodeId)})`;
}
/**
* Replace internal canvas node IDs in assistant Markdown with semantic links.
* Fenced code is left untouched, while the historical "新节点 ID" wording is
* shortened so the assistant exposes a node card instead of an implementation ID.
*/
export function rewriteAgentNodeLinks(text: string, references: CanvasResourceReference[] = []) {
if (!text) return text;
const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference]));
const exactNodeIds = [...referenceByNodeId.keys()].sort((left, right) => right.length - left.length).map(escapeAgentNodePattern);
const nodeIdPattern = exactNodeIds.length ? `(?:${exactNodeIds.join("|")}|${BUILTIN_AGENT_NODE_ID_PATTERN})` : BUILTIN_AGENT_NODE_ID_PATTERN;
const nodeTokenPattern = new RegExp(`(?<![A-Za-z0-9_-])(?:\\x60(${nodeIdPattern})\\x60|(${nodeIdPattern}))(?![A-Za-z0-9_-])`, "gu");
const newNodePrefixPattern = /(新节点|节点|新建节点)\s*ID(?=\s*[::])/gu;
let inFence = false;
return text.split(/(\r?\n)/u).map((part) => {
if (/^\r?\n$/u.test(part)) return part;
const fence = /^\s{0,3}(`{3,}|~{3,})/u.test(part);
if (fence) {
inFence = !inFence;
return part;
}
if (inFence) return part;
const normalized = part.replace(newNodePrefixPattern, "$1");
return normalized.replace(nodeTokenPattern, (match, backtickedId: string | undefined, plainId: string | undefined) => {
const nodeId = backtickedId || plainId;
return nodeId ? createAgentNodeLink(nodeId, referenceByNodeId.get(nodeId)) : match;
});
}).join("");
}
function agentNodeIdFromHref(href?: string) {
if (!href?.startsWith(AGENT_NODE_LINK_PREFIX)) return null;
try {
const nodeId = decodeURIComponent(href.slice(AGENT_NODE_LINK_PREFIX.length));
return nodeId || null;
} catch {
return null;
}
}
function createAgentMessageMarkdownComponents(references: CanvasResourceReference[], onFocusNode?: (nodeId: string) => void): Components {
const referenceByNodeId = new Map(references.filter((reference) => reference.nodeId).map((reference) => [reference.nodeId, reference]));
return {
a: ({ children, href, className, ...props }) => {
const nodeId = agentNodeIdFromHref(href);
if (!nodeId) {
return <a {...props} href={href} className={`ai-message-markdown-link ${className || ""}`.trim()} target="_blank" rel="noreferrer">{children}</a>;
}
const reference = referenceByNodeId.get(nodeId);
const title = reference?.title || reference?.label || "画布节点";
const previewUrl = reference?.previewUrl;
return (
<a
{...props}
href={href}
className="agent-message-node-link"
data-agent-node-id={nodeId}
aria-label={`定位到画布中的${title}`}
title={`定位到画布中的${title}`}
onClick={(event) => {
event.preventDefault();
onFocusNode?.(nodeId);
}}
>
{previewUrl ? <img className="agent-message-node-link-preview" src={previewUrl} alt="" loading="lazy" /> : <span className="agent-message-node-link-icon" aria-hidden="true"><CircleDot className="size-3.5" /></span>}
<span className="agent-message-node-link-copy">
<span className="agent-message-node-link-kind">{agentNodeTypeLabel(nodeId, reference)}</span>
<span className="agent-message-node-link-title">{reference?.title || reference?.label || children}</span>
</span>
</a>
);
},
};
}
export function AgentChatMessage({
item,
theme,
@@ -138,13 +268,16 @@ export function AgentChatMessage({
const isUser = item.role === "user";
const isSystem = item.role === "system";
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
const color = item.role === "error" ? "#ef4444" : theme.node.text;
const markdownComponents = useMemo(() => createAgentMessageMarkdownComponents(references, onFocusNode), [onFocusNode, references]);
const agentMarkdownText = rewriteAgentNodeLinks(displayedText, references);
const errorTone = item.errorSeverity === "warning" ? "warning" : "error";
const color = item.role === "error" ? (errorTone === "warning" ? "#b45309" : "#ef4444") : theme.node.text;
if (item.reasoning) {
return <AgentReasoningFeed items={[item]} theme={theme} />;
}
if (isSystem) {
return (
<div className="agent-status-message flex items-start gap-2 text-xs">
<div className="agent-status-message agent-status-message--system flex items-start gap-2 text-xs">
<AgentTimelineMarker theme={theme} tone="muted" icon={<Sparkles className="size-3" />} />
<div className="min-w-0 flex-1 py-0.5 leading-5" style={{ color: theme.node.muted }}>
{item.text}
@@ -163,17 +296,17 @@ export function AgentChatMessage({
}
if (item.role === "error") {
return (
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="error" icon={<CircleAlert className="size-3.5" />} />
<div className={`agent-status-message agent-status-message--${errorTone} flex items-start gap-2`}>
<AgentTimelineMarker theme={theme} tone={errorTone} icon={errorTone === "warning" ? <CircleAlert className="size-3.5" /> : <CircleAlert className="size-3.5" />} />
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
<div className="font-medium" style={{ color }}>
{item.title || "Agent 暂时无法继续"}
<div className="agent-error-title font-medium" style={{ color }}>
{item.title || (errorTone === "warning" ? "Agent 正在重试" : "Agent 暂时无法继续")}
</div>
<div className="mt-0.5 whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
<div className="agent-error-detail whitespace-pre-wrap break-words" style={{ color: theme.node.muted }}>
{item.text}
</div>
{item.meta ? (
<div className="mt-1 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
<div className="agent-error-meta text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
{item.meta}
</div>
) : null}
@@ -188,6 +321,11 @@ export function AgentChatMessage({
}
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? (
<span className="agent-message-icon agent-message-icon--assistant" aria-hidden="true">
<MessageCircle className="size-4" />
</span>
) : null}
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-full flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
@@ -198,8 +336,8 @@ export function AgentChatMessage({
</span>
) : null}
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none">
{displayedText}
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none" components={markdownComponents}>
{agentMarkdownText}
</AIMessageMarkdown>
) : (
<AgentMessageText text={item.text} references={references} />
@@ -207,6 +345,11 @@ export function AgentChatMessage({
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[var(--fs-label)] opacity-45">{item.meta}</div> : null}
</div>
{isUser ? (
<span className="agent-message-icon agent-message-icon--user" aria-hidden="true">
<UserRound className="size-4" />
</span>
) : null}
</div>
);
}
@@ -229,6 +372,9 @@ export function AgentReasoningFeed({
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${streaming ? " is-streaming" : ""}`}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className={`agent-reasoning-icon${streaming ? " is-live" : ""}`} aria-hidden="true">
<BrainCircuit className="size-4" />
</span>
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`}</span>
@@ -580,6 +726,7 @@ export function AgentOperationFeed({
if (!latest) return null;
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
const categoryIcon = agentToolCategoryIcon(category);
const label = agentOperationSegmentLabel(items);
// 只看最新一步的状态。前面的参数错误如果已被自动纠正,不应让整段
// 操作流继续保持红色并默认展开,否则用户会误以为最终动作仍然失败。
@@ -597,6 +744,9 @@ export function AgentOperationFeed({
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
<span className="agent-operation-icon" aria-hidden="true">
{categoryIcon}
</span>
<span className="agent-operation-kind">
<span>{categoryLabel}</span>
</span>
@@ -679,7 +829,12 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<div className="flex items-start gap-2 px-3 pt-2.5">
<HelpCircle className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{question.question}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] opacity-55">
{question.round && question.maxRounds ? `确认 ${question.round}/${question.maxRounds}` : "需要确认"}
</div>
<div className="text-xs font-semibold leading-5">{question.question}</div>
</div>
</div>
<div className="flex flex-wrap gap-2 px-3 pb-2 pt-2">
{question.options.map((option) => (
@@ -701,6 +856,21 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
{option.detail ? <span className="mt-0.5 block break-words text-[10px] leading-4 opacity-60">{option.detail}</span> : null}
</button>
))}
<button
type="button"
disabled={disabled}
title="使用安全默认方案继续"
className="max-w-full rounded-md border-0 px-3 py-1.5 text-left text-xs font-medium transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.accent.primary, color: theme.accent.onPrimary }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onAnswer("直接开始");
}}
>
按默认方案开始
</button>
</div>
<div className="px-3 pb-2 text-[10px] opacity-50">{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}</div>
</div>
@@ -1248,8 +1418,8 @@ export function AgentPanelTabs<T extends string>({
* 的模型品牌 glyph —— 那个圆环标志会被读成"这条是模型/OpenAI 出品",与它实际表达的
* "这是谁的一行"无关。
*/
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "error"; icon: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; tone: "agent" | "muted" | "approval" | "warning" | "error"; icon: ReactNode }) {
const color = tone === "error" ? "#ef4444" : tone === "warning" ? "#b45309" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="agent-timeline-marker relative" aria-hidden="true">
<span className="relative grid size-5 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>

View File

@@ -22,7 +22,7 @@ import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import { agentApprovalMatchesSettings, agentImageApproval } from "@/lib/canvas/agent-media-approval";
import type { AgentMediaSettings } from "@/services/api/agent";
import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings";
import { addSkill, listAddedSkills, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillPreset } from "@/services/api/skills";
import { addSkill, listAddedSkills, listSkillLibraryCategories, listSkills, listSkillPresets, type Skill, type SkillCategory, type SkillLibraryCategory, type SkillPreset } from "@/services/api/skills";
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
@@ -77,6 +77,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const [skillHasMore, setSkillHasMore] = useState(false);
const [skillPage, setSkillPage] = useState(1);
const [skillCategories, setSkillCategories] = useState<SkillCategory[]>([]);
const [libraryCategories, setLibraryCategories] = useState<SkillLibraryCategory[]>([]);
const [skillTag, setSkillTag] = useState("all");
const [skillsOpen, setSkillsOpen] = useState(false);
const [busy, setBusy] = useState(false);
@@ -335,7 +336,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
setMarketSkills(result.skills);
setSkillHasMore(result.hasMore);
setSkillPage(result.page);
if (result.categories.length > 0) setSkillCategories(result.categories);
setSkillCategories(result.categories);
}
})
.catch(() => {
@@ -349,6 +350,15 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
};
}, [view, skillsOpen, debouncedSkillSearch, skillTag]);
useEffect(() => {
if (view !== "settings" && !skillsOpen) return;
let active = true;
void listSkillLibraryCategories("mine")
.then((result) => { if (active) setLibraryCategories(result.categories); })
.catch(() => { if (active) setLibraryCategories([]); });
return () => { active = false; };
}, [skillsOpen, userId, view]);
const loadMoreSkills = async () => {
if (skillsLoading || skillPageRequestRef.current || !skillHasMore || skillSearch.trim() !== debouncedSkillSearch) return;
skillPageRequestRef.current = true;
@@ -363,6 +373,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
sort: "popular",
});
setMarketSkills((current) => [...current, ...result.skills.filter((skill) => !current.some((item) => item.skillId === skill.skillId))]);
setSkillCategories(result.categories);
setSkillPage(result.page);
setSkillHasMore(result.hasMore);
} finally {
@@ -682,6 +693,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const newConversation = () => {
const id = nanoid();
const now = new Date().toISOString();
const inheritedSkillIds = [...selectedSkillIds];
currentScope.current = `${canvasId}:${id}`;
presetApplyingRef.current = null;
setPresetApplyingId("");
@@ -693,10 +706,27 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
setActiveConversationId(id);
setRun(null);
setMessages([]);
setSelectedSkillIds([]);
// Skills are a user-selected Agent workspace setting. Keep them when
// starting a fresh conversation so the skill picker does not appear to
// lose the skills the user just enabled.
setSelectedSkillIds(inheritedSkillIds);
setPrompt("");
setApproval(null);
lastSeqRef.current = 0;
// Persist the blank conversation immediately. Otherwise the active id
// can point at no conversation after a reload, losing the inherited
// skill selection before the first message is sent.
setConversations((current) => [{
id,
title: "新对话",
messages: [],
run: null,
model: selectedModel || undefined,
permissionMode,
skillIds: inheritedSkillIds,
createdAt: now,
updatedAt: now,
}, ...current.filter((conversation) => conversation.messages.length > 0 || conversation.run)]);
setView("chat");
};
@@ -932,6 +962,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
marketSkills={marketSkills}
selectedSkillIds={selectedSkillIds}
categories={skillCategories}
libraryCategories={libraryCategories}
category={skillTag}
search={skillSearch}
loading={skillsLoading}
@@ -1446,7 +1477,9 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
if (terminal) {
setMessages((current) => current.map((message) => message.id === `plan-${event.runId}` && message.planItems?.length ? { ...message, planTerminal: true, streaming: false } : message));
}
if (payload.failureMessage) setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage)));
if (terminal && nextStatus === "failed" && payload.failureMessage) {
setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage), "Agent 执行失败", { severity: "error" }));
}
if (snapshotApproval && !snapshotApproval.decision && snapshotApproval.approvalId) {
setApproval((current) => ({ approvalId: snapshotApproval.approvalId, detail: snapshotApproval, reason: current?.approvalId === snapshotApproval.approvalId ? current.reason : snapshotApproval.reason || "" }));
} else {
@@ -1520,7 +1553,7 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
id,
role: "assistant",
text: "",
question: { question, options, allowFreeform: payload.allowFreeform !== false },
question: { question, options, allowFreeform: payload.allowFreeform !== false, round: Number.isFinite(Number(payload.round)) ? Number(payload.round) : undefined, maxRounds: Number.isFinite(Number(payload.maxRounds)) ? Number(payload.maxRounds) : undefined },
}));
return;
}
@@ -1549,6 +1582,26 @@ function applyAgentEvent(event: AgentEvent, setMessages: Dispatch<SetStateAction
setMessages((current) => mergeAgentToolRetry(current, message));
if (event.type === "tool_failed") return;
}
if (event.type === "tool_completed" && payload.toolName === "director_preview") {
const result = payload.result && typeof payload.result === "object" ? payload.result as Record<string, unknown> : {};
window.dispatchEvent(new CustomEvent("director:preview-requested", {
detail: {
sceneId: String(result.sceneId || ""),
shotId: String(result.shotId || ""),
duration: Number(result.duration || 0),
fps: Number(result.fps || 0),
previewRequestId: String(result.previewRequestId || payload.callId || ""),
},
}));
setMessages((current) => appendUniqueMessage(current, {
id: event.eventId,
role: "tool",
title: "director_preview",
text: text || "已请求导演台生成白模预演",
detail: { ...payload, eventType: event.type },
}));
return;
}
if (event.type === "tool_completed" && payload.toolName === "canvas_apply_ops" && payload.callId) {
const id = `canvas-${event.runId}-${payload.callId}`;
setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: text || "画布操作已完成", detail: { ...payload, eventType: event.type } }));
@@ -1657,11 +1710,11 @@ function upsertTextMessage(current: CloudAgentChatMessage[], id: string, text: s
return next;
}
function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string) {
function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string, options?: { severity?: CloudAgentChatMessage["errorSeverity"] }) {
const message = agentErrorPresentation(cause, fallback);
const last = current.at(-1);
if (last?.role === "error" && last.title === message.title && last.text === message.text) return current;
return appendUniqueMessage(current, { id, role: "error", ...message });
return appendUniqueMessage(current, { id, role: "error", ...message, ...(options?.severity ? { errorSeverity: options.severity } : {}) });
}
function isNotFoundError(cause: unknown) {

View File

@@ -384,8 +384,8 @@
}
.agent-conversation {
padding: var(--space-3) var(--space-5) var(--space-5);
scroll-padding-block: var(--space-5);
padding: 10px var(--space-4) var(--space-4);
scroll-padding-block: var(--space-4);
}
.agent-conversation-messages {
@@ -396,12 +396,56 @@
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
margin-top: 9px;
}
/* 状态行的图标只是语义标记,不应把正文向右推成另一条内容轴。 */
.agent-status-message {
min-width: 0;
line-height: 1.45;
}
.agent-status-message--system {
opacity: 0.78;
}
.agent-status-message--warning {
margin-inline: -4px;
border: 1px solid color-mix(in srgb, #f59e0b 26%, transparent);
border-radius: 10px;
background: color-mix(in srgb, #fef3c7 72%, transparent);
padding: 8px 10px;
}
.agent-status-message--error {
margin-inline: -4px;
border: 1px solid color-mix(in srgb, #ef4444 16%, transparent);
border-radius: 10px;
background: color-mix(in srgb, #ef4444 5%, transparent);
padding: 8px 10px;
}
.agent-status-message--warning .agent-timeline-marker,
.agent-status-message--error .agent-timeline-marker {
align-self: flex-start;
padding-top: 1px;
}
.agent-error-title {
font-size: 13px;
line-height: 18px;
}
.agent-error-detail {
margin-top: 2px;
font-size: 12px;
line-height: 17px;
}
.agent-error-meta {
margin-top: 3px;
opacity: 0.72;
line-height: 16px;
}
.agent-message-user {
@@ -409,6 +453,15 @@
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
}
.agent-message-body .ai-message-markdown-paragraph {
margin: 0.32em 0;
}
.agent-message-body .ai-message-markdown-list {
margin-top: 0.35em;
margin-bottom: 0.35em;
}
.agent-welcome {
display: flex;
min-height: 100%;
@@ -788,14 +841,94 @@
line-height: 1;
}
.agent-message-node-link {
display: inline-flex;
max-width: min(100%, 300px);
align-items: center;
gap: 7px;
margin: 1px 3px;
border: 1px solid color-mix(in srgb, var(--workspace-accent) 28%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--workspace-accent) 9%, transparent);
padding: 3px 9px 3px 4px;
color: var(--foreground);
line-height: 1.15;
text-align: left;
text-decoration: none;
vertical-align: middle;
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease;
}
.agent-message-node-link:hover {
border-color: color-mix(in srgb, var(--workspace-accent) 50%, transparent);
background: color-mix(in srgb, var(--workspace-accent) 16%, transparent);
color: var(--foreground);
text-decoration: none;
transform: translateY(-1px);
}
.agent-message-node-link:focus-visible {
outline: 2px solid var(--agent-focus-ring, var(--workspace-accent));
outline-offset: 2px;
}
.agent-message-node-link-preview,
.agent-message-node-link-icon {
display: grid;
width: 25px;
height: 25px;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: color-mix(in srgb, var(--workspace-accent) 16%, transparent);
color: var(--workspace-accent);
}
.agent-message-node-link-preview {
object-fit: cover;
}
.agent-message-node-link-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 1px;
}
.agent-message-node-link-kind,
.agent-message-node-link-title {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-message-node-link-kind {
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: 10px;
font-weight: 500;
}
.agent-message-node-link-title {
color: var(--foreground);
font-size: 12px;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.agent-message-node-link { transition: none; }
}
.agent-tool-row {
/* 日志是事件流,不是连续卡片;只用间距和状态图标建立层次。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
min-height: 36px;
min-height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
padding: 5px 0;
padding: 2px 0;
transition: transform 160ms ease;
}
@@ -831,11 +964,11 @@
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
.agent-tool-row--plain {
min-height: 28px;
min-height: 24px;
border-color: transparent;
border-radius: 9px;
background: transparent;
padding: 3px 0;
padding: 1px 0;
}
.agent-tool-row--read {
@@ -861,10 +994,10 @@
.agent-tool-header {
display: flex;
min-width: 0;
align-items: center;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
line-height: 18px;
gap: 4px 6px;
line-height: 17px;
}
.agent-tool-category {
@@ -978,19 +1111,37 @@
--agent-tool-accent: var(--agent-accent, var(--foreground));
}
.agent-operation-feed[data-agent-category="read"] {
--agent-tool-accent: #2563eb;
}
.agent-operation-feed[data-agent-category="create"] {
--agent-tool-accent: #d97706;
}
.agent-operation-feed[data-agent-category="operate"] {
--agent-tool-accent: #0f766e;
}
.agent-operation-feed[data-agent-category="other"] {
--agent-tool-accent: #64748b;
}
.agent-operation-toggle {
display: flex;
width: 100%;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
justify-content: flex-start;
gap: 5px;
border: 0;
border-radius: 9px;
border-radius: 8px;
background: transparent;
padding: 5px 0;
padding: 3px 0;
color: color-mix(in srgb, var(--agent-ink, currentColor) 72%, transparent);
font-size: var(--fs-caption);
line-height: 18px;
line-height: 17px;
text-align: left;
cursor: pointer;
transition:
@@ -1004,9 +1155,27 @@
padding-inline: 0;
}
.agent-operation-icon {
display: inline-flex;
width: 16px;
height: 18px;
flex: 0 0 16px;
align-items: center;
justify-content: center;
color: var(--agent-tool-accent);
}
.agent-operation-icon svg {
width: 14px;
height: 14px;
}
.agent-operation-feed.is-failed .agent-operation-icon {
color: #dc2626;
}
.agent-operation-kind {
flex: 0 0 auto;
color: color-mix(in srgb, var(--agent-tool-accent) 86%, var(--foreground));
font-size: var(--fs-caption);
font-weight: 600;
white-space: nowrap;
@@ -1087,7 +1256,13 @@
}
.agent-operation-list > * + * {
margin-top: var(--space-1);
margin-top: 3px;
}
.agent-operation-feed.is-failed .agent-operation-toggle {
border-radius: 8px;
background: color-mix(in srgb, #dc2626 5%, transparent);
padding-inline: 6px;
}
/*
@@ -1344,11 +1519,26 @@
justify-content: center;
}
.agent-message-icon {
display: inline-flex;
width: 20px;
height: 24px;
flex: 0 0 20px;
align-items: flex-start;
justify-content: center;
padding-top: 3px;
color: var(--agent-accent, var(--foreground));
}
.agent-message-icon--user {
opacity: 0.76;
}
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
border: 0;
border-radius: 10px;
border-radius: 0;
background: transparent;
box-shadow: none;
transition: background-color 160ms ease;
@@ -1391,6 +1581,10 @@
color: color-mix(in srgb, var(--agent-reasoning-accent) 82%, var(--foreground));
}
.agent-reasoning-icon.is-live {
animation: agent-reasoning-icon-pulse 1.8s ease-in-out infinite;
}
.agent-reasoning-copy {
display: flex;
min-width: 0;
@@ -1474,7 +1668,7 @@
gap: 0;
overflow: auto;
border-top: 0;
padding: 4px 0 10px;
padding: 2px 0 7px;
scrollbar-gutter: stable;
}
@@ -1487,6 +1681,11 @@
overflow-wrap: anywhere;
}
@keyframes agent-reasoning-icon-pulse {
0%, 100% { opacity: .55; transform: rotate(-8deg) scale(.9); }
50% { opacity: 1; transform: rotate(8deg) scale(1.08); }
}
@keyframes agent-reasoning-pulse {
0%, 100% { opacity: .55; transform: scale(.82); }
50% { opacity: 1; transform: scale(1); }
@@ -1496,7 +1695,8 @@
.agent-reasoning-card,
.agent-reasoning-chevron { transition: none; }
.agent-reasoning-status-dot { animation: none; }
.agent-reasoning-status-dot,
.agent-reasoning-icon.is-live { animation: none; }
}
.agent-working-indicator {
@@ -1898,6 +2098,25 @@
overscroll-behavior: contain;
}
.canvas-agent-skill-library-category-group {
display: grid;
gap: var(--space-1);
}
.canvas-agent-skill-library-category-group + .canvas-agent-skill-library-category-group {
margin-top: var(--space-4);
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
.canvas-agent-skill-library-category-heading {
padding: 0 var(--space-3) var(--space-1);
color: var(--muted-foreground);
font-size: var(--fs-caption);
font-weight: 650;
letter-spacing: 0.04em;
}
.canvas-agent-skill-library-tab {
display: flex;
min-height: var(--space-12);
@@ -2099,7 +2318,25 @@
min-width: 0;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-3);
padding: var(--space-4) var(--space-2) var(--space-2);
}
.canvas-agent-skill-card-body.is-text-only {
min-height: 204px;
border-radius: var(--r-2xl);
background: var(--library-surface);
padding: var(--space-5);
}
.canvas-agent-skill-card-category.is-inline {
position: static;
display: inline-flex;
width: fit-content;
max-width: 100%;
align-self: flex-start;
background: var(--library-surface-hover);
color: var(--muted-foreground);
backdrop-filter: none;
}
.canvas-agent-skill-card-title-row {
@@ -2111,6 +2348,7 @@
}
.canvas-agent-skill-card-title-row h3 {
display: -webkit-box;
min-width: 0;
overflow: hidden;
margin: 0;
@@ -2119,7 +2357,9 @@
font-weight: 650;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
}
.canvas-agent-skill-card-body > p {
@@ -2131,10 +2371,14 @@
font-size: var(--fs-body);
line-height: 1.65;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
overflow-wrap: anywhere;
}
.canvas-agent-skill-card-body.is-text-only > p {
-webkit-line-clamp: 5;
}
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
@@ -2250,11 +2494,32 @@
.canvas-agent-skill-library-categories {
flex-direction: row;
gap: var(--space-1);
gap: var(--space-3);
overflow-x: auto;
overscroll-behavior-x: contain;
}
.canvas-agent-skill-library-category-group {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.canvas-agent-skill-library-category-group + .canvas-agent-skill-library-category-group {
margin-top: 0;
border-top: 0;
border-left: 1px solid var(--border);
padding-top: 0;
padding-left: var(--space-3);
}
.canvas-agent-skill-library-category-heading {
flex: 0 0 auto;
padding: 0 var(--space-2);
font-size: var(--fs-caption);
}
.canvas-agent-skill-library-tab {
min-height: var(--space-10);
gap: var(--space-2);

View File

@@ -1,7 +1,7 @@
import { App, Button, ColorPicker, Dropdown, Input, InputNumber, Slider } from "antd";
import { Switch } from "@/components/ui/base/switch";
import type { MenuProps } from "antd";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, X } from "lucide-react";
import { Box, BoxSelect, Camera, Circle, Cuboid, FileUp, Focus, Image as ImageIcon, LampDesk, Lightbulb, MousePointer2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2, UserRound, Video, WandSparkles, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from "react";
import { nanoid } from "nanoid";
import { Euler, Quaternion } from "three";
@@ -17,7 +17,7 @@ import { advanceDirectorPlayhead, resolveDirectorCameraAlignment, resolveDirecto
import { createDirectorTransaction, installDirectorTerminalListeners, type DirectorTransaction } from "@/lib/canvas/director/director-gesture-transaction";
import { recordDirectorDiagnostic } from "@/lib/canvas/director/director-diagnostics-recorder";
import { DIRECTOR_MODES, directorModeCapabilities, type DirectorModeCapabilities } from "@/lib/canvas/director/director-modes";
import { resolveDirectorPlacement, resolveDirectorPlacementAnchor } from "@/lib/canvas/director/director-placement";
import { resolveDirectorPlacement, resolveDirectorPlacementAnchor, type DirectorGroundPoint } from "@/lib/canvas/director/director-placement";
import { isDirectorOutputSnapshotCurrent, shouldReinitializeDirectorSession } from "@/lib/canvas/director/director-session";
import { blocksDirectorShortcut, releaseDirectorFocusAfterPointer, resolveDirectorShortcut, type DirectorShortcutAction } from "@/lib/canvas/director/director-shortcuts";
import { createDirectorActor, createDirectorBillboard, createDirectorCamera, createDirectorLight, createDirectorModel, createDirectorObject, DIRECTOR_ACTOR_COLORS, directorBoneLabel, directorFocalLengthToFov, directorPoseLabel, interpolateDirectorTransform, removeDirectorSceneKeyframe, setDirectorSceneKeyframeEasing, touchDirectorScene, upsertDirectorBoneKeyframe } from "@/lib/canvas/director/director-scene";
@@ -42,6 +42,8 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
const [future, setFuture] = useState<DirectorScene[]>([]);
const [saving, setSaving] = useState(false);
const [recording, setRecording] = useState(false);
// 默认进入镜头优先的快速工作流,完整的摆场/姿态/动画能力仍保留在高级模式。
const [quickMode, setQuickMode] = useState(true);
const [onboardingRestartSignal, setOnboardingRestartSignal] = useState(0);
const mode = useDirectorWorkbenchStore((state) => state.mode);
const viewMode = useDirectorWorkbenchStore((state) => state.viewMode);
@@ -692,6 +694,71 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
}
};
const quickActors = draft?.objects.filter((item) => item.kind === "actor" || item.primitive === "character") || [];
const quickSelectedActor = quickActors.find((item) => item.id === selectedObjectId) || quickActors[0] || null;
const handleQuickGroundClick = useCallback((point: DirectorGroundPoint) => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
setSelectedObjectId(quickSelectedActor.id);
handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, {
...quickSelectedActor.transform,
position: [point.x, quickSelectedActor.transform.position[1], point.z],
});
message.success("演员已落位");
}, [handleObjectTransform, message, quickSelectedActor]);
const quickPoseOptions: Array<{ value: DirectorPose; label: string }> = [
{ value: "stand", label: "站立" },
{ value: "sit", label: "坐下" },
{ value: "walk", label: "走动" },
{ value: "wave", label: "挥手" },
{ value: "think", label: "思考" },
{ value: "fight", label: "对抗" },
];
const applyQuickPose = (pose: DirectorPose) => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
setSelectedObjectId(quickSelectedActor.id);
updateObject(quickSelectedActor.id, { pose, activeMotionClipId: undefined, boneOverrides: {} });
};
const placeQuickActor = () => {
if (!quickSelectedActor) {
message.info("先添加或选择一个演员");
return;
}
const intent = viewportRef.current?.readPlacementIntent();
const point = intent?.pointer || intent?.orbitTarget;
if (!point) {
message.info("先在视口里移动鼠标到地面,或旋转视图后再落位");
return;
}
setSelectedObjectId(quickSelectedActor.id);
handleObjectTransform(quickSelectedActor.id, quickSelectedActor.transform, { ...quickSelectedActor.transform, position: [point.x, quickSelectedActor.transform.position[1], point.z] });
};
const addQuickActor = () => {
addActor();
message.success("演员已加入;直接点击视口地面即可落位");
};
const selectQuickShotSize = (shotSize: DirectorShotSize) => {
if (activeShot) updateShot(activeShot.id, { shotSize });
};
const selectQuickCameraMove = (move: DirectorCameraMove) => {
if (activeShot) updateShot(activeShot.id, { cameraMove: move });
};
useEffect(() => {
const onPreviewRequested = (event: Event) => {
const detail = (event as CustomEvent<{ sceneId?: string; shotId?: string; duration?: number; fps?: number }>).detail;
if (!detail || detail.sceneId !== draft?.id || (detail.shotId && detail.shotId !== activeShot?.id)) return;
void exportClayVideo();
};
window.addEventListener("director:preview-requested", onPreviewRequested);
return () => window.removeEventListener("director:preview-requested", onPreviewRequested);
}, [activeShot?.id, draft?.id, exportClayVideo]);
if (!open || !draft || !activeShot) return null;
return (
@@ -704,7 +771,25 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
<IconButton label="重做" disabled={!future.length} onClick={redo}><Redo2 className="size-4" /></IconButton>
<span className="h-5 w-px" style={{ background: theme.toolbar.border }} />
{/* 一级模式切换:小屏也必须可达,因此不加 max-lg:hidden。 */}
<nav className="director-mode-switch" aria-label="导演台模式">
<button
type="button"
className={`director-mode-switch-button ${quickMode ? "is-active" : ""}`}
aria-pressed={quickMode}
title="用镜头优先的最短路径完成摆位、姿态和预演"
onClick={() => setQuickMode(true)}
>
快速镜头
</button>
<button
type="button"
className={`director-mode-switch-button ${!quickMode ? "is-active" : ""}`}
aria-pressed={!quickMode}
title="打开完整的摆场、姿态、动画和摄影机工具"
onClick={() => setQuickMode(false)}
>
高级工作台
</button>
{!quickMode ? <nav className="director-mode-switch" aria-label="导演台模式">
{DIRECTOR_MODES.map((item) => (
<button
key={item.mode}
@@ -722,7 +807,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
{item.label}
</button>
))}
</nav>
</nav> : null}
<div className="ml-auto flex items-center gap-2">
<span
aria-live="polite"
@@ -741,6 +826,48 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
</div>
</header>
{quickMode ? <div className="grid min-h-0 flex-1 grid-cols-[minmax(220px,280px)_minmax(0,1fr)_minmax(260px,320px)] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-3 flex items-center justify-between">
<div>
<div className="text-sm font-semibold">镜头对象</div>
<div className="text-[var(--fs-tiny)] opacity-55">先解决站位和关系,不先建复杂模型</div>
</div>
<Button size="small" type="primary" icon={<Plus className="size-3.5" />} onClick={addQuickActor}>演员</Button>
</div>
<div className="space-y-1">
{quickActors.map((actor) => <button key={actor.id} type="button" className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left text-sm transition-colors ${quickSelectedActor?.id === actor.id ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent hover:bg-black/5 dark:hover:bg-white/5"}`} onClick={() => setSelectedObjectId(actor.id)}>
<span className="flex size-7 items-center justify-center rounded-full text-white" style={{ background: actor.color }}><UserRound className="size-3.5" /></span>
<span className="min-w-0 flex-1 truncate">{actor.name}</span>
<span className="text-[var(--fs-tiny)] opacity-45">{actor.pose ? directorPoseLabel(actor.pose) : "站立"}</span>
</button>)}
</div>
{!quickActors.length ? <div className="rounded-lg border border-dashed p-4 text-center text-xs opacity-60">还没有演员<br />先加一个人偶,再用镜头完成预演</div> : null}
<div className="mt-5 rounded-lg border p-3" style={{ borderColor: theme.toolbar.border }}>
<div className="mb-2 flex items-center gap-2 text-sm font-medium"><MousePointer2 className="size-4" />快速落位</div>
<p className="mb-3 text-xs leading-5 opacity-60">直接点击视口地面即可落位;也可以先移动鼠标,再用按钮落到当前指针位置。无需输入 XYZ。</p>
<Button block size="small" disabled={!quickSelectedActor} onClick={placeQuickActor}>按当前指针落位</Button>
</div>
</aside>
<main className="relative min-h-0 overflow-hidden bg-neutral-900">
<DirectorViewport ref={viewportRef} scene={draft} selectedObjectId={quickSelectedActor?.id || null} selectedBone={null} transformMode="translate" renderMode="clay" playhead={0} playing={false} showMotionPaths={false} viewMode={viewMode} onViewModeChange={setViewMode} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onGroundClick={handleQuickGroundClick} onObjectTransform={handleObjectTransform} onBoneTransform={handleBoneTransform} onActorRigReady={handleActorRigReady} />
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2 rounded-md bg-black/35 px-2.5 py-1.5 text-xs font-medium text-white/80"><WandSparkles className="size-3.5" />{activeShot.name} · {activeCamera?.name || "无摄影机"}</div>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-lg border border-white/10 bg-black/55 p-1.5 text-white shadow-lg">
<span className="px-2 text-xs text-white/65">白模预演</span>
<Button size="small" ghost icon={<Video className="size-3.5" />} loading={recording} onClick={() => void exportClayVideo()}>生成预演</Button>
<Button size="small" type="primary" icon={<Save className="size-3.5" />} loading={saving} onClick={() => void applyToCanvas()}>应用镜头</Button>
</div>
</main>
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-l p-3" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<div className="mb-4 flex items-center justify-between"><div><div className="text-sm font-semibold">镜头卡</div><div className="text-[var(--fs-tiny)] opacity-55">用语义参数替代工程参数</div></div><Camera className="size-4 opacity-45" /></div>
<div className="mb-4 grid grid-cols-3 gap-1.5">{(["wide", "full", "medium", "close_up", "extreme_close_up"] as DirectorShotSize[]).map((size) => <button key={size} type="button" className={`rounded-md border px-2 py-2 text-xs ${activeShot.shotSize === size ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => selectQuickShotSize(size)}>{({ wide: "全景", full: "全身", medium: "中景", close_up: "近景", extreme_close_up: "特写" } as Record<string, string>)[size]}</button>)}</div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">姿态</div><div className="grid grid-cols-3 gap-1.5">{quickPoseOptions.map((option) => <button key={option.value} type="button" disabled={!quickSelectedActor} className={`rounded-md border px-2 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-35 ${quickSelectedActor?.pose === option.value ? "border-[var(--accent-primary)] bg-[var(--accent-primary)]/10" : "border-transparent bg-black/5 dark:bg-white/5"}`} onClick={() => applyQuickPose(option.value)}>{option.label}</button>)}</div></div>
<div className="mb-4"><div className="mb-2 text-xs font-medium opacity-65">运镜</div><Select className="w-full" size="small" value={activeShot.cameraMove} options={[{ value: "static", label: "固定" }, { value: "push_in", label: "推进" }, { value: "pull_out", label: "拉远" }, { value: "pan_left", label: "左摇" }, { value: "pan_right", label: "右摇" }, { value: "handheld", label: "手持" }]} onChange={selectQuickCameraMove} /></div>
<div className="space-y-2"><Button block size="small" icon={<Focus className="size-3.5" />} onClick={alignCameraToView}>用当前视图设机位</Button><Button block size="small" onClick={applyCameraMove}>生成运镜</Button></div>
<div className="mt-5 rounded-lg border p-3 text-xs leading-5 opacity-70" style={{ borderColor: theme.toolbar.border }}><div className="mb-1 font-medium opacity-100">给视频模型的参考</div>白模只负责镜头、动作和空间关系;生成真实人体比例,不沿用白模轮廓。</div>
</aside>
</div> : null}
{!quickMode ? <>
<div className="grid min-h-0 flex-1 grid-cols-[220px_minmax(0,1fr)_292px] max-lg:grid-cols-[180px_minmax(0,1fr)]">
<aside className="thin-scrollbar min-h-0 overflow-y-auto border-r" style={{ background: theme.node.panel, borderColor: theme.toolbar.border }}>
<PanelTitle title="场景对象" action={<AddMenuButton label="添加场景对象" items={addObjectMenuItems} />} />
@@ -780,6 +907,7 @@ export function CanvasDirectorWorkbench({ open, scene, imageNodes, onboardingSco
{/* 时间轴只属于动画模式:其他模式下它不渲染,Auto Key 与录制入口一并消失。 */}
{capabilities.timeline ? <DirectorSequencer scene={draft} shot={activeShot} camera={activeCamera} objects={draft.objects} selectedObjectId={selectedObjectId} selectedBone={selectedBone} playhead={playhead} playing={playing} autoKey={autoKey} height={sequencerHeight} visible={sequencerVisible} onPlayToggle={() => setPlaying(!playing)} onPlayheadChange={setPlayhead} onAutoKeyChange={setAutoKey} onHeightChange={setSequencerHeight} onVisibilityChange={setSequencerVisible} onSelectObject={setSelectedObjectId} onSelectBone={setSelectedBone} onRecordKeyframe={recordSelectedKeyframe} onAddShot={addShot} onDeleteKeyframe={deleteKeyframe} onSetKeyframeEasing={setKeyframeEasing} onSelectShot={(id) => { commit((current) => ({ ...current, activeShotId: id })); setPlayhead(0); }} /> : null}
</> : null}
</div>
);
}

View File

@@ -45,6 +45,8 @@ type DirectorViewportProps = {
onViewModeChange?: (mode: DirectorViewMode) => void;
onSelectObject: (id: string | null) => void;
onSelectBone: (bone: string | null) => void;
/** 快速镜头模式下,点击地面直接落位;不提供则保持高级工作台原有行为。 */
onGroundClick?: (point: DirectorGroundPoint) => void;
onObjectTransform: (id: string, from: DirectorTransform, to: DirectorTransform) => void;
onBoneTransform: (id: string, bone: string, rotation: DirectorQuat) => void;
onActorRigReady: (id: string, rig: DirectorRig, animations: AnimationClip[]) => void;
@@ -288,7 +290,7 @@ function DirectorViewportNotice({ title, description, actionLabel, onAction, var
);
}
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) {
function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transformMode, renderMode, playhead, playing, showMotionPaths = false, viewMode = DIRECTOR_DEFAULT_VIEW_MODE, onSelectObject, onSelectBone, onGroundClick, onObjectTransform, onBoneTransform, onActorRigReady, onCaptureContext, onRelease, onContextLost, onContextRestored, onLoadStateChange, onGroundPoint, onOrbitControls }: DirectorCanvasSurfaceProps) {
const { gl, camera, scene: threeScene, invalidate, set, size } = useThree();
const orbitRef = useRef<DirectorOrbitControls>(null);
const [transforming, setTransforming] = useState(false);
@@ -355,25 +357,51 @@ function DirectorSceneContent({ scene, selectedObjectId, selectedBone, transform
const groundPlane = new Plane(new Vector3(0, 1, 0), 0);
const hit = new Vector3();
const ndc = new Vector2();
let pointerDown: { id: number; x: number; y: number; button: number } | null = null;
const onPointerMove = (event: PointerEvent) => {
const resolveGroundPoint = (event: PointerEvent) => {
const bounds = canvasElement.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
if (bounds.width <= 0 || bounds.height <= 0) return null;
ndc.set(((event.clientX - bounds.left) / bounds.width) * 2 - 1, -((event.clientY - bounds.top) / bounds.height) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
// 射线与地面平行或背离时 intersectPlane 返回 null:保留上一个合法点,不写非法值。
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return;
const point = finiteDirectorGroundPoint(hit.x, hit.z);
if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
return finiteDirectorGroundPoint(hit.x, hit.z);
};
const onPointerMove = (event: PointerEvent) => {
const point = resolveGroundPoint(event);
// 射线与地面平行或背离时保留上一个合法点,不写非法值。
if (point) onGroundPoint(canvasElement, point);
};
const onPointerDown = (event: PointerEvent) => {
if (!onGroundClick || event.button !== 0 || event.isPrimary === false) return;
pointerDown = { id: event.pointerId, x: event.clientX, y: event.clientY, button: event.button };
};
const onPointerUp = (event: PointerEvent) => {
const start = pointerDown;
pointerDown = null;
if (!start || start.id !== event.pointerId || start.button !== 0 || event.button !== 0) return;
// OrbitControls 旋转、平移或缩放结束时也会收到 pointerup,移动阈值避免误落位。
const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
if (distance > 5) return;
const point = resolveGroundPoint(event);
if (point) onGroundClick?.(point);
};
canvasElement.addEventListener("pointermove", onPointerMove, { passive: true });
canvasElement.addEventListener("pointerdown", onPointerDown, { passive: true });
canvasElement.addEventListener("pointerup", onPointerUp, { passive: true });
return () => {
canvasElement.removeEventListener("pointermove", onPointerMove);
canvasElement.removeEventListener("pointerdown", onPointerDown);
canvasElement.removeEventListener("pointerup", onPointerUp);
pointerDown = null;
// 这个 renderer 的 canvas 卸载后,它记录的地面点不得再被读到。
onGroundPoint(canvasElement, null);
};
}, [camera, gl, onGroundPoint]);
}, [camera, gl, onGroundClick, onGroundPoint]);
// OrbitControls 的真实 target 只能从实例读;activeCamera.target 只是初始 prop。
// 挂载期登记一次即可:drei 重建实例会连带重跑本 effect。