import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { ArrowUp, AtSign, Boxes, ChevronDown, FileText, ImageIcon, ImagePlus, Maximize2, Music2, Pencil, SlidersHorizontal, Square, UserRound, Video } from "lucide-react"; import { Button, Image as AntImage, Modal, Tooltip } from "antd"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, modelOptionName, resolveModelChannel, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-generation"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover"; import { CanvasVideoPromptTools } from "./canvas-video-prompt-tools"; import { CanvasPresetPicker, type CanvasPromptPreset } from "./canvas-preset-picker"; import { CanvasPortraitTexturePopover } from "./canvas-portrait-texture-popover"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; export type CanvasNodeGenerationMode = CanvasGenerationMode; type CanvasNodePromptPanelProps = { node: CanvasNodeData; isRunning: boolean; onPromptChange: (nodeId: string, prompt: string) => void; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; onStop: (nodeId: string) => void; mentionReferences?: CanvasResourceReference[]; onImageSettingsOpenChange?: (open: boolean) => void; workspaceMode?: CanvasWorkspaceMode; }; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; const PROMPT_REFERENCE_SHELF_HEIGHT = 36; const PROMPT_EDITOR_MIN_HEIGHT = 44; const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 76; const PROMPT_EDITOR_LINE_HEIGHT = 20; const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24; const PROMPT_EDITOR_VERTICAL_PADDING = 12; const PROMPT_EDITOR_EXPANDED_VERTICAL_PADDING = 20; const PROMPT_EDITOR_MAX_LINES = 8; export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, workspaceMode = "professional" }: CanvasNodePromptPanelProps) { const globalConfig = useEffectiveConfig(); const themeName = useThemeStore((state) => state.theme); const theme = canvasThemes[themeName]; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const simpleMode = workspaceMode === "simple"; const mode = defaultMode(node.type); const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; const [prompt, setPrompt] = useState(savedPrompt); const [presetOpen, setPresetOpen] = useState(false); const [expandedPresetOpen, setExpandedPresetOpen] = useState(false); const [expandedPromptOpen, setExpandedPromptOpen] = useState(false); const [promptContentHeight, setPromptContentHeight] = useState(PROMPT_EDITOR_MIN_HEIGHT); const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT); const [manualPromptHeight, setManualPromptHeight] = useState(null); const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState(null); const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const activeReferences = mentionReferences.filter((item) => item.active && item.kind !== "skill"); const requirements: ModelRequirements = { capability: mode, input: { textCount: (prompt.trim() ? 1 : 0) + activeReferences.filter((item) => item.kind === "text").length, imageCount: activeReferences.filter((item) => item.kind === "image").length, videoCount: activeReferences.filter((item) => item.kind === "video").length, audioCount: activeReferences.filter((item) => item.kind === "audio").length, characterCount: activeReferences.filter((item) => item.kind === "character").length, }, videoOperation: node.metadata?.videoEditOperation, videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? generationCount : 1, seconds: mode === "video" ? config.videoSeconds : 1, }); const activeReferenceCount = activeReferences.length; const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); const monochromeAccent = theme.node.activeStroke; const controlSurface = "var(--canvas-composer-control-surface)"; const promptBounds = promptEditorBounds(false, activeReferenceCount > 0); const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0); const composerHeight = clampPromptHeight(manualPromptHeight ?? promptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), promptBounds); const expandedComposerHeight = clampPromptHeight(manualExpandedPromptHeight ?? expandedPromptContentHeight + (activeReferenceCount ? PROMPT_REFERENCE_SHELF_HEIGHT : 0), expandedPromptBounds); const isSubmitDisabled = !isRunning && !prompt.trim(); const canExpandPrompt = mode === "image" || mode === "video"; const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); useEffect(() => { setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? ""); }, [node.id, node.metadata?.composerContent, node.metadata?.prompt]); useEffect(() => { setExpandedPromptOpen(false); setExpandedPresetOpen(false); setPromptContentHeight(PROMPT_EDITOR_MIN_HEIGHT); setExpandedPromptContentHeight(PROMPT_EDITOR_EXPANDED_MIN_HEIGHT); setManualPromptHeight(null); setManualExpandedPromptHeight(null); }, [node.id]); const skillReferences = useMemo(() => mentionReferences.filter((item) => item.kind === "skill"), [mentionReferences]); const updatePrompt = (value: string) => { setPrompt(value); onPromptChange(node.id, value); if (/(^|\s)\/[\p{L}\p{N}_-]*$/u.test(value)) { if (expandedPromptOpen) setExpandedPresetOpen(true); else setPresetOpen(true); } }; const applyPreset = (preset: CanvasPromptPreset) => { const withoutSlash = prompt.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, "$1").trimEnd(); updatePrompt(withoutSlash ? `${withoutSlash}\n${preset.prompt}` : preset.prompt); }; const insertPromptReference = (reference: CanvasResourceReference) => { const insertText = `${canvasResourceMentionToken(reference)} `; const pendingMentionMatch = /@[^\s@,。!?、,.!?;:]*\s*$/.exec(prompt); if (pendingMentionMatch) { const prefix = prompt.slice(0, pendingMentionMatch.index).replace(/\s*$/, ""); updatePrompt(prefix ? `${prefix} ${insertText}` : insertText); return; } const basePrompt = prompt.replace(/\s*$/, ""); updatePrompt(basePrompt ? `${basePrompt} ${insertText}` : insertText); }; const submit = () => { const text = prompt.trim(); if (!text || isRunning) return false; onGenerate(node.id, mode, text); return true; }; const submitExpandedPrompt = () => { if (submit()) { setExpandedPresetOpen(false); setExpandedPromptOpen(false); } }; const renderComposerHeader = (expanded: boolean) => (
{isPortraitTexture ? ( onConfigChange(node.id, { portraitTexture })} /> ) : (
{modeDisplayName(mode)}创作
)} {!simpleMode ? : null}
{activeReferenceCount ? } label={`参考 ${activeReferenceCount}`} /> : null} {!expanded && canExpandPrompt ? ( ) : null}
); const renderSubmitButton = (expanded: boolean) => { const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString(); const actionLabel = isRunning ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; return ( ); }; const renderComposerControls = (expanded: boolean) => simpleMode ? (
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} {renderSubmitButton(expanded)}
) : (
onConfigChange(node.id, { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} variant="creation" />
{mode === "image" ? ( onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} onMissingConfig={() => navigateToSettings({ continueCreation: true })} onOpenChange={expanded ? undefined : onImageSettingsOpenChange} /> ) : mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "audio" ? ( onConfigChange(node.id, audioConfigPatch(key, value))} /> ) : null} {renderSubmitButton(expanded)}
); const renderPromptEditor = (expanded: boolean) => { const bounds = expanded ? expandedPromptBounds : promptBounds; const height = expanded ? expandedComposerHeight : composerHeight; return ( <>
); }; return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {renderComposerHeader(false)} {renderPromptEditor(false)} {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} {mode === "video" && !simpleMode ? (
{paramsExpanded ? (
onConfigChange(node.id, patch)} />
) : null}
) : null} {renderComposerControls(false)} { setExpandedPresetOpen(false); setExpandedPromptOpen(false); }} styles={{ container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" }, body: { minHeight: 0, padding: 0 }, }} >
{renderComposerHeader(true)}
{renderPromptEditor(true)} {mode === "video" && !simpleMode ? (
onConfigChange(node.id, patch)} />
) : null}
{renderComposerControls(true)}
); } function ComposerPill({ theme, icon, label }: { theme: CanvasTheme; icon: ReactNode; label: string }) { return ( {icon} {label} ); } function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) { if (mode === "image") return ; if (mode === "video") return