import { Button, Image as AntImage, InputNumber, Modal, Popover } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; 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, Link2, LoaderCircle, Maximize2, Music2, Pencil, SlidersHorizontal, UserRound, Video, WandSparkles, X } from "lucide-react"; 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 { modelQuoteRequest } from "@/lib/model-pricing"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, 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 { CanvasPromptOptimizerDrawer } from "./canvas-prompt-optimizer-drawer"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode } from "@/types/canvas"; import { autoMentionCanvasResourceReferences, canvasResourceMentionToken, normalizeCanvasNodeMentionTokens, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useResolvedCanvasResourceReferences } from "./use-resolved-canvas-resource-references"; import { quoteLogicalModel } from "@/services/api/logical-models"; 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; mentionReferences?: CanvasResourceReference[]; onRemoveReference?: (nodeId: string, reference: CanvasResourceReference) => void; onClose?: () => void; onNodeMouseDown?: (event: ReactPointerEvent, nodeId: string) => void; onImageSettingsOpenChange?: (open: boolean) => void; workspaceMode?: CanvasWorkspaceMode; }; type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; const PROMPT_REFERENCE_SHELF_HEIGHT = 58; 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, mentionReferences = [], onRemoveReference, onClose, onNodeMouseDown, 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 promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); 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(() => estimatePromptContentHeight(savedPrompt, false)); const [expandedPromptContentHeight, setExpandedPromptContentHeight] = useState(() => estimatePromptContentHeight(savedPrompt, true)); const [manualPromptHeight, setManualPromptHeight] = useState(null); const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState(null); const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); const [autoLinkEnabled, setAutoLinkEnabled] = useState(true); const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences); const normalizedSavedPrompt = useMemo(() => normalizeCanvasNodeMentionTokens(savedPrompt, mentionReferences), [mentionReferences, savedPrompt]); const activeReferences = resolvedMentionReferences.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: mode === "video" ? node.metadata?.seconds || globalConfig.videoSeconds : undefined, options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed, }, mode), }; const config = buildNodeConfig(globalConfig, node, mode, requirements); const resolvedRequirements: ModelRequirements = { ...requirements, options: modelRequestOptions(config, mode), videoSeconds: mode === "video" ? config.videoSeconds : undefined, }; const promptOptimizerProvider = useMemo(() => { if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig)); }, [globalConfig, promptOptimizerEnabled, promptOptimizerInstallation]); const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); const configuredCredits = 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, capability: mode, config, requirements: resolvedRequirements, }); const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements); const quoteRequestKey = JSON.stringify(quoteRequest || null); const [quotedCredits, setQuotedCredits] = useState(null); const credits = quotedCredits ?? configuredCredits; const activeReferenceCount = activeReferences.length; const videoFrameOptions = resolvedMentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); const hasVideoPromptTools = mode === "video" && !simpleMode && videoFrameOptions.length > 0; const monochromeAccent = theme.node.activeStroke; const composerTokens = { "--canvas-composer-surface": theme.node.panel, "--canvas-composer-control-surface": theme.toolbar.itemHover, "--canvas-composer-control-hover": theme.toolbar.activeBg, "--canvas-composer-shadow": theme.node.shadow, "--cn-text": theme.node.text, } as CSSProperties; const composerSurfaceStyle = { ...composerTokens, background: theme.node.panel, color: theme.node.text, boxShadow: theme.node.shadow, } as CSSProperties; 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 canOptimizePrompt = Boolean(promptOptimizerProvider) && canExpandPrompt; const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); const autoMentionedPrompt = useMemo(() => autoMentionCanvasResourceReferences(prompt, resolvedMentionReferences), [prompt, resolvedMentionReferences]); const canAutoMention = autoMentionedPrompt !== prompt; useEffect(() => { setPrompt(normalizedSavedPrompt); if (normalizedSavedPrompt !== savedPrompt) onPromptChange(node.id, normalizedSavedPrompt); }, [node.id, normalizedSavedPrompt, onPromptChange, savedPrompt]); useEffect(() => { setExpandedPromptOpen(false); setExpandedPresetOpen(false); setPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, false)); setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true)); setManualPromptHeight(null); setManualExpandedPromptHeight(null); }, [node.id]); useEffect(() => { if (!creditsEnabled || !quoteRequest) { setQuotedCredits(null); return; } const controller = new AbortController(); setQuotedCredits(null); quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal) .then(({ quote }) => setQuotedCredits(quote.amountMicrocredits / 1_000_000)) .catch(() => { if (!controller.signal.aborted) setQuotedCredits(null); }); return () => controller.abort(); // quoteRequestKey captures the full normalized request without retriggering on object identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [creditsEnabled, quoteRequestKey]); const skillReferences = useMemo(() => resolvedMentionReferences.filter((item) => item.kind === "skill"), [resolvedMentionReferences]); 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) => (
{ const target = event.target instanceof Element ? event.target : null; if (!target?.closest("button, input, textarea, select, a, [contenteditable=\"true\"], [data-canvas-no-drag]")) onNodeMouseDown?.(event, node.id); }} > {isPortraitTexture ? ( <> onConfigChange(node.id, { portraitTexture })} /> {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null} ) : (
{modeDisplayName(mode)}生成 {activeReferenceCount > 0 ? {referenceShelfHeading(activeReferences)} : null}
)}
{!simpleMode ? : null} {canOptimizePrompt ? ( ) : null} {!expanded && canExpandPrompt ? ( ) : null} {!expanded && onClose ? ( ) : null}
); const renderSubmitButton = (expanded: boolean) => { const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const actionLabel = isRunning ? "生成中" : showCost ? `预计消耗 ${formattedCredits} 积分,生成` : "生成"; return ( ); }; const renderComposerControls = (expanded: boolean) => simpleMode ? (
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} updatePrompt(autoMentionedPrompt)} onAutoLinkEnabledChange={setAutoLinkEnabled} accent={monochromeAccent} compact /> {renderSubmitButton(expanded)}
) : (
onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={resolvedRequirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} showOptionPrices={creditsEnabled} variant="creation" showConfiguredModelName />
updatePrompt(autoMentionedPrompt)} onAutoLinkEnabledChange={setAutoLinkEnabled} accent={monochromeAccent} compact={!expanded} /> {mode === "text" ? ( onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" className="!w-14 !h-7 [&_.ant-input-number-input]:!text-[var(--fs-tiny)]" /> ) : 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 ( <>
onRemoveReference?.(node.id, reference)} />
); }; return ( item.model === modelOptionName(config.model))?.protocol || priceChannel.interfaceType} config={globalConfig} optimizerModel={globalConfig.textModel} references={activeReferences} provider={promptOptimizerProvider} onClose={() => setPromptOptimizerOpen(false)} onApply={(nextPrompt) => updatePrompt(nextPrompt)} >
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} > {renderComposerHeader(false)} {renderPromptEditor(false)} {/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/} {hasVideoPromptTools ? (
{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: theme.node.panel, boxShadow: theme.node.shadow }, body: { minHeight: 0, padding: 0 }, }} >
{renderComposerHeader(true)}
{renderPromptEditor(true)} {hasVideoPromptTools ? (
onConfigChange(node.id, patch)} />
) : null}
{renderComposerControls(true)}
); } function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention, onAutoLinkEnabledChange, accent, compact }: { canAutoMention: boolean; autoLinkEnabled: boolean; onAutoMention: () => void; onAutoLinkEnabledChange: (enabled: boolean) => void; accent: string; compact: boolean }) { return (
智能引用
输入素材序号或名称后按 Tab,可快速引用
AutoLink
} >
); } function GenerationModeIcon({ mode }: { mode: CanvasNodeGenerationMode }) { if (mode === "image") return ; if (mode === "video") return