perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突

This commit is contained in:
ddCat
2026-09-05 13:24:10 +08:00
parent 165b4443ab
commit 58f01d1eb6
107 changed files with 3025 additions and 1089 deletions

View File

@@ -0,0 +1,11 @@
export { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce } from "@/lib/canvas/canvas-project-generation";
export { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
export { subscribeGenerationTasks } from "@/services/api/task-center";
export { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
export { uploadMediaFile } from "@/services/file-storage";
export { uploadImage } from "@/services/image-storage";
export { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
export { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
export { recoverCreationTextTask } from "@/services/creation-text-task-recovery";
export { skillRuntime } from "@/services/skill-runtime";

View File

@@ -1,22 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { Reorder } from "motion/react";
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
import { ArrowDown, ArrowUp, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, Waves, X } from "lucide-react";
import { Link } from "react-router";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import type { AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { CanvasPromptOptimizerDrawer } from "@/components/canvas/canvas-prompt-optimizer-drawer";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { ModelPicker } from "@/components/model-picker";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import type { GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { createClientId } from "@/lib/client-id";
import { formatShotOrdinal } from "@/lib/shot-label";
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
@@ -26,18 +25,11 @@ import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSi
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { inferVideoOperation, resolveCompatibleModel, mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
import type { BackendGenerationResult } from "@/services/api/generation-task";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveResourceUrl } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
import { recoverCreationTextTask } from "@/services/creation-text-task-recovery";
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
@@ -48,9 +40,13 @@ import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins
import { createPluginHostContext } from "@/services/plugin-host";
import { usePluginStore } from "@/stores/use-plugin-store";
import { buildCreationMentionReferences, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, replaceCreationAttachmentReference, selectedCreationReferences, type CreationReference } from "./creation-references";
import { skillRuntime } from "@/services/skill-runtime";
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromExternalAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, removeCreationAttachment, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
const AssetLibraryPickerModal = lazy(() => import("@/components/assets/asset-library-picker-modal").then((module) => ({ default: module.AssetLibraryPickerModal })));
const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer })));
const loadCreationRuntime = () => import("./creation-runtime");
type CreationRuntime = Awaited<ReturnType<typeof loadCreationRuntime>>;
type CreationMode = "text" | "image" | "video";
type CreationViewMode = "chat" | "storyboard";
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
@@ -129,10 +125,10 @@ function shotsFromMessages(messages: CreationMessage[]): CreationShot[] {
return shots;
}
function completedCreationGenerationTask(input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask {
function completedCreationGenerationTask(runtime: CreationRuntime, input: { taskId: string; task?: GenerationTask; mode: "image" | "video"; prompt: string; result: BackendGenerationResult; conversationId: string; messageId: string; batchIndex?: number; batchCount?: number }): GenerationTask {
const now = new Date().toISOString();
const task = input.task ?? { id: input.taskId, type: input.mode, status: "succeeded" as const, prompt: input.prompt, attempts: 1, createdAt: now, updatedAt: now };
return projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result);
return runtime.projectGenerationTaskResult({ ...task, status: "succeeded", prompt: input.prompt, clientContext: { conversationId: input.conversationId, messageId: input.messageId, ...(typeof input.batchIndex === "number" ? { batchIndex: input.batchIndex } : {}), ...(typeof input.batchCount === "number" ? { batchCount: input.batchCount } : {}) } }, input.result);
}
export default function CreatePage() {
@@ -165,11 +161,14 @@ export default function CreatePage() {
const attachmentsRef = useRef(attachments);
const [draftReferences, setDraftReferences] = useState<CreationReference[]>([]);
const [addedSkills, setAddedSkills] = useState<Skill[]>([]);
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(true);
const [textThinking, setTextThinking] = useState(false);
const [busy, setBusy] = useState(false);
const [referenceReplacementBusy, setReferenceReplacementBusy] = useState(false);
const [viewMode, setViewMode] = useState<CreationViewMode>("chat");
@@ -323,25 +322,26 @@ export default function CreatePage() {
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(contextual, observationController.signal);
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 consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
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: generationTaskMaterializedUrls });
}, { 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(current, persistedTasks));
if (!cancelled) setConversations((current) => reconcileCreationTaskMessages(runtime, current, persistedTasks));
};
const warnSync = (error: unknown) => {
if (cancelled || observationController.signal.aborted) return;
@@ -352,9 +352,15 @@ export default function CreatePage() {
}
};
let applyChain = Promise.resolve();
const unsubscribe = subscribeGenerationTasks(recoverableTaskIds, (task) => {
applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync);
});
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();
@@ -362,16 +368,13 @@ export default function CreatePage() {
};
}, [hydrated, recoveryTaskKey, toast]);
useEffect(() => {
let cancelled = false;
listAddedSkills().then(({ skills }) => {
if (!cancelled) setAddedSkills(skills);
}).catch(() => {
if (!cancelled) setAddedSkills([]);
});
return () => {
cancelled = true;
};
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(() => {
@@ -454,6 +457,7 @@ export default function CreatePage() {
...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 {
@@ -519,6 +523,15 @@ export default function CreatePage() {
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);
@@ -603,9 +616,10 @@ export default function CreatePage() {
characterCount: 0,
});
const skillReferences = references.flatMap((reference) => (reference.skill ? [reference.skill] : []));
let skillExecution: Awaited<ReturnType<typeof skillRuntime.prepare<"creation">>>;
const runtime = await loadCreationRuntime();
let skillExecution: Awaited<ReturnType<typeof runtime.skillRuntime.prepare>>;
try {
skillExecution = await skillRuntime.prepare({
skillExecution = await runtime.skillRuntime.prepare({
profile: "creation",
prompt: expandCreationPrompt(text, references, attachments),
skills: skillReferences,
@@ -620,7 +634,7 @@ export default function CreatePage() {
const referenceMetadata = skillExecution.metadata;
followLatestMessageRef.current = true;
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings, ...retryContext });
const 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<string>();
@@ -650,7 +664,7 @@ export default function CreatePage() {
setComposingNextShot(false);
setBusy(true);
const controller = new AbortController();
const requestLifecycle = beginGenerationConsumer(controller.signal);
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;
@@ -668,7 +682,7 @@ export default function CreatePage() {
};
try {
if (mode === "text") {
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
const result = await runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.runBackendGenerationTask({
mode: "text",
prompt: expandedPrompt,
config: requestConfig,
@@ -679,14 +693,16 @@ export default function CreatePage() {
signal: requestLifecycle.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
onTaskUpdate: bindTask,
onTextDelta: (value) => updateOriginAssistant((item) => ({ ...item, content: value })),
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 runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTaskBatch({
const settled = await runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.runBackendGenerationTaskBatch({
mode: "image",
prompt: expandedPrompt,
config: { ...requestConfig, count: "1" },
@@ -711,11 +727,11 @@ export default function CreatePage() {
const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value);
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 = generationTaskMaterializedUrls(materialized)[0];
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
if (!url) throw new Error("图片结果资源不可用");
return url;
}));
@@ -729,7 +745,7 @@ export default function CreatePage() {
if (failedCount) toast.warning(`${resultUrls.length} 张图片已生成,${failedCount} 张生成失败`);
updateOriginAssistant((item) => ({ ...item, content: failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成" }));
} else {
const result = await runGenerationOperationOnce(retryContext?.clientOperationId, () => runBackendGenerationTask({
const result = await runtime.runGenerationOperationOnce(retryContext?.clientOperationId, () => runtime.runBackendGenerationTask({
mode: "video",
prompt: expandedPrompt,
config: requestConfig,
@@ -744,15 +760,15 @@ export default function CreatePage() {
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const taskId = Array.from(boundTaskIds)[0];
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask({ taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id });
const materialized = await consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value);
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 (!generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
if (!runtime.generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
}
updateOriginAssistant((item) => ({ ...item, status: "done" }));
} catch (error) {
if (isGenerationTaskCancelled(error, requestLifecycle.signal)) {
if (runtime.isGenerationTaskCancelled(error, requestLifecycle.signal)) {
updateOriginAssistant((item) => ({ ...item, status: "cancelled", content: "已停止" }));
return;
}
@@ -880,8 +896,9 @@ export default function CreatePage() {
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 createGenerationRetryContext(retryOf, attemptGroupId)), ...(item.taskIds && item.taskIds.length > 1 ? { retryContextsByBatchIndex: await createGenerationBatchRetryContexts(item.taskIds, attemptGroupId) } : {}) };
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);
@@ -927,6 +944,7 @@ export default function CreatePage() {
references: mentionReferences,
onRemoveAttachment: removeAttachment,
onClearAttachments: clearAttachments,
onClearComposer: clearComposer,
onReorderAttachments: reorderAttachments,
onReplaceAttachment: replaceReferenceFromTrack,
onReplaceReferenceFiles: replaceReferenceFromFiles,
@@ -948,8 +966,13 @@ export default function CreatePage() {
setVideoQuality: setComposerVideoQuality,
count,
setCount: setComposerCount,
textStreaming,
setTextStreaming,
textThinking,
setTextThinking,
promptOptimizerProvider,
composerFocusRef,
onPromptFocus: loadAddedSkills,
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined,
onSubmit: () => void submit(),
};
@@ -1021,7 +1044,8 @@ export default function CreatePage() {
</div>}
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} />
<AssetLibraryPickerModal
{libraryOpen ? <Suspense fallback={null}><AssetLibraryPickerModal
remoteLibrary
open={libraryOpen}
items={libraryItems}
categoryLabels={{ ...creationAssetCategoryLabels, ...externalAssetSources.categoryLabels }}
@@ -1030,7 +1054,7 @@ export default function CreatePage() {
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}
/>
/></Suspense> : null}
</>;
}
@@ -1104,7 +1128,7 @@ function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant
if (item.role === "user") return <CreationUserMessage item={item} />;
const mode = item.mode || "text";
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : `${brandName} AI`}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `${brandName}正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>;
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : `${brandName} AI`}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `${brandName}正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}{mode !== "text" && item.status === "done" ? <button type="button" className="creation-message-variant-action" onClick={onCreateVariant}><RefreshCw />生成同款</button> : null}</>;
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
return <article className={`creation-assistant-message is-${mode}`}>
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div></> : <GenerationToolCard status={toolStatus} isBulk={(item.resultUrls?.length || Number(item.settings?.count) || 1) > 1} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
@@ -1146,7 +1170,7 @@ function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: Creation
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成同款</button><Link to={canvasPath}>{resultAssetIds.length ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Link to={canvasPath}>{resultAssetIds.length ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}
@@ -1272,6 +1296,7 @@ type ComposerProps = {
references: CreationReference[];
onRemoveAttachment: (id: string) => void;
onClearAttachments: () => void;
onClearComposer: () => void;
onReorderAttachments: (attachments: CreationAttachment[]) => void;
onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void;
onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void;
@@ -1293,8 +1318,13 @@ type ComposerProps = {
setVideoQuality: (value: string) => void;
count: string;
setCount: (value: string) => void;
textStreaming: boolean;
setTextStreaming: (value: boolean) => void;
textThinking: boolean;
setTextThinking: (value: boolean) => void;
promptOptimizerProvider: PromptOptimizerProvider | null;
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
onPromptFocus: () => void;
placeholderOverride?: string;
onSubmit: () => void;
};
@@ -1312,7 +1342,7 @@ function CreationComposer(props: ComposerProps) {
const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null);
const suppressAttachmentClickRef = useRef(false);
const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false });
const previousAttachmentCountRef = useRef(props.attachments.length);
const previousAttachmentCountRef = useRef(0);
const interactionBusy = props.busy || props.referenceReplacementBusy;
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
@@ -1438,7 +1468,7 @@ function CreationComposer(props: ComposerProps) {
const composer = <section className={`creation-chat-composer is-${props.variant}`}>
<div className="creation-chat-writing-surface">
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
{trackState.isExpanded ? <div className="creation-reference-panel-header">
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
@@ -1528,6 +1558,11 @@ function CreationComposer(props: ComposerProps) {
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} showOptionPrices variant="creation" />
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
{props.mode === "text" ? <>
<Tooltip title={props.textStreaming ? "流式输出已开启" : "流式输出已关闭"}><button type="button" className="creation-chat-control" aria-pressed={props.textStreaming} onClick={() => props.setTextStreaming(!props.textStreaming)}><Waves /><span>流式</span></button></Tooltip>
<Tooltip title={props.textThinking ? "思考已开启,会展示模型返回的推理摘要" : "开启模型思考"}><button type="button" className="creation-chat-control" aria-pressed={props.textThinking} onClick={() => props.setTextThinking(!props.textThinking)}><Brain /><span>思考</span></button></Tooltip>
</> : null}
{props.prompt.trim() || props.attachments.length || props.references.some((reference) => reference.active) ? <Tooltip title="清空提示词和参考内容"><button type="button" className="creation-chat-control is-clear" onClick={props.onClearComposer} disabled={interactionBusy} aria-label="清空提示词和参考内容"><Trash2 /><span>清空</span></button></Tooltip> : null}
</div>
<Button
type="text"
@@ -1549,8 +1584,10 @@ function CreationComposer(props: ComposerProps) {
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</section>;
if (!promptOptimizerOpen) return composer;
return (
<CanvasPromptOptimizerDrawer
<Suspense fallback={composer}><CanvasPromptOptimizerDrawer
open={promptOptimizerOpen}
prompt={props.prompt}
generationMode={props.mode === "video" ? "video" : "image"}
@@ -1564,7 +1601,7 @@ function CreationComposer(props: ComposerProps) {
onApply={props.setPrompt}
>
{composer}
</CanvasPromptOptimizerDrawer>
</CanvasPromptOptimizerDrawer></Suspense>
);
}
@@ -1920,13 +1957,13 @@ function attachCreationTaskContexts(tasks: GenerationTask[], conversations: Crea
});
}
async function materializeCreationTaskResults(tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
async function materializeCreationTaskResults(runtime: CreationRuntime, tasks: GenerationTask[], signal?: AbortSignal): Promise<PersistedCreationTask[]> {
return Promise.all(tasks.map(async (task): Promise<PersistedCreationTask> => {
// 文本正文保存在 resultJson,不进入媒体资源化链路。
if (task.status !== "succeeded" || !task.clientContext || task.type === "canvas_text") return task;
try {
const materialized = await runGenerationConsumer(signal, (managedSignal) => materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = generationTaskMaterializedUrls(materialized);
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
} catch (error) {
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
@@ -1934,7 +1971,7 @@ async function materializeCreationTaskResults(tasks: GenerationTask[], signal?:
}));
}
function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
function reconcileCreationTaskMessages(runtime: CreationRuntime, conversations: CreationConversation[], tasks: PersistedCreationTask[]) {
let changed = false;
const next = conversations.map((conversation) => {
let conversationChanged = false;
@@ -1945,7 +1982,7 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
.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));
if (message.role === "assistant" && message.mode === "text") {
const recovery = recoverCreationTextTask(message, matches);
const recovery = runtime.recoverCreationTextTask(message, matches);
if (!recovery) return message;
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
conversationChanged = true;
@@ -1968,8 +2005,8 @@ function reconcileCreationTaskMessages(conversations: CreationConversation[], ta
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) {
const localOnly = matches.find(isLocalDreaminaWaitStopped);
return { ...message, status: "cancelled" as const, content: localOnly ? localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
const localOnly = matches.find(runtime.isLocalDreaminaWaitStopped);
return { ...message, status: "cancelled" as const, content: localOnly ? runtime.localDreaminaCancellationMessage(localOnly) : "已停止", error: undefined, taskIds: nextTaskIds };
}
const failed = matches.find((task) => task.status === "failed" || task.creationError);
return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.creationError || failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds };