refactor(canvas): 节点注册表 - 合并 PR #284 并收敛类型判据

This commit is contained in:
ddCat
2026-08-21 09:18:17 +08:00
25 changed files with 844 additions and 91 deletions

View File

@@ -7,7 +7,7 @@
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"test": "bun test test/channel-model-catalog.test.ts test/agent-prompt-cache.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."

View File

@@ -1,11 +1,14 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Sparkles, UserRound, Wrench, X, XCircle } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
import type { LocalUser } from "@/stores/use-user-store";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
export type CanvasAgentMode = "online" | "local";
@@ -179,6 +182,9 @@ export function AgentChatComposer({
onAddFiles,
onRemoveAttachment,
left,
references = [],
slashSkills,
includeAssetLibrary,
}: {
prompt: string;
attachments?: CanvasAgentChatAttachment[];
@@ -191,9 +197,78 @@ export function AgentChatComposer({
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
left?: ReactNode;
/** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */
references?: CanvasResourceReference[];
/** 供「/」弹出的技能候选(可选) */
slashSkills?: Skill[];
/** 是否在「@」候选里包含素材库资源 */
includeAssetLibrary?: boolean;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [slash, setSlash] = useState<{ start: number; query: string } | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const availableSlashSkills = slashSkills ?? [];
const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length);
const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(availableSlashSkills.length - 1, 0));
// 在输入值末尾检测「/关键词」打开技能候选;选择后替换为 @[skill:xxx] 引用 token(保持在 prompt 文本里)。
const handlePromptChange = (value: string) => {
onPromptChange(value);
const match = /(^|\s)\/([^\s/]*)$/.exec(value);
if (match && availableSlashSkills.length) {
const next = { start: match.index + match[1].length, query: match[2] };
setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next));
setSlashIndex(0);
} else if (slash) {
setSlash(null);
}
};
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skill_id}] `;
const next = slash
? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + slash.query.length)}`
: prompt
? `${prompt.replace(/\s+$/u, "")} ${token}`
: token;
setSlash(null);
setSlashIndex(0);
onPromptChange(next);
};
// slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到)
const handleSlashKeyCapture = (event: ReactKeyboardEvent) => {
if (!slash || !availableSlashSkills.length) return;
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
setSlashIndex((index) => Math.min(index + 1, availableSlashSkills.length - 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
setSlashIndex((index) => Math.max(index - 1, 0));
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
event.stopPropagation();
applySlashSkill(availableSlashSkills[activeSlashIndex]);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setSlash(null);
}
};
// 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片)
const handlePasteCapture = (event: ReactClipboardEvent) => {
if (!onAddFiles) return;
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (!images.length) return;
event.preventDefault();
event.stopPropagation();
void onAddFiles(images);
};
return (
<div className="px-3 pb-3 pt-1" onWheelCapture={(event) => event.stopPropagation()}>
<div className="rounded-lg border px-3 pb-2.5 pt-3 transition-[border-color,box-shadow] duration-150 focus-within:border-current" style={{ background: theme.node.fill, borderColor: theme.toolbar.border, color: theme.accent.primary, boxShadow: `0 10px 30px ${theme.spatial.shadow}` }}>
@@ -211,25 +286,47 @@ export function AgentChatComposer({
))}
</div>
) : null}
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
onPaste={(event) => {
if (!onAddFiles) return;
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
if (!images.length) return;
event.preventDefault();
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
void onSubmit();
}}
className="thin-scrollbar max-h-40 min-h-[60px] w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
style={{ color: theme.node.text }}
placeholder={placeholder}
/>
<div className="relative" onKeyDownCapture={handleSlashKeyCapture} onPasteCapture={handlePasteCapture}>
<div className="thin-scrollbar max-h-40 min-h-[60px] overflow-y-auto">
<CanvasResourceMentionTextarea
value={prompt}
references={references}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter
disabled={disabled}
onChange={handlePromptChange}
onSubmit={onSubmit}
className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45"
containerClassName="min-h-[60px]"
style={{ color: theme.node.text }}
placeholder={placeholder}
aria-label="Agent 输入"
/>
</div>
{slash && availableSlashSkills.length ? (
<div
data-agent-slash-menu
className="absolute bottom-full left-0 z-[var(--z-toolbar)] mb-1.5 w-full max-w-xs overflow-hidden rounded-lg border p-1 shadow-lg"
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}
onMouseDown={(event) => event.preventDefault()}
>
{availableSlashSkills.map((skill, index) => (
<button
key={skill.skill_id}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
style={{ background: index === activeSlashIndex ? theme.toolbar.itemHover : "transparent", color: theme.node.text }}
onMouseEnter={() => setSlashIndex(index)}
onClick={() => applySlashSkill(skill)}
>
<Sparkles className="size-3.5 shrink-0 opacity-70" />
<span className="min-w-0 truncate font-medium">{skill.skill_name}</span>
{skill.description ? <span className="min-w-0 flex-1 truncate opacity-50">{skill.description}</span> : null}
</button>
))}
</div>
) : null}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1">
{onAddFiles ? (

View File

@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
import { Button, Segmented, Select } from "antd";
import { Button, InputNumber, Segmented, Select } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -69,6 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))));
const priceChannel = resolveModelChannel(config, config.model);
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
const hasPrice = creditsEnabled && credits !== null;
@@ -165,9 +166,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
{simpleMode ? (
<div className="mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
) : (
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" || mode === "text" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
{mode === "video" ? (
{mode === "text" ? (
<div className="flex h-10 min-w-0 cursor-default items-center justify-between gap-2 rounded-lg border px-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<span className="inline-flex items-center gap-1 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.muted }}><MessageSquare className="size-3.5" />文本份数</span>
<InputNumber size="small" min={1} max={15} value={textCountValue} onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" />
</div>
) : mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
) : mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Button, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, LoaderCircle, MessageSquareText, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
@@ -23,6 +23,8 @@ import {
type AgentThreadSummary,
} from "@/stores/canvas/use-canvas-agent-store";
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { isProjectAgentReadTool, isProjectAgentToolName, runProjectAgentTool } from "@/services/api/project-agent-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
@@ -103,6 +105,22 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
} = useCanvasAgentStore();
const [resizing, setResizing] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
// 供 Agent 输入框「@」插入的画布节点引用候选(active 标记为可用,供「@」菜单列出),与「/」弹出的已加入技能候选
const composerReferences = useMemo(() => buildCanvasResourceReferences(snapshot.nodes, snapshot.connections).map((item) => ({ ...item, active: true })), [snapshot]);
const [composerSkills, setComposerSkills] = useState<Skill[]>([]);
useEffect(() => {
let cancelled = false;
listAddedSkills()
.then((result) => {
if (!cancelled) setComposerSkills(result?.skills ?? []);
})
.catch(() => {
// 技能列表加载失败只影响「/」菜单,不影响输入主功能
});
return () => {
cancelled = true;
};
}, []);
const snapshotRef = useRef(snapshot);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
@@ -637,6 +655,9 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({
sending={sending || waiting}
placeholder="询问 Codex,或让它操作画布"
theme={theme}
references={composerReferences}
slashSkills={composerSkills}
includeAssetLibrary
onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt}
onAddFiles={addAttachments}

View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { CanvasNodeData } from "@/types/canvas";
// 批次子图操作条(下载/创建副本/删除)与主图位下载需要调用画布级动作,
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
export type CanvasNodeActionContextValue = {
download?: (node: CanvasNodeData) => void;
duplicate?: (node: CanvasNodeData) => void;
deleteNode?: (node: CanvasNodeData) => void;
};
export const CanvasNodeActionContext = createContext<CanvasNodeActionContextValue>({});
export function useCanvasNodeActions() {
return useContext(CanvasNodeActionContext);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react";
import { Button, Image as AntImage, Modal, Tooltip } from "antd";
import { Button, Image as AntImage, InputNumber, Modal, Tooltip } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -261,7 +261,19 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
/>
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
{mode === "image" ? (
{mode === "text" ? (
<Tooltip title={`文本生成份数(默认 1,可在生成配置中调整)`}>
<InputNumber
size="small"
min={1}
max={15}
value={Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 1))))}
onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })}
aria-label="文本生成份数"
className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]"
/>
</Tooltip>
) : mode === "image" ? (
<CanvasImageSettingsPopover
config={config}
placement={expanded ? "topRight" : "topLeft"}

View File

@@ -3,6 +3,7 @@ import { Input, Modal } from "antd";
import { BookOpenText, FileText, Image, Pencil, Search, Video } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open: boolean; nodes: CanvasNodeData[]; onClose: () => void; onFocus: (nodeId: string) => void }) {
@@ -10,7 +11,7 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
const results = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase();
if (!keyword) return nodes.slice(0, 40);
return nodes.filter((node) => [node.title, node.type, nodeTypeLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
return nodes.filter((node) => [node.title, node.type, getNodeListLabel(node.type), node.metadata?.prompt, node.metadata?.composerContent, node.metadata?.model, node.metadata?.chapterTitle, node.metadata?.workflowTitle, node.metadata?.workflowDescription, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : "", ...(node.metadata?.assetTags || [])]
.some((value) => typeof value === "string" && value.toLocaleLowerCase().includes(keyword))).slice(0, 80);
}, [nodes, query]);
@@ -44,19 +45,5 @@ export function CanvasNodeSearchModal({ open, nodes, onClose, onFocus }: { open:
function nodeContextLabel(node: CanvasNodeData) {
const location = [node.metadata?.chapterTitle, typeof node.metadata?.shotIndex === "number" ? `镜头 ${node.metadata.shotIndex + 1}` : ""].filter(Boolean).join(" · ");
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || nodeTypeLabel(node.type);
}
function nodeTypeLabel(type: CanvasNodeType) {
return ({
[CanvasNodeType.Image]: "图片节点",
[CanvasNodeType.Text]: "文本节点",
[CanvasNodeType.Drawing]: "绘图节点",
[CanvasNodeType.Script]: "分镜脚本节点",
[CanvasNodeType.Skill]: "技能节点",
[CanvasNodeType.Config]: "生成配置节点",
[CanvasNodeType.Video]: "视频节点",
[CanvasNodeType.Audio]: "音频节点",
[CanvasNodeType.Frame]: "背板",
} as Record<CanvasNodeType, string>)[type] || "未知节点";
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || getNodeListLabel(node.type);
}

View File

@@ -1,6 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Copy, Download, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, RefreshCw, Replace, Settings2, Star, Trash2, Type, Upload, Video } from "lucide-react";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { canvasThemes } from "@/lib/canvas-theme";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
@@ -8,7 +10,7 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { getNodeMinSize, shouldKeepAspectRatio } from "@/lib/canvas/node-registry";
import { CanvasNodeContent, CanvasNodeImageInfo } from "./canvas-node-content";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -106,6 +108,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title);
const { download: downloadNode, duplicate: duplicateNode, deleteNode } = useCanvasNodeActions();
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
@@ -173,9 +176,10 @@ export const CanvasNode = React.memo(function CanvasNode({
const dx = (event.clientX - resizeRef.current.startX) / scale;
const dy = (event.clientY - resizeRef.current.startY) / scale;
const isMediaNode = data.type === CanvasNodeType.Image || data.type === CanvasNodeType.Video;
const minWidth = data.type === CanvasNodeType.Script ? 800 : isMediaNode ? MEDIA_NODE_MIN_SIZE.width : 220;
const minHeight = scriptMinHeight || (isMediaNode ? MEDIA_NODE_MIN_SIZE.height : 160);
const minSize = getNodeMinSize(data.type);
const minWidth = minSize.width;
// 分镜脚本的高度由表格内容动态撑开,覆盖注册表里的静态下限。
const minHeight = scriptMinHeight || minSize.height;
const startRight = resizeRef.current.startLeft + resizeRef.current.startWidth;
const startBottom = resizeRef.current.startTop + resizeRef.current.startHeight;
const fromLeft = resizeRef.current.corner.includes("left");
@@ -227,7 +231,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
keepRatio: shouldKeepAspectRatio(data),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
@@ -428,6 +432,30 @@ export const CanvasNode = React.memo(function CanvasNode({
{data.metadata?.locked ? <NodeLockBadge theme={theme} /> : null}
</div>
) : null}
{/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */}
{isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
<div
className="absolute inset-x-0 bottom-2 z-[var(--node-z-overlay)] flex justify-center"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] border px-1 py-1 backdrop-blur-xl" style={{ background: `${theme.toolbar.panel}e6`, borderColor: theme.toolbar.border }}>
{hasImageContent ? <BatchChildActionButton theme={theme} label="下载图片" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} /> : null}
{hasImageContent ? <BatchChildActionButton theme={theme} label="创建副本" icon={<Copy className="size-3.5" />} onClick={() => duplicateNode?.(data)} /> : null}
{hasImageContent ? (
<BatchChildActionButton theme={theme} label={batchPrimary ? "当前主图" : "设为主图"} icon={<Star className={`size-3.5 ${batchPrimary ? "fill-current" : ""}`} style={{ color: theme.accent.primary }} />} onClick={() => onSetBatchPrimary?.(data)} />
) : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="重试" icon={<RefreshCw className="size-3.5" />} onClick={() => onRetry?.(data)} /> : null}
{data.metadata?.status === "error" ? <BatchChildActionButton theme={theme} label="删除" icon={<Trash2 className="size-3.5" />} danger onClick={() => deleteNode?.(data)} /> : null}
</div>
</div>
) : null}
{/* 批次主图位(折叠根节点封面)常驻下载按钮 */}
{isBatchRoot && hasImageContent && !readOnly ? (
<div className="absolute bottom-2 right-2 z-[var(--node-z-overlay)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
</div>
) : null}
{assetTags.length || (showImageInfo && hasImageContent) ? (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
@@ -539,6 +567,24 @@ function BatchPrimaryBadge({ visible, selected, theme, onSelect }: { visible: bo
);
}
function BatchChildActionButton({ theme, label, icon, onClick, danger = false }: { theme: CanvasTheme; label: string; icon: ReactNode; onClick: () => void; danger?: boolean }) {
return (
<button
type="button"
data-canvas-no-zoom
className="canvas-node-inline-action grid size-8 place-items-center rounded-[var(--r-sm)] p-0 backdrop-blur-xl transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ color: danger ? theme.accent.danger : theme.node.text, outlineColor: theme.accent.primary }}
title={label}
aria-label={label}
onClick={(event) => { event.stopPropagation(); onClick(); }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{icon}
</button>
);
}
function AssetTagBadges({ tags, theme }: { tags: string[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-1">

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { applyCanvasLiveViewport, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "@/types/canvas";
@@ -382,7 +382,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "dots"
"--canvas-grid-size": `${48 * viewport.k}px`,
"--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`,
"--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`,
"--canvas-dot-size": viewport.k < 0.12 ? "0.8px" : "1.15px",
"--canvas-dot-size": canvasDotPx(viewport.k),
} as React.CSSProperties}
onPointerDown={handlePointerDown}
onDoubleClick={(event) => {
@@ -426,7 +426,7 @@ function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) {
backgroundImage,
backgroundSize: "var(--canvas-grid-size) var(--canvas-grid-size)",
transform: "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
opacity: mode === "dots" ? 0.28 : 0.2,
opacity: 0.55,
willChange: "transform",
}}
/>

View File

@@ -5,8 +5,8 @@ export const canvasThemes = {
light: {
canvas: {
background: "#ffffff",
dot: "rgba(15,23,42,.14)",
line: "rgba(15,23,42,.065)",
dot: "rgba(15,23,42,.30)",
line: "rgba(15,23,42,.15)",
selectionFill: "rgba(17,17,17,.10)",
},
node: {
@@ -68,8 +68,8 @@ export const canvasThemes = {
dark: {
canvas: {
background: "#090a0c",
dot: "rgba(174,184,199,.18)",
line: "rgba(174,184,199,.06)",
dot: "rgba(174,184,199,.32)",
line: "rgba(174,184,199,.16)",
selectionFill: "rgba(255,255,255,.12)",
},
node: {

View File

@@ -1,6 +1,7 @@
import { maxModelInputCapacity, type ModelInputSummary } from "@/lib/model-selection";
import { getNodeGenerationMode, getNodeInputKind } from "@/lib/canvas/node-registry";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import { type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
type ConnectionCandidate = Pick<CanvasConnection, "fromNodeId" | "toNodeId">;
type CanvasConnectionPolicyOptions = {
@@ -11,7 +12,7 @@ type CanvasConnectionPolicyOptions = {
export function canvasConnectionError(config: AiConfig, nodes: CanvasNodeData[], connections: CanvasConnection[], candidate: ConnectionCandidate, options: CanvasConnectionPolicyOptions = {}) {
const target = nodes.find((node) => node.id === candidate.toNodeId);
if (!target) return "找不到连线目标节点";
const mode = nodeGenerationMode(target);
const mode = getNodeGenerationMode(target);
if (!mode) return "";
const input = connectionInputSummary(target.id, nodes, connections, candidate);
const visualInputCount = input.imageCount + input.characterCount;
@@ -35,25 +36,18 @@ export function connectionInputSummary(targetNodeId: string, nodes: CanvasNodeDa
const input: ModelInputSummary = { textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 };
sourceIds.forEach((sourceId) => {
const source = nodes.find((node) => node.id === sourceId);
if (!source || source.type === CanvasNodeType.Config || source.type === CanvasNodeType.Frame) return;
if (!source) return;
// 生成配置与背板不是参考素材,不参与容量计数——这一步必须早于角色卡判定,
// 否则一个带角色元数据的配置/背板节点会被多算成角色。
const inputKind = getNodeInputKind(source.type);
if (!inputKind) return;
// 角色卡是跨类型覆盖:落在可计数类型上时改记为角色。
if (source.metadata?.workflowKind === "character") input.characterCount += 1;
else if (source.type === CanvasNodeType.Image || source.type === CanvasNodeType.Drawing) input.imageCount += 1;
else if (source.type === CanvasNodeType.Video) input.videoCount += 1;
else if (source.type === CanvasNodeType.Audio) input.audioCount += 1;
else input.textCount += 1;
else input[`${inputKind}Count`] += 1;
});
return input;
}
function nodeGenerationMode(node: CanvasNodeData): CanvasGenerationMode | null {
if (node.type === CanvasNodeType.Config) return node.metadata?.generationMode || "image";
if (node.type === CanvasNodeType.Image) return "image";
if (node.type === CanvasNodeType.Video) return "video";
if (node.type === CanvasNodeType.Audio) return "audio";
if (node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Script) return "text";
return null;
}
function capacityError(config: AiConfig, capability: "image" | "video", kind: "image" | "video" | "audio", count: number, label: string) {
const maximum = maxModelInputCapacity(config, capability, kind);
if (maximum === null || count <= maximum) return "";

View File

@@ -4,6 +4,12 @@ export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
// 空间网格点模式的点半径(像素单位)。远距(缩放 < 0.12)时用更小半径避免糊成一团。
// 同时被 <CanvasGrid>(infinite-canvas)与滚动同步(applyCanvasLiveViewport)共享,避免两处漂移。
export function canvasDotPx(scale: number): string {
return scale < 0.12 ? "1.2px" : "2px";
}
export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) {
if (!container) return;
const gridSize = 48 * viewport.k;
@@ -17,7 +23,7 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
container.style.setProperty("--canvas-dot-size", viewport.k < 0.12 ? "0.8px" : "1.15px");
container.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {

View File

@@ -1,4 +1,5 @@
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { getNodeResourceKind } from "@/lib/canvas/node-registry";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { Skill } from "@/services/api/skills";
import type { Asset, AssetCategory } from "@/stores/use-asset-store";
@@ -146,14 +147,9 @@ function isResourceNode(node: CanvasNodeData) {
}
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
// 角色卡是跨类型覆盖:任何节点带上角色元数据都按角色处理,故先于按类型判定。
if (node.metadata?.workflowKind === "character" && node.metadata.characterAssetId) return "character";
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
if (node.type === CanvasNodeType.Drawing && node.metadata?.drawingId) return "image";
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
if (node.type === CanvasNodeType.Skill && (node.metadata?.skillSnapshot || node.metadata?.content)) return "text";
return null;
return getNodeResourceKind(node);
}
function skillResourceText(node: CanvasNodeData) {

View File

@@ -0,0 +1,121 @@
import { Clapperboard, Image as ImageIcon, Music2, PanelTop, Pencil, Settings2, Sparkles, Type, Video } from "lucide-react";
import { NODE_SPECS } from "@/constant/canvas";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeDefinition } from "../node-definition";
import { registerNodeDefinitions } from "../node-registry";
/** 非媒体节点的拉伸下限 */
const DEFAULT_MIN_SIZE = { width: 220, height: 160 } as const;
/**
* 每种内置节点的自有特征。
*
* 尺寸、默认标题与初始 metadata 不在这里重复——它们的数据源是 constant/canvas.ts 的
* NODE_SPECS,下方装配时统一读取。
*
* `satisfies Record<CanvasNodeType, …>` 是刻意的:往 CanvasNodeType 加成员后若不补定义,
* 这里会编译失败,而不是在运行期悄悄回落成兜底值。
*/
const BUILTIN_NODE_TRAITS = {
[CanvasNodeType.Image]: {
label: "图片",
icon: <ImageIcon />,
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "image" : null),
generationMode: () => "image",
inputKind: "image",
},
[CanvasNodeType.Text]: {
label: "文本",
icon: <Type />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content || node.metadata?.prompt ? "text" : null),
generationMode: () => "text",
inputKind: "text",
},
[CanvasNodeType.Drawing]: {
label: "绘图",
icon: <Pencil />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
// 绘图产出的是图像,所以作为素材与输入都按图片计。
resourceKind: (node: CanvasNodeData) => (node.metadata?.drawingId ? "image" : null),
inputKind: "image",
},
[CanvasNodeType.Script]: {
label: "分镜脚本",
icon: <Clapperboard />,
// 分镜脚本的表格布局需要更宽的下限;高度仍由内容动态撑开(见 canvas-node.tsx)。
minSize: { width: 800, height: DEFAULT_MIN_SIZE.height },
showInCreateMenu: true,
generationMode: () => "text",
inputKind: "text",
},
[CanvasNodeType.Skill]: {
label: "技能",
icon: <Sparkles />,
minSize: DEFAULT_MIN_SIZE,
// 技能节点由技能库插入,不占创建菜单格位。
showInCreateMenu: false,
// 技能注入的是提示词文本,故按文本素材计。
resourceKind: (node: CanvasNodeData) => (node.metadata?.skillSnapshot || node.metadata?.content ? "text" : null),
inputKind: "text",
},
[CanvasNodeType.Config]: {
label: "生成配置",
icon: <Settings2 />,
minSize: DEFAULT_MIN_SIZE,
// 生成配置由连线时自动创建。
showInCreateMenu: false,
// 生成模式由用户在配置节点上选择,缺省按图片。
generationMode: (node: CanvasNodeData) => node.metadata?.generationMode || "image",
// 不设 inputKind:配置节点本身不是参考素材,不参与容量计数。
},
[CanvasNodeType.Video]: {
label: "视频",
icon: <Video />,
minSize: MEDIA_NODE_MIN_SIZE,
keepAspectRatio: () => true,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "video" : null),
generationMode: () => "video",
inputKind: "video",
},
[CanvasNodeType.Audio]: {
label: "音频",
icon: <Music2 />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
resourceKind: (node: CanvasNodeData) => (node.metadata?.content ? "audio" : null),
generationMode: () => "audio",
inputKind: "audio",
},
[CanvasNodeType.Frame]: {
label: "背板",
// 背板在列表里就叫「背板」,不是「背板节点」——显式钉住,避免被 label 派生改写。
listLabel: "背板",
icon: <PanelTop />,
minSize: DEFAULT_MIN_SIZE,
showInCreateMenu: true,
// 背板只是视觉容器,既不是素材也不参与计数。
},
} satisfies Record<CanvasNodeType, Omit<CanvasNodeDefinition, "type" | "defaultTitle" | "defaultSize" | "defaultMetadata">>;
export const BUILTIN_NODE_DEFINITIONS: CanvasNodeDefinition[] = (Object.keys(BUILTIN_NODE_TRAITS) as CanvasNodeType[]).map((type) => {
const spec = NODE_SPECS[type];
return {
type,
...BUILTIN_NODE_TRAITS[type],
defaultTitle: spec.title,
defaultSize: { width: spec.width, height: spec.height },
defaultMetadata: spec.metadata,
};
});
registerNodeDefinitions(BUILTIN_NODE_DEFINITIONS);

View File

@@ -0,0 +1,4 @@
/**
* 导入此模块会触发所有内置节点定义的注册。
*/
import "./builtin-nodes";

View File

@@ -0,0 +1,3 @@
export type { CanvasNodeDefinition, CanvasNodeInputKind } from "./node-definition";
export { getNodeDefinition, getNodeGenerationMode, getNodeIcon, getNodeInputKind, getNodeLabel, getNodeListLabel, getNodeMinSize, getNodeOwnerId, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, registerNodeDefinitions, shouldKeepAspectRatio, unregisterNodeDefinitions } from "./node-registry";
import "./definitions";

View File

@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasNodeType } from "@/types/canvas";
/** 作为上游输入被容量校验计数时归入的类别 */
export type CanvasNodeInputKind = "image" | "video" | "audio" | "text";
/**
* 节点定义——注册表的基本单元。
*
* 一种画布节点类型的静态知识(叫什么、长什么样、能拉多小、是否锁比例)集中在这里,
* 避免同一份知识散落到创建菜单、搜索弹窗、拉伸逻辑等多处后各自漂移。
*/
export type CanvasNodeDefinition = {
type: CanvasNodeType;
/** UI 短标签——创建菜单等处显示,如「文本」 */
label: string;
/** 列表/搜索标签,缺省派生为 `${label}节点` */
listLabel?: string;
/** 不带 className——由渲染处用 [&_svg]:size-* 统一控制尺寸 */
icon: ReactNode;
/** 新建节点的默认标题(与 label 分开:菜单叫「文本」,新建出来的节点名是「Note」) */
defaultTitle: string;
defaultSize: { width: number; height: number };
defaultMetadata?: CanvasNodeMetadata;
/** 手动拉伸的最小尺寸 */
minSize: { width: number; height: number };
/** 拉伸时是否锁定宽高比,缺省不锁 */
keepAspectRatio?: (node: CanvasNodeData) => boolean;
/** 是否出现在添加节点菜单(技能、生成配置由其他入口创建) */
showInCreateMenu: boolean;
/**
* 作为 @ 引用素材时归入的类型;不设或返回 null 表示该节点不是可引用素材。
* 判定依赖内容——空节点不构成素材。
*
* 注意:角色卡(workflowKind === "character")是**跨类型覆盖**,不在这里表达,
* 由 canvas-resource-references 在查注册表之前先行判定。
*/
resourceKind?: (node: CanvasNodeData) => CanvasResourceKind | null;
/** 作为生成节点时的生成模式;不设表示该类型不产生生成行为 */
generationMode?: (node: CanvasNodeData) => CanvasGenerationMode | null;
/**
* 作为上游输入被参考素材容量校验计数时的类别;
* 不设表示不参与计数(生成配置、背板)。与 resourceKind 不同,计数不看内容。
*/
inputKind?: CanvasNodeInputKind;
};

View File

@@ -0,0 +1,92 @@
import type { CanvasNodeData, CanvasNodeType } from "@/types/canvas";
import type { CanvasNodeDefinition } from "./node-definition";
/** 模块级注册表 */
const definitions = new Map<CanvasNodeType, CanvasNodeDefinition>();
/** 节点类型 → 归属方。内置节点归属 "builtin",为后续扩展留出隔离位。 */
const ownerByType = new Map<CanvasNodeType, string>();
/** 未注册类型的兜底最小尺寸——与非媒体节点的历史下限一致 */
const FALLBACK_MIN_SIZE = { width: 220, height: 160 } as const;
/** 批量注册节点定义 */
export function registerNodeDefinitions(defs: CanvasNodeDefinition[], ownerId = "builtin") {
for (const def of defs) {
definitions.set(def.type, def);
ownerByType.set(def.type, ownerId);
}
}
/**
* 注销某归属方的全部节点定义。
* 只删归属该 ownerId 的条目——内置节点不会被其他归属方的增删波及。
*/
export function unregisterNodeDefinitions(ownerId: string) {
for (const [type, owner] of ownerByType) {
if (owner !== ownerId) continue;
definitions.delete(type);
ownerByType.delete(type);
}
}
export function getNodeDefinition(type: CanvasNodeType) {
return definitions.get(type);
}
export function getNodeOwnerId(type: CanvasNodeType) {
return ownerByType.get(type) || "builtin";
}
export function listNodeDefinitions() {
return [...definitions.values()];
}
/** 添加节点菜单可见的节点定义 */
export function listCreatableNodeDefinitions() {
return listNodeDefinitions().filter((def) => def.showInCreateMenu);
}
/** UI 短标签 */
export function getNodeLabel(type: CanvasNodeType) {
return definitions.get(type)?.label || "未知节点";
}
/** 列表/搜索标签,缺省派生自 label */
export function getNodeListLabel(type: CanvasNodeType) {
const def = definitions.get(type);
if (!def) return "未知节点";
return def.listLabel || `${def.label}节点`;
}
export function getNodeIcon(type: CanvasNodeType) {
return definitions.get(type)?.icon ?? null;
}
/** 手动拉伸的最小尺寸 */
export function getNodeMinSize(type: CanvasNodeType) {
return definitions.get(type)?.minSize ?? FALLBACK_MIN_SIZE;
}
/** 拉伸时是否锁定宽高比 */
export function shouldKeepAspectRatio(node: CanvasNodeData) {
return definitions.get(node.type)?.keepAspectRatio?.(node) ?? false;
}
/**
* 该节点作为 @ 引用素材时的类型,非素材返回 null。
* 只按类型判定——角色卡那类跨类型覆盖由调用方在此之前处理。
*/
export function getNodeResourceKind(node: CanvasNodeData) {
return definitions.get(node.type)?.resourceKind?.(node) ?? null;
}
/** 该节点作为生成节点时的生成模式,不产生生成行为返回 null */
export function getNodeGenerationMode(node: CanvasNodeData) {
return definitions.get(node.type)?.generationMode?.(node) ?? null;
}
/** 该节点作为上游输入被计数时的类别,不参与计数返回 undefined */
export function getNodeInputKind(type: CanvasNodeType) {
return definitions.get(type)?.inputKind;
}

View File

@@ -0,0 +1,67 @@
import { buildGenerationConfig } from "@/lib/canvas/canvas-project-generation";
import { runBackendGenerationTask } from "@/services/api/generation-task";
import type { AiConfig } from "@/stores/use-config-store";
export type SkillDraft = {
skill_name: string;
description: string;
instruction: string;
tag?: string;
};
const SKILL_DRAFT_PROMPT = `你是一位技能编写助手。根据用户的想法,为一个「可复用的创作技能」生成一份草稿。
只输出一个 JSON 对象,不要输出任何其他文字、解释或 Markdown 代码块。JSON 字段:
- "skill_name": 技能名称,简短(不超过 20 个字)
- "tag": 分类,从以下值中选一个字符串:drama(短剧影视)、ecommerce(电商营销)、creative(创意设计)、social(社媒内容)、others(其他)
- "description": 技能简介(不超过 120 字),说明适用场景、输入条件和最终产出
- "instruction": 技能指令(Markdown 格式,至少 300 字),写给后续在画布中使用该技能的 AI 模型阅读,必须包含:角色设定、输入与约束、分步执行流程、检查清单、输出格式
用户的想法:
{{{IDEA}}}`;
export function buildSkillDraftPrompt(idea: string): string {
return SKILL_DRAFT_PROMPT.replace("{{{IDEA}}}", idea.trim());
}
const MAX_NAME_LENGTH = 80;
const MAX_DESCRIPTION_LENGTH = 500;
export function parseSkillDraft(text: string): Partial<SkillDraft> {
const trimmed = (text || "").trim();
if (!trimmed) return {};
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start >= 0 && end > start) {
try {
const raw = JSON.parse(trimmed.slice(start, end + 1)) as Record<string, unknown>;
if (raw && typeof raw === "object") {
return {
skill_name: String(raw.skill_name ?? "").trim().slice(0, MAX_NAME_LENGTH),
description: String(raw.description ?? "").trim().slice(0, MAX_DESCRIPTION_LENGTH),
instruction: String(raw.instruction ?? raw.instructions ?? "").trim(),
tag: typeof raw.tag === "string" && raw.tag.trim() ? raw.tag.trim() : undefined,
};
}
} catch {
// JSON 解析失败时回退到文本整体方案
}
}
// 回退:首行作为名称,正文作为简介与指令,用户随后可自由编辑
const firstLine = trimmed.split(/\r?\n/)[0]?.trim().slice(0, 30) || "未命名技能";
return {
skill_name: firstLine,
description: trimmed.slice(0, MAX_DESCRIPTION_LENGTH),
instruction: trimmed,
};
}
export async function generateSkillDraft(idea: string, config: AiConfig, signal?: AbortSignal): Promise<Partial<SkillDraft>> {
const generationConfig = buildGenerationConfig(config, undefined, "text");
const result = await runBackendGenerationTask({
mode: "text",
prompt: buildSkillDraftPrompt(idea),
config: generationConfig,
signal,
});
return parseSkillDraft(result.text || "");
}

View File

@@ -1,19 +1,27 @@
import { Clapperboard, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
import { FolderOpen, Layers3, Palette, UploadCloud, UserRound } from "lucide-react";
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "node", ...rest };
}
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
// 创作节点
{ id: "text", label: "文本", icon: <Type />, section: "node", defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() },
{ id: "drawing", label: "绘图", icon: <Pencil />, section: "node", defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() },
{ id: "script", label: "分镜脚本", icon: <Clapperboard />, badge: "核心", section: "node", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() },
{ id: "frame", label: "背板", icon: <PanelTop />, section: "node", defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() },
{ id: "image", label: "图片", icon: <ImageIcon />, section: "node", defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() },
{ id: "video", label: "视频", icon: <Video />, section: "node", defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() },
nodeCommand(CanvasNodeType.Text, { defaultOrder: 10, run: (ctx) => ctx.handlers.onAddText() }),
nodeCommand(CanvasNodeType.Drawing, { defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() }),
nodeCommand(CanvasNodeType.Script, { badge: "核心", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() }),
nodeCommand(CanvasNodeType.Frame, { defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() }),
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
{ id: "audio", label: "音频", icon: <Music2 />, section: "node", defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() },
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
// 导入资源
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },

View File

@@ -31,7 +31,8 @@ export async function executeTextGeneration({
}: CanvasGenerationExecution) {
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isDirectTextTarget = sourceNode?.type === CanvasNodeType.Text && !sourceNode.metadata?.content?.trim() && !editingTextNode;
const textCount = isConfigNode || (isDirectTextTarget && sourceNode?.metadata?.count) ? getGenerationCount(generationConfig.count) : 1;
// 独立文本份数(textCount),默认 1,不再复用餐图片数量 count(对齐上游 v0.16 语义)
const textCount = getGenerationCount(String(sourceNode?.metadata?.textCount ?? 1));
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text];
const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
const parentPosition = sourceNode?.position || { x: 0, y: 0 };

View File

@@ -81,6 +81,7 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { CanvasRefreshShell } from "./canvas-refresh-shell";
import type { CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel";
import { CanvasEmotionWorkspace } from "@/components/canvas/canvas-emotion-workspace";
@@ -1780,6 +1781,7 @@ function InfiniteCanvasPage() {
onFileDragLeave={handleFileDragLeave}
onFileDragOver={handleFileDragOver}
>
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])) }}>
<CanvasProjectWorldLayers
projectId={projectId}
viewportScale={viewport.k}
@@ -1848,6 +1850,7 @@ function InfiniteCanvasPage() {
onOpenDrawing={openDrawingNode}
onStartBatchConnection={startBatchConnection}
/>
</CanvasNodeActionContext.Provider>
</InfiniteCanvas>
<CanvasActiveTaskPanel tasks={activeTasks} topInset={focusMode ? "var(--space-3)" : "var(--canvas-topbar-offset)"} />

View File

@@ -1,8 +1,11 @@
import { App, Button, Drawer, Form, Input, Select, Switch } from "antd";
import { Minus, Plus, Save } from "lucide-react";
import { Minus, Plus, Save, Wand2 } from "lucide-react";
import { useEffect, useState } from "react";
import { fallbackSkillCategories } from "@/pages/skills/skill-catalog";
import { generateSkillDraft } from "@/lib/canvas/skill-drafting";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { createSkill, updateSkill, type Skill, type SkillMutationInput, type SkillShowcaseMedia } from "@/services/api/skills";
type SkillFormValues = Omit<SkillMutationInput, "is_private"> & { is_public: boolean };
@@ -12,6 +15,9 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
const [form] = Form.useForm<SkillFormValues>();
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [draftIdea, setDraftIdea] = useState("");
const [drafting, setDrafting] = useState(false);
const effectiveConfig = useEffectiveConfig();
useEffect(() => {
if (!open) return;
@@ -60,8 +66,57 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
}
};
const draftFromIdea = async () => {
const idea = draftIdea.trim();
if (!idea) {
message.warning("请先描述你想沉淀的技能");
return;
}
if (!useConfigStore.getState().isAiConfigReady(effectiveConfig, effectiveConfig.model)) {
message.info("尚未配置可用的文本模型,请先到设置页配置");
navigateToSettings({ section: "models", continueCreation: true });
return;
}
setDrafting(true);
try {
const draft = await generateSkillDraft(idea, effectiveConfig);
form.setFieldsValue({
skill_name: draft.skill_name || "",
description: draft.description || "",
instruction: draft.instruction || "",
...(draft.tag ? { tag: draft.tag } : {}),
});
setDirty(true);
message.success("草稿已生成,请检查并调整后保存");
} catch (error) {
message.error(error instanceof Error ? `起草失败:${error.message}` : "起草失败");
} finally {
setDrafting(false);
}
};
return (
<Drawer className="library-drawer" open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
<div className="mb-4 rounded-xl border bg-foreground/[.02] p-3">
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<Wand2 className="size-4" />
AI 起草
<span className="font-normal text-foreground/45">描述想法,一键生成名称、简介与指令草稿(可再编辑)</span>
</div>
<Input.TextArea
value={draftIdea}
onChange={(event) => setDraftIdea(event.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
maxLength={2000}
showCount
disabled={drafting}
placeholder="例如:我要一个竖屏短剧分镜技能——输入剧本段落,输出按景别排列的分镜表,每个镜头包含画面、台词、时长与转场…"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-xs text-foreground/45">将使用你的文本模型生成一次草稿</span>
<Button type="primary" loading={drafting} disabled={!draftIdea.trim()} icon={<Wand2 className="size-4" />} onClick={() => void draftFromIdea()}>生成草稿</Button>
</div>
</div>
<Form form={form} layout="vertical" requiredMark="optional" onFinish={submit} onValuesChange={() => setDirty(true)}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item name="skill_name" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>

View File

@@ -180,6 +180,7 @@ export type CanvasNodeMetadata = {
quality?: string;
transparentBackground?: string;
count?: number;
textCount?: number;
seconds?: string;
vquality?: string;
generateAudio?: string;

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { getNodeGenerationMode, getNodeInputKind, getNodeListLabel, getNodeMinSize, getNodeResourceKind, listCreatableNodeDefinitions, listNodeDefinitions, shouldKeepAspectRatio } from "../src/lib/canvas/node-registry";
import { connectionInputSummary } from "../src/lib/canvas/canvas-connection-policy";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "../src/types/canvas";
function node(type: CanvasNodeType, metadata?: CanvasNodeMetadata, id = type): CanvasNodeData {
return { id, type, title: id, position: { x: 0, y: 0 }, width: 100, height: 100, metadata };
}
const ALL_TYPES = Object.values(CanvasNodeType);
describe("节点注册表——覆盖完整性", () => {
test("每种节点类型都有定义", () => {
expect(listNodeDefinitions().length).toBe(ALL_TYPES.length);
for (const type of ALL_TYPES) expect(getNodeMinSize(type)).toBeDefined();
});
test("仅技能与生成配置不进创建菜单", () => {
const hidden = ALL_TYPES.filter((type) => !listCreatableNodeDefinitions().some((def) => def.type === type));
expect(hidden.sort()).toEqual([CanvasNodeType.Config, CanvasNodeType.Skill].sort());
});
});
describe("节点注册表——几何", () => {
test("最小尺寸按类型区分", () => {
expect(getNodeMinSize(CanvasNodeType.Image)).toEqual({ width: 420, height: 236 });
expect(getNodeMinSize(CanvasNodeType.Video)).toEqual({ width: 420, height: 236 });
expect(getNodeMinSize(CanvasNodeType.Script).width).toBe(800);
expect(getNodeMinSize(CanvasNodeType.Text)).toEqual({ width: 220, height: 160 });
});
test("仅图片(未开自由比例)与视频锁定宽高比", () => {
expect(shouldKeepAspectRatio(node(CanvasNodeType.Image))).toBe(true);
expect(shouldKeepAspectRatio(node(CanvasNodeType.Image, { freeResize: true }))).toBe(false);
expect(shouldKeepAspectRatio(node(CanvasNodeType.Video))).toBe(true);
expect(shouldKeepAspectRatio(node(CanvasNodeType.Video, { freeResize: true }))).toBe(true);
for (const type of [CanvasNodeType.Text, CanvasNodeType.Drawing, CanvasNodeType.Script, CanvasNodeType.Skill, CanvasNodeType.Config, CanvasNodeType.Audio, CanvasNodeType.Frame]) {
expect(shouldKeepAspectRatio(node(type))).toBe(false);
}
});
});
describe("节点注册表——素材类型(resourceKind)", () => {
test("有内容才算素材,空节点不算", () => {
expect(getNodeResourceKind(node(CanvasNodeType.Image, { content: "x" }))).toBe("image");
expect(getNodeResourceKind(node(CanvasNodeType.Image))).toBeNull();
expect(getNodeResourceKind(node(CanvasNodeType.Video, { content: "x" }))).toBe("video");
expect(getNodeResourceKind(node(CanvasNodeType.Video))).toBeNull();
expect(getNodeResourceKind(node(CanvasNodeType.Audio, { content: "x" }))).toBe("audio");
expect(getNodeResourceKind(node(CanvasNodeType.Audio))).toBeNull();
});
test("绘图按图片计,且认的是 drawingId 而非 content", () => {
expect(getNodeResourceKind(node(CanvasNodeType.Drawing, { drawingId: "d1" }))).toBe("image");
expect(getNodeResourceKind(node(CanvasNodeType.Drawing, { content: "x" }))).toBeNull();
});
test("文本认 content 或 prompt", () => {
expect(getNodeResourceKind(node(CanvasNodeType.Text, { content: "x" }))).toBe("text");
expect(getNodeResourceKind(node(CanvasNodeType.Text, { prompt: "p" }))).toBe("text");
expect(getNodeResourceKind(node(CanvasNodeType.Text))).toBeNull();
});
test("技能按文本计,认 skillSnapshot 或 content", () => {
expect(getNodeResourceKind(node(CanvasNodeType.Skill, { content: "x" }))).toBe("text");
expect(getNodeResourceKind(node(CanvasNodeType.Skill))).toBeNull();
});
test("分镜脚本、生成配置、背板都不是素材", () => {
for (const type of [CanvasNodeType.Script, CanvasNodeType.Config, CanvasNodeType.Frame]) {
expect(getNodeResourceKind(node(type, { content: "x" }))).toBeNull();
}
});
});
describe("节点注册表——生成模式(generationMode)", () => {
test("按类型映射", () => {
expect(getNodeGenerationMode(node(CanvasNodeType.Image))).toBe("image");
expect(getNodeGenerationMode(node(CanvasNodeType.Video))).toBe("video");
expect(getNodeGenerationMode(node(CanvasNodeType.Audio))).toBe("audio");
expect(getNodeGenerationMode(node(CanvasNodeType.Text))).toBe("text");
expect(getNodeGenerationMode(node(CanvasNodeType.Script))).toBe("text");
});
test("生成配置由 metadata 决定,缺省按图片", () => {
expect(getNodeGenerationMode(node(CanvasNodeType.Config, { generationMode: "video" }))).toBe("video");
expect(getNodeGenerationMode(node(CanvasNodeType.Config))).toBe("image");
});
test("绘图、技能、背板不产生生成行为", () => {
for (const type of [CanvasNodeType.Drawing, CanvasNodeType.Skill, CanvasNodeType.Frame]) {
expect(getNodeGenerationMode(node(type))).toBeNull();
}
});
});
describe("节点注册表——输入计数类别(inputKind)", () => {
test("生成配置与背板不参与计数,其余各归其类", () => {
expect(getNodeInputKind(CanvasNodeType.Config)).toBeUndefined();
expect(getNodeInputKind(CanvasNodeType.Frame)).toBeUndefined();
expect(getNodeInputKind(CanvasNodeType.Image)).toBe("image");
expect(getNodeInputKind(CanvasNodeType.Drawing)).toBe("image");
expect(getNodeInputKind(CanvasNodeType.Video)).toBe("video");
expect(getNodeInputKind(CanvasNodeType.Audio)).toBe("audio");
for (const type of [CanvasNodeType.Text, CanvasNodeType.Script, CanvasNodeType.Skill]) {
expect(getNodeInputKind(type)).toBe("text");
}
});
});
describe("connectionInputSummary——计数与跨类型覆盖", () => {
const conn = (fromNodeId: string, toNodeId: string): CanvasConnection => ({ id: `c-${fromNodeId}`, fromNodeId, toNodeId });
test("与内容无关:空图片节点仍计入 imageCount", () => {
const nodes = [node(CanvasNodeType.Image, undefined, "img"), node(CanvasNodeType.Text, undefined, "target")];
expect(connectionInputSummary("target", nodes, [conn("img", "target")]).imageCount).toBe(1);
});
test("绘图计入 imageCount,脚本与技能计入 textCount", () => {
const nodes = [
node(CanvasNodeType.Drawing, undefined, "draw"),
node(CanvasNodeType.Script, undefined, "script"),
node(CanvasNodeType.Skill, undefined, "skill"),
node(CanvasNodeType.Text, undefined, "target"),
];
const summary = connectionInputSummary("target", nodes, [conn("draw", "target"), conn("script", "target"), conn("skill", "target")]);
expect(summary.imageCount).toBe(1);
expect(summary.textCount).toBe(2);
});
test("生成配置与背板作为上游一律不计数", () => {
const nodes = [
node(CanvasNodeType.Config, undefined, "config"),
node(CanvasNodeType.Frame, undefined, "frame"),
node(CanvasNodeType.Text, undefined, "target"),
];
const summary = connectionInputSummary("target", nodes, [conn("config", "target"), conn("frame", "target")]);
expect(summary).toEqual({ textCount: 0, imageCount: 0, videoCount: 0, audioCount: 0, characterCount: 0 });
});
test("角色卡覆盖类型归类:图片节点带角色元数据记为角色而非图片", () => {
const nodes = [node(CanvasNodeType.Image, { workflowKind: "character" }, "char"), node(CanvasNodeType.Text, undefined, "target")];
const summary = connectionInputSummary("target", nodes, [conn("char", "target")]);
expect(summary.characterCount).toBe(1);
expect(summary.imageCount).toBe(0);
});
test("不可计数类型即使带角色元数据也不记为角色(排除早于覆盖)", () => {
const nodes = [node(CanvasNodeType.Frame, { workflowKind: "character" }, "frame"), node(CanvasNodeType.Text, undefined, "target")];
expect(connectionInputSummary("target", nodes, [conn("frame", "target")]).characterCount).toBe(0);
});
});
describe("节点注册表——列表标签", () => {
test("派生自 label,背板显式钉住不带「节点」后缀", () => {
expect(getNodeListLabel(CanvasNodeType.Image)).toBe("图片节点");
expect(getNodeListLabel(CanvasNodeType.Text)).toBe("文本节点");
expect(getNodeListLabel(CanvasNodeType.Drawing)).toBe("绘图节点");
expect(getNodeListLabel(CanvasNodeType.Script)).toBe("分镜脚本节点");
expect(getNodeListLabel(CanvasNodeType.Skill)).toBe("技能节点");
expect(getNodeListLabel(CanvasNodeType.Config)).toBe("生成配置节点");
expect(getNodeListLabel(CanvasNodeType.Video)).toBe("视频节点");
expect(getNodeListLabel(CanvasNodeType.Audio)).toBe("音频节点");
expect(getNodeListLabel(CanvasNodeType.Frame)).toBe("背板");
});
});