feat(canvas): 画布工作区 - 整合侧栏与风格预设并修复提示词回填
选择性整合 #547,保留主分支节点与工具条设计。新增默认折叠的节点、资产、任务和历史面板,风格与预设保留在节点内,移除重复入口。 补齐工具权限、提示词解析、资源引用保护及数据库迁移,修复提示词清空和连续编辑后的生成配置同步。 验证:前端全量 1925 项测试、构建、类型检查及 lint 通过;后端 go test ./... 通过。真实模型、PostgreSQL 实库与登录态页面验收仍待进行。 Refs #547 Co-authored-by: bensharp <bensharp@qq.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
web/src/pages/canvas/use-canvas-workspace-tasks.ts
Normal file
51
web/src/pages/canvas/use-canvas-workspace-tasks.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user