feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填

选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。

补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。

验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。

Refs #547

Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
ddCat
2026-09-20 16:40:08 +08:00
parent be5f2733b6
commit e93bf3ca39
66 changed files with 6127 additions and 97 deletions

View File

@@ -1,3 +1,4 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
@@ -35,7 +36,6 @@ import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer
import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel";
import { CanvasCloudAgentPanel } from "@/components/canvas/canvas-cloud-agent-panel";
import { CanvasActiveTaskPanel } from "@/components/canvas/canvas-active-task-panel";
import { CanvasAssetTray } from "@/components/canvas/canvas-asset-tray";
import { CanvasProjectSidebar } from "@/components/canvas/canvas-project-sidebar";
import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asset-modal";
import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
@@ -372,6 +372,7 @@ function InfiniteCanvasPage() {
const activeChatIdRef = useRef(activeChatId);
const selectedNodeIdsRef = useRef(selectedNodeIds);
const viewportRef = useRef(viewport);
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string, options?: CanvasNodeGenerationOptions) => Promise<void>) | null>(null);
useEffect(() => {
@@ -732,7 +733,6 @@ function InfiniteCanvasPage() {
closeUploadModal,
closeAssetPicker,
createVideoNodeFromBlob,
createImageAssetNode,
fileDropActive,
handleAssetsInsert,
handleDrop,
@@ -848,6 +848,7 @@ function InfiniteCanvasPage() {
extractVideoFrames,
extractingVideoFramesNodeId,
frameDialogNodeId,
generateNineGridNode,
generateAngleNode,
generateLightingNode,
openPanoramaConfig,
@@ -1384,14 +1385,12 @@ function InfiniteCanvasPage() {
annotationNode,
batchChildCountById,
batchMotionById,
canvasImageNodes,
configInputsById,
connectionLayerBounds,
contextMenuNode,
cropNode,
displayConnections,
frameChildrenById,
imageAssets,
infoNode,
maskEditNode,
imageEditNode,
@@ -2439,7 +2438,15 @@ function InfiniteCanvasPage() {
跳转到画布主内容
</a>
<main id="canvas-main" tabIndex={-1} className="flex h-full min-h-0 overflow-hidden outline-none" style={{ background: resolvedCanvasAppearance.background, color: theme.node.text }}>
{!focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
{!focusMode && !versions.preview ? <CanvasWorkspacePanel key={projectId} open={workspaceOpen} onOpen={() => setWorkspaceOpen(true)} onInsertAssets={handleProjectAssetsInsert} projectId={projectId} nodes={nodes} selectedNodeIds={selectedNodeIds} onClose={() => setWorkspaceOpen(false)} onAssets={() => openCanvasAssetLibrary()} onProjectAssets={currentProject?.projectId ? () => openProjectAssets() : undefined} onCancelTask={cancelCanvasTask} onFocus={nodeId => {
const target = nodeById.get(nodeId);
const parent = target?.parentId ? nodeById.get(target.parentId) : null;
if (parent?.metadata?.frame?.collapsed) toggleFrameCollapsed(parent.id);
const batchRoot = target?.metadata?.batchRootId ? nodeById.get(target.metadata.batchRootId) : null;
if (batchRoot && !batchRoot.metadata?.imageBatchExpanded) toggleBatchExpanded(batchRoot.id);
const selection = new Set([nodeId]); selectedNodeIdsRef.current = selection; setSelectedNodeIds(selection); setSelectedConnectionId(null); focusCanvasNode(nodeId);
}} /> : null}
{!workspaceOpen && !focusMode && !versions.preview && shortDramaEnabled && currentProject?.projectId ? (
<CanvasProjectSidebar projectId={currentProject.projectId} detail={linkedProjectQuery.data} onAddChapter={handleProjectChapterInsert} onLocateStyle={locateProjectStyleNode} onOpenAssets={() => openProjectAssets()} />
) : null}
<CanvasOverlayLayerProvider>
@@ -2691,6 +2698,7 @@ function InfiniteCanvasPage() {
onSaveAppearanceDefault={saveCanvasAppearanceDefault}
onBackgroundModeChange={setBackgroundMode}
onShowImageInfoChange={setShowImageInfo}
onOpenWorkspace={() => setWorkspaceOpen(value => !value)}
onOpenMyAssets={() => {
openCanvasAssetLibrary();
}}
@@ -2883,6 +2891,7 @@ function InfiniteCanvasPage() {
extractingVideoFrames={toolbarNode?.id === extractingVideoFramesNodeId}
extractingAudio={segmentRunningMode === "audio"}
trimmingVideo={segmentRunningMode === "video"}
onNineGrid={(node, id, label, icon) => void generateNineGridNode(node, id, label, icon)}
onReversePrompt={createImageReversePromptNodes}
onRetry={retryCanvasNode}
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
@@ -2913,14 +2922,6 @@ function InfiniteCanvasPage() {
onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)}
onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}
/>
<CanvasAssetTray
assetImages={imageAssets}
canvasImages={canvasImageNodes}
showLibrary={!currentProject?.projectId}
activeNodeId={selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null}
onInsertAssetImage={(asset) => void createImageAssetNode(asset)}
onFocusCanvasImage={focusCanvasImageNode}
/>
</CanvasOverlayLayerContainer>
) : null}

View File

@@ -282,7 +282,7 @@ export default function SharedCanvasPage() {
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
</InfiniteCanvas>
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} onNineGrid={unauthorized} />
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>

View File

@@ -37,7 +37,7 @@ import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size"
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
import { IMAGE_PROMPT_REVERSE } from "@/lib/prompts";
import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
import { createPortraitTextureNode, createNineGridNode } from "@/lib/canvas/canvas-image-source";
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
@@ -47,6 +47,7 @@ import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { defaultImageParamsForModel } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import { storeGeneratedVideo } from "@/services/api/video";
import { getTool } from "@/services/api/tools";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
@@ -1100,6 +1101,21 @@ export function useCanvasMediaTools({
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedNodeIds, startGenerationRequest]);
const generateNineGridNode = useCallback(async (node: CanvasNodeData, toolId: number, label: string, icon: string) => {
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
message.warning("图片节点为空,无法执行九宫格工具");
return;
}
const child = createNineGridNode(node, nanoid(), toolId, label, "nine_grid",icon);
setHoveredNodeId(null);
setToolbarNodeId(null);
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]);
setSelectedNodeIds(new Set([child.id]));
setSelectedConnectionId(null);
setDialogNodeId(child.id);
}, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const generateLightingNode = useCallback((node: CanvasNodeData, options: CanvasImageLightingOptions, prompt: string) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
@@ -1211,6 +1227,7 @@ export function useCanvasMediaTools({
frameDialogNodeId,
handleSegmentConfirm,
generateAngleNode,
generateNineGridNode,
generateLightingNode,
openPanoramaConfig,
createPanoramaViewerWithConfig,

View File

@@ -4,7 +4,7 @@ import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/component
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buildToolMentionReference, parseToolMentionTokens } from "@/lib/canvas/canvas-resource-references";
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
import type { Skill } from "@/services/api/skills";
@@ -353,12 +353,32 @@ export function useCanvasRenderModel({
);
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
const toolMentionReferencesByNodeId = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildToolMentionReference>[]>();
for (const node of semanticNodes) {
const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? "";
const tokens = parseToolMentionTokens(text);
if (!tokens.length) continue;
const seen = new Set<number>();
const refs: ReturnType<typeof buildToolMentionReference>[] = [];
for (const { type, toolId, label, icon } of tokens) {
if (seen.has(toolId)) continue;
seen.add(toolId);
refs.push(buildToolMentionReference(toolId, label, type, icon));
}
if (refs.length) map.set(node.id, refs);
}
return map;
}, [semanticNodes]);
const mentionReferencesByNodeId = useMemo(() => {
const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes);
if (!skillMentionReferences.length) return map;
map.forEach((references, nodeId) => map.set(nodeId, [...references, ...skillMentionReferences]));
if (!skillMentionReferences.length && toolMentionReferencesByNodeId.size === 0) return map;
map.forEach((references, nodeId) => {
const extras = [...skillMentionReferences, ...(toolMentionReferencesByNodeId.get(nodeId) ?? [])];
if (extras.length) map.set(nodeId, [...references, ...extras]);
});
return map;
}, [connections, semanticNodes, skillMentionReferences, visibleNodes]);
}, [connections, semanticNodes, skillMentionReferences, toolMentionReferencesByNodeId, visibleNodes]);
return {
activeDirectorNode,

View File

@@ -0,0 +1,51 @@
import { useUserStore } from "@/stores/use-user-store";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { listGenerationTasks, type GenerationTask } from "@/services/api/task-center";
export function useCanvasWorkspaceTasks(projectId: string, enabled: boolean) {
const userId = useUserStore((s) => s.user?.id);
const query = useQuery<GenerationTask[]>({
queryKey: ["canvas-workspace-tasks", userId, projectId],
queryFn: () => listGenerationTasks(30, { projectId }),
enabled: enabled && Boolean(projectId) && Boolean(userId),
refetchInterval: (current) => {
const data = current.state.data;
if (!data?.length) return 10_000;
const hasActive = data.some((task) => (task.status === "queued" || task.status === "running") && !isInternalAgentTask(task));
return hasActive ? 3_000 : 10_000;
},
refetchOnWindowFocus: true,
});
useEffect(() => {
if (!enabled) return;
const handleTaskChanged = (event: Event) => {
const task = (event as CustomEvent<{ task?: GenerationTask }>).detail?.task;
if (task?.projectId === projectId) void query.refetch();
};
window.addEventListener("canvas:task-created", handleTaskChanged);
window.addEventListener("canvas:task-cancelled", handleTaskChanged);
return () => {
window.removeEventListener("canvas:task-created", handleTaskChanged);
window.removeEventListener("canvas:task-cancelled", handleTaskChanged);
};
}, [enabled, projectId, query.refetch]);
// 任务面板隐藏 Agent 内部任务;历史面板消费全量 allTasks。
const tasks = useMemo(() => (query.data || []).filter((task) => !isInternalAgentTask(task)), [query.data]);
return {
tasks,
allTasks: query.data || [],
error: query.error,
loading: query.isLoading,
refreshing: query.isFetching,
refetch: query.refetch,
};
}
function isInternalAgentTask(task: GenerationTask) {
return task.operation?.startsWith("cloud_agent") === true;
}