import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react"; 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"; import { ModelPicker } from "@/components/model-picker"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useUserStore } from "@/stores/use-user-store"; import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; type CreationViewMode = "chat" | "storyboard"; type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled"; type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string }; type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] }; type CreationMessage = { id: string; role: "user" | "assistant"; mode?: CreationMode; content: string; createdAt: string; status?: CreationStatus; model?: string; resultUrls?: string[]; error?: string; generationErrorCode?: string; generationOperation?: string; attachments?: CreationAttachment[]; references?: CreationReference[]; settings?: CreationSettings; taskIds?: string[]; clientOperationId?: string; retryOf?: string; attemptGroupId?: string; generationStage?: string; generationEffectKeys?: string[]; }; type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] }; const modeLabels: Record = { text: "文本", image: "图片", video: "视频" }; const shotScriptLabels: Record = { text: "创作思路", image: "画面指令", video: "镜头脚本" }; const ratioOptions = [ { value: "1:1", label: "方形" }, { value: "16:9", label: "横屏" }, { value: "9:16", label: "竖屏" }, { value: "4:3", label: "标准横屏" }, { value: "3:4", label: "标准竖屏" }, { value: "21:9", label: "宽银幕" }, ]; const qualityOptions = [ { value: "auto", label: "自动", description: "由模型决定" }, { value: "low", label: "低", description: "更快生成" }, { value: "medium", label: "中", description: "均衡模式" }, { value: "high", label: "高", description: "优先细节" }, // grok2api / xAI Imagine:quality 映射 resolution { value: "1k", label: "1K", description: "标准清晰度" }, { value: "2k", label: "2K", description: "更高清晰度" }, ]; const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) })); const countOptions = ["1", "2", "3", "4"]; const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024; const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }); const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); function newConversation(): CreationConversation { return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] }; } function newMessage(role: CreationMessage["role"], content: string, extra: Partial = {}): CreationMessage { return { id: createClientId(), role, content, createdAt: new Date().toISOString(), ...extra }; } type CreationShot = { user?: CreationMessage; result?: CreationMessage }; function shotsFromMessages(messages: CreationMessage[]): CreationShot[] { const shots: CreationShot[] = []; for (const message of messages) { if (message.role === "user") { shots.push({ user: message }); } else if (shots.length) { shots[shots.length - 1].result = message; } else { shots.push({ result: message }); } } return shots; } function completedCreationGenerationTask(input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask { const now = new Date().toISOString(); const task = input.task ?? { id: input.taskId, type: input.mode, status: "succeeded" as const, prompt: input.prompt, attempts: 1, createdAt: now, updatedAt: now }; return projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result); } export default function CreatePage() { const { message: toast, modal } = App.useApp(); const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const [conversations, setConversations] = useState([]); const conversationsRef = useRef([]); const [activeId, setActiveId] = useState(""); const activeIdRef = useRef(""); const [hydrated, setHydrated] = useState(false); const [mode, setMode] = useState("video"); const [prompt, setPrompt] = useState(""); const [attachments, setAttachments] = useState([]); const [draftReferences, setDraftReferences] = useState([]); const [addedSkills, setAddedSkills] = useState([]); const [ratio, setRatio] = useState("16:9"); const [seconds, setSeconds] = useState("6"); const [quality, setQuality] = useState("auto"); const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); const [viewMode, setViewMode] = useState("chat"); const [selectedShotIndex, setSelectedShotIndex] = useState(-1); const [composingNextShot, setComposingNextShot] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false); const abortRef = useRef(null); const fileInputRef = useRef(null); const composerFocusRef = useRef(null); const threadScrollRef = useRef(null); const followLatestMessageRef = useRef(true); const taskSyncWarningRef = useRef(false); const retryPreparingRef = useRef(new Set()); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const [retrySequence, setRetrySequence] = useState(0); const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]); const historyConversations = useMemo( () => conversations.filter((conversation) => conversation.id === activeId || conversation.messages.length > 0).sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)), [activeId, conversations], ); const preferredModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel; const hasPrompt = Boolean(prompt.trim()); const modelRequirements = useMemo(() => ({ capability: mode, input: { textCount: hasPrompt ? 1 : 0, imageCount: attachments.filter(isImageAttachment).length, videoCount: attachments.filter(isVideoAttachment).length, audioCount: 0, characterCount: 0, }, videoSeconds: seconds, }), [attachments, hasPrompt, mode, seconds]); const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]); const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1; useEffect(() => { if (mode !== "image") return; const normalized = normalizeImageValue(imageProfile, { size: ratio, quality, count }); setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); }, [mode, selectedModel, imageProfile]); useEffect(() => { if (mode !== "video") return; const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` }); setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); }, [mode, selectedModel, videoProfile]); useEffect(() => { const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences); if (reconciled.attachments === attachments) return; setAttachments(reconciled.attachments); if (reconciled.removedReferences.length) setPrompt((current) => removeCreationReferenceTokens(current, reconciled.removedReferences)); }, [attachments, maxReferences, mentionReferences]); useEffect(() => { let cancelled = false; void loadCreationConversations().then((stored) => { if (cancelled) return; const next = stored?.length ? stored : [newConversation()]; conversationsRef.current = next; setConversations(next); setActiveId(next[0].id); setHydrated(true); }); return () => { cancelled = true; // 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。 }; }, []); useEffect(() => () => abortRef.current?.abort(), []); useEffect(() => { activeIdRef.current = activeId; }, [activeId]); useEffect(() => { conversationsRef.current = conversations; if (hydrated) void saveCreationConversations(conversations); }, [conversations, hydrated]); useEffect(() => { if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return; let cancelled = false; const observationController = new AbortController(); const applyTasks = async (tasks: GenerationTask[]) => { const contextual = attachCreationTaskContexts(tasks, conversations); const persistedTasks = await materializeCreationTaskResults(contextual, observationController.signal); if (cancelled) return; taskSyncWarningRef.current = false; const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length)); for (const task of attachable) { await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => { if (cancelled) return; await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value, ); }, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls }); } if (!attachable.length && !cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks)); }; const warnSync = (error: unknown) => { if (cancelled || observationController.signal.aborted) return; console.warn("创作任务状态同步失败", error); if (!taskSyncWarningRef.current) { taskSyncWarningRef.current = true; toast.warning("任务状态暂时无法同步,请稍后刷新"); } }; let applyChain = Promise.resolve(); const unsubscribe = subscribeGenerationTasks(pendingTaskIds, (task) => { applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync); }); return () => { cancelled = true; observationController.abort(); unsubscribe(); }; }, [hydrated, pendingMediaKey, toast]); useEffect(() => { let cancelled = false; listAddedSkills().then(({ skills }) => { if (!cancelled) setAddedSkills(skills); }).catch(() => { if (!cancelled) setAddedSkills([]); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!followLatestMessageRef.current) return; const frame = window.requestAnimationFrame(() => { const container = threadScrollRef.current; if (container) container.scrollTop = container.scrollHeight; }); return () => window.cancelAnimationFrame(frame); }, [activeConversation?.id, activeConversation?.messages]); const updateActive = useCallback((updater: (conversation: CreationConversation) => CreationConversation) => { const next = updateCreationConversationSnapshot(conversationsRef.current, activeId, updater); conversationsRef.current = next; setConversations(next); }, [activeId]); const updateConversationMessage = useCallback(async (conversationId: string, id: string, updater: (item: CreationMessage) => CreationMessage) => { const next = updateCreationConversationSnapshot(conversationsRef.current, conversationId, (conversation) => ({ ...conversation, updatedAt: new Date().toISOString(), messages: conversation.messages.map((item) => item.id === id ? updater(item) : item), })); conversationsRef.current = next; setConversations(next); await saveCreationConversations(next); }, []); const selectMode = (next: CreationMode) => { setMode(next); const nextModels = selectableModelsByCapability(config, next); const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel; if (!nextModels.includes(current) && nextModels[0]) { updateConfig(next === "text" ? "textModel" : next === "image" ? "imageModel" : "videoModel", nextModels[0]); } }; const libraryItems = useMemo(() => assets .filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio") .map((asset) => ({ id: asset.id, title: asset.title, category: asset.category || "other", kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片", asset, searchText: asset.tags.join(" "), disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined, })), [assets, mode]); const uploadCreationAsset = async (file: File) => { if (file.type.startsWith("video/")) { const uploaded = await uploadMediaFile(file, "create-upload"); return { asset: creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), attachment: creationAttachmentFromVideo(file, uploaded), }; } if (file.type.startsWith("audio/")) { const uploaded = await uploadMediaFile(file, "create-upload"); return { asset: creationAudioAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), attachment: creationAttachmentFromAudio(file, uploaded), }; } if (!file.type.startsWith("image/")) { const uploaded = await uploadMediaFile(file, "create-upload"); return { attachment: creationAttachmentFromDocument(file, uploaded) }; } const uploaded = await uploadImage(file); return { asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), attachment: creationAttachmentFromImage(file, uploaded), }; }; const addAttachments = (files: FileList | File[]) => { if ((mode === "image" || mode === "video") && maxReferences === 0) { toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频"); return; } const next = Array.from(files) .filter((file) => creationFileAccepted(mode, file)) .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { const { asset, attachment } = await uploadCreationAsset(file); if (asset) addAsset(asset); return attachment; })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (items.length) setAttachments((current) => [...current, ...items].slice(0, maxReferences)); if (failed.length) toast.error(`${failed.length} 个参考素材上传失败,请重试`); }); }; const uploadLibraryAssets = async (files: FileList | File[]) => { const next = Array.from(files).filter((file) => creationFileAccepted(mode, file)); if (!next.length) return []; const settled = await Promise.allSettled(next.map(async (file) => { const { asset } = await uploadCreationAsset(file); return asset ? addAsset(asset) : ""; })); const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" && entry.value ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`); if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); return assetIds; }; const handleFileChange = (event: ChangeEvent) => { if (event.target.files) addAttachments(event.target.files); event.target.value = ""; }; const handleLibrarySelect = (selected: Asset[]) => { const next = selected.flatMap((asset): CreationAttachment[] => { if (asset.kind === "image") return [creationAttachmentFromAsset(asset)]; if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)]; if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)]; return []; }); if (!next.length) return; setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences)); setLibraryOpen(false); }; const removeAttachment = (id: string) => { const reference = mentionReferences.find((item) => item.attachmentId === id); setAttachments((current) => current.filter((item) => item.id !== id)); if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference])); }; const submit = async (retryContext?: CreationRetryContext, retryLockKey?: string) => { const releaseRetryLock = () => { if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); }; const text = prompt.trim(); if (!text || busy || !activeConversation) { releaseRetryLock(); return; } if (!selectedModel) { toast.warning(`请先在设置中配置${modeLabels[mode]}模型`); releaseRetryLock(); return; } if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) { toast.error("当前模型不支持所选视频时长,请重新选择"); releaseRetryLock(); return; } if (attachments.length > maxReferences) { toast.warning("参考内容正在按当前模型能力调整,请稍后重试"); releaseRetryLock(); return; } const settings = { ratio, seconds, quality, videoQuality, count }; const references = selectedCreationReferences(text, mentionReferences); // 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。 const { referenceImages, referenceVideos, referenceAudios } = splitCreationAttachments(attachments); const expandedPrompt = expandCreationPrompt(text, references, attachments); const referenceMetadata = creationReferenceMetadata(references); followLatestMessageRef.current = true; const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings }); const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings, ...retryContext }); const originConversationId = activeConversation.id; const updateOriginAssistant = (updater: (item: CreationMessage) => CreationMessage) => updateConversationMessage(originConversationId, assistantMessage.id, updater); const boundTaskIds = new Set(); const boundTaskIdsByBatchIndex = new Map(); const boundTasks = new Map(); const bindTask = (task: GenerationTask) => { if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id); boundTaskIds.add(task.id); boundTasks.set(task.id, task); updateOriginAssistant((item) => ({ ...item, generationStage: task.stage, generationOperation: task.operation, generationErrorCode: task.errorCode, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])), clientOperationId: task.clientOperationId, retryOf: task.retryOf, attemptGroupId: task.attemptGroupId })); if (abortRef.current === controller) { abortRef.current = null; setBusy(false); } }; updateActive((conversation) => ({ ...conversation, title: conversation.messages.length ? conversation.title : text.slice(0, 24), updatedAt: new Date().toISOString(), messages: [...conversation.messages, userMessage, assistantMessage], })); setPrompt(""); setAttachments([]); setDraftReferences([]); setSelectedShotIndex(-1); setComposingNextShot(false); setBusy(true); const controller = new AbortController(); const requestLifecycle = beginGenerationConsumer(controller.signal); abortRef.current = controller; const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count }; try { if (mode === "text") { const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({ role: item.role, content: item.role === "user" ? await buildTextMessageContent(item) : item.content, }))); await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal }); } else if (mode === "image") { const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1))); const settled = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTaskBatch({ mode: "image", prompt: expandedPrompt, config: { ...requestConfig, count: "1" }, referenceImages, signal: requestLifecycle.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata }, onTaskUpdate: bindTask, count: taskCount, ...retryContext, })); if (requestLifecycle.signal.aborted) throw new DOMException("Aborted", "AbortError"); const boundTaskIdList = Array.from(boundTaskIds); const generatedImages = settled.flatMap((entry, batchIndex) => { if (entry.status !== "fulfilled") return []; return (entry.value.images || []).map((image, resultIndex) => ({ image, taskId: boundTaskIdsByBatchIndex.get(batchIndex) || boundTaskIdList[batchIndex], batchIndex, resultIndex, })); }); const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected"); const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => { if (!taskId) throw new Error("生成任务缺少稳定任务标识"); const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount }); const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => { await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value); }, { signal: requestLifecycle.signal }); const url = generationTaskMaterializedUrls(materialized)[0]; if (!url) throw new Error("图片结果资源不可用"); return url; })); const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const resourceFailures = storedImages.filter((entry) => entry.status === "rejected"); const failedCount = taskFailures.length + resourceFailures.length; if (!resultUrls.length) { const reason = taskFailures[0]?.reason || resourceFailures[0]?.reason; throw reason instanceof Error ? reason : new Error("后端任务没有返回图片"); } if (failedCount) toast.warning(`${resultUrls.length} 张图片已生成,${failedCount} 张生成失败`); updateOriginAssistant((item) => ({ ...item, content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成" })); } else { const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({ mode: "video", prompt: expandedPrompt, config: requestConfig, referenceImages, referenceVideos, referenceAudios, signal: requestLifecycle.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: referenceAudios.length && !referenceImages.length && !referenceVideos.length ? "audio_to_video" : attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata }, onTaskUpdate: bindTask, ...retryContext, })); if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频"); const taskId = Array.from(boundTaskIds)[0]; if (!taskId) throw new Error("生成任务缺少稳定任务标识"); const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id }); const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => { await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value); }, { signal: requestLifecycle.signal }); if (!generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用"); } updateOriginAssistant((item) => ({ ...item, status: "done" })); } catch (error) { if (isGenerationTaskCancelled(error, requestLifecycle.signal)) { updateOriginAssistant((item) => ({ ...item, status: "cancelled", content: "已停止" })); return; } const message = generationErrorMessage(error); updateOriginAssistant((item) => ({ ...item, status: "error", error: message, generationErrorCode: item.generationErrorCode || generationErrorCode(error), generationOperation: item.generationOperation || (mode === "video" ? (attachments.length ? "image_to_video" : "text_to_video") : mode), createdAt: assistantMessage.createdAt, content: "生成失败" })); } finally { requestLifecycle.release(); releaseRetryLock(); if (abortRef.current === controller) { abortRef.current = null; setBusy(false); } } }; useEffect(() => { if (!retrySequence) return; const pending = pendingRetryRef.current; if (!pending) return; pendingRetryRef.current = null; void submit(pending.context, pending.lockKey); }, [retrySequence]); const startNewConversation = () => { const next = newConversation(); followLatestMessageRef.current = true; setConversations((current) => [next, ...current]); setActiveId(next.id); setPrompt(""); setAttachments([]); setDraftReferences([]); setSelectedShotIndex(-1); setComposingNextShot(false); setHistoryOpen(false); }; const selectConversation = (conversation: CreationConversation) => { followLatestMessageRef.current = true; setActiveId(conversation.id); setPrompt(""); setAttachments([]); setDraftReferences([]); setSelectedShotIndex(-1); setComposingNextShot(false); setHistoryOpen(false); }; const confirmDeleteConversation = (conversation: CreationConversation) => { const title = conversation.title.trim() || "新创作"; const label = title.length > 32 ? `${title.slice(0, 32)}...` : title; modal.confirm({ className: "workspace-modal workspace-modal-compact", title: "删除历史对话?", content: `确定删除「${label}」吗?这只会删除历史对话记录,不会删除已上传或生成的任何素材。此操作不可撤销。`, okText: "删除对话", okButtonProps: { danger: true }, cancelText: "保留", onOk: async () => { try { const remaining = removeCreationConversationSnapshot(conversationsRef.current, conversation.id); const sortedRemaining = [...remaining].sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)); const fallback = sortedRemaining.find((item) => item.messages.length > 0) || sortedRemaining[0] || newConversation(); const next = remaining.length ? remaining : [fallback]; await saveCreationConversations(next); conversationsRef.current = next; setConversations(next); if (activeIdRef.current === conversation.id) { followLatestMessageRef.current = true; activeIdRef.current = fallback.id; setActiveId(fallback.id); setPrompt(""); setAttachments([]); setDraftReferences([]); setSelectedShotIndex(-1); setComposingNextShot(false); } toast.success("历史对话已删除,素材仍保留"); } catch (error) { toast.error(error instanceof Error ? error.message : "历史对话删除失败"); throw error; } }, }); }; const restoreMessageDraft = (item: CreationMessage) => { const nextMode = item.mode || "text"; const nextSettings = item.settings; setMode(nextMode); setPrompt(item.content); setAttachments(item.attachments ? [...item.attachments] : []); setDraftReferences(item.references ? [...item.references] : []); if (item.model) updateConfig(nextMode === "text" ? "textModel" : nextMode === "image" ? "imageModel" : "videoModel", item.model); if (!nextSettings) return; setRatio(nextSettings.ratio); setSeconds(nextSettings.seconds); setQuality(nextSettings.quality); setVideoQuality(nextSettings.videoQuality); setCount(nextSettings.count); }; const retryFailedMessage = async (item: CreationMessage, index: number) => { const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item; if (!previous?.content || busy) return; const retryOf = item.taskIds?.[0]; const restoreForRetry = () => { followLatestMessageRef.current = true; restoreMessageDraft(previous); setSelectedShotIndex(-1); setComposingNextShot(false); const removedIds = new Set([item.id, previous.id]); updateActive((conversation) => { const messages = conversation.messages.filter((message) => !removedIds.has(message.id)); const firstPrompt = messages.find((message) => message.role === "user")?.content.trim(); return { ...conversation, title: firstPrompt ? firstPrompt.slice(0, 24) : "新创作", updatedAt: new Date().toISOString(), messages }; }); }; if (!retryOf) { restoreForRetry(); return; } if (retryPreparingRef.current.has(retryOf)) return; retryPreparingRef.current.add(retryOf); try { const attemptGroupId = item.attemptGroupId || item.retryOf || retryOf; const context: CreationRetryContext = { ...(await createGenerationRetryContext(retryOf, attemptGroupId)), ...(item.taskIds && item.taskIds.length > 1 ? { retryContextsByBatchIndex: await createGenerationBatchRetryContexts(item.taskIds, attemptGroupId) } : {}) }; restoreForRetry(); pendingRetryRef.current = { context, lockKey: retryOf }; setRetrySequence((current) => current + 1); } catch (error) { retryPreparingRef.current.delete(retryOf); toast.error(generationErrorMessage(error)); } }; const createVariant = (item: CreationMessage, index: number) => { const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item; if (!previous?.content || busy) return; restoreMessageDraft(previous); }; const cancelPendingMessage = async (item: CreationMessage) => { if (!item.taskIds?.length || !activeConversation) return; const settled = await Promise.allSettled(item.taskIds.map((taskId) => cancelGenerationTask(taskId))); const fulfilled = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const background = fulfilled.map((task) => ({ task, outcome: localDreaminaDetachOutcome(task) })).find((entry) => entry.outcome?.kind === "background"); if (background?.outcome?.kind === "background") { await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: background.outcome!.creationStatus, generationStage: background.task.stage, content: background.outcome!.message })); toast.info(background.outcome.message); return; } const cancelled = fulfilled.filter((task) => task.status === "cancelled"); if (cancelled.length === item.taskIds.length) { const outcome = localDreaminaDetachOutcome(cancelled[0]!); await updateConversationMessage(activeConversation.id, item.id, (message) => ({ ...message, status: outcome?.creationStatus ?? "cancelled", generationStage: "cancelled", content: outcome?.message ?? cancelled[0]!.error ?? "任务已取消" })); } }; if (!hydrated || !activeConversation) return
; const handleThreadScroll = () => { const container = threadScrollRef.current; if (!container) return; followLatestMessageRef.current = container.scrollHeight - container.scrollTop - container.clientHeight <= 160; }; const nextShotNumber = shots.length + 1; const beginComposeNextShot = () => { setComposingNextShot(true); setSelectedShotIndex(-1); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }; const cancelComposeNextShot = () => setComposingNextShot(false); const composerProps = { mode, prompt, setPrompt, busy, attachments, maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onOpenLibrary: () => setLibraryOpen(true), fileInputRef, onFileChange: handleFileChange, onModeChange: selectMode, model: selectedModel, modelRequirements, imageProfile, videoProfile, config, onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value), ratio, setRatio, seconds, setSeconds, quality, setQuality, videoQuality, setVideoQuality, count, setCount, composerFocusRef, placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), onStop: () => abortRef.current?.abort(), }; const visibleShot = shots[visibleShotIndex]; const visibleShotResultIndex = visibleShot?.result ? activeConversation.messages.indexOf(visibleShot.result) : -1; return <>
{isEmpty ? <>
{ selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }} />
: viewMode === "chat" ?
setHistoryOpen(true)} />
{activeConversation.messages.map((item, index) => retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} onCancel={() => void cancelPendingMessage(item)} />)}
:
{ setSelectedShotIndex(index); setComposingNextShot(false); }} onBeginCompose={beginComposeNextShot} onCancelCompose={cancelComposeNextShot} onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} viewMode={viewMode} onViewModeChange={setViewMode} />
{composingNextShot ? : visibleShot ? { if (visibleShotResultIndex >= 0 && visibleShot.result) retryFailedMessage(visibleShot.result, visibleShotResultIndex); }} onCreateVariant={() => { if (visibleShotResultIndex >= 0 && visibleShot.result) createVariant(visibleShot.result, visibleShotResultIndex); }} onCancel={() => { if (visibleShot.result) void cancelPendingMessage(visibleShot.result); }} /> : null}
}
setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} /> item.id.startsWith("asset:")).map((item) => item.id.slice(6))} upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }} onClose={() => setLibraryOpen(false)} onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))} /> ; } const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) { const [keyword, setKeyword] = useState(""); useEffect(() => { if (open) setKeyword(""); }, [open]); const visibleConversations = useMemo(() => { const query = keyword.trim().toLowerCase(); if (!query) return conversations; return conversations.filter((conversation) => { const latest = conversationPreviewMessage(conversation); const searchable = [ conversation.title, ...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]), latest?.mode ? modeLabels[latest.mode] : "创作", formatConversationTime(conversation.updatedAt), ].filter(Boolean).join(" ").toLowerCase(); return searchable.includes(query); }); }, [conversations, keyword]); return } className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={
历史对话{conversations.length} 个对话
}>
{visibleConversations.length ?
    {visibleConversations.map((conversation) => { const latest = conversationPreviewMessage(conversation); const active = conversation.id === activeId; return
  • ; })}
:
{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
}
; } function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) { return
; } function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) { return
; } function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { if (item.role === "user") return ; const mode = item.mode || "text"; const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; return
{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}{mode !== "text" ? {item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}
{mode === "text" ?
{item.content ? {item.content} : 正在生成…}
: } {item.error && mode === "text" ?
{generationErrorMessage(item.error)}
: null}
; } function CreationUserMessage({ item }: { item: CreationMessage }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const copyText = useCopyText(); const visiblePrompt = displayCreationPrompt(item.content, item.references || []); return
你{item.createdAt ? : null}

{visiblePrompt}

{item.references?.length ? : null} {item.attachments?.length ?
{item.attachments.map((attachment) => { const kind = creationAttachmentKind(attachment); const previewable = kind === "image" || kind === "video"; const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; return ; })}
: null} setPreviewUrl("")} />
; } function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const assets = useAssetStore((state) => state.assets); const resultUrls = item.resultUrls || []; const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : []; const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas"; if (item.status === "pending") return ; if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return
{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}
; if (!resultUrls.length) return
没有返回可预览结果
; const isVideo = item.mode === "video"; return
{isVideo ? :
{resultUrls.map((url) => )}
}
{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}{resultAssetIds.length ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} />
; } function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) { return
影策正在生成{mode === "video" ? "视频" : "图像"}{onCancel ? : null}
; } function CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; return {reference.previewUrl && (reference.kind === "image" || reference.kind === "video") ? : }{reference.label}; })}
; } function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { return {url ? type === "video" ? ; } function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false, onPreview, onRemove, onAdd }: { item: CreationAttachment; primary?: boolean; canAddMore?: boolean; onPreview: (type: "image" | "video", url: string) => void; onRemove: (id: string) => void; onAdd?: () => void; }) { const kind = creationAttachmentKind(item); const previewable = kind === "image" || kind === "video"; const url = (kind === "video" ? item.url : item.previewUrl) || ""; return
{primary && canAddMore && onAdd ? : null}
; } type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; prompt: string; setPrompt: (value: string) => void; busy: boolean; attachments: CreationAttachment[]; maxReferences: number; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onOpenLibrary: () => void; fileInputRef: RefObject; onFileChange: (event: ChangeEvent) => void; onModeChange: (mode: CreationMode) => void; model: string; modelRequirements: ModelRequirements; videoProfile: VideoCapabilityConfig; imageProfile: ImageCapabilityConfig; config: ReturnType; 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; composerFocusRef: RefObject; placeholderOverride?: string; onSubmit: () => void; onStop: () => void; }; function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const priceChannel = resolveModelChannel(props.config, props.model); 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, }); const showCost = creditsEnabled && credits !== null; const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 }); const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送"; 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.videoProfile.operations.includes("image_to_video"); const [primaryAttachment, ...secondaryAttachments] = props.attachments; const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences; const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; const previewAttachment = (type: "image" | "video", url: string) => { setPreviewType(type); setPreviewUrl(url); }; return
{primaryAttachment ? : }
{secondaryAttachments.length ?
{secondaryAttachments.map((item) => )}
: null}
props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
setPreviewUrl("")} />
; } function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) { const [open, setOpen] = useState(false); const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [ { mode: "video", icon: , label: "视频生成" }, { mode: "image", icon: , label: "图片生成" }, { mode: "text", icon: , label: "文本创作" }, ]; const current = items.find((item) => item.mode === mode) || items[0]; return {items.map((item) => )}}> ; } function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); 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 usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size); const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : []; const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined; const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1"); const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto"; const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : []; const imageRatios = usesImageResolutionPicker ? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio))) : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios; const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; const selectImageRatio = (nextRatio: string) => { if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") { props.setRatio(nextRatio); return; } props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio); }; const selectImageResolution = (choice: ImageResolutionChoice) => { if (choice === "auto") { props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio); return; } const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size; if (nextSize) props.setRatio(nextSize); }; const videoResolutionSupported = props.mode === "video" && resolutions.length > 0; const imageSummary = [ ...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const summary = props.mode === "video" ? [props.ratio, ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary; const panel =
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return ; })}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ? (videoResolutionSupported ?
{resolutions.map((option) => )}
: null) : <> {imageResolutionChoiceOptions.length ?
{imageResolutionChoiceOptions.map((choice) => )}
: null} {props.imageProfile.quality.supported ? 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}}>
; } const creationEmptyBannerFrames = [ { src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" }, { src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" }, { src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" }, ]; function CreationEmptyBanner() { return ; } const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [ { mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" }, { mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true }, { mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" }, { mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" }, ]; function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) { return
{creationEmptySuggestions.map((item) => { const Icon = item.icon; return ; })}
; } type CreationThinking = { title: string; hint: string; steps: string[] }; function thinkingFor(mode: CreationMode): CreationThinking { if (mode === "image") return { title: "正在为你画这一镜", hint: "影策正在理解你的构图意图,并把画面交给模型出图。", steps: ["理解构图", "定调画风", "生成画面"] }; if (mode === "text") return { title: "正在为你写这段", hint: "影策正在梳理你的创作脉络,组织语言与结构。", steps: ["梳理脉络", "组织语言", "输出段落"] }; return { title: "正在为你拍这一镜", hint: "影策正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。", steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] }; } function directorNoteFor(mode: CreationMode, settings: CreationSettings): string { if (mode === "video") return `已按 ${[`${settings.seconds}s`, ...(settings.videoQuality ? [videoResolutionLabel(settings.videoQuality)] : []), settings.ratio].join(" · ")} 渲染这一镜,等待你的下一句指令。`; if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`; return ""; } function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) { const [railOpen, setRailOpen] = useState(false); const nextShotNumber = shots.length + 1; const closeRail = () => setRailOpen(false); const statusOf = (shot: CreationShot) => shot.result?.status || "queued"; const shotTitle = (shot: CreationShot) => shot.user ? displayCreationPrompt(shot.user.content, shot.user.references || []).trim() || "未命名镜头" : "镜头"; return
{railOpen ?
镜头时间线{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}
    {shots.map((shot, index) => { const status = statusOf(shot); const title = shotTitle(shot); const thumbUrl = shot.result?.resultUrls?.[0]; const thumbIsVideo = shot.result?.mode === "video"; return
  • ; })} {composing ?
  • : null}
: null}
; } function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant, onCancel }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) { const user = shot.user; const result = shot.result; const status = result?.status || "queued"; const mode = result?.mode || user?.mode || "video"; const briefVisible = Boolean(user?.content.trim() || user?.references?.length || user?.attachments?.length); const copyText = useCopyText(); const assets = useAssetStore((state) => state.assets); const visiblePrompt = user ? displayCreationPrompt(user.content, user.references || []) : ""; const resultUrls = result?.resultUrls || []; const resultAssetIds = result && resultUrls.length ? creationResultAssetIds(assets, { messageId: result.id, taskIds: result.taskIds || [], resultUrls }) : []; const canvasHandoffPath = result ? creationCanvasHandoffPath(resultAssetIds, resultUrls.length) : ""; const canvasPath = canvasHandoffPath || "/canvas"; return
SC.{String(shotNumber).padStart(2, "0")}镜头 {shotNumber} {mode === "video" ? : mode === "image" ? : }{modeLabels[mode]} {modelName ? {modelName} : null} {status === "pending" ? 生成中 : status === "error" ? 生成失败 : status === "done" ? 已完成 : 待生成}
{status === "error" ? : null} {status === "done" && result?.resultUrls?.length ? : null} {status === "done" && resultUrls.length ? {canvasHandoffPath ? "添加到画布" : "打开画布"} : null} {resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
{briefVisible && user ?
{shotScriptLabels[mode]}{user.createdAt ? : null}

{visiblePrompt}

{user.references?.length ? : null} {user.attachments?.length ? : null}
: null} {briefVisible && user ? : null}
影策 AI{modelName ? {modelName} : null}{result?.createdAt ? : null}
; } function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; onCancel: () => void }) { return
SC.{String(shotNumber).padStart(2, "0")}下一镜 {shotNumber} 待撰写
SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本 在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。
; } function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); return <>
{attachments.map((attachment) => { const kind = creationAttachmentKind(attachment); const previewable = kind === "image" || kind === "video"; const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || ""; return ; })}
setPreviewUrl("")} />; } function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, onCancel, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); }; if (!result) return
这一镜还没开始——在下方写出你的脚本,我来接手。
; const mode = result.mode || "video"; const status = result.status || "queued"; const resultUrls = result.resultUrls || []; if (status === "pending" || status === "queued") { const thinking = thinkingFor(mode); const cancellationCopy = result.taskIds?.map((taskId) => localDreaminaCancellationCopy({ id: taskId, status: "running", stage: result.generationStage, receiptRecorded: result.generationStage === "submitted" || result.generationStage === "generating" })).find(Boolean); const hasLocalDreaminaTask = Boolean(result.taskIds?.some(isLocalDreaminaTaskId)); const showCancellationAction = !hasLocalDreaminaTask || Boolean(cancellationCopy); return
{thinking.title}{thinking.hint} {result.taskIds?.length && showCancellationAction ? : null}
; } if (status === "error") return
{generationErrorMessage(result.error || "")}
; if (status === "cancelled") return
{result.content || "已停止"}
; if (mode === "text") return
{result.content ? {result.content} : 正在生成…}
; if (!resultUrls.length) return
没有返回可预览结果
; const note = result.settings ? directorNoteFor(mode, result.settings) : ""; return <> {mode === "video" ? :
{resultUrls.map((url) => )}
} {note ?

导演手记{note}

: null}
{mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}{canvasHandoffAvailable ? "添加到画布" : "打开画布"}{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} /> ; } function videoResolutionLabel(value: string | number) { return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`; } function formatMessageTime(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : ""; } function conversationPreviewMessage(conversation: CreationConversation) { let fallback: CreationMessage | undefined; for (let index = conversation.messages.length - 1; index >= 0; index -= 1) { const message = conversation.messages[index]; if (!message.content.trim()) continue; fallback ||= message; if (message.role === "user") return message; } return fallback; } async function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); const attachments = item.attachments || []; if (!attachments.length) return content; const parts: AiTextContentPart[] = [{ type: "text", text: content }]; for (const attachment of attachments) { if (isImageAttachment(attachment)) { parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } }); continue; } const url = await creationAttachmentDataUrl(attachment); parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } }); } return parts; } async function creationAttachmentDataUrl(attachment: CreationAttachment) { if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); const attachmentUrl = attachment.url || ""; if (attachmentUrl.startsWith("data:")) return attachmentUrl; const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null; if (blob) { if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`); return blobToDataUrl(blob); } if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl; throw new Error(`${attachment.name} 无法读取,请重新上传后再试`); } function blobToDataUrl(blob: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = () => reject(reader.error || new Error("附件读取失败")); reader.readAsDataURL(blob); }); } function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { return creationAttachmentKind(attachment) === "video"; } function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { return creationAttachmentKind(attachment) === "image"; } type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; function attachCreationTaskContexts(tasks: GenerationTask[], conversations: CreationConversation[]) { const contexts = new Map }>(); for (const conversation of conversations) { for (const [messageIndex, message] of conversation.messages.entries()) { if (message.role !== "assistant" || !message.taskIds?.length) continue; const prompt = conversation.messages[messageIndex - 1]?.role === "user" ? conversation.messages[messageIndex - 1].content : ""; for (const [batchIndex, taskId] of message.taskIds.entries()) contexts.set(taskId, { prompt, clientContext: { conversationId: conversation.id, messageId: message.id, batchIndex, batchCount: message.taskIds.length } }); } } return tasks.map((task) => { const context = contexts.get(task.id); return context ? { ...task, prompt: context.prompt, clientContext: context.clientContext } : task; }); } async function materializeCreationTaskResults(tasks: GenerationTask[], signal?: AbortSignal): Promise { return Promise.all(tasks.map(async (task): Promise => { if (task.status !== "succeeded" || !task.clientContext) return task; try { const materialized = await runGenerationConsumer(signal, (managedSignal) => materializeGenerationTaskAssets(task, managedSignal)); const creationResultUrls = generationTaskMaterializedUrls(materialized); return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized; } catch (error) { return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" }; } })); } function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: PersistedCreationTask[]) { let changed = false; const next = conversations.map((conversation) => { let conversationChanged = false; let completedAt = conversation.updatedAt; const messages = conversation.messages.map((message) => { if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message; const taskIds = new Set(message.taskIds || []); const matches = tasks .filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id)) .sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0)); const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0)); if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message; const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls))); const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length; const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)])); completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt); conversationChanged = true; changed = true; if (resultUrls.length) { const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成"; return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds }; } if (matches.every((task) => task.status === "cancelled")) { const localOnly = matches.find(isLocalDreaminaWaitStopped); return { ...message, status: "cancelled" as const, content: localOnly ? localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds }; } const failed = matches.find((task) => task.status === "failed" || task.creationError); return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds }; }); return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation; }); return changed ? next : conversations; } function creationTaskResultUrls(task: PersistedCreationTask) { if (task.creationResultUrls?.length) return task.creationResultUrls; return []; } function conversationTimestamp(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; } function formatConversationTime(value: string) { const timestamp = conversationTimestamp(value); if (!timestamp) return "时间未知"; return conversationTimeFormatter.format(timestamp); } 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: 14, height: 14 }; const scale = Math.min(28 / width, 20 / height); return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) }; }