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。
|
||||
|
||||
Reference in New Issue
Block a user