feat(canvas): 画布创作对话 - 统一流式 Markdown 展示与交互修复

This commit is contained in:
ddCat
2026-08-15 02:31:42 +08:00
parent 33e3bf416f
commit bf4a2f9c39
16 changed files with 730 additions and 117 deletions

View File

@@ -1181,6 +1181,7 @@ function InfiniteCanvasPage() {
projectId,
domainProjectId: currentProject?.projectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,
@@ -1221,6 +1222,7 @@ function InfiniteCanvasPage() {
projectId,
domainProjectId: currentProject?.projectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,

View File

@@ -14,6 +14,7 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { executeImageGeneration } from "./canvas-image-generation-executor";
@@ -24,6 +25,7 @@ type UseCanvasGenerationExecutorOptions = {
projectId: string;
domainProjectId?: string;
addedSkills: Skill[];
assets: Asset[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -50,6 +52,7 @@ export function useCanvasGenerationExecutor({
projectId,
domainProjectId,
addedSkills,
assets,
nodesRef,
connectionsRef,
setNodes,
@@ -128,7 +131,7 @@ export function useCanvasGenerationExecutor({
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
try {
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, promptOnly);
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, assets, promptOnly);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig.videoSeconds, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements);
@@ -267,7 +270,7 @@ export function useCanvasGenerationExecutor({
setRunningNodeId(null);
}
},
[addedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
[addedSkills, assets, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
);
}

View File

@@ -33,12 +33,14 @@ import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveModelRequestConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { Asset } from "@/stores/use-asset-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
type UseCanvasGenerationRetryOptions = {
projectId: string;
domainProjectId?: string;
addedSkills: Skill[];
assets: Asset[];
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
@@ -52,7 +54,7 @@ const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkills, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkills, assets, nodesRef, connectionsRef, setNodes, setRunningNodeId, startGenerationRequest, finishGenerationRequest, bindGenerationTask }: UseCanvasGenerationRetryOptions) {
const { message } = App.useApp();
const effectiveConfig = useEffectiveConfig();
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
@@ -88,7 +90,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
let rawContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>> | null;
try {
const promptOnly = retryMode === "video";
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryContextPrompt, promptOnly);
const baseContext = buildNodeGenerationContext(sourceNode.id, nodesRef.current, connectionsRef.current, retryContextPrompt, assets, promptOnly);
rawContext = hasSavedImageMetadata && !baseContext.characterReferences.length ? null : await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
} catch (error) {
const failure = generationFailureMetadata(error, retryPromptSource);
@@ -227,7 +229,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
setRunningNodeId(null);
}
},
[addedSkills, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
[addedSkills, assets, bindGenerationTask, connectionsRef, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, projectId, setNodes, setRunningNodeId, startGenerationRequest],
);
}

View File

@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import localforage from "localforage";
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Link } from "react-router";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
@@ -1061,7 +1061,7 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant }: { res
</div></div>;
}
if (status === "error") return <div className="storyboard-workbench-error"><span>{generationErrorMessage(result.error || "")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (mode === "text") return <div className="creation-message-content storyboard-workbench-text">{result.content ? <ReactMarkdown>{result.content}</ReactMarkdown> : <span>正在生成…</span>}</div>;
if (mode === "text") return <div className="creation-message-content storyboard-workbench-text">{result.content ? <AIMessageMarkdown isStreaming={status === "streaming"}>{result.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div>;
if (!resultUrls.length) return <div className="storyboard-workbench-empty"><Film />没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const note = result.settings ? directorNoteFor(mode, result.settings) : "";
return <>