feat(*): 画布创作体验 - 完善 Agent 引导、生成防重、搜索快捷键、素材多选与分辨率兼容

This commit is contained in:
ddCat committed 2026-08-28 16:24:58 +08:00
1 parent 913cf4b304
commit ab89c05362
52 files changed
+2344 -500

No files matched your search

@@ -2,7 +2,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, CloudDownload, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal } from "antd";
import { Button, Dropdown } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -13,6 +13,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import type { CanvasMediaPerformanceMode, CanvasWorkspaceMode } from "@/types/canvas";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
type CanvasTopBarProps = {
title: string;
@@ -114,7 +115,7 @@ export function CanvasTopBar({
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入素材", onClick: onImportImage },
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ K" />, onClick: onOpenSearch },
{ key: "search", icon: <Search className="size-4" />, label: <MenuLabel text="搜索节点" shortcut="⌘ F" />, onClick: onOpenSearch },
{
key: "performance",
icon: <Gauge className="size-4" />,
@@ -233,7 +234,7 @@ export function CanvasTopBar({
</Link>
</CanvasTopBarTooltip>
) : null}
<CanvasTopBarTooltip label="进入专注模式(⇧⌘F)">
<CanvasTopBarTooltip label="进入专注模式(Shift + Ctrl/Cmd + F)">
<Button
type="text"
className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0"
@@ -274,30 +275,7 @@ export function CanvasTopBar({
</Button>
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["空白处左键拖动", "空格 + 左键 / 中键"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Shift / Ctrl / Cmd + 左键拖动"]} value="框选多个节点" />
<Shortcut keys={["工具栏「框选」", "左键拖动"]} value="框选多个节点,完成后自动回到「移动与选择」" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Alt", "点击 / 框选"]} value="移除选择节点" />
<Shortcut keys={["Ctrl / Cmd", "1 / 2 / 3"]} value="100% / 适应画布 / 适应选择" />
<Shortcut keys={["?"]} value="打开快捷键" />
<Shortcut keys={["Shift / Ctrl / Cmd", "F"]} value="进入 / 退出专注模式" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "K"]} value="搜索并定位节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "S"]} value="保存画布布局和位置" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
</div>
</Modal>
<CanvasShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
</>
);
}
@@ -428,21 +406,3 @@ function CompactAgentStatus({ status, onClick }: { status: { connected: boolean;
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return <CanvasTopBarTooltip label="打开本地 Codex 面板"><button type="button" className="canvas-topbar-action flex h-10 items-center gap-2 rounded-xl px-3 text-sm font-medium" style={{ background: "transparent", color: theme.node.text }} onClick={onClick} aria-label="打开本地 Codex 面板"><span className="size-2 rounded-full" style={{ background: dotColor }} /><span className="max-w-[180px] truncate">{label}</span></button></CanvasTopBarTooltip>;
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{keys.map((key, index) => (
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
{index ? <span className="text-xs opacity-35">+</span> : null}
<kbd className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]" style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}>
{key}
</kbd>
</span>
))}
</span>
<span className="text-right text-sm opacity-55">{value}</span>
</div>
);
}
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Command, Search } from "lucide-react";
import { Modal } from "antd";
import {
CANVAS_SHORTCUT_CATEGORIES,
CANVAS_SHORTCUTS,
filterCanvasShortcuts,
type CanvasShortcutCategoryId,
type CanvasShortcutItem,
} from "@/lib/canvas/canvas-shortcuts";
type ShortcutCategoryFilter = CanvasShortcutCategoryId | "all";
export function CanvasShortcutsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<ShortcutCategoryFilter>("all");
useEffect(() => {
if (!open) return;
setQuery("");
setCategory("all");
}, [open]);
const results = useMemo(() => filterCanvasShortcuts(query, category), [category, query]);
const categoryCounts = useMemo(
() => new Map(CANVAS_SHORTCUT_CATEGORIES.map((entry) => [entry.id, CANVAS_SHORTCUTS.filter((shortcut) => shortcut.category === entry.id).length])),
[],
);
return (
<Modal
className="workspace-modal workspace-modal-wide canvas-shortcuts-modal"
open={open}
onCancel={onClose}
footer={null}
title={null}
centered
keyboard
width="min(860px, calc(100vw - 24px))"
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
afterOpenChange={(visible) => {
if (visible) window.requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<div className="canvas-shortcuts-shell">
<header className="canvas-shortcuts-header">
<div className="canvas-shortcuts-heading">
<span className="canvas-shortcuts-heading-icon" aria-hidden>
<Command />
</span>
<span>
<strong>画布快捷键</strong>
<small>快速找到键盘、鼠标和视图操作</small>
</span>
</div>
<label className="canvas-shortcuts-search">
<Search aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索按键或操作…"
aria-label="搜索画布快捷键"
/>
{query ? (
<button type="button" onClick={() => setQuery("")} aria-label="清空搜索">
清除
</button>
) : null}
</label>
</header>
<div className="canvas-shortcuts-body">
<nav className="canvas-shortcuts-categories" aria-label="快捷键分类">
<CategoryButton label="全部" count={CANVAS_SHORTCUTS.length} active={category === "all"} onClick={() => setCategory("all")} />
{CANVAS_SHORTCUT_CATEGORIES.map((entry) => (
<CategoryButton
key={entry.id}
label={entry.label}
count={categoryCounts.get(entry.id) || 0}
active={category === entry.id}
onClick={() => setCategory(entry.id)}
/>
))}
</nav>
<main className="canvas-shortcuts-results" aria-live="polite">
{results.length ? (
<div className="canvas-shortcuts-list">
{results.map((shortcut) => (
<ShortcutRow key={shortcut.id} shortcut={shortcut} showCategory={category === "all" || Boolean(query)} />
))}
</div>
) : (
<div className="canvas-shortcuts-empty">
<Search aria-hidden />
<strong>没有找到相关操作</strong>
<span>试试搜索“粘贴”“缩放”或按键名称</span>
</div>
)}
</main>
</div>
<footer className="canvas-shortcuts-footer">
<span>共 {results.length} 个快捷键</span>
<span className="canvas-shortcuts-close-hint"><kbd>Esc</kbd> 关闭</span>
</footer>
</div>
</Modal>
);
}
function CategoryButton({ label, count, active, onClick }: { label: string; count: number; active: boolean; onClick: () => void }) {
return (
<button type="button" className={active ? "is-active" : undefined} aria-pressed={active} onClick={onClick}>
<span>{label}</span>
<small>{count}</small>
</button>
);
}
function ShortcutRow({ shortcut, showCategory }: { shortcut: CanvasShortcutItem; showCategory: boolean }) {
const categoryLabel = CANVAS_SHORTCUT_CATEGORIES.find((entry) => entry.id === shortcut.category)?.label;
return (
<article className="canvas-shortcuts-row">
<div className="canvas-shortcuts-row-copy">
<span className="canvas-shortcuts-row-title">
<strong>{shortcut.title}</strong>
{showCategory ? <small>{categoryLabel}</small> : null}
</span>
<p>{shortcut.description}</p>
</div>
<div className="canvas-shortcuts-keys" aria-label={shortcut.keys.map((combination) => combination.join(" 加 ")).join(" 或 ")}>
{shortcut.keys.map((combination, combinationIndex) => (
<span key={`${shortcut.id}-${combination.join("-")}`} className="canvas-shortcuts-combination">
{combinationIndex ? <em>或</em> : null}
{combination.map((key, keyIndex) => (
<span key={`${key}-${keyIndex}`} className="canvas-shortcuts-key-part">
{keyIndex ? <i>+</i> : null}
<kbd>{key}</kbd>
</span>
))}
</span>
))}
</div>
</article>
);
}
+29 -29
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useSearchParams } from "react-router";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
@@ -72,6 +72,7 @@ import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-gra
import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
@@ -217,7 +218,21 @@ function InfiniteCanvasPage() {
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled);
const directorOnboardingScope = useUserStore((state) => state.user?.id?.trim() || "");
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
const nodesRef = useRef<CanvasNodeData[]>([]);
const [nodes, setNodesState] = useState<CanvasNodeData[]>([]);
const setNodes = useCallback<Dispatch<SetStateAction<CanvasNodeData[]>>>((value) => {
if (typeof value === "function") {
setNodesState((current) => {
const next = stampCanvasNodeChanges(current, value(current));
nodesRef.current = next;
return next;
});
return;
}
const next = stampCanvasNodeChanges(nodesRef.current, value);
nodesRef.current = next;
setNodesState(next);
}, []);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
@@ -303,19 +318,6 @@ function InfiniteCanvasPage() {
didInitialCenterRef.current = false;
}, [projectId]);
useEffect(() => {
const openSearch = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLocaleLowerCase() !== "k") return;
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) return;
event.preventDefault();
setNodeSearchOpen(true);
};
window.addEventListener("keydown", openSearch);
return () => window.removeEventListener("keydown", openSearch);
}, []);
const nodesRef = useRef(nodes);
const connectionsRef = useRef(connections);
const chatSessionsRef = useRef(chatSessions);
const activeChatIdRef = useRef(activeChatId);
@@ -625,7 +627,7 @@ function InfiniteCanvasPage() {
createVideoNodeFromBlob,
createImageAssetNode,
fileDropActive,
handleAssetInsert,
handleAssetsInsert,
handleDrop,
handleFileDragEnter,
handleFileDragLeave,
@@ -725,22 +727,18 @@ function InfiniteCanvasPage() {
return null;
};
const handleTimelineAssetInsert = useCallback(
async (payload: InsertAssetPayload) => {
const handleLibraryAssetsInsert = useCallback(
async (payloads: InsertAssetPayload[]) => {
if (assetInsertScope === "timeline") {
const media = payloadToTimelineMedia(payload);
if (media) {
timelineMediaAddRef.current?.(media);
closeAssetPicker();
} else {
message.info("图片/文本素材暂不支持直接入轨,请先在画布中添加节点");
}
const media = payloads.map(payloadToTimelineMedia).filter((item): item is TimelineDirectMedia => Boolean(item));
if (media.length !== payloads.length) throw new Error("图片和文本素材暂不支持直接入轨,请先插入画布");
media.forEach((item) => timelineMediaAddRef.current?.(item));
return;
}
const node = await handleAssetInsert(payload, { openDialog: false });
if (node) timelineAddNodeRef.current?.(node);
const created = await handleAssetsInsert(payloads);
created.forEach((node) => timelineAddNodeRef.current?.(node));
},
[assetInsertScope, closeAssetPicker, handleAssetInsert],
[assetInsertScope, handleAssetsInsert],
);
// 项目资产库引入到时间线:复用现有引入逻辑,把创建出的节点回填到弹窗草稿。
@@ -1063,6 +1061,7 @@ function InfiniteCanvasPage() {
toggleNodeFreeResize,
} = useCanvasNodeEditor({
canvasId: projectId,
canvasTitle: currentProject?.title || "未命名画布",
domainProjectId: linkedProjectId,
nodesRef,
setNodes,
@@ -1450,6 +1449,7 @@ function InfiniteCanvasPage() {
focusMode,
exitFocusMode,
toggleFocusMode,
onOpenSearch: () => setNodeSearchOpen(true),
beginBatchConnection: () => beginBatchConnectionMode(Array.from(selectedNodeIdsRef.current)),
});
@@ -2697,7 +2697,7 @@ function InfiniteCanvasPage() {
onConfirmClear={clearCanvas}
/>
<AssetPickerModal open={assetPickerOpen} onInsert={handleTimelineAssetInsert} onClose={closeAssetPicker} />
<AssetPickerModal open={assetPickerOpen} multiple={assetInsertScope === "canvas"} onInsert={handleLibraryAssetsInsert} onClose={closeAssetPicker} />
<CanvasProjectAssetModal open={projectAssetOpen} detail={linkedProjectQuery.data} initialCategory={projectAssetInitialCategory} initialFolderId={projectAssetInitialFolderId} onClose={closeProjectAssets} onInsert={handleTimelineProjectAssetsInsert} onInsertFolder={projectAssetScope === "canvas" ? handleProjectFolderInsert : undefined} />
{codexCompactAgent && !assistantMounted ? (
<CanvasLocalAgentPanel headless snapshot={agentSnapshot} canUndoOps={canUndoAgentOps} undoOpsCount={agentUndoCount} onApplyOps={applyAgentOps} onUndoOps={undoAgentOps} autoConnect={codexAutoConnect} />
@@ -2,20 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { applyCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { createGenerationRetryContext, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { createGenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { persistCanvasAgentGenerationContinuationEffect } from "@/services/canvas-generation-consumer";
import { consumeGenerationTaskAgent } from "@/services/project-asset-sync";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
import type { CanvasNodeGenerationOptions } from "./use-canvas-generation-executor";
export type CanvasAgentGenerationContext = { conversationId?: string; messageId?: string; source?: "online" | "local" };
type CanvasAgentRetryContext = GenerationRetryContext;
type CanvasAgentGenerationContinuation = NonNullable<NonNullable<CanvasNodeData["metadata"]>["agentGenerationContinuation"]>;
type CanvasAgentGenerationOptions = {
context?: CanvasAgentGenerationContext;
retryContext?: CanvasAgentRetryContext;
onTaskUpdate?: (task: GenerationTask) => void;
};
type CanvasAgentGenerationOptions = Pick<CanvasNodeGenerationOptions, "context" | "retryContext" | "onTaskUpdate" | "skipDuplicateConfirmation">;
type CanvasAgentGenerationContinuationDependencies = {
consumeAgent?: typeof consumeGenerationTaskAgent;
persistContinuation?: typeof persistCanvasAgentGenerationContinuationEffect;
@@ -149,6 +146,7 @@ export async function runCanvasAgentGenerationOps({
let continuationTaskId = "";
const generationPromise = generate(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt, {
context: context ? { conversationId: context.conversationId, messageId: context.messageId } : undefined,
skipDuplicateConfirmation: true,
...(retryContext ? { retryContext } : {}),
onTaskUpdate: (task) => {
if (continuationTaskId) return;
@@ -213,6 +213,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no
void handleGenerateNode(node.id, generationMode, prompt, {
controller,
waitForTaskCapacity: true,
skipDuplicateConfirmation: true,
retryContext:
node.metadata?.retryOf && node.metadata.attemptGroupId && node.metadata.taskClientOperationId
? { retryOf: node.metadata.retryOf, attemptGroupId: node.metadata.attemptGroupId, clientOperationId: node.metadata.taskClientOperationId }
@@ -1,9 +1,10 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation";
import { canvasGenerationRequestFingerprint, runCanvasGenerationSubmissionOnce } from "@/lib/canvas/canvas-generation-submission";
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -50,6 +51,7 @@ export type CanvasNodeGenerationOptions = {
context?: { conversationId?: string; messageId?: string };
retryContext?: { retryOf: string; attemptGroupId: string; clientOperationId: string };
onTaskUpdate?: (task: GenerationTask) => void;
skipDuplicateConfirmation?: boolean;
};
export function useCanvasGenerationExecutor({
@@ -70,251 +72,280 @@ export function useCanvasGenerationExecutor({
bindGenerationTask,
applyGenerationTaskResult,
}: UseCanvasGenerationExecutorOptions) {
const { message } = App.useApp();
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const submissionLocksRef = useRef(new Map<string, Promise<unknown>>());
const confirmDuplicateSubmission = useCallback(
() =>
new Promise<boolean>((resolve) => {
modal.confirm({
title: "再次生成相同内容?",
content: "当前节点已使用相同提示词、模型、参数和参考素材提交过任务。再次生成会新建任务,并可能再次消耗积分。",
okText: "仍然生成",
cancelText: "取消",
centered: true,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});
}),
[modal],
);
return useCallback(
async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
}
let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
const hasLiveBatchChildren = sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id));
const hasStaleImageBatchState = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren;
if (hasStaleImageBatchState) {
setNodes((current) =>
current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}),
);
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
}
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
try {
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
status: controller.signal.aborted ? NODE_STATUS_IDLE : NODE_STATUS_ERROR,
taskStage: undefined,
taskProgress: undefined,
taskCreatedAt: undefined,
taskStartedAt: undefined,
taskCompletedAt: undefined,
taskDurationMs: undefined,
errorDetails: controller.signal.aborted ? undefined : errorDetails,
},
}
: node,
),
);
}
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
if (!controller.signal.aborted) message.error(errorDetails);
return;
}
const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills);
let effectivePrompt = expandedPrompt.trim();
let styleMetadata = {};
if (mode === "image") {
try {
const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode);
if (styleRuntime) {
effectivePrompt = styleRuntime.prompt;
styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan };
}
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage)
setNodes((current) =>
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)),
);
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error(errorDetails);
return;
}
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (controller.signal.aborted) {
if (isPreparingEmptyImage)
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node)));
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined } } : node,
),
);
let pendingNodeIds: string[] = [];
const execution = {
projectId,
(nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) =>
runCanvasGenerationSubmissionOnce(
submissionLocksRef.current,
nodeId,
sourceNode,
canvasNodes: nodesRef.current,
canvasConnections: connectionsRef.current,
prompt,
effectivePrompt,
generationConfig,
generationContext,
controller,
editingTextNode,
styleMetadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
options?.onTaskUpdate?.(task);
},
applyGenerationTaskResult,
showError: (content: string) => message.error(content),
registerPendingNodeIds: (nodeIds: string[]) => {
pendingNodeIds = nodeIds;
},
};
async () => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
if (sourceNode?.type === CanvasNodeType.Video && sourceNode.metadata?.videoEditOperation === "concat") {
message.info("合并成片节点不直接重新生成,请重新选择源视频合并");
return;
}
let generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
const hasLiveBatchChildren =
sourceNode?.type === CanvasNodeType.Image && (sourceNode.metadata?.batchChildIds || []).some((childId) => nodesRef.current.some((node) => node.id === childId && node.metadata?.batchRootId === sourceNode.id));
const hasStaleImageBatchState =
mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content && Boolean(sourceNode.metadata?.isBatchRoot || sourceNode.metadata?.batchChildIds?.length) && !hasLiveBatchChildren;
if (hasStaleImageBatchState) {
setNodes((current) =>
current.map((node) => {
if (node.id !== sourceNode.id) return node;
const metadata = { ...node.metadata };
delete metadata.isBatchRoot;
delete metadata.batchChildIds;
delete metadata.primaryImageId;
delete metadata.imageBatchExpanded;
delete metadata.batchUsesReferenceImages;
return { ...node, metadata };
}),
);
}
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
try {
if (mode === "image") await executeImageGeneration(execution);
else if (mode === "video") await executeVideoGeneration(execution);
else if (mode === "audio") await executeAudioGeneration(execution);
else await executeTextGeneration(execution);
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, prompt);
if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) {
setNodes((current) =>
current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
delete metadata.taskStartedAt;
delete metadata.taskCompletedAt;
delete metadata.taskDurationMs;
return { ...node, metadata };
}),
);
return;
}
message.error(failure.errorDetails);
setNodes((current) =>
current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)),
);
} finally {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
}
},
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
const generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
const usesWorkflowProvider = Boolean(mode !== "text" && generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model");
try {
const baseContext = buildNodeGenerationContext(
nodeId,
nodesRef.current,
connectionsRef.current,
editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt,
assets,
promptOnly,
);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
return;
}
const expandedPrompt = promptOnly ? rawGenerationContext.prompt : expandSkillMentions(rawGenerationContext.prompt, addedSkills);
let effectivePrompt = expandedPrompt.trim();
let styleMetadata = {};
if (mode === "image") {
try {
const styleRuntime = resolveCanvasStyleExecution(nodesRef.current, sourceNode, effectivePrompt, generationConfig, mode);
if (styleRuntime) {
effectivePrompt = styleRuntime.prompt;
styleMetadata = { styleProfileJson: styleRuntime.profileJson, styleExecutionPlan: styleRuntime.plan };
}
} catch (error) {
const errorDetails = generationErrorMessage(error);
message.error(errorDetails);
return;
}
}
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
if (mode === "audio" && generationContext.characterReferences.length) {
if (generationContext.characterReferences.length !== 1) {
message.error("角色配音一次只能引用一个角色卡");
return;
}
const voice = generationContext.resolvedCharacterVoices[0];
if (!voice) {
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
return;
}
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
}
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
return;
}
const requestFingerprint = canvasGenerationRequestFingerprint({
nodeId,
mode,
prompt: effectivePrompt,
model: generationConfig.model,
options: modelRequestOptions(generationConfig, mode),
workflow:
generationConfig.taskWorkflowProvider && generationConfig.taskWorkflowProvider !== "model"
? {
provider: generationConfig.taskWorkflowProvider,
workflowId: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.workflowId : generationConfig.comfyBridge.workflowId,
workflowKind: generationConfig.taskWorkflowProvider === "runninghub" ? generationConfig.runningHub.selectedKind : undefined,
parameters: sourceNode?.metadata?.workflowParameters,
}
: undefined,
operation: sourceNode?.metadata?.videoEditOperation,
audioInstructions: generationConfig.audioInstructions,
promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation,
promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables,
context: generationContext,
});
const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint;
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return;
setRunningNodeId(nodeId);
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
if (controller.signal.aborted) {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
return;
}
if (isPreparingEmptyImage) {
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: {
...node.metadata,
prompt,
status: NODE_STATUS_LOADING,
taskStage: "正在准备生成任务",
taskProgress: 0,
taskCreatedAt: new Date().toISOString(),
errorDetails: undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
},
}
: node,
),
);
}
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
if (markSourceStatus)
setNodes((current) =>
current.map((node) =>
node.id === nodeId
? {
...node,
metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
}
: node,
),
);
let pendingNodeIds: string[] = [];
const execution = {
projectId,
nodeId,
sourceNode,
canvasNodes: nodesRef.current,
canvasConnections: connectionsRef.current,
prompt,
effectivePrompt,
generationConfig,
generationContext,
controller,
editingTextNode,
styleMetadata,
taskContext: options?.context,
retryContext: options?.retryContext,
setNodes,
setConnections,
setSelectedNodeIds,
setSelectedConnectionId,
setDialogNodeId,
startGenerationRequest,
finishGenerationRequest,
bindGenerationTask: (targetNodeId: string, task: GenerationTask) => {
bindGenerationTask(targetNodeId, task);
setNodes((current) => {
const source = current.find((node) => node.id === nodeId);
if (!source || source.metadata?.lastGenerationRequestFingerprint === requestFingerprint) return current;
return current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, lastGenerationRequestFingerprint: requestFingerprint } } : node));
});
options?.onTaskUpdate?.(task);
},
applyGenerationTaskResult,
showError: (content: string) => message.error(content),
registerPendingNodeIds: (nodeIds: string[]) => {
pendingNodeIds = nodeIds;
},
};
try {
if (mode === "image") await executeImageGeneration(execution);
else if (mode === "video") await executeVideoGeneration(execution);
else if (mode === "audio") await executeAudioGeneration(execution);
else await executeTextGeneration(execution);
} catch (error) {
if (isGenerationCanceled(error)) return;
const failure = generationFailureMetadata(error, prompt);
if (options?.waitForTaskCapacity && isGenerationTaskCapacityError(error)) {
setNodes((current) =>
current.map((node) => {
if (node.id !== nodeId && !pendingNodeIds.includes(node.id)) return node;
const metadata = { ...(node.metadata || {}), status: NODE_STATUS_IDLE, errorDetails: undefined };
delete metadata.taskId;
delete metadata.taskStatus;
delete metadata.taskProgress;
delete metadata.taskStage;
delete metadata.taskCreatedAt;
delete metadata.taskUpdatedAt;
delete metadata.taskStartedAt;
delete metadata.taskCompletedAt;
delete metadata.taskDurationMs;
return { ...node, metadata };
}),
);
return;
}
message.error(failure.errorDetails);
setNodes((current) =>
current.map((node) => (node.id === nodeId || pendingNodeIds.includes(node.id) ? (node.id === nodeId && !markSourceStatus ? node : { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, ...failure } }) : node)),
);
} finally {
finishGenerationRequest(nodeId, controller);
setRunningNodeId(null);
}
},
() => message.info({ key: `canvas-generation-submission-${nodeId}`, content: "该节点已有生成请求正在提交或执行,请勿重复点击" }),
),
[
addedSkills,
applyGenerationTaskResult,
bindGenerationTask,
confirmDuplicateSubmission,
domainProjectId,
effectiveConfig,
finishGenerationRequest,
+20 -2
View File
@@ -34,9 +34,20 @@ type UseCanvasKeyboardOptions = {
focusMode: boolean;
exitFocusMode: () => void;
toggleFocusMode: () => void;
onOpenSearch: () => void;
beginBatchConnection: () => void;
};
type TextSelectionLike = {
isCollapsed: boolean;
rangeCount: number;
toString(): string;
};
export function hasCanvasTextSelection(selection: TextSelectionLike | null | undefined) {
return Boolean(selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.toString());
}
export function useCanvasKeyboard({
nodesRef,
selectedNodeIdsRef,
@@ -69,6 +80,7 @@ export function useCanvasKeyboard({
focusMode,
exitFocusMode,
toggleFocusMode,
onOpenSearch,
beginBatchConnection,
}: UseCanvasKeyboardOptions) {
useEffect(() => {
@@ -101,8 +113,13 @@ export function useCanvasKeyboard({
return;
}
if (isModifierShortcut && !event.altKey && key === "f") {
if (target?.closest(".ant-modal-wrap, .ant-dropdown, .ant-popover")) return;
event.preventDefault();
if (!event.repeat) toggleFocusMode();
event.stopPropagation();
if (!event.repeat) {
if (event.shiftKey) toggleFocusMode();
else onOpenSearch();
}
return;
}
if (isTextEditingTarget) return;
@@ -145,6 +162,7 @@ export function useCanvasKeyboard({
return;
}
if (isModifierShortcut && !event.altKey && key === "c") {
if (hasCanvasTextSelection(window.getSelection())) return;
event.preventDefault();
copySelectedNodes();
return;
@@ -200,5 +218,5 @@ export function useCanvasKeyboard({
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("paste", handlePaste, true);
};
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}, [beginBatchConnection, cancelSelectionBox, copySelectedNodes, deleteConnection, deleteNodes, deselectCanvas, exitFocusMode, fitCanvasContent, fitCanvasSelection, focusMode, nodesRef, onOpenSearch, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, restoreCopiedNodesFromText, saveCanvasProject, selectedConnectionId, selectedNodeIdsRef, setAnnotationNodeId, setContextMenu, setCropNodeId, setInfoNodeId, setMaskEditNodeId, setSelectedConnectionId, setSelectedNodeIds, setShortcutRequestNonce, shouldPreferCopiedNodes, toggleFocusMode, undoCanvas, zoomCanvasIn, zoomCanvasOut, zoomToActualSize]);
}
@@ -5,14 +5,16 @@ import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame";
import { buildCanvasMediaDownloadFileName } from "@/lib/canvas/canvas-media-download";
import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
import { audioExtension, imageExtension, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
type UseCanvasNodeEditorOptions = {
canvasId: string;
canvasTitle: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -25,6 +27,7 @@ type UseCanvasNodeEditorOptions = {
export function useCanvasNodeEditor({
canvasId,
canvasTitle,
domainProjectId,
nodesRef,
setNodes,
@@ -180,8 +183,8 @@ export function useCanvasNodeEditor({
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : node.type === CanvasNodeType.Audio ? audioExtension(node.metadata.mimeType) : imageExtension(node.metadata.content)}`);
}, []);
saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node));
}, [canvasTitle]);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");
@@ -7,7 +7,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
@@ -461,8 +461,11 @@ export function useCanvasNodeOperations({
const dy = center.y - (bounds.top + bounds.bottom) / 2;
const idMap = new Map(clipboard.nodes.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
const copiedSourceIds = new Set(clipboard.nodes.map((node) => node.id));
const reservedTitles = new Set(nodesRef.current.map((node) => node.title));
const nextNodes = clipboard.nodes.map((node) => {
const metadata = isolateCopiedNodeMetadata(node, idMap);
const title = nextCopiedNodeTitle(node.title, reservedTitles);
reservedTitles.add(title);
if (node.type === CanvasNodeType.Drawing && metadata) {
metadata.drawingId = `${idMap.get(node.id)}-document`;
metadata.drawingRevision = 0;
@@ -473,7 +476,7 @@ export function useCanvasNodeOperations({
return {
...node,
id: idMap.get(node.id)!,
title: node.title.endsWith(" Copy") ? node.title : `${node.title} Copy`,
title,
position: { x: node.position.x + dx, y: node.position.y + dy },
parentId: node.parentId ? idMap.get(node.parentId) : undefined,
metadata,
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
@@ -109,7 +110,10 @@ export function useCanvasProjectLifecycle({
};
const restore = async () => {
const initialNodes = resetInterruptedGeneration(project.nodes);
const initialNodes = normalizeCanvasNodeTimestamps(resetInterruptedGeneration(project.nodes), {
createdAt: project.createdAt,
updatedAt: project.updatedAt,
});
const initialSessions = project.chatSessions || [];
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
+18 -23
View File
@@ -714,7 +714,7 @@ export function useCanvasUpload({
const storedImage = payload.url
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: payload.storageKey
? { url: payload.dataUrl, storageKey: payload.storageKey, width: 1, height: 1, bytes: 0, mimeType: "image/png" }
? { url: payload.dataUrl, storageKey: payload.storageKey, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }
: await uploadImage(payload.dataUrl);
const meta = !payload.storageKey && (!payload.width || !payload.height) ? await readImageMeta(storedImage.url) : storedImage;
const size = fitNodeSize(meta.width, meta.height);
@@ -725,38 +725,33 @@ export function useCanvasUpload({
return { id, type: CanvasNodeType.Image, title: payload.title.slice(0, 32) || "Generated Image", position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { ...metadata, prompt: payload.title, assetId: payload.assetId } } satisfies CanvasNodeData;
}, []);
const handleAssetInsert = useCallback(async (payload: InsertAssetPayload, options: { openDialog?: boolean } = {}): Promise<CanvasNodeData | null> => {
const center = assetInsertPositionRef.current || getCanvasCenter();
try {
const node = await createAssetPayloadNode(payload, center);
setNodes((current) => [...current, node]);
selectInsertedNode(node.id, options.openDialog === false ? "preserve" : payload.kind === "image" || payload.kind === "video" ? "open" : "preserve");
closeAssetPicker();
return node;
} catch (error) {
message.error(error instanceof Error ? error.message : "素材插入失败");
return null;
}
}, [closeAssetPicker, createAssetPayloadNode, getCanvasCenter, message, selectInsertedNode, setNodes]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise<CanvasNodeData[]> => {
const origin = position || getCanvasCenter();
const insertAssetPayloads = useCallback(async (payloads: InsertAssetPayload[], origin: Position, successMessage: string, failureMessage: string): Promise<CanvasNodeData[]> => {
try {
const created = await Promise.all(payloads.map((payload, index) => createAssetPayloadNode(payload, {
x: origin.x + (index % 3) * 380,
y: origin.y + Math.floor(index / 3) * 300,
x: origin.x + (index % BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_COLUMN_GAP,
y: origin.y + Math.floor(index / BATCH_UPLOAD_COLUMNS) * BATCH_UPLOAD_ROW_GAP,
})));
setNodes((current) => [...current, ...created]);
setSelectedNodeIds(new Set(created.map((node) => node.id)));
setSelectedConnectionId(null);
setDialogNodeId(null);
message.success(`已引入 ${created.length} 项项目资产`);
message.success(successMessage);
return created;
} catch (error) {
message.error(error instanceof Error ? error.message : "项目资产引入失败");
message.error(error instanceof Error ? error.message : failureMessage);
throw error;
}
}, [createAssetPayloadNode, getCanvasCenter, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
}, [createAssetPayloadNode, message, setDialogNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
const handleAssetsInsert = useCallback(async (payloads: InsertAssetPayload[]): Promise<CanvasNodeData[]> => {
const origin = assetInsertPositionRef.current || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已插入 ${payloads.length} 项素材`, "素材插入失败");
}, [getCanvasCenter, insertAssetPayloads]);
const handleProjectAssetsInsert = useCallback(async (payloads: InsertAssetPayload[], position?: Position): Promise<CanvasNodeData[]> => {
const origin = position || getCanvasCenter();
return insertAssetPayloads(payloads, origin, `已引入 ${payloads.length} 项项目资产`, "项目资产引入失败");
}, [getCanvasCenter, insertAssetPayloads]);
return {
assetPickerOpen,
@@ -765,7 +760,7 @@ export function useCanvasUpload({
createAssetPayloadNode,
createImageAssetNode,
fileDropActive,
handleAssetInsert,
handleAssetsInsert,
handleDrop,
handleFileDragEnter,
handleFileDragLeave,
+1 -5
View File
@@ -20,7 +20,7 @@ import { generationErrorCode, generationErrorMessage } from "@/lib/generation-er
import { useCopyText } from "@/hooks/use-copy-text";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { backendModelRuntimeRequired, isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
@@ -1761,10 +1761,6 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasP
</>;
}
function videoResolutionLabel(value: string | number) {
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
}
function formatMessageTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
@@ -65,7 +65,7 @@ my-plugin.yingce-plugin
## 4. Provider 与工作流
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和字符串变换(`|trim`、`|lower`、`|upper`、`|video-resolution`),不执行插件代码。`video-resolution` 会 trim 并转为小写,仅在输入是纯数字时补上 `p`,因此同时兼容历史状态中的 `768`、标准值 `768p` 和工作流值 `768p竖`。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和通用字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。渠道声明的枚举值由画布完整保留,插件可使用通用大小写变换适配上游,不应要求宿主增加渠道专用归一化。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存影策资源引用。