import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { App, Spin } from "antd"; import { Tooltip } from "@/components/ui/base/tooltip"; import { History } from "lucide-react"; import { useNavigate } from "react-router"; import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal"; import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error"; import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff"; import { getActiveUserScope } from "@/lib/user-scope"; import { continueCreationConversationOnCanvas } from "@/services/creation-canvas-conversation"; import { useExternalAssetSources } from "@/hooks/use-external-asset-sources"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions } from "@/lib/model-capabilities"; import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection"; import type { BackendGenerationResult } from "@/services/api/generation-task"; import type { Skill } from "@/services/api/skills"; import type { GenerationTask } from "@/services/api/task-center"; import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-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, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets"; import { modeLabels, type CreationConversation, type CreationMessage, type CreationMode, type CreationRetryContext, type CreationSettings, type CreationShotRailEntry, type CreationStatus } from "./creation-types"; import { attachCreationTaskContexts, completedCreationGenerationTask, conversationTimestamp, creationShotRail, creationVideoShotOrdinal, isImageAttachment, isVideoAttachment, materializeCreationTaskResults, newConversation, newMessage, reconcileCreationTaskMessages } from "./creation-conversations"; import { CreationComposer, CreationEmptyBanner, CreationEmptySuggest, CreationHistoryDrawer, CreationMessageView, CreationWorkspaceToolbar, creationAssetCategoryLabels } from "./creation-workspace"; const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal }))); const loadCreationRuntime = () => import("./creation-runtime"); type CreationRuntime = Awaited>; const TEXT_STREAMING_PREF_KEY = "creation.composer.text-streaming"; const TEXT_THINKING_PREF_KEY = "creation.composer.text-thinking"; function readComposerPref(key: string, fallback: boolean): boolean { try { const stored = window.localStorage.getItem(key); return stored === null ? fallback : stored === "1"; } catch { return fallback; } } function writeComposerPref(key: string, value: boolean) { try { window.localStorage.setItem(key, value ? "1" : "0"); } catch { // 存储不可用(隐私模式/禁用):仅当前会话生效,忽略 } } export default function CreatePage() { const { message: toast, modal } = App.useApp(); const navigate = useNavigate(); const [openingCanvas, setOpeningCanvas] = useState(false); const openingCanvasRef = useRef(false); const brandName = useAppearanceStore((state) => state.appearance.brandName); const config = useEffectiveConfig(); const composerPreferencesHydrated = useCreationPreferencesStore((state) => state.hydrated); const rememberMode = useCreationPreferencesStore((state) => state.rememberMode); const rememberImageSettings = useCreationPreferencesStore((state) => state.rememberImageSettings); const rememberVideoSettings = useCreationPreferencesStore((state) => state.rememberVideoSettings); const initialComposerPreferences = useCreationPreferencesStore.getState().preferences; 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(() => initialComposerPreferences.mode || "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 addedSkillsRequestedRef = useRef(false); 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 [textStreaming, setTextStreaming] = useState(() => readComposerPref(TEXT_STREAMING_PREF_KEY, true)); const [textThinking, setTextThinking] = useState(() => readComposerPref(TEXT_THINKING_PREF_KEY, false)); const [busy, setBusy] = useState(false); const [referenceReplacementBusy, setReferenceReplacementBusy] = 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 activeGenerationTaskIdsRef = useRef(new Set()); const retryPreparingRef = useRef(new Set()); const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null); const [retrySequence, setRetrySequence] = useState(0); const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false); 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 pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); const recoveryTaskKey = useMemo(() => pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)).join("|"), [pendingTaskIds]); const videoShots = useMemo(() => creationShotRail(activeConversation?.messages || []), [activeConversation]); const jumpToShot = (shot: CreationShotRailEntry) => { const id = shot.result?.id; if (id) document.getElementById(`creation-shot-${id}`)?.scrollIntoView({ block: "start", behavior: "smooth" }); }; useEffect(() => { writeComposerPref(TEXT_STREAMING_PREF_KEY, textStreaming); writeComposerPref(TEXT_THINKING_PREF_KEY, textThinking); }, [textStreaming, textThinking]); useEffect(() => { if (!composerPreferencesHydrated || composerPreferencesInitialized) return; const saved = useCreationPreferencesStore.getState().preferences; const nextMode = saved.mode || "video"; setMode(nextMode); if (nextMode === "image" && saved.image) { if (saved.image.ratio) setRatio(saved.image.ratio); if (saved.image.quality) setQuality(saved.image.quality); if (saved.image.count) setCount(saved.image.count); } if (nextMode === "video" && saved.video) { if (saved.video.ratio) setRatio(saved.video.ratio); if (saved.video.seconds) setSeconds(saved.video.seconds); if (saved.video.videoQuality) setVideoQuality(saved.video.videoQuality); } setComposerPreferencesInitialized(true); }, [composerPreferencesHydrated, composerPreferencesInitialized]); useEffect(() => { if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "image") return; const saved = useCreationPreferencesStore.getState().preferences.image; // 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeImageValue 才回退到模型默认值。 const normalized = normalizeImageValue(imageProfile, { size: saved?.ratio || imageProfile.size.default, quality: saved?.quality || imageProfile.quality.default, count: saved?.count || count, }); setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); }, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, imageProfile]); useEffect(() => { if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "video") return; const saved = useCreationPreferencesStore.getState().preferences.video; // 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeVideoValue 才回退到模型默认值。 const normalized = normalizeVideoValue(videoProfile, { seconds: saved?.seconds || String(videoProfile.duration.default), ratio: saved?.ratio || videoProfile.defaultRatio, resolution: saved?.videoQuality || 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)); }, [composerPreferencesHydrated, composerPreferencesInitialized, 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 || !recoveryTaskKey || !pendingTaskIds.length) return; // 当前页面主动提交的任务由 submit 自己等待并收尾;恢复监听只接管刷新前遗留的任务,避免同一任务被双重轮询。 const recoverableTaskIds = pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)); if (!recoverableTaskIds.length) return; let cancelled = false; const observationController = new AbortController(); const applyTasks = async (tasks: GenerationTask[]) => { const runtime = await loadCreationRuntime(); const contextual = attachCreationTaskContexts(tasks, conversations); const persistedTasks = await materializeCreationTaskResults(runtime, 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) { try { await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => { if (cancelled) return; await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) => runtime.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: runtime.generationTaskMaterializedUrls }); } catch (error) { if (cancelled || observationController.signal.aborted) return; console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error); } } if (!cancelled) setConversations((current) => reconcileCreationTaskMessages(runtime, 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(); let unsubscribe: () => void = () => {}; void loadCreationRuntime() .then((runtime) => { if (cancelled) return; unsubscribe = runtime.subscribeGenerationTasks(recoverableTaskIds, (task) => { applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync); }); }) .catch(warnSync); return () => { cancelled = true; observationController.abort(); unsubscribe(); }; }, [hydrated, recoveryTaskKey, toast]); const loadAddedSkills = useCallback(() => { if (addedSkillsRequestedRef.current) return; addedSkillsRequestedRef.current = true; void import("@/services/api/skills") .then(({ listAddedSkills }) => listAddedSkills()) .then(({ skills }) => setAddedSkills(skills)) .catch(() => setAddedSkills([])); }, []); 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); rememberMode(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 setComposerRatio = (value: string) => { setRatio(value); if (mode === "image") rememberImageSettings({ ratio: value }); if (mode === "video") rememberVideoSettings({ ratio: value }); }; const setComposerSeconds = (value: string) => { setSeconds(value); if (mode === "video") rememberVideoSettings({ seconds: value }); }; const setComposerQuality = (value: string) => { setQuality(value); if (mode === "image") rememberImageSettings({ quality: value }); }; const setComposerVideoQuality = (value: string) => { setVideoQuality(value); if (mode === "video") rememberVideoSettings({ videoQuality: value }); }; const setComposerCount = (value: string) => { setCount(value); if (mode === "image") rememberImageSettings({ count: value }); }; 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) => { const { uploadImage, uploadMediaFile } = await loadCreationRuntime(); 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 clearComposer = () => { promptRef.current = ""; attachmentsRef.current = []; setPrompt(""); setAttachments([]); setDraftReferences([]); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }; 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] : [])); const runtime = await loadCreationRuntime(); let skillExecution: Awaited>; try { skillExecution = await runtime.skillRuntime.prepare({ profile: "creation", prompt: expandCreationPrompt(text, references, attachments), skills: skillReferences, selectedSkillIds: skillReferences.map((skill) => skill.skillId), }); } 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" && textStreaming ? "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); activeGenerationTaskIdsRef.current.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([]); setBusy(true); const controller = new AbortController(); const requestLifecycle = runtime.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 runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.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, streamText: textStreaming, enableThinking: textThinking, onTextDelta: textStreaming ? (value) => updateOriginAssistant((item) => ({ ...item, content: value })) : undefined, ...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 runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.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(runtime, { 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 runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => { await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value); }, { signal: requestLifecycle.signal }); const url = runtime.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 runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.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(runtime, { taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id }); const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => { await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value); }, { signal: requestLifecycle.signal }); if (!runtime.generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用"); } updateOriginAssistant((item) => ({ ...item, status: "done" })); } catch (error) { if (runtime.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 { for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId); 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([]); setHistoryOpen(false); }; const continueOnCanvas = async (selectedAssetIds?: string[]) => { if (!activeConversation || openingCanvasRef.current) return; openingCanvasRef.current = true; setOpeningCanvas(true); const scope = getActiveUserScope(); const source = activeConversation; try { const assets = useAssetStore.getState().assets; const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => { if (!item.resultUrls?.length) return []; const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls }); if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。"); return ids; }); const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean))); const referenceAssetIds = assets.filter((asset) => (asset.kind === "image" || asset.kind === "video") && asset.data.storageKey && referenceKeys.has(asset.data.storageKey)).map((asset) => asset.id); const assetIds = [...generatedAssetIds, ...referenceAssetIds]; const result = await continueCreationConversationOnCanvas(source); if (scope !== getActiveUserScope()) return; const next = updateCreationConversationSnapshot(conversationsRef.current, source.id, (item) => ({ ...item, canvasId: result.id })); conversationsRef.current = next; setConversations(next); await saveCreationConversations(next); if (scope !== getActiveUserScope()) return; if (result.syncError) toast.warning("会话已保存在本机,云端同步尚未完成。"); const params = new URLSearchParams({ conversation: result.sessionId }); if (assetIds.length) { params.set("mode", "handoff"); [...new Set(assetIds)].forEach((id) => params.append("asset", id)); } navigate(`/canvas/${result.id}?${params.toString()}`); } catch (cause) { if (scope === getActiveUserScope()) toast.error(cause instanceof Error ? cause.message : "转入画布失败,原会话已保留"); } finally { openingCanvasRef.current = false; setOpeningCanvas(false); } }; const selectConversation = (conversation: CreationConversation) => { followLatestMessageRef.current = true; setActiveId(conversation.id); setPrompt(""); setAttachments([]); setDraftReferences([]); 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([]); } toast.success("历史对话已删除,素材仍保留"); } catch (error) { toast.error(error instanceof Error ? error.message : "历史对话删除失败"); throw error; } }, }); }; const renameConversationTitle = (conversation: CreationConversation, title: string) => { const nextTitle = title.trim().slice(0, 120); if (!nextTitle || nextTitle === conversation.title.trim()) return; const next = updateCreationConversationSnapshot(conversationsRef.current, conversation.id, (item) => ({ ...item, title: nextTitle })); conversationsRef.current = next; setConversations(next); void saveCreationConversations(next).catch((error) => toast.error(error instanceof Error ? error.message : "对话重命名保存失败")); }; const restoreMessageDraft = (item: CreationMessage) => { const nextMode = item.mode || "text"; const nextSettings = item.settings; selectMode(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); if (nextMode === "image") rememberImageSettings({ ratio: nextSettings.ratio, quality: nextSettings.quality, count: nextSettings.count }); if (nextMode === "video") rememberVideoSettings({ ratio: nextSettings.ratio, seconds: nextSettings.seconds, videoQuality: nextSettings.videoQuality }); }; 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); 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 runtime = await loadCreationRuntime(); const attemptGroupId = item.attemptGroupId || item.retryOf || retryOf; const context: CreationRetryContext = { ...(await runtime.createGenerationRetryContext(retryOf, attemptGroupId)), ...(item.taskIds && item.taskIds.length > 1 ? { retryContextsByBatchIndex: await runtime.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 generationActive = activeConversation.messages.some((message) => message.role === "assistant" && message.status === "pending"); const composerProps = { mode, prompt, setPrompt, busy, generationActive, referenceReplacementBusy, attachments, referenceImageSize, maxReferences, references: mentionReferences, onRemoveAttachment: removeAttachment, onClearAttachments: clearAttachments, onClearComposer: clearComposer, 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: setComposerRatio, seconds, setSeconds: setComposerSeconds, quality, setQuality: setComposerQuality, videoQuality, setVideoQuality: setComposerVideoQuality, count, setCount: setComposerCount, textStreaming, setTextStreaming, textThinking, setTextThinking, promptOptimizerProvider, composerFocusRef, onPromptFocus: loadAddedSkills, onSubmit: () => void submit(), }; return <>
{isEmpty ? <>
{ selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }} />
:
setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
{activeConversation.messages.map((item, index) =>
retryFailedMessage(item, index)} onCreateVariant={() => createVariant(item, index)} onContinueCanvas={(ids) => void continueOnCanvas(ids)} openingCanvas={openingCanvas} onEditUserMessage={(text) => { setPrompt(text); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} />
)}
}
setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} onRename={renameConversationTitle} /> {libraryOpen ? 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} /> : null} ; }