import { ImageSizePicker } from "@/components/image-size-picker"; import { imageResolutionUsesQuality } from "@/lib/image-size-presets"; import { createPortal } from "react-dom"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react"; import { Button, Popover } from "antd"; import { useWorkspaceTopBarMount } from "@/components/layout/workspace-top-bar-extension"; import { Tooltip } from "@/components/ui/base/tooltip"; import { Reorder, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Plus, SlidersHorizontal, Trash2, WandSparkles, Waves, X } from "lucide-react"; import { WorkingGlow } from "@/components/ai/working-indicator"; import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { HoverBorderGradient } from "@/components/ui/aceternity/hover-border-gradient"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { ModelPicker } from "@/components/model-picker"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import { formatShotOrdinal } from "@/lib/shot-label"; import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers"; import { normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing"; import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models"; import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { type CreationReference } from "./creation-references"; import { creationAttachmentKind, type CreationAttachment, type CreationMode } from "./creation-assets"; import { countOptions, modeLabels, qualityOptions, resolutionOptions, type CreationShotRailEntry } from "./creation-types"; import "./creation-product.css"; import "./creation-scrollbars.css"; import { CreationAttachmentThumbnail, CreationMediaPreviewModal } from "./creation-workspace-messages"; export { CreationHistoryDrawer } from "./creation-workspace-history"; export { CreationMessageView } from "./creation-workspace-messages"; export { CreationEmptyBanner, CreationEmptySuggest, CreationFeaturedWorks } from "./creation-workspace-empty"; const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer }))); export const creationAssetCategoryLabels: Record = { all: "全部素材", ...ASSET_CATEGORY_LABELS }; 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(null); useEffect(() => { if (!railOpen) return; const onPointerDown = (event: MouseEvent) => { if (railRef.current && !railRef.current.contains(event.target as Node)) setRailOpen(false); }; window.addEventListener("mousedown", onPointerDown); return () => window.removeEventListener("mousedown", onPointerDown); }, [railOpen]); const mount = useWorkspaceTopBarMount(); const toolbar =
{railOpen ?
镜头时间线{shots.length ? `共 ${shots.length} 镜` : "空轨道"}
{shots.length ?
    {shots.map((shot) => { const resultStatus = shot.result?.status; const statusLabel = resultStatus === "done" ? "完成" : resultStatus === "error" ? "生成失败" : resultStatus === "pending" ? "生成中" : resultStatus === "cancelled" ? "已停止" : "待生成"; return
  1. ; })}
:

在下方发送一条视频消息,就会自动成为第 1 镜。

}
: null}
; if (mount) return createPortal(toolbar, mount); if (mount === null) return null; return toolbar; } type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; prompt: string; setPrompt: (value: string) => void; busy: boolean; generationActive: boolean; referenceReplacementBusy: boolean; attachments: CreationAttachment[]; referenceImageSize?: { width: number; height: number }; maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onClearAttachments: () => void; onClearComposer: () => void; onReorderAttachments: (attachments: CreationAttachment[]) => void; onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void; onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void; onOpenLibrary: () => void; onModeChange: (mode: CreationMode) => void; model: string; modelRequirements: ModelRequirements; videoProfile: VideoCapabilityConfig; imageProfile: ImageCapabilityConfig; config: AiConfig; onModelChange: (value: string) => void; ratio: string; setRatio: (value: string) => void; seconds: string; setSeconds: (value: string) => void; quality: string; setQuality: (value: string) => void; videoQuality: string; setVideoQuality: (value: string) => void; count: string; setCount: (value: string) => void; textStreaming: boolean; setTextStreaming: (value: boolean) => void; textThinking: boolean; setTextThinking: (value: boolean) => void; promptOptimizerProvider: PromptOptimizerProvider | null; composerFocusRef: RefObject; onPromptFocus: () => void; placeholderOverride?: string; onSubmit: () => void; }; type CreationReferenceFilter = "all" | "image" | "video" | "audio" | "file"; export function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false); const [referenceFilter, setReferenceFilter] = useState("all"); const [canDragReferences, setCanDragReferences] = useState(false); const [dropTargetReferenceId, setDropTargetReferenceId] = useState(null); const attachmentTrackRef = useRef(null); const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null); const suppressAttachmentClickRef = useRef(false); const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false }); const previousAttachmentCountRef = useRef(0); const interactionBusy = props.busy || props.referenceReplacementBusy; const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); const quoteRequest = useMemo(() => modelQuoteRequest(props.config, props.model, props.mode, props.modelRequirements), [props.config, props.mode, props.model, props.modelRequirements]); const [routeQuote, setRouteQuote] = useState(null); const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video"); const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill"); const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(props.model), count: props.mode === "image" ? props.count : 1, seconds: props.mode === "video" ? props.seconds : 1, capability: props.mode, config: props.config, requirements: props.modelRequirements, }); useEffect(() => { if (!creditsEnabled || !quoteRequest) { setRouteQuote(null); return; } const controller = new AbortController(); setRouteQuote(null); quoteModel(quoteRequest, controller.signal) .then(({ quote }) => setRouteQuote(quote)) .catch(() => { if (!controller.signal.aborted) setRouteQuote(null); }); return () => controller.abort(); }, [creditsEnabled, quoteRequest]); const generationCredits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : credits; const showCost = creditsEnabled && generationCredits !== null && generationCredits !== undefined; const formattedCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,发送` : "发送"; // Send-button working state must span the WHOLE generation (not just the // submit-lock window): spinner + glow stay while a message is pending and // the composer is empty; typing a next prompt returns the arrow so the // user knows a new send is possible. const showWorkingSpinner = interactionBusy || (props.generationActive && !canSubmit); const showWorkingGlow = props.generationActive && !canSubmit; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" ? "描述画面、人物、场景、构图与风格" : "描述镜头内容、运动、光线与节奏"; const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.maxReferences > 0; const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`; const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => { const kind = creationAttachmentKind(attachment); counts[kind] += 1; return counts; }, { image: 0, video: 0, audio: 0, file: 0 }), [props.attachments]); const visibleAttachments = useMemo(() => referenceFilter === "all" ? props.attachments : props.attachments.filter((attachment) => creationAttachmentKind(attachment) === referenceFilter), [props.attachments, referenceFilter]); const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; const updateTrackScrollState = useCallback(() => { const track = attachmentTrackRef.current; if (!track) return; setTrackState((current) => ({ ...current, canScrollLeft: track.scrollLeft > 1, canScrollRight: track.scrollLeft + track.clientWidth < track.scrollWidth - 1, })); }, []); const setReferencePanelExpanded = useCallback((isExpanded: boolean) => { setTrackState((current) => ({ ...current, isExpanded })); if (!isExpanded) setReferenceFilter("all"); }, []); useEffect(() => { const hadAttachments = previousAttachmentCountRef.current > 0; if (!props.attachments.length) setReferencePanelExpanded(false); else if (!hadAttachments) setReferencePanelExpanded(true); previousAttachmentCountRef.current = props.attachments.length; updateTrackScrollState(); }, [props.attachments.length, setReferencePanelExpanded, updateTrackScrollState]); useEffect(() => { const query = window.matchMedia("(hover: hover) and (pointer: fine)"); const update = () => setCanDragReferences(query.matches); update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update); }, []); useEffect(() => { const frame = window.requestAnimationFrame(updateTrackScrollState); return () => window.cancelAnimationFrame(frame); }, [referenceFilter, trackState.isExpanded, updateTrackScrollState, visibleAttachments.length]); const beginCardDrag = (event: PointerEvent) => { if (event.button !== 0 || interactionBusy || !trackState.isExpanded) return; if ((event.target as HTMLElement).closest(".creation-reference-card-remove")) return; cardDragRef.current = { startX: event.clientX, startY: event.clientY, moved: false }; }; const endCardDrag = (event: PointerEvent) => { const drag = cardDragRef.current; if (!drag) return; cardDragRef.current = null; if (drag.moved) { suppressAttachmentClickRef.current = true; window.setTimeout(() => { suppressAttachmentClickRef.current = false; }, 0); } if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); setTrackState((current) => ({ ...current, isDragging: false })); }; const moveCardDrag = (event: PointerEvent) => { const drag = cardDragRef.current; if (!drag || drag.moved) return; if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= 4) return; drag.moved = true; setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }; const previewAttachment = (type: "image" | "video", url: string) => { if (suppressAttachmentClickRef.current || cardDragRef.current?.moved) return; setPreviewType(type); setPreviewUrl(url); }; const reorderVisibleAttachments = useCallback((next: CreationAttachment[]) => { if (referenceFilter === "all") { props.onReorderAttachments(next); return; } const visibleIds = new Set(visibleAttachments.map((attachment) => attachment.id)); const reordered = [...next]; props.onReorderAttachments(props.attachments.map((attachment) => visibleIds.has(attachment.id) ? reordered.shift() || attachment : attachment)); }, [props.attachments, props.onReorderAttachments, referenceFilter, visibleAttachments]); useEffect(() => { if (!canOptimizePrompt) setPromptOptimizerOpen(false); }, [canOptimizePrompt]); const scrollAttachmentTrack = (direction: -1 | 1) => { const track = attachmentTrackRef.current; if (!track) return; track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" }); window.setTimeout(updateTrackScrollState, 180); }; const imageReferenceAtPoint = (x: number, y: number) => { for (const element of document.elementsFromPoint(x, y)) { const chip = element.closest("[data-mention-reference-id]"); const referenceId = chip?.dataset.mentionReferenceId; const reference = referenceId ? props.references.find((item) => item.id === referenceId) : undefined; if (reference?.kind === "image" && reference.attachmentId) return reference; } return undefined; }; const composer =
{ const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} /> {props.attachments.length || referencesSupported ?
{trackState.isExpanded ?
{([ { id: "all", label: "全部", count: props.attachments.length }, { id: "image", label: "图片", count: referenceCounts.image }, { id: "video", label: "视频", count: referenceCounts.video }, { id: "audio", label: "音频", count: referenceCounts.audio }, { id: "file", label: "文件", count: referenceCounts.file }, ] as const).map((filter) => )}
{props.attachments.length ? : null}
: null}
{trackState.canScrollLeft ? : null} as="ul" ref={attachmentTrackRef} className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${visibleAttachments.length ? "" : " is-empty"}`} axis="x" values={visibleAttachments} onReorder={reorderVisibleAttachments} layoutScroll role="list" aria-label="参考内容轨道" onScroll={updateTrackScrollState} > {visibleAttachments.map((item) => key={item.id} value={item} layout="position" drag={trackState.isExpanded && canDragReferences && !interactionBusy} className="creation-reference-stack-card" onPointerDown={beginCardDrag} onPointerMove={moveCardDrag} onPointerUp={endCardDrag} onPointerCancel={endCardDrag} onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }} onDrag={(_, info) => { if (creationAttachmentKind(item) !== "image") return; const target = imageReferenceAtPoint(info.point.x, info.point.y); setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null); }} onDragEnd={(_, info) => { const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined; setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true })); if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item); }} > )} {!visibleAttachments.length && props.attachments.length ?
  • 该类型暂无参考内容
  • : null} {referencesSupported ?
  • : null} {trackState.canScrollRight ? : null} {!trackState.isExpanded && props.attachments.length ? : null}
    : null}
    {props.variant === "thread" ? : null} props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> {canOptimizePrompt ? : null} {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null} {props.mode === "text" ? <> : null} {props.prompt.trim() || props.attachments.length || props.references.some((reference) => reference.active) ? : null}
    setPreviewUrl("")} />
    ; if (!promptOptimizerOpen) return composer; return ( item.model === modelOptionName(props.model))?.protocol || priceChannel.interfaceType} config={props.config} optimizerModel={props.config.textModel} references={optimizerReferences} provider={props.promptOptimizerProvider} onClose={() => setPromptOptimizerOpen(false)} onApply={props.setPrompt} > {composer} ); } export function CreationModeTabs({ mode, onModeChange, agentActive = false, onAgentSelect, orientation = "horizontal" }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void; agentActive?: boolean; onAgentSelect?: () => void; orientation?: "horizontal" | "vertical" }) { const reducedMotion = useReducedMotion(); const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [ { mode: "video", icon: , label: "视频" }, { mode: "image", icon: , label: "图片" }, { mode: "text", icon: , label: "文本" }, ]; const indicator = (pressed: boolean) => pressed ? ( ) : null; return
    {items.map((item) => ( ))} {onAgentSelect ? : null}
    ; } function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) { return ; } function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; // 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。 const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel); const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(mergedProfile.size); const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(mergedProfile.size.values) : []; const ratios = props.videoProfile.ratios; const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined; const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : ""; const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : ""; const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue); const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; const selectReferenceImageSize = () => { if (!referenceImageSizeValue) return; props.setRatio(referenceImageSizeValue); }; const videoResolutionSupported = props.mode === "video" && resolutions.length > 0; const imageSummary = [ ...(mergedProfile.size.parameter !== "none" ? [referenceImageSizeSelected ? referenceImageSizeLabel : usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const videoRatioSupported = props.mode === "video" && ratios.length > 0; const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary; const panel =
    {props.mode === "image" ? { props.setRatio(size); if (quality) props.setQuality(quality); }} /> : videoRatioSupported ?
    {ratios.map((value) => )}
    : null} {props.mode === "image" && referenceImageSizeValue ? : null} {props.mode === "video" ? (videoResolutionSupported ?
    {resolutions.map((option) => )}
    : null) : <> {props.imageProfile.quality.supported && !imageResolutionUsesQuality(mergedProfile) ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
    {activeQualityOptions.map((option) => )}
    : null} {props.imageProfile.maxOutputs > 1 ?
    {countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
    : null} }
    ; return ; } function SettingSection({ title, value, children }: { title: string; value?: string; children: ReactNode }) { return

    {title}

    {value ? {value} : null}
    {children}
    ; } function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) { const [open, setOpen] = useState(false); const value = Number(normalizeVideoValue(profile, { seconds }).seconds); const presets = profile.duration.selection === "enum" ? videoDurationOptions(profile) : []; const fallbackPreset = presets.length ? presets : [profile.duration.default]; const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...fallbackPreset); const max = profile.duration.selection === "range" ? Math.max(min, profile.duration.max || min) : Math.max(...fallbackPreset); const step = Math.max(1, profile.duration.step || 1); const durationControl = profile.duration.selection === "range" ? <> onChange(event.target.value)} />
    {min}s{max}s
    :
    {presets.map((item) => )}
    ; return
    时长{value} 秒
    {durationControl}}>
    ; } function ratioPreviewStyle(value: string) { const [width, height] = value.replace("x", ":").split(":").map(Number); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 10, height: 10 }; // 画幅容器的可用空间是 14×10;同时计算宽高,避免 CSS 的 max-width/max-height 把宽银幕比例压扁。 const scale = Math.min(14 / width, 10 / height); return { width: Math.max(4, Math.round(width * scale)), height: Math.max(4, Math.round(height * scale)) }; }