feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续

This commit is contained in:
ddCat
2026-09-09 22:46:45 +08:00
97 changed files with 8434 additions and 326 deletions

View File

@@ -10,7 +10,7 @@ export function getPanelWidthBounds(): { min: number; max: number } {
}
// 智能体面板列包裹器:承载面板宽度管理、resize 拖拽和顶部停靠偏移。
// 面板始终作为 flex 子元素避让画布;topInset 用于避让绝对定位的顶部栏(沉浸专注时顶部栏隐藏,传 0)。
// 面板停靠在画布工具栏下方,避免悬浮工具栏遮挡 Agent 标题和消息;沉浸模式传 0。
// closing 期间保持宽度和渲染,让面板播放滑出动画;动画结束后由父组件卸载列。
export function AssistantPanelColumn({
width,
@@ -53,7 +53,7 @@ export function AssistantPanelColumn({
return (
<div
ref={columnRef}
className="relative flex shrink-0 overflow-hidden"
className="relative z-[var(--z-toolbar)] flex shrink-0 overflow-hidden"
style={{
width,
paddingTop: topInset,

View File

@@ -34,6 +34,7 @@ type CanvasTopBarProps = {
onRedo: () => void;
onShare: () => void;
agentOpen: boolean;
agentPanelWidth?: number;
compactAgentStatus?: { connected: boolean; enabled: boolean; activity: string };
onToggleAgent: () => void;
shortcutRequestNonce: number;
@@ -64,6 +65,7 @@ export function CanvasTopBar({
onRedo,
onShare,
agentOpen,
agentPanelWidth,
compactAgentStatus,
onToggleAgent,
shortcutRequestNonce,
@@ -101,7 +103,7 @@ export function CanvasTopBar({
return (
<>
<div className="canvas-topbar pointer-events-none absolute left-0 right-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5">
<div className="canvas-topbar pointer-events-none absolute left-0 top-0 z-[var(--z-toolbar)] flex h-[var(--canvas-topbar-h)] items-center justify-between px-4 sm:px-5" style={{ right: agentOpen && agentPanelWidth ? `calc(${agentPanelWidth}px + var(--space-3))` : 0, transition: "right var(--motion-dur-base-calc) var(--motion-ease-out)" }}>
<div className="canvas-topbar-cluster canvas-topbar-project-cluster pointer-events-auto flex min-w-0 items-center gap-2" style={dockStyle}>
<CanvasTopBarTooltip label="打开画布菜单">
<Dropdown
@@ -187,7 +189,7 @@ export function CanvasTopBar({
<CanvasTopBarTooltip label="搜索画布节点">
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" />
</CanvasTopBarTooltip>
<CanvasTopBarTooltip label="导入第三方画布">
{!agentOpen ? <CanvasTopBarTooltip label="导入第三方画布">
<Dropdown
trigger={["click"]}
placement="bottomRight"
@@ -202,8 +204,8 @@ export function CanvasTopBar({
<span className="hidden lg:inline">导入第三方画布</span>
</Button>
</Dropdown>
</CanvasTopBarTooltip>
<CanvasTopBarTooltip label="媒体性能模式">
</CanvasTopBarTooltip> : null}
{!agentOpen ? <CanvasTopBarTooltip label="媒体性能模式">
<Dropdown
trigger={["click"]}
menu={{
@@ -219,7 +221,7 @@ export function CanvasTopBar({
>
<Button type="text" className="canvas-topbar-action !hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Gauge className="size-4" />} aria-label="媒体性能模式" />
</Dropdown>
</CanvasTopBarTooltip>
</CanvasTopBarTooltip> : null}
{compactAgentStatus ? <CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} /> : null}
{user && creditsEnabled ? (
<CanvasTopBarTooltip label="查看积分明细">
@@ -229,7 +231,7 @@ export function CanvasTopBar({
style={{ color: theme.node.text }}
aria-label="查看积分明细"
>
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" /> : <Coins className="size-3.5" />}
{refreshing && availableMicrocredits === null ? <LoaderCircle className="size-3.5 animate-spin opacity-60" style={{ color: theme.accent.primary }} /> : <Coins className="size-3.5" style={{ color: theme.accent.primary }} />}
<span>{availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}</span>
</Link>
</CanvasTopBarTooltip>

View File

@@ -290,6 +290,8 @@ function InfiniteCanvasPage() {
const [scriptEditorNodeId, setScriptEditorNodeId] = useState<string | null>(null);
const [portraitClearanceNodeId, setPortraitClearanceNodeId] = useState<string | null>(null);
const [artCritiqueNodeId, setArtCritiqueNodeId] = useState<string | null>(null);
const artCritiqueRunningRef = useRef(false);
const [artCritiqueStartRequest, setArtCritiqueStartRequest] = useState<{ nodeId: string; id: string; restart: boolean } | null>(null);
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
@@ -552,6 +554,21 @@ function InfiniteCanvasPage() {
openAgent("local");
}, [codexAutoConnect, codexCompactAgent, openAgent, projectLoaded, setAgentMode]);
useEffect(() => {
const sessionId = searchParams.get("conversation");
if (!projectLoaded || !sessionId) return;
if (!chatSessions.some((session) => session.id === sessionId)) {
message.warning("未找到要接续的会话,请从首页重新进入。");
} else {
activeChatIdRef.current = sessionId;
setActiveChatId(sessionId);
openAgent("online");
}
const next = new URLSearchParams(searchParams);
next.delete("conversation");
setSearchParams(next, { replace: true });
}, [projectLoaded, chatSessions, searchParams, setSearchParams, openAgent, message]);
// 沉浸专注进入时收起智能体与小地图、重置 Dock 唤出态;仅响应「进入」瞬间,避免关闭专注内主动唤出的面板。
const prevFocusModeRef = useRef(focusMode);
useEffect(() => {
@@ -1433,7 +1450,8 @@ function InfiniteCanvasPage() {
focusSelection: fitCanvasSelection,
});
const { selectCanvasStyle, styleApplying } = useCanvasStyleWorkflow({
const { selectCanvasStyle, applyCanvasStyleAsync, styleApplying } = useCanvasStyleWorkflow({
canvasId: projectId,
domainProjectId: currentProject?.projectId,
nodesRef,
selectedNodeIdsRef,
@@ -2131,6 +2149,7 @@ function InfiniteCanvasPage() {
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
agentOpen={assistantOpen}
agentPanelWidth={assistantMounted ? assistantWidth : undefined}
compactAgentStatus={codexCompactAgent ? { connected: localAgentConnected, enabled: localAgentEnabled, activity: localAgentActivity } : undefined}
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
shortcutRequestNonce={shortcutRequestNonce}
@@ -2379,7 +2398,7 @@ function InfiniteCanvasPage() {
</div>
{assistantMounted ? (
<AssistantPanelColumn width={assistantWidth} closing={assistantClosing} topInset={focusMode ? "0px" : "var(--canvas-topbar-offset)"} onWidthChange={setAssistantWidth}>
<AssistantPanelColumn width={assistantWidth} closing={assistantClosing} topInset="0px" onWidthChange={setAssistantWidth}>
{(resizing) => (
<CanvasAssistantPanel
nodes={nodes}
@@ -2391,6 +2410,13 @@ function InfiniteCanvasPage() {
onSelectNodeIds={setSelectedNodeIds}
onSessionsChange={handleAssistantSessionsChange}
onApplyOps={applyAgentOps}
onApplyStyle={applyCanvasStyleAsync}
onGenerateStoryboard={generateScriptRows}
onStartArtCritique={(nodeId, restart) => {
if (artCritiqueRunningRef.current) throw new Error("已有审美分析正在进行,请先完成或停止当前分析");
setArtCritiqueStartRequest({ nodeId, id: nanoid(), restart });
setArtCritiqueNodeId(nodeId);
}}
canUndoOps={canUndoAgentOps}
undoOpsCount={agentUndoCount}
onUndoOps={undoAgentOps}
@@ -2759,6 +2785,9 @@ function InfiniteCanvasPage() {
/>
<AiArtCritiqueModal
startRequestId={artCritiqueStartRequest && artCritiqueStartRequest.nodeId === artCritiqueNode?.id ? artCritiqueStartRequest.id : undefined}
restartRequested={artCritiqueStartRequest?.nodeId === artCritiqueNode?.id && artCritiqueStartRequest?.restart}
onRunningChange={(running) => { artCritiqueRunningRef.current = running; }}
node={artCritiqueNode}
upstreamNodes={artCritiqueInputs}
open={Boolean(artCritiqueNode)}

View File

@@ -1,4 +1,4 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
@@ -27,7 +27,9 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
import { skillRuntime } from "@/services/skill-runtime";
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { submitStoryboardTask } from "@/services/storyboard-submission";
import { getActiveUserScope } from "@/lib/user-scope";
import { modelDisplayName, resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import {
CanvasNodeType,
type CanvasConnection,
@@ -65,6 +67,13 @@ export function useCanvasStoryboard({
const { message, modal } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
const storyboardRequests = useRef(new Set<string>());
const storyboardLifetime = useRef(new AbortController());
useEffect(() => {
const controller = new AbortController();
storyboardLifetime.current = controller;
return () => controller.abort();
}, [projectId]);
const confirmGenerationSubmission = useCallback((count: number, model: string, taskLabel: string) => new Promise<boolean>((resolve) => {
if (!count) return resolve(false);
@@ -99,9 +108,9 @@ export function useCanvasStoryboard({
const previousRows = new Map((nodesRef.current.find((node) => node.id === nodeId)?.metadata?.storyboard?.rows || []).map((row) => [row.id, row]));
const nextRows = rows.map((row) => invalidateEditedPromptVariables(previousRows.get(row.id), row));
setConnections((current) => current
.filter((connection) => !connection.storyboardRowId || storyboardRowIds.has(connection.storyboardRowId))
.filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId || rowIds.has(connection.fromHandleId))
.filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId || rowIds.has(connection.toHandleId)));
.filter((connection) => connection.fromNodeId !== nodeId && connection.toNodeId !== nodeId || !connection.storyboardRowId || storyboardRowIds.has(connection.storyboardRowId))
.filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId?.startsWith("row:") || rowIds.has(connection.fromHandleId))
.filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId?.startsWith("row:") || rowIds.has(connection.toHandleId)));
updateScriptRows(nodeId, () => nextRows);
}, [nodesRef, setConnections, updateScriptRows]);
@@ -119,9 +128,19 @@ export function useCanvasStoryboard({
replaceScriptRows(nodeId, rows);
}, [nodesRef, replaceScriptRows]);
const generateScriptRows = useCallback(async (nodeId: string, prompt: string) => {
const generateScriptRows = useCallback(async (nodeId: string, prompt: string, callerSignal?: AbortSignal) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
if (!scriptNode || !prompt.trim()) return;
if (storyboardRequests.current.has(nodeId) || scriptNode.metadata?.status === NODE_STATUS_LOADING) { message.warning("当前分镜正在处理,请等待原任务完成"); return false; }
const lifetimeSignal = storyboardLifetime.current.signal;
const signal = callerSignal ? AbortSignal.any([lifetimeSignal, callerSignal]) : lifetimeSignal;
const scope = getActiveUserScope();
const originalRows = JSON.stringify(scriptNode.metadata?.storyboard?.rows || []);
const assertCurrent = () => {
signal.throwIfAborted();
const current = nodesRef.current.find((node) => node.id === nodeId);
if (getActiveUserScope() !== scope || !current || JSON.stringify(current.metadata?.storyboard?.rows || []) !== originalRows) throw new Error("画布、账号或分镜内容已变化,未覆盖现有镜头,请重新读取后处理");
};
let storyboardContext: ReturnType<typeof resolveStoryboardGenerationContext>;
try {
storyboardContext = resolveStoryboardGenerationContext(nodesRef.current);
@@ -139,14 +158,17 @@ export function useCanvasStoryboard({
navigateToSettings({ continueCreation: true });
return;
}
storyboardRequests.current.add(nodeId);
try {
const skillExecution = await skillRuntime.prepare({
profile: "shortDrama",
prompt: expandedPrompt,
skills: addedSkills,
});
assertCurrent();
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, composerContent: prompt, status: NODE_STATUS_LOADING, taskStage: "正在创建任务", taskProgress: 0, errorDetails: undefined, ...skillExecution.metadata } } : node));
const task = await createGenerationTask({
assertCurrent();
const request = {
projectId,
type: "agent_storyboard_rows",
operation: "storyboard_rows",
@@ -154,6 +176,8 @@ export function useCanvasStoryboard({
model: generationConfig.model,
...(logicalModelIDForConfig(generationConfig) ? { logicalModelId: logicalModelIDForConfig(generationConfig) } : {}),
input: {
mode: "text",
prompt: skillExecution.prompt,
canvasAssets: buildStoryboardAssetCatalog(nodesRef.current),
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
projectStyle: storyboardContext.projectStyle,
@@ -163,14 +187,41 @@ export function useCanvasStoryboard({
config: backendProviderConfig(generationConfig, "text"),
metadata: { nodeId, ...skillExecution.metadata },
},
});
};
const managed = Boolean(logicalModelIDForConfig(generationConfig) || resolveModelRequestConfig(generationConfig, generationConfig.model).channelId);
const task = managed ? await submitStoryboardTask(request, { signal, assertCurrent, confirm: (submission) => new Promise<boolean>((resolve) => {
const quote = submission.quote;
const dialog = modal.confirm({
title: "确认生成分镜",
content: `使用 ${modelDisplayName(effectiveConfig, quote.model)} 拆分镜头。${quote.estimated ? "预计" : ""}平台费用 ${quote.amountMicrocredits / 1_000_000} 积分;自定义渠道费用由渠道另计,按用量计费以实际结算为准。${(scriptNode.metadata?.storyboard?.rows || []).length ? "本次将替换现有分镜行,原图片视频保留,镜头关联需要重新核对。" : "确认后生成可编辑的分镜表。"}`,
okText: "确认生成", cancelText: "取消", centered: true,
onOk: () => resolve(true), onCancel: () => resolve(false),
afterClose: () => signal.removeEventListener("abort", cancel),
});
const cancel = () => { dialog.destroy(); resolve(false); };
signal.addEventListener("abort", cancel, { once: true });
if (signal.aborted) cancel();
}) }) : await (async () => {
const confirmed = await confirmGenerationSubmission(1, generationConfig.model, `专业拆镜${(scriptNode.metadata?.storyboard?.rows || []).length ? "(替换现有镜头,保留原媒体)" : ""}`);
if (!confirmed) return undefined;
assertCurrent();
return createGenerationTask(request);
})();
if (!task) {
if (!signal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: scriptNode.metadata?.status || NODE_STATUS_IDLE, taskStage: undefined } } : node));
return false;
}
assertCurrent();
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(task), status: NODE_STATUS_LOADING } } : node));
const completed = await waitForGenerationTask(task.id, {
initialTask: task,
signal,
useTextEvents: true,
onTaskUpdate: (next) => setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(next), status: NODE_STATUS_LOADING } } : node)),
onTaskUpdate: (next) => { if (!signal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...generationTaskMetadata(next), status: NODE_STATUS_LOADING } } : node)); },
});
const result = storyboardRowsFromTask(completed);
assertCurrent();
replaceScriptRows(nodeId, result.rows);
setNodes((current) => current.map((node) => node.id === nodeId ? {
...node,
title: result.title || node.title,
@@ -189,12 +240,20 @@ export function useCanvasStoryboard({
message.success(`已生成 ${result.rows.length} 个镜头`);
return true;
} catch (error) {
if (signal.aborted || scope !== getActiveUserScope()) return false;
const details = generationErrorMessage(error);
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : node));
message.error(details);
return false;
} finally {
if (signal.aborted && !lifetimeSignal.aborted && scope === getActiveUserScope()) setNodes((current) => current.map((node) => {
if (node.id !== nodeId) return node;
const active = node.metadata?.taskStatus === "queued" || node.metadata?.taskStatus === "running";
return { ...node, metadata: { ...node.metadata, status: active ? NODE_STATUS_LOADING : NODE_STATUS_IDLE, taskStage: active ? "已提交后台任务,等待结果" : undefined } };
}));
storyboardRequests.current.delete(nodeId);
}
}, [addedSkills, connectionsRef, effectiveConfig, isAiConfigReady, message, nodesRef, projectId, setNodes]);
}, [addedSkills, confirmGenerationSubmission, connectionsRef, effectiveConfig, isAiConfigReady, message, modal, nodesRef, projectId, replaceScriptRows, setNodes]);
const ensureScriptImageNodes = useCallback((nodeId: string, rowIds: string[]) => {
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);

View File

@@ -1,4 +1,4 @@
import { useCallback, type Dispatch, type SetStateAction } from "react";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
@@ -7,8 +7,10 @@ import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { createStyleProfileSnapshot, serializeStyleProfile } from "@/lib/canvas/style-profile";
import { updateProject as updateDomainProject } from "@/services/api/projects";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
import { getActiveUserScope } from "@/lib/user-scope";
type UseCanvasStyleWorkflowOptions = {
canvasId: string;
domainProjectId?: string;
nodesRef: { current: CanvasNodeData[] };
selectedNodeIdsRef: { current: Set<string> };
@@ -20,9 +22,13 @@ type UseCanvasStyleWorkflowOptions = {
setStylePickerOpen: Dispatch<SetStateAction<boolean>>;
};
export function useCanvasStyleWorkflow({ domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen }: UseCanvasStyleWorkflowOptions) {
export function useCanvasStyleWorkflow({ canvasId, domainProjectId, nodesRef, selectedNodeIdsRef, getCanvasCenter, setNodes, setSelectedNodeIds, setSelectedConnectionId, setDialogNodeId, setStylePickerOpen }: UseCanvasStyleWorkflowOptions) {
const { message } = App.useApp();
const queryClient = useQueryClient();
const liveContext = useRef({ canvasId, domainProjectId, mounted: true });
liveContext.current = { canvasId, domainProjectId, mounted: liveContext.current.mounted };
const applyingRef = useRef(false);
useEffect(() => { liveContext.current.mounted = true; return () => { liveContext.current.mounted = false; }; }, []);
const applyCanvasStyle = useCallback(
(preset: CanvasStylePreset, profileJson: string) => {
@@ -66,25 +72,29 @@ export function useCanvasStyleWorkflow({ domainProjectId, nodesRef, selectedNode
if (!domainProjectId) throw new Error("画布尚未关联项目");
return updateDomainProject(domainProjectId, { stylePresetId: preset.id, styleProfileJson: profileJson });
},
onSuccess: (_project, { preset, profileJson }) => {
applyCanvasStyle(preset, profileJson);
void queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
},
onError: (error) => message.error(error instanceof Error ? error.message : "项目画风保存失败"),
});
const applyCanvasStyleAsync = useCallback(async (preset: CanvasStylePreset) => {
if (applyingRef.current) throw new Error("画风正在保存,请稍后重试");
const scope = getActiveUserScope();
applyingRef.current = true;
try {
const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset));
if (domainProjectId) {
await persistStyleMutation.mutateAsync({ preset, profileJson });
void queryClient.invalidateQueries({ queryKey: ["project", domainProjectId] });
}
if (!liveContext.current.mounted || liveContext.current.canvasId !== canvasId || liveContext.current.domainProjectId !== domainProjectId || getActiveUserScope() !== scope) throw new Error("页面或账号已变化,未向当前画布应用画风;如项目保存已完成,请回原项目查看");
applyCanvasStyle(preset, profileJson);
} finally { applyingRef.current = false; }
}, [canvasId, domainProjectId, persistStyleMutation.mutateAsync, queryClient, applyCanvasStyle]);
const selectCanvasStyle = useCallback(
(preset: CanvasStylePreset) => {
if (persistStyleMutation.isPending) return;
const profileJson = serializeStyleProfile(preset.profile || createStyleProfileSnapshot(preset));
if (!domainProjectId) {
applyCanvasStyle(preset, profileJson);
return;
}
persistStyleMutation.mutate({ preset, profileJson });
void applyCanvasStyleAsync(preset).catch((error) => message.error(error instanceof Error ? error.message : "项目画风保存失败"));
},
[applyCanvasStyle, domainProjectId, persistStyleMutation],
[applyCanvasStyleAsync, message],
);
return { selectCanvasStyle, styleApplying: persistStyleMutation.isPending };
return { selectCanvasStyle, applyCanvasStyleAsync, styleApplying: persistStyleMutation.isPending };
}