refactor(canvas): 节点注册表 - 合并 PR #284 并收敛类型判据
This commit is contained in:
@@ -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 ."
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
18
web/src/components/canvas/canvas-node-action-context.ts
Normal file
18
web/src/components/canvas/canvas-node-action-context.ts
Normal 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);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
121
web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
Normal file
121
web/src/lib/canvas/node-registry/definitions/builtin-nodes.tsx
Normal 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);
|
||||
4
web/src/lib/canvas/node-registry/definitions/index.ts
Normal file
4
web/src/lib/canvas/node-registry/definitions/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 导入此模块会触发所有内置节点定义的注册。
|
||||
*/
|
||||
import "./builtin-nodes";
|
||||
3
web/src/lib/canvas/node-registry/index.ts
Normal file
3
web/src/lib/canvas/node-registry/index.ts
Normal 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";
|
||||
48
web/src/lib/canvas/node-registry/node-definition.ts
Normal file
48
web/src/lib/canvas/node-registry/node-definition.ts
Normal 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;
|
||||
};
|
||||
92
web/src/lib/canvas/node-registry/node-registry.ts
Normal file
92
web/src/lib/canvas/node-registry/node-registry.ts
Normal 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;
|
||||
}
|
||||
67
web/src/lib/canvas/skill-drafting.ts
Normal file
67
web/src/lib/canvas/skill-drafting.ts
Normal 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 || "");
|
||||
}
|
||||
@@ -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() },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)"} />
|
||||
|
||||
@@ -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 个字符" }]}>
|
||||
|
||||
@@ -180,6 +180,7 @@ export type CanvasNodeMetadata = {
|
||||
quality?: string;
|
||||
transparentBackground?: string;
|
||||
count?: number;
|
||||
textCount?: number;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
generateAudio?: string;
|
||||
|
||||
167
web/test/canvas-node-registry.test.ts
Normal file
167
web/test/canvas-node-registry.test.ts
Normal 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("背板");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user