feat(canvas): 创作助手 - 合并创作对话、能力发现与画布接续
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type CreationMessage = {
|
||||
generationStage?: string;
|
||||
generationEffectKeys?: string[];
|
||||
};
|
||||
export type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] };
|
||||
export type CreationConversation = { id: string; title: string; updatedAt: string; canvasId?: string; messages: CreationMessage[] };
|
||||
|
||||
export const modeLabels: Record<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
|
||||
export const shotScriptLabels: Record<CreationMode, string> = { text: "创作思路", image: "画面指令", video: "镜头脚本" };
|
||||
|
||||
@@ -12,8 +12,7 @@ import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
||||
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
|
||||
import { MessageReasoning } from "@/components/ai/message-reasoning";
|
||||
import { Link } from "react-router";
|
||||
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
@@ -161,7 +160,7 @@ export function CreationHistoryDrawer({ open, conversations, activeId, onNew, on
|
||||
</AppDrawer>;
|
||||
}
|
||||
|
||||
export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void }) {
|
||||
export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory, onContinueCanvas, openingCanvas }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void; onContinueCanvas: () => void; openingCanvas: boolean }) {
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -187,13 +186,14 @@ export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversatio
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="creation-toolbar-actions">
|
||||
<Button size="small" loading={openingCanvas} onClick={onContinueCanvas}>画布中继续</Button>
|
||||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-toolbar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="creation-toolbar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void }) {
|
||||
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage, onContinueCanvas, openingCanvas }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
if (item.role === "user") return <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
|
||||
const mode = item.mode || "text";
|
||||
@@ -206,7 +206,7 @@ export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreate
|
||||
);
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} /></GenerationToolCard>}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -235,21 +235,20 @@ function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: Cr
|
||||
</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas, openingCanvas }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const resultUrls = item.resultUrls || [];
|
||||
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
|
||||
const canvasHandoffPath = creationCanvasHandoffPath(resultAssetIds, resultUrls.length);
|
||||
const canvasPath = canvasHandoffPath || "/canvas";
|
||||
const canContinueWithResults = resultUrls.length > 0 && resultAssetIds.length === resultUrls.length;
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
const isVideo = item.mode === "video";
|
||||
return <div className="creation-media-result">
|
||||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Link to={canvasPath}>{canvasHandoffPath ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>;
|
||||
}
|
||||
@@ -485,7 +484,7 @@ export function CreationComposer(props: ComposerProps) {
|
||||
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能;回车发送,Shift+回车换行" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
|
||||
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
|
||||
{trackState.isExpanded ? <div className="creation-reference-panel-header">
|
||||
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
|
||||
|
||||
@@ -2,9 +2,13 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||
import { App, Spin } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { History } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||||
@@ -51,6 +55,9 @@ function writeComposerPref(key: string, value: boolean) {
|
||||
|
||||
export default function CreatePage() {
|
||||
const { message: toast, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [openingCanvas, setOpeningCanvas] = useState(false);
|
||||
const openingCanvasRef = useRef(false);
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const config = useEffectiveConfig();
|
||||
const composerPreferencesHydrated = useCreationPreferencesStore((state) => state.hydrated);
|
||||
@@ -722,6 +729,42 @@ export default function CreatePage() {
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
const continueOnCanvas = async (selectedAssetIds?: string[]) => {
|
||||
if (!activeConversation || openingCanvasRef.current) return;
|
||||
openingCanvasRef.current = true;
|
||||
setOpeningCanvas(true);
|
||||
const scope = getActiveUserScope();
|
||||
const source = activeConversation;
|
||||
try {
|
||||
const assets = useAssetStore.getState().assets;
|
||||
const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => {
|
||||
if (!item.resultUrls?.length) return [];
|
||||
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls });
|
||||
if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
|
||||
return ids;
|
||||
});
|
||||
const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean)));
|
||||
const referenceAssetIds = assets.filter((asset) => (asset.kind === "image" || asset.kind === "video") && asset.data.storageKey && referenceKeys.has(asset.data.storageKey)).map((asset) => asset.id);
|
||||
const assetIds = [...generatedAssetIds, ...referenceAssetIds];
|
||||
const result = await continueCreationConversationOnCanvas(source);
|
||||
if (scope !== getActiveUserScope()) return;
|
||||
const next = updateCreationConversationSnapshot(conversationsRef.current, source.id, (item) => ({ ...item, canvasId: result.id }));
|
||||
conversationsRef.current = next;
|
||||
setConversations(next);
|
||||
await saveCreationConversations(next);
|
||||
if (scope !== getActiveUserScope()) return;
|
||||
if (result.syncError) toast.warning("会话已保存在本机,云端同步尚未完成。");
|
||||
const params = new URLSearchParams({ conversation: result.sessionId });
|
||||
if (assetIds.length) {
|
||||
params.set("mode", "handoff");
|
||||
[...new Set(assetIds)].forEach((id) => params.append("asset", id));
|
||||
}
|
||||
navigate(`/canvas/${result.id}?${params.toString()}`);
|
||||
} catch (cause) {
|
||||
if (scope === getActiveUserScope()) toast.error(cause instanceof Error ? cause.message : "转入画布失败,原会话已保留");
|
||||
} finally { openingCanvasRef.current = false; setOpeningCanvas(false); }
|
||||
};
|
||||
|
||||
const selectConversation = (conversation: CreationConversation) => {
|
||||
followLatestMessageRef.current = true;
|
||||
setActiveId(conversation.id);
|
||||
@@ -913,13 +956,15 @@ export default function CreatePage() {
|
||||
/>
|
||||
</main>
|
||||
</> : <div className="creation-thread-workbench">
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} />
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <div key={item.id} id={`creation-shot-${item.id}`} className="creation-thread-message"><CreationMessageView
|
||||
item={item}
|
||||
shotNumber={creationVideoShotOrdinal(videoShots, item)}
|
||||
onRetryFailure={() => retryFailedMessage(item, index)}
|
||||
onCreateVariant={() => createVariant(item, index)}
|
||||
onContinueCanvas={(ids) => void continueOnCanvas(ids)}
|
||||
openingCanvas={openingCanvas}
|
||||
onEditUserMessage={(text) => { setPrompt(text); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/></div>)}</div></section>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user