import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; import { Reorder } from "motion/react"; import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card"; import { MessageReasoning } from "@/components/ai/message-reasoning"; import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { CachedResourceImage } from "@/components/cached-resource-image"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { CanvasPromptOptimizerDrawer } from "@/components/canvas/canvas-prompt-optimizer-drawer"; 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 { ASSET_CATEGORY_LABELS } from "@/lib/asset-category"; import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation"; import { createClientId } from "@/lib/client-id"; import { formatShotOrdinal } from "@/lib/shot-label"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center"; import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection"; import { resolveResourceUrl } from "@/services/api/resources"; import { 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, pendingCreationTaskIds, pendingCreationTaskKey, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { recoverCreationTextTask } from "@/services/creation-text-task-recovery"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useAppearanceStore } from "@/stores/use-appearance-store"; import { useUserStore } from "@/stores/use-user-store"; import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types"; import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; import { createPluginHostContext } from "@/services/plugin-host"; import { usePluginStore } from "@/stores/use-plugin-store"; import { buildCreationMentionReferences, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; import { skillRuntime } from "@/services/skill-runtime"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, 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; reasoning?: 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 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 brandName = useAppearanceStore((state) => state.appearance.brandName); const config = useEffectiveConfig(); const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)); const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled)); const promptOptimizerProvider = useMemo(() => { if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null; return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, config)); }, [config, promptOptimizerEnabled, promptOptimizerInstallation]); 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 promptRef = useRef(prompt); const attachmentsRef = useRef(attachments); 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 [referenceReplacementBusy, setReferenceReplacementBusy] = 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 externalAssetSources = useExternalAssetSources(libraryOpen); const abortRef = 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); promptRef.current = prompt; attachmentsRef.current = attachments; 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: attachments.filter((attachment) => creationAttachmentKind(attachment) === "audio").length, characterCount: 0, }, videoSeconds: mode === "video" ? seconds : undefined, imageSize: mode === "image" ? ratio : undefined, options: mode === "image" ? { size: ratio, quality, count: Number(count), transparentBackground: config.transparentBackground === "true" } : mode === "video" ? { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" } : {}, }), [attachments, config.transparentBackground, config.videoGenerateAudio, config.videoWatermark, count, hasPrompt, mode, quality, ratio, seconds, videoQuality]); 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 referenceImageSize = useMemo(() => { const imageAttachments = attachments.filter(isImageAttachment); if (imageAttachments.length !== 1) return undefined; const { width, height } = imageAttachments[0]; if (typeof width !== "number" || typeof height !== "number" || width <= 0 || height <= 0) return undefined; return { width, height }; }, [attachments]); const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; const pendingTaskKey = useMemo(() => pendingCreationTaskKey(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: imageProfile.size.default, quality: imageProfile.quality.default, count, }); setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); }, [mode, selectedModel, imageProfile]); useEffect(() => { if (mode !== "video") return; // 前台逻辑模型的默认参数必须直接落到创作端状态,提交任务时才不会被旧状态覆盖。 const normalized = normalizeVideoValue(videoProfile, { seconds: String(videoProfile.duration.default), ratio: videoProfile.defaultRatio, resolution: videoProfile.defaultResolution, }); setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0; if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences)); }, [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 || !pendingTaskKey || !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, pendingTaskKey, 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 externalLibraryItems = useMemo( () => externalAssetSources.items.map((item) => ({ ...item, disabledReason: mode === "image" && item.external?.item.kind !== "image" ? "图片创作仅支持参考图" : undefined, })), [externalAssetSources.items, mode], ); 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, })), ...externalLibraryItems, ], [assets, externalLibraryItems, 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 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 handleLibrarySelect = (selectedIds: string[]) => { const next = selectedIds.flatMap((id): CreationAttachment[] => { const asset = assets.find((item) => item.id === id); 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)]; const external = libraryItems.find((item) => item.id === id)?.external; return external ? [creationAttachmentFromExternalAsset(external)] : []; }); 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) => removeCreationAttachment(current, id)); if (reference) setPrompt((current) => removeCreationReferenceTokens(current, [reference])); }; const clearAttachments = () => { const attachmentIds = new Set(attachments.map((item) => item.id)); const references = mentionReferences.filter((item) => item.attachmentId && attachmentIds.has(item.attachmentId)); setAttachments([]); if (references.length) setPrompt((current) => removeCreationReferenceTokens(current, references)); }; const reorderAttachments = useCallback((next: CreationAttachment[]) => { attachmentsRef.current = next; setAttachments(next); }, []); const replaceAttachmentReference = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => { const currentAttachments = attachmentsRef.current; const target = currentAttachments.find((attachment) => attachment.id === targetAttachmentId); if (!target) throw new Error("要替换的参考图不存在"); if (creationAttachmentKind(target) !== "image" || creationAttachmentKind(replacement) !== "image") throw new Error("目前只支持替换提示词中的图片引用"); if (target.id === replacement.id) return false; const result = replaceCreationAttachmentReference(promptRef.current, currentAttachments, targetAttachmentId, replacement); promptRef.current = result.prompt; attachmentsRef.current = result.attachments; setPrompt(result.prompt); setAttachments(result.attachments); return true; }, []); const replaceReferenceFromTrack = useCallback((targetAttachmentId: string, replacement: CreationAttachment) => { try { if (replaceAttachmentReference(targetAttachmentId, replacement)) toast.success("参考图已替换,槽位不变,提示词无需修改"); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图替换失败"); } }, [replaceAttachmentReference, toast]); const replaceReferenceFromFiles = useCallback(async (targetAttachmentId: string, files: File[]) => { if (busy || referenceReplacementBusy) return; const file = files.find((item) => item.type.startsWith("image/")); if (!file) { toast.warning("请拖入图片文件进行替换"); return; } setReferenceReplacementBusy(true); try { const { asset, attachment } = await uploadCreationAsset(file); if (creationAttachmentKind(attachment) !== "image") throw new Error("上传结果不是可用图片"); if (asset) addAsset(asset); if (replaceAttachmentReference(targetAttachmentId, attachment)) toast.success("参考图已替换,槽位不变,提示词无需修改"); } catch (error) { toast.error(error instanceof Error ? error.message : "参考图上传或替换失败"); } finally { setReferenceReplacementBusy(false); } }, [addAsset, busy, referenceReplacementBusy, replaceAttachmentReference, toast]); 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 videoOperation = inferVideoOperation({ textCount: text ? 1 : 0, imageCount: referenceImages.length, videoCount: referenceVideos.length, audioCount: referenceAudios.length, characterCount: 0, }); const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : [])); let skillExecution: Awaited>>; try { skillExecution = await skillRuntime.prepare({ profile: "creation", prompt: expandCreationPrompt(text, references, attachments), skills: skillReferences, selectedSkillIds: skillReferences.map((skill) => skill.skill_id), }); } catch (error) { toast.error(error instanceof Error ? error.message : "技能上下文加载失败"); releaseRetryLock(); return; } const expandedPrompt = skillExecution.prompt; const referenceMetadata = skillExecution.metadata; 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 normalizedImage = mode === "image" ? normalizeImageValue(imageProfile, { size: ratio, quality, count }) : undefined; const normalizedVideo = mode === "video" ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : undefined; const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, ...(mode === "image" ? { size: normalizedImage?.size || ratio, quality: normalizedImage?.quality || quality, count: normalizedImage?.count || count, videoSeconds: config.videoSeconds } : mode === "video" ? { size: normalizedVideo?.ratio ?? ratio, videoSeconds: normalizedVideo?.seconds || seconds, vquality: (normalizedVideo?.resolution ?? videoQuality).replace(/p$/i, "") } : {}), }; try { if (mode === "text") { const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({ mode: "text", prompt: expandedPrompt, config: requestConfig, referenceImages, referenceVideos, referenceAudios, textHistory: (activeConversation.messages || []).filter((item) => item.content.trim()).map((item) => ({ role: item.role, content: item.content })), signal: requestLifecycle.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata }, onTaskUpdate: bindTask, onTextDelta: (value) => updateOriginAssistant((item) => ({ ...item, content: value })), ...retryContext, })); if (!result.text?.trim()) throw new Error("后端任务没有返回文本"); updateOriginAssistant((item) => ({ ...item, content: result.text || "", reasoning: result.reasoning })); } 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: videoOperation, ...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" ? videoOperation : 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); }; 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, referenceReplacementBusy, attachments, referenceImageSize, maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onClearAttachments: clearAttachments, onReorderAttachments: reorderAttachments, onReplaceAttachment: replaceReferenceFromTrack, onReplaceReferenceFiles: replaceReferenceFromFiles, onOpenLibrary: () => setLibraryOpen(true), 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, promptOptimizerProvider, composerFocusRef, placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: () => void submit(), }; 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)} />)}
:
{ 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); }} /> : null}
}
setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} /> item.id.startsWith("asset:") ? [item.id.slice(6)] : item.id.startsWith("external:") ? [item.id] : [])} upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets, external: { accept: "image/*", description: "写入当前 Eagle 文件夹;Eagle 当前支持图片文件", onUpload: (files, folderId) => externalAssetSources.uploadExternalFiles(files, folderId) } }} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} /> ; } const creationAssetCategoryLabels: Record = { all: "全部素材", ...ASSET_CATEGORY_LABELS }; 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 }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); if (item.role === "user") return ; const mode = item.mode || "text"; const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : ""; const heading = <>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : `${brandName} AI`}{mode !== "text" ? {item.status === "pending" ? `${brandName}正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null} : null}{modelName ? {modelName} : null}{item.createdAt ? : null}{stateLabel ? {stateLabel} : null}; const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed"; return
{mode === "text" ? <>
{heading}
{item.reasoning ? : null}
{item.content ? {item.content} : 正在生成…}
: 1} heading={heading}>} {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) || ""; const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : ""; const previewUrl = kind === "image" ? imageUrl : url; return ; })}
: null} setPreviewUrl("")} />
; } function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => 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 }: { mode: CreationMode; ratio?: string }) { const brandName = useAppearanceStore((state) => state.appearance.brandName); return
{brandName}正在生成{mode === "video" ? "视频" : "图像"}
; } 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; const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl; return {imageUrl && reference.kind === "video" ? ; })}
; } function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { return {url ? type === "video" ? ; } function CreationAttachmentThumbnail({ item, onPreview, onRemove }: { item: CreationAttachment; onPreview: (type: "image" | "video", url: string) => void; onRemove: (id: string) => void; }) { const kind = creationAttachmentKind(item); const previewable = kind === "image" || kind === "video"; const url = (kind === "video" ? item.url : item.previewUrl) || ""; const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : ""; const previewUrl = kind === "image" ? imageUrl : url; const content = kind === "video" ?