import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; import localforage from "localforage"; import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { Link } from "react-router"; import { AssetMediaPreview } from "@/components/asset-media-preview"; import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea"; import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button"; import { ModelPicker } from "@/components/model-picker"; import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references"; import { createClientId } from "@/lib/client-id"; import { generationErrorMessage } from "@/lib/generation-error"; import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options"; import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities"; import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection"; import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task"; import { requestImageQuestion } from "@/services/api/image"; import { listAddedSkills, type Skill } from "@/services/api/skills"; import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center"; import { storeGeneratedVideo } from "@/services/api/video"; import { uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset, type NewAsset } from "@/stores/use-asset-store"; import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references"; import { creationAssetKey, creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets"; type CreationMode = "text" | "image" | "video"; type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled"; type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string }; type CreationMessage = { id: string; role: "user" | "assistant"; mode?: CreationMode; content: string; createdAt: string; status?: CreationStatus; model?: string; resultUrls?: string[]; error?: string; attachments?: CreationAttachment[]; references?: CreationReference[]; settings?: CreationSettings; taskIds?: string[]; }; type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] }; const STORAGE_KEY = "creation-conversations-v1"; 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 }; type CreationImageResult = NonNullable[number]; 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; } async function persistCreationImageResult(image: CreationImageResult): Promise { if (!image.storageKey) return uploadImage(image.dataUrl); const url = await resolveImageUrl(image.storageKey, image.dataUrl); if (!url) throw new Error("图片结果资源不可用"); return { url, storageKey: image.storageKey, width: image.width || 1024, height: image.height || 1024, bytes: image.bytes || 0, mimeType: image.mimeType || "image/png", }; } function addCreationAssetOnce(asset: NewAsset, identity: CreationAssetIdentity) { const store = useAssetStore.getState(); const key = creationAssetKey(identity); if (key && store.assets.some((existing) => isSameCreationAsset(existing, identity))) return false; store.addAsset(key ? { ...asset, metadata: { ...asset.metadata, creationAssetKey: key } } : asset); return true; } export default function CreatePage() { const { message: toast } = App.useApp(); const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const assets = useAssetStore((state) => state.assets); const addAsset = useAssetStore((state) => state.addAsset); const [conversations, setConversations] = useState([]); const [activeId, setActiveId] = useState(""); const [hydrated, setHydrated] = useState(false); const [mode, setMode] = useState("video"); const [prompt, setPrompt] = useState(""); const [attachments, setAttachments] = useState([]); const [draftReferences, setDraftReferences] = useState([]); const [addedSkills, setAddedSkills] = useState([]); const [ratio, setRatio] = useState("16:9"); const [seconds, setSeconds] = useState("6"); const [quality, setQuality] = useState("auto"); const [videoQuality, setVideoQuality] = useState(config.vquality || "720"); const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1)))); const [busy, setBusy] = useState(false); const [selectedShotIndex, setSelectedShotIndex] = useState(-1); const [composingNextShot, setComposingNextShot] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false); const abortRef = useRef(null); const fileInputRef = useRef(null); const composerFocusRef = useRef(null); const threadScrollRef = useRef(null); const followLatestMessageRef = useRef(true); const taskSyncWarningRef = useRef(false); const taskSyncInFlightRef = useRef(false); const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]); const historyConversations = useMemo( () => conversations.filter((conversation) => conversation.id === activeId || conversation.messages.length > 0).sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)), [activeId, conversations], ); const preferredModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel; const hasPrompt = Boolean(prompt.trim()); const modelRequirements = useMemo(() => ({ capability: mode, input: { textCount: hasPrompt ? 1 : 0, imageCount: attachments.filter(isImageAttachment).length, videoCount: attachments.filter(isVideoAttachment).length, audioCount: 0, characterCount: 0, }, videoSeconds: seconds, }), [attachments, hasPrompt, mode, seconds]); const selectedModel = resolveCompatibleModel(config, preferredModel, modelRequirements) || preferredModel; const imageProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).image!, [config, selectedModel]); const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]); const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]); const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]); const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1; useEffect(() => { if (mode !== "image") return; const normalized = normalizeImageValue(imageProfile, { size: ratio, quality, count }); setRatio(normalized.size); setQuality(normalized.quality); setCount(normalized.count); if (attachments.length > imageProfile.references.maxImages) setAttachments((current) => current.slice(0, imageProfile.references.maxImages)); }, [mode, selectedModel, imageProfile]); useEffect(() => { if (mode !== "video") return; const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` }); setSeconds(normalized.seconds); setRatio(normalized.ratio); setVideoQuality(normalized.resolution.replace(/p$/i, "")); }, [mode, selectedModel, videoProfile]); useEffect(() => { let cancelled = false; void localforage.getItem(STORAGE_KEY).then((stored) => { if (cancelled) return; const next = stored?.length ? stored : [newConversation()]; setConversations(next); setActiveId(next[0].id); setHydrated(true); }); return () => { cancelled = true; // 页面卸载只停止当前页面的状态更新,后台任务由任务中心继续执行,返回页面后再恢复状态。 }; }, []); useEffect(() => { if (hydrated) void localforage.setItem(STORAGE_KEY, conversations); }, [conversations, hydrated]); useEffect(() => { if (!hydrated || !pendingMediaKey || !pendingTaskIds.length) return; let cancelled = false; const syncTasks = async () => { if (taskSyncInFlightRef.current) return; taskSyncInFlightRef.current = true; try { const summaries = await listGenerationTasks(100); const tasks = await enrichCreationTaskSummaries(summaries); const pendingTaskIdSet = new Set(pendingTaskIds); const persistedTasks = await persistCreationTaskResults(tasks.filter((task) => pendingTaskIdSet.has(task.id))); if (cancelled) return; taskSyncWarningRef.current = false; setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks)); } catch (error) { if (cancelled) return; console.warn("创作任务状态同步失败", error); if (!taskSyncWarningRef.current) { taskSyncWarningRef.current = true; toast.warning("任务状态暂时无法同步,请稍后刷新"); } } finally { taskSyncInFlightRef.current = false; } }; void syncTasks(); const timer = window.setInterval(() => void syncTasks(), 3000); return () => { cancelled = true; window.clearInterval(timer); }; }, [hydrated, pendingMediaKey, pendingTaskIds, 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) => { setConversations((current) => current.map((item) => item.id === activeId ? updater(item) : item)); }, [activeId]); const updateAssistant = useCallback((id: string, updater: (item: CreationMessage) => CreationMessage) => { updateActive((conversation) => ({ ...conversation, updatedAt: new Date().toISOString(), messages: conversation.messages.map((item) => item.id === id ? updater(item) : item), })); }, [updateActive]); 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 maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : mode === "image" ? imageProfile.references.maxImages : 6; 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), }; } const uploaded = await uploadImage(file); return { asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }), attachment: creationAttachmentFromImage(file, uploaded), }; }; const addAttachments = (files: FileList | File[]) => { if ((mode === "image" || mode === "video") && maxReferences === 0) { toast.warning(mode === "image" ? "当前图片模型不支持参考图" : "当前模型不支持图生视频"); return; } const next = Array.from(files) .filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))) .slice(0, Math.max(0, maxReferences - attachments.length)); if (!next.length) return; void Promise.allSettled(next.map(async (file) => { const { asset, attachment } = await uploadCreationAsset(file); addAsset(asset); return attachment; })).then((settled) => { const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (items.length) setAttachments((current) => [...current, ...items].slice(0, maxReferences)); if (failed.length) toast.error(`${failed.length} 个参考素材上传失败,请重试`); }); }; const uploadLibraryAssets = async (files: FileList | File[]) => { const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/"))); if (!next.length) return []; const settled = await Promise.allSettled(next.map(async (file) => { const { asset } = await uploadCreationAsset(file); return addAsset(asset); })); const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []); const failed = settled.filter((entry) => entry.status === "rejected"); if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`); if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`); return assetIds; }; const handleFileChange = (event: ChangeEvent) => { if (event.target.files) addAttachments(event.target.files); event.target.value = ""; }; const handleLibrarySelect = (selected: Asset[]) => { const next = selected.flatMap((asset): CreationAttachment[] => { if (asset.kind === "image") return [creationAttachmentFromAsset(asset)]; if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)]; return []; }); if (!next.length) return; setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences)); setLibraryOpen(false); }; const removeAttachment = (id: string) => { const reference = mentionReferences.find((item) => item.attachmentId === id); setAttachments((current) => current.filter((item) => item.id !== id)); if (reference) setPrompt((current) => removeReferenceTokens(current, [reference])); }; const submit = async () => { const text = prompt.trim(); if (!text || busy || !activeConversation) return; if (!selectedModel) { toast.warning(`请先在设置中配置${modeLabels[mode]}模型`); return; } if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) { toast.error("当前模型不支持所选视频时长,请重新选择"); return; } const settings = { ratio, seconds, quality, videoQuality, count }; const references = selectedCreationReferences(text, mentionReferences); // 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。 const referenceImages = attachments.filter(isImageAttachment); const referenceVideos = attachments.filter(isVideoAttachment); const expandedPrompt = expandCreationPrompt(text, references, attachments); const referenceMetadata = creationReferenceMetadata(references); followLatestMessageRef.current = true; const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings }); const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings }); const boundTaskIds = new Set(); const boundTaskIdsByBatchIndex = new Map(); const bindTask = (task: GenerationTask) => { if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id); if (boundTaskIds.has(task.id)) return; boundTaskIds.add(task.id); updateAssistant(assistantMessage.id, (item) => ({ ...item, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])) })); }; 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(); abortRef.current = controller; const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count }; try { if (mode === "text") { const history = [...(activeConversation.messages || []), userMessage].map((item) => ({ role: item.role, content: item.role === "user" ? buildTextMessageContent(item) : item.content, })); await requestImageQuestion(requestConfig, history, (text) => updateAssistant(assistantMessage.id, (item) => ({ ...item, content: text })), { signal: controller.signal }); } else if (mode === "image") { const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1))); const settled = await runBackendGenerationTaskBatch({ mode: "image", prompt: expandedPrompt, config: { ...requestConfig, count: "1" }, referenceImages, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata }, onTaskUpdate: bindTask, count: taskCount, }); if (controller.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], resultIndex, })); }); const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected"); const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, resultIndex }) => { const uploaded = await persistCreationImageResult(image); addCreationAssetOnce(creationImageAsset({ title: expandedPrompt.slice(0, 24), uploaded, metadata: { source: "create-generation", conversationId: activeConversation.id, messageId: assistantMessage.id, taskId, taskIds: boundTaskIdList, resultIndex, prompt: expandedPrompt } }), { taskId, messageId: assistantMessage.id, resultIndex }); return uploaded.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} 张生成失败`); updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成", resultUrls })); } else { const result = await runBackendGenerationTask({ mode: "video", prompt: expandedPrompt, config: requestConfig, referenceImages, referenceVideos, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata }, onTaskUpdate: bindTask, }); if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频"); const storedVideo = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" }); if (!storedVideo.url) throw new Error("视频结果资源不可用"); const taskId = Array.from(boundTaskIds)[0]; addCreationAssetOnce(creationVideoAsset({ title: expandedPrompt.slice(0, 24), uploaded: storedVideo, metadata: { source: "create-generation", conversationId: activeConversation.id, messageId: assistantMessage.id, taskId, taskIds: Array.from(boundTaskIds), resultIndex: 0, prompt: expandedPrompt } }), { taskId, messageId: assistantMessage.id, resultIndex: 0 }); updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: [storedVideo.url] })); } updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done" })); } catch (error) { if (controller.signal.aborted) { updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "cancelled", content: "已停止" })); return; } const message = generationErrorMessage(error); updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "error", error: message, content: "生成失败" })); } finally { abortRef.current = null; setBusy(false); } }; 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 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 = (item: CreationMessage, index: number) => { const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item; if (!previous?.content || busy) return; 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, }; }); }; 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, attachments, references: mentionReferences, onRemoveAttachment: removeAttachment, onOpenLibrary: () => setLibraryOpen(true), fileInputRef, onFileChange: handleFileChange, onModeChange: selectMode, model: selectedModel, modelRequirements, imageProfile, videoProfile, config, onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value), ratio, setRatio, seconds, setSeconds, quality, setQuality, videoQuality, setVideoQuality, count, setCount, composerFocusRef, placeholderOverride: composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined, onSubmit: submit, onStop: () => abortRef.current?.abort(), }; const visibleShot = shots[visibleShotIndex]; const visibleShotResultIndex = visibleShot?.result ? activeConversation.messages.indexOf(visibleShot.result) : -1; return <>
{isEmpty ? <>
{ selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }} onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }} />
:
{ setSelectedShotIndex(index); setComposingNextShot(false); }} onBeginCompose={beginComposeNextShot} onCancelCompose={cancelComposeNextShot} onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} />
{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} /> item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={uploadLibraryAssets} /> ; } const creationAssetCategoryLabels: Record = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: (files: FileList | File[]) => Promise }) { const [category, setCategory] = useState("all"); const [keyword, setKeyword] = useState(""); const [selected, setSelected] = useState>(new Set()); const [uploading, setUploading] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); const uploadInputRef = useRef(null); const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract => asset.kind === "image" || asset.kind === "video"), [assets]); const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]); const visibleAssets = useMemo(() => { const query = keyword.trim().toLowerCase(); return mediaAssets.filter((asset) => (category === "all" || (asset.category || "other") === category) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query))); }, [category, keyword, mediaAssets]); useEffect(() => { if (!open) return; setCategory("all"); setKeyword(""); setSelected(new Set(selectedIds)); }, [open]); const toggle = (asset: Asset) => { if (mode !== "video" && asset.kind === "video") return; setSelected((current) => { const next = new Set(current); if (next.has(asset.id)) next.delete(asset.id); else next.add(asset.id); return next; }); }; const handleUpload = async (event: ChangeEvent) => { if (!event.target.files || uploading) return; const fileCount = event.target.files.length; if (!fileCount) return; setUploadingCount(fileCount); setUploading(true); try { const assetIds = await onUpload(event.target.files); if (assetIds.length) setSelected((current) => new Set([...current, ...assetIds])); } finally { event.target.value = ""; setUploading(false); setUploadingCount(0); } }; const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image")); const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length; return { if (!uploading) onClose(); }} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
参考内容素材库
setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
已选 {selectedAssets.length} · {count} 个素材
{visibleAssets.length ? visibleAssets.map((asset) => toggle(asset)} />) :
这个分类还没有素材换个分类,或从底部上传一份新素材。
}
; } function CreationLibraryCard({ asset, selected, disabled, onToggle }: { asset: Extract; selected: boolean; disabled: boolean; onToggle: () => void }) { const isVideo = asset.kind === "video"; return ; } function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (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 CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; return {reference.previewUrl && (reference.kind === "image" || reference.kind === "video") ? : }{reference.label}; })}
; } function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { return {url ? type === "video" ? ; } type ComposerProps = { variant: "empty" | "thread"; mode: CreationMode; prompt: string; setPrompt: (value: string) => void; busy: boolean; attachments: CreationAttachment[]; references: CreationReference[]; onRemoveAttachment: (id: string) => void; onOpenLibrary: () => void; fileInputRef: RefObject; onFileChange: (event: ChangeEvent) => void; onModeChange: (mode: CreationMode) => void; model: string; modelRequirements: ModelRequirements; videoProfile: VideoCapabilityConfig; imageProfile: ImageCapabilityConfig; config: ReturnType; onModelChange: (value: string) => void; ratio: string; setRatio: (value: string) => void; seconds: string; setSeconds: (value: string) => void; quality: string; setQuality: (value: string) => void; videoQuality: string; setVideoQuality: (value: string) => void; count: string; setCount: (value: string) => void; composerFocusRef: RefObject; placeholderOverride?: string; onSubmit: () => void; onStop: () => void; }; function CreationComposer(props: ComposerProps) { const [previewUrl, setPreviewUrl] = useState(""); const [previewType, setPreviewType] = useState<"image" | "video">("image"); const canSubmit = Boolean(props.prompt.trim()) && !props.busy; const placeholder = props.mode === "text" ? "描述你的故事、角色或想继续讨论的创意" : props.mode === "image" ? "描述画面、人物、场景、构图与风格" : "描述镜头内容、运动、光线与节奏"; const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作"; const imageReferencesSupported = props.imageProfile.references.maxImages > 0; const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.videoProfile.operations.includes("image_to_video"); const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1; return
{props.attachments.length ?
{props.attachments.map((item) => { const isVideo = isVideoAttachment(item); const url = isVideo ? item.url : item.previewUrl; return
; })}
: null}
props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)} /> {props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? : null} {props.mode === "video" ? : null}
{props.busy ? : }
setPreviewUrl("")} />
; } function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) { const [open, setOpen] = useState(false); const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [ { mode: "video", icon: , label: "视频生成" }, { mode: "image", icon: , label: "图片生成" }, { mode: "text", icon: , label: "文本创作" }, ]; const current = items.find((item) => item.mode === mode) || items[0]; return {items.map((item) => )}}> ; } function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; const imageSummary = [ ...(props.imageProfile.size.parameter !== "none" ? [props.ratio] : []), ...(props.imageProfile.quality.supported ? [qualityLabel] : []), ...(props.imageProfile.maxOutputs > 1 ? [props.count] : []), ].join(" · "); const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary; const panel =
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => )}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ?
{resolutions.map((option) => )}
: <> {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} }
; return ; } function SettingSection({ title, value, children }: { title: string; value?: string; children: ReactNode }) { return

{title}

{value ? {value} : null}
{children}
; } function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) { const [open, setOpen] = useState(false); const value = Number(normalizeVideoValue(profile, { seconds }).seconds); const presets = profile.duration.selection === "enum" ? videoDurationOptions(profile) : []; const fallbackPreset = presets.length ? presets : [profile.duration.default]; const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...fallbackPreset); const max = profile.duration.selection === "range" ? Math.max(min, profile.duration.max || min) : Math.max(...fallbackPreset); const step = Math.max(1, profile.duration.step || 1); const durationControl = profile.duration.selection === "range" ? <> onChange(event.target.value)} />
{min}s{max}s
:
{presets.map((item) => )}
; return
时长{value} 秒
{durationControl}}>
; } const creationEmptyBannerFrames = [ { src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" }, { src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" }, { src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" }, ]; function CreationEmptyBanner() { return ; } const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [ { mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" }, { mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true }, { mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" }, { mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" }, ]; function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) { return
{creationEmptySuggestions.map((item) => { const Icon = item.icon; return ; })}
; } type CreationThinking = { title: string; hint: string; steps: string[] }; function thinkingFor(mode: CreationMode): CreationThinking { if (mode === "image") return { title: "正在为你画这一镜", hint: "影策正在理解你的构图意图,并把画面交给模型出图。", steps: ["理解构图", "定调画风", "生成画面"] }; if (mode === "text") return { title: "正在为你写这段", hint: "影策正在梳理你的创作脉络,组织语言与结构。", steps: ["梳理脉络", "组织语言", "输出段落"] }; return { title: "正在为你拍这一镜", hint: "影策正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。", steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] }; } function directorNoteFor(mode: CreationMode, settings: CreationSettings): string { if (mode === "video") return `已按 ${settings.seconds}s · ${videoResolutionLabel(settings.videoQuality)} · ${settings.ratio} 渲染这一镜,等待你的下一句指令。`; if (mode === "image") return `已按 ${settings.ratio} 出图 ${settings.count} 张,等待你的下一句指令。`; return ""; } function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) { const [railOpen, setRailOpen] = useState(false); const nextShotNumber = shots.length + 1; const closeRail = () => setRailOpen(false); const statusOf = (shot: CreationShot) => shot.result?.status || "queued"; const shotTitle = (shot: CreationShot) => shot.user ? displayCreationPrompt(shot.user.content, shot.user.references || []).trim() || "未命名镜头" : "镜头"; return
{railOpen ?
镜头时间线{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}
    {shots.map((shot, index) => { const status = statusOf(shot); const title = shotTitle(shot); const thumbUrl = shot.result?.resultUrls?.[0]; const thumbIsVideo = shot.result?.mode === "video"; return
  • ; })} {composing ?
  • : null}
: null}
; } function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) { const user = shot.user; const result = shot.result; const status = result?.status || "queued"; const mode = result?.mode || user?.mode || "video"; const briefVisible = Boolean(user?.content.trim() || user?.references?.length || user?.attachments?.length); return
SC.{String(shotNumber).padStart(2, "0")}镜头 {shotNumber} {mode === "video" ? : mode === "image" ? : }{modeLabels[mode]} {modelName ? {modelName} : null} {status === "pending" ? 生成中 : status === "error" ? 生成失败 : status === "done" ? 已完成 : 待生成}
{status === "error" ? : null} {status === "done" && result?.resultUrls?.length ? : null} {status === "done" && result?.resultUrls?.length ? 添加到画布 : null} {result?.resultUrls?.map((url, index) => {result.resultUrls!.length > 1 ? `下载 ${index + 1}` : <>下载})}
{briefVisible && user ?
{shotScriptLabels[mode]}{user.createdAt ? : null}

{displayCreationPrompt(user.content, user.references || [])}

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

导演手记{note}

: null}
{mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}添加到画布{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} /> ; } function videoResolutionLabel(value: string | number) { return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`; } function formatMessageTime(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : ""; } function conversationPreviewMessage(conversation: CreationConversation) { let fallback: CreationMessage | undefined; for (let index = conversation.messages.length - 1; index >= 0; index -= 1) { const message = conversation.messages[index]; if (!message.content.trim()) continue; fallback ||= message; if (message.role === "user") return message; } return fallback; } function buildTextMessageContent(item: CreationMessage) { const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []); const images = (item.attachments || []).filter(isImageAttachment); if (!images.length) return content; return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))]; } function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } { return attachment.type.startsWith("video/"); } function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } { return !isVideoAttachment(attachment); } function removeReferenceTokens(value: string, references: CreationReference[]) { return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); } function pendingCreationMediaKey(conversations: CreationConversation[]) { return conversations.flatMap((conversation) => conversation.messages.flatMap((message) => message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])).join("|"); } function pendingCreationTaskIds(conversations: CreationConversation[]) { const taskIds = conversations.flatMap((conversation) => conversation.messages.flatMap((message) => { if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return []; return message.taskIds || []; })); return Array.from(new Set(taskIds)); } async function enrichCreationTaskSummaries(tasks: GenerationTask[]) { return Promise.all(tasks.map(async (task) => { if (!task.clientContext || (task.status !== "failed" && (task.status !== "succeeded" || task.previewUrl))) return task; const detail = await queryGenerationTask(task.id).catch(() => null); return detail ? { ...task, ...detail, clientContext: task.clientContext } : task; })); } type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string }; async function persistCreationTaskResults(tasks: GenerationTask[]): Promise { const addAsset = useAssetStore.getState().addAsset; return Promise.all(tasks.map(async (task): Promise => { if (task.status !== "succeeded" || !task.clientContext) return task; try { const result = task.resultJson ? parseBackendGenerationResult(task) : null; const images = result?.images?.length ? result.images : task.previewUrl && task.previewKind !== "video" ? [{ dataUrl: task.previewUrl }] : []; if (images.length) { const storedImages = await Promise.all(images.map(async (image, resultIndex) => { const uploaded = await persistCreationImageResult(image); addCreationAssetOnce(creationImageAsset({ title: task.prompt.slice(0, 24), uploaded, metadata: { source: "create-generation", taskId: task.id, conversationId: task.clientContext?.conversationId, messageId: task.clientContext?.messageId, batchIndex: task.clientContext?.batchIndex, resultIndex, prompt: task.prompt } }), { taskId: task.id, messageId: task.clientContext?.messageId, resultIndex }); return uploaded.url; })); return { ...task, creationResultUrls: storedImages }; } const videoUrl = result?.video?.dataUrl || (task.previewKind === "video" ? task.previewUrl : ""); if (videoUrl) { const storedVideo = await storeGeneratedVideo({ url: videoUrl, mimeType: result?.video?.mimeType || "video/mp4" }); if (!storedVideo.url) throw new Error("视频结果资源不可用"); addCreationAssetOnce(creationVideoAsset({ title: task.prompt.slice(0, 24), uploaded: storedVideo, metadata: { source: "create-generation", taskId: task.id, conversationId: task.clientContext?.conversationId, messageId: task.clientContext?.messageId, batchIndex: task.clientContext?.batchIndex, resultIndex: 0, prompt: task.prompt } }), { taskId: task.id, messageId: task.clientContext?.messageId, resultIndex: 0 }); return { ...task, creationResultUrls: [storedVideo.url] }; } return task; } catch (error) { return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" }; } })); } function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: PersistedCreationTask[]) { let changed = false; const next = conversations.map((conversation) => { let conversationChanged = false; let completedAt = conversation.updatedAt; const messages = conversation.messages.map((message) => { if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message; const taskIds = new Set(message.taskIds || []); const matches = tasks .filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id)) .sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0)); const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0)); if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message; const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls))); const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length; const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)])); completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt); conversationChanged = true; changed = true; if (resultUrls.length) { const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成"; return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds }; } if (matches.every((task) => task.status === "cancelled")) return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds }; const failed = matches.find((task) => task.status === "failed" || task.creationError); return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds }; }); return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation; }); return changed ? next : conversations; } function creationTaskResultUrls(task: PersistedCreationTask) { if (task.creationResultUrls?.length) return task.creationResultUrls; return []; } function conversationTimestamp(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; } function formatConversationTime(value: string) { const timestamp = conversationTimestamp(value); if (!timestamp) return "时间未知"; return conversationTimeFormatter.format(timestamp); } function ratioPreviewStyle(value: string) { const [width, height] = value.replace("x", ":").split(":").map(Number); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 14, height: 14 }; const scale = Math.min(28 / width, 20 / height); return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) }; }