import { useCallback, useEffect, useMemo, useState, 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 { 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]; 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 config = buildNodeConfig(globalConfig, node, mode); 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(0); const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) 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 = mentionReferences.filter((item) => item.active && item.kind !== "skill").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 darkSurface = themeName === "dark"; const monochromeAccent = theme.node.activeStroke; const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)"; const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)"; const shellSurface = darkSurface ? theme.canvas.background : "rgba(250,251,252,.97)"; const composerSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.025)"; const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)"; const controlsSurface = darkSurface ? controlSurface : "transparent"; const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)"; const composerShadow = darkSurface ? "inset 0 1px 4px rgba(0,0,0,.22)" : "inset 0 0 0 1px rgba(15,23,42,.045)"; const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)"; const referenceShelfHeight = activeReferenceCount ? 42 : 0; const composerMinHeight = activeReferenceCount ? 82 : 58; const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight))); const isSubmitDisabled = !isRunning && !prompt.trim(); const canExpandPrompt = mode === "image" || mode === "video"; const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); const updatePromptContentHeight = useCallback((height: number) => { setPromptContentHeight((current) => (Math.abs(current - height) < 1 ? current : height)); }, []); useEffect(() => { setPrompt(node.metadata?.composerContent ?? node.metadata?.prompt ?? ""); }, [node.id, node.metadata?.composerContent, node.metadata?.prompt]); useEffect(() => setPromptContentHeight(0), [node.id]); useEffect(() => { setExpandedPromptOpen(false); setExpandedPresetOpen(false); }, [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 renderComposerControls = (expanded: boolean) => simpleMode ? (
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
) : (
onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
{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} {creditsEnabled ? : null}
); return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {renderComposerHeader(false)}
{/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} {mode === "video" && !simpleMode ? (
{paramsExpanded ? (
onConfigChange(node.id, patch)} />
) : null}
) : null}
{renderComposerControls(false)}
{ setExpandedPresetOpen(false); setExpandedPromptOpen(false); }} styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, body: { minHeight: 0, flex: 1, padding: 0 }, }} >
{renderComposerHeader(true)}
{mode === "video" && !simpleMode ? (
onConfigChange(node.id, patch)} />
) : null}
{renderComposerControls(true)}
); } function ComposerPill({ theme, borderColor, icon, label }: { theme: CanvasTheme; borderColor: string; icon: ReactNode; label: string }) { return ( {icon} {label} ); } function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) { if (mode === "image") return ; if (mode === "video") return