perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
11
web/src/pages/create/creation-runtime.ts
Normal file
11
web/src/pages/create/creation-runtime.ts
Normal 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";
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user