fix(canvas): 创作区 - 接通媒体任务计费并恢复消息滚动跟随

This commit is contained in:
ddCat
2026-08-02 17:08:44 +08:00
parent 1bbbb4aaab
commit e34986c71f
6 changed files with 292 additions and 130 deletions

View File

@@ -3,28 +3,13 @@ import { fitNodeSize, nodeSizeFromRatio, VIDEO_NODE_MAX_SIZE } from "@/lib/canva
import { compositeEmotionImage } from "@/lib/canvas/canvas-emotion";
import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video";
import { parseBackendGenerationResult } from "@/services/api/generation-task";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveMediaUrl, type UploadedFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
type BackendGenerationResult = {
mode?: CanvasGenerationMode;
images?: Array<{ dataUrl: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string }>;
video?: { dataUrl: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string };
audio?: { dataUrl: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; format?: string };
text?: string;
};
export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult {
if (!task.resultJson) throw new Error("后端任务没有返回结果");
const result = JSON.parse(task.resultJson) as BackendGenerationResult;
if (!result || typeof result !== "object") throw new Error("后端任务结果格式错误");
return result;
}
export function generationTaskInput(task: GenerationTask) {
if (!task.inputJson) return null;
try {

View File

@@ -1,12 +1,13 @@
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { configuredModelMatchesCapability, defaultConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { getImageBlob, resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getMediaBlob, resolveMediaUrl } from "@/services/file-storage";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, runBackendGenerationTask } from "@/services/api/generation-task";
import { configuredModelMatchesCapability, defaultConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { imageMetadata, parseBackendGenerationResult } from "@/lib/canvas/canvas-generation-task-sync";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
@@ -40,89 +41,22 @@ export async function runBackendCanvasGenerationTask({
metadata?: Record<string, unknown>;
onTaskCreated?: (task: GenerationTask) => void;
}) {
const taskReferenceImages = await Promise.all(referenceImages.map(prepareBackendImageReference));
const taskReferenceVideos = await Promise.all(referenceVideos.map((video) => mediaToBackendReference(video)));
const taskReferenceAudios = await Promise.all(referenceAudios.map((audio) => mediaToBackendReference(audio)));
const taskMask = mask ? await prepareBackendImageReference(mask) : undefined;
const task = await createGenerationTask({
return runBackendGenerationTask({
projectId,
type: `canvas_${mode}`,
operation: mode === "video" ? String(metadata?.videoEditOperation || "image_to_video") : mode,
mode,
prompt,
model: config.model,
input: {
mode,
prompt,
config: backendProviderConfig(config),
referenceImages: taskReferenceImages,
referenceVideos: taskReferenceVideos,
referenceAudios: taskReferenceAudios,
mask: taskMask,
metadata: { nodeId, ...metadata },
},
config,
referenceImages,
referenceVideos,
referenceAudios,
mask,
signal,
metadata: { nodeId, ...(mode === "video" && !metadata?.videoEditOperation ? { videoEditOperation: "image_to_video" } : {}), ...metadata },
onTaskUpdate: onTaskCreated,
});
onTaskCreated?.(task);
const completed = await waitForGenerationTask(task.id, { signal, initialTask: task, onTaskUpdate: onTaskCreated });
return parseBackendGenerationResult(completed);
}
async function mediaToBackendReference(media: ReferenceVideo | ReferenceAudio) {
if (resourceIdFromStorageKey(media.storageKey)) return { ...media, dataUrl: "" };
const url = media.url || "";
if (/^https?:\/\//i.test(url)) return media;
let blob: Blob | null = null;
if (media.storageKey) blob = await getMediaBlob(media.storageKey);
if (!blob && (url.startsWith("blob:") || url.startsWith("data:"))) blob = await (await fetch(url)).blob();
if (!blob) throw new Error("参考媒体尚未保存,请重新上传后再生成");
try {
const kind: "video" | "audio" | "file" = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, { fileName: media.name, width: "width" in media ? media.width : undefined, height: "height" in media ? media.height : undefined, durationMs: media.durationMs });
return { ...media, url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), dataUrl: "", type: resource.mimeType || media.type || blob.type };
} catch (error) {
throw new Error(error instanceof Error ? `参考媒体上传失败:${error.message}` : "参考媒体上传失败");
}
}
async function prepareBackendImageReference(image: ReferenceImage) {
if (resourceIdFromStorageKey(image.storageKey)) return { ...image, dataUrl: "" };
if (/^https?:\/\//i.test(image.dataUrl)) return { ...image, url: image.url || image.dataUrl, dataUrl: "" };
const blob = image.storageKey ? await getImageBlob(image.storageKey) : image.dataUrl ? await (await fetch(image.dataUrl)).blob() : null;
if (blob) {
try {
const resource = await uploadResourceFile(blob, "image", { fileName: image.name });
return { ...image, dataUrl: "", url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), type: resource.mimeType || image.type || blob.type };
} catch (error) {
throw new Error(error instanceof Error ? `参考图片上传失败:${error.message}` : "参考图片上传失败");
}
}
throw new Error("参考图片尚未保存,请重新上传后再生成");
}
export function backendProviderConfig(config: AiConfig) {
const requestConfig = resolveModelRequestConfig(config, config.model);
return {
channelId: requestConfig.channelId,
apiFormat: requestConfig.apiFormat,
interfaceType: requestConfig.interfaceType,
baseUrl: requestConfig.baseUrl,
apiKey: requestConfig.apiKey,
secretKey: requestConfig.secretKey,
model: requestConfig.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
}
export { backendProviderConfig };
export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata {
const progress = normalizeTaskProgress(task.progress, task.status);

View File

@@ -7,8 +7,8 @@ import { Link } from "react-router";
import { ModelPicker } from "@/components/model-picker";
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestVideoGeneration } from "@/services/api/video";
import { runBackendGenerationTask, runBackendGenerationTaskBatch } from "@/services/api/generation-task";
import { requestImageQuestion } from "@/services/api/image";
import { modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
@@ -78,6 +78,8 @@ export default function CreatePage() {
const [historyOpen, setHistoryOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const threadScrollRef = useRef<HTMLElement>(null);
const followLatestMessageRef = useRef(true);
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
const historyConversations = useMemo(
@@ -106,6 +108,15 @@ export default function CreatePage() {
if (hydrated) void localforage.setItem(STORAGE_KEY, conversations);
}, [conversations, hydrated]);
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]);
@@ -149,6 +160,7 @@ export default function CreatePage() {
return;
}
const settings = { ratio, seconds, quality, videoQuality, count };
followLatestMessageRef.current = true;
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, settings });
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
updateActive((conversation) => ({
@@ -173,12 +185,37 @@ export default function CreatePage() {
}));
await requestImageQuestion(requestConfig, history, (delta) => updateAssistant(assistantMessage.id, (item) => ({ ...item, content: item.content + delta })), { signal: controller.signal });
} else if (mode === "image") {
const result = attachments.length ? await requestEdit(requestConfig, text, attachments, undefined, { signal: controller.signal }) : await requestGeneration(requestConfig, text, { signal: controller.signal });
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "图片已生成", resultUrls: result.map((entry) => entry.dataUrl) }));
const taskCount = Math.max(1, Math.min(15, Math.floor(Number(count) || 1)));
const settled = await runBackendGenerationTaskBatch({
mode: "image",
prompt: text,
config: { ...requestConfig, count: "1" },
referenceImages: attachments,
signal: controller.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id },
count: taskCount,
});
if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
const resultUrls = settled.flatMap((entry) => entry.status === "fulfilled" ? (entry.value.images || []).map((image) => image.dataUrl).filter(Boolean) : []);
const failed = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
if (!resultUrls.length) {
const reason = failed[0]?.reason;
throw reason instanceof Error ? reason : new Error("后端任务没有返回图片");
}
if (failed.length) toast.warning(`${resultUrls.length} 张图片已生成,${failed.length} 张生成失败`);
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: failed.length ? `${resultUrls.length} 张图片已生成,${failed.length} 张失败` : "图片已生成", resultUrls }));
} else {
const result = await requestVideoGeneration(requestConfig, text, attachments, [], [], { signal: controller.signal });
const resultUrl = result.url || (result.blob ? URL.createObjectURL(result.blob) : "");
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: resultUrl ? [resultUrl] : [] }));
const result = await runBackendGenerationTask({
mode: "video",
prompt: text,
config: requestConfig,
referenceImages: attachments,
signal: controller.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video" },
});
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const videoUrl = result.video.dataUrl;
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: [videoUrl] }));
}
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done" }));
} catch (error) {
@@ -196,6 +233,7 @@ export default function CreatePage() {
const startNewConversation = () => {
const next = newConversation();
followLatestMessageRef.current = true;
setConversations((current) => [next, ...current]);
setActiveId(next.id);
setPrompt("");
@@ -204,6 +242,7 @@ export default function CreatePage() {
};
const selectConversation = (conversation: CreationConversation) => {
followLatestMessageRef.current = true;
setActiveId(conversation.id);
setPrompt("");
setAttachments([]);
@@ -220,6 +259,12 @@ export default function CreatePage() {
if (!hydrated || !activeConversation) return <div className="grid h-full place-items-center"><Spin /></div>;
const handleThreadScroll = () => {
const container = threadScrollRef.current;
if (!container) return;
followLatestMessageRef.current = container.scrollHeight - container.scrollTop - container.clientHeight <= 160;
};
const composerProps = {
mode,
prompt,
@@ -244,11 +289,11 @@ export default function CreatePage() {
count,
setCount,
onSubmit: submit,
onStop: () => { abortRef.current?.abort(); setBusy(false); },
onStop: () => abortRef.current?.abort(),
};
return <>
<main className="creation-home relative flex h-full min-h-0 flex-col overflow-y-auto">
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-home creation-scrollbar relative flex h-full min-h-0 flex-col overflow-y-scroll overscroll-contain">
<div className="creation-top-actions">
{!isEmpty ? <Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-top-action" onClick={startNewConversation}><Plus /></button></Tooltip> : null}
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>

View File

@@ -0,0 +1,168 @@
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
export type BackendGenerationMode = "text" | "image" | "video" | "audio";
export type BackendGenerationResult = {
mode?: BackendGenerationMode;
images?: Array<{ dataUrl: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string }>;
video?: { dataUrl: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string };
audio?: { dataUrl: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; format?: string };
text?: string;
};
type BackendGenerationTaskOptions = {
projectId?: string;
mode: BackendGenerationMode;
prompt: string;
config: AiConfig;
referenceImages?: ReferenceImage[];
referenceVideos?: ReferenceVideo[];
referenceAudios?: ReferenceAudio[];
mask?: ReferenceImage;
signal?: AbortSignal;
metadata?: Record<string, unknown>;
onTaskUpdate?: (task: GenerationTask) => void;
};
type PreparedGenerationReferences = {
referenceImages: Awaited<ReturnType<typeof prepareBackendImageReference>>[];
referenceVideos: Awaited<ReturnType<typeof prepareBackendMediaReference>>[];
referenceAudios: Awaited<ReturnType<typeof prepareBackendMediaReference>>[];
mask?: Awaited<ReturnType<typeof prepareBackendImageReference>>;
};
// 生成、计费、取消和任务记录必须共用后端任务生命周期,页面层不能再直连供应商。
export async function runBackendGenerationTask({
projectId,
mode,
prompt,
config,
referenceImages = [],
referenceVideos = [],
referenceAudios = [],
mask,
signal,
metadata,
onTaskUpdate,
}: BackendGenerationTaskOptions) {
throwIfAborted(signal);
const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask });
throwIfAborted(signal);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, signal, metadata, onTaskUpdate }, prepared);
}
export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }) {
const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1));
throwIfAborted(options.signal);
const prepared = await prepareGenerationReferences(options);
throwIfAborted(options.signal);
return Promise.allSettled(Array.from({ length: count }, (_, batchIndex) => createAndWaitGenerationTask({
...options,
metadata: { ...options.metadata, batchIndex, batchCount: count },
}, prepared)));
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
}
async function prepareGenerationReferences({ referenceImages = [], referenceVideos = [], referenceAudios = [], mask }: Pick<BackendGenerationTaskOptions, "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
const preparedImages = await Promise.all(referenceImages.map(prepareBackendImageReference));
const preparedVideos = await Promise.all(referenceVideos.map(prepareBackendMediaReference));
const preparedAudios = await Promise.all(referenceAudios.map(prepareBackendMediaReference));
const preparedMask = mask ? await prepareBackendImageReference(mask) : undefined;
return { referenceImages: preparedImages, referenceVideos: preparedVideos, referenceAudios: preparedAudios, mask: preparedMask };
}
async function createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages = [], signal, metadata, onTaskUpdate }: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences) {
const videoOperation = String(metadata?.videoEditOperation || (referenceImages.length ? "image_to_video" : "text_to_video"));
const task = await createGenerationTask({
...(projectId ? { projectId } : {}),
type: `canvas_${mode}`,
operation: mode === "video" ? videoOperation : mode,
prompt,
model: config.model,
input: {
mode,
prompt,
config: backendProviderConfig(config),
referenceImages: prepared.referenceImages,
referenceVideos: prepared.referenceVideos,
referenceAudios: prepared.referenceAudios,
mask: prepared.mask,
metadata,
},
});
onTaskUpdate?.(task);
const completed = await waitForGenerationTask(task.id, { signal, initialTask: task, onTaskUpdate });
return parseBackendGenerationResult(completed);
}
async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) {
if (resourceIdFromStorageKey(media.storageKey)) return { ...media, dataUrl: "" };
const url = media.url || "";
if (/^https?:\/\//i.test(url)) return media;
let blob: Blob | null = null;
if (media.storageKey) blob = await getMediaBlob(media.storageKey);
if (!blob && (url.startsWith("blob:") || url.startsWith("data:"))) blob = await (await fetch(url)).blob();
if (!blob) throw new Error("参考媒体尚未保存,请重新上传后再生成");
try {
const kind: "video" | "audio" | "file" = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, { fileName: media.name, width: "width" in media ? media.width : undefined, height: "height" in media ? media.height : undefined, durationMs: media.durationMs });
return { ...media, url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), dataUrl: "", type: resource.mimeType || media.type || blob.type };
} catch (error) {
throw new Error(error instanceof Error ? `参考媒体上传失败:${error.message}` : "参考媒体上传失败");
}
}
async function prepareBackendImageReference(image: ReferenceImage) {
if (resourceIdFromStorageKey(image.storageKey)) return { ...image, dataUrl: "" };
if (/^https?:\/\//i.test(image.dataUrl)) return { ...image, url: image.url || image.dataUrl, dataUrl: "" };
const blob = image.storageKey ? await getImageBlob(image.storageKey) : image.dataUrl ? await (await fetch(image.dataUrl)).blob() : null;
if (!blob) throw new Error("参考图片尚未保存,请重新上传后再生成");
try {
const resource = await uploadResourceFile(blob, "image", { fileName: image.name });
return { ...image, dataUrl: "", url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), type: resource.mimeType || image.type || blob.type };
} catch (error) {
throw new Error(error instanceof Error ? `参考图片上传失败:${error.message}` : "参考图片上传失败");
}
}
export function backendProviderConfig(config: AiConfig) {
const requestConfig = resolveModelRequestConfig(config, config.model);
return {
channelId: requestConfig.channelId,
apiFormat: requestConfig.apiFormat,
interfaceType: requestConfig.interfaceType,
baseUrl: requestConfig.baseUrl,
apiKey: requestConfig.apiKey,
secretKey: requestConfig.secretKey,
model: requestConfig.model,
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
count: config.count,
videoSeconds: config.videoSeconds,
vquality: config.vquality,
videoGenerateAudio: config.videoGenerateAudio,
videoWatermark: config.videoWatermark,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
systemPrompt: config.systemPrompt,
};
}
export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult {
if (!task.resultJson) throw new Error("后端任务没有返回结果");
const result = JSON.parse(task.resultJson) as BackendGenerationResult;
if (!result || typeof result !== "object") throw new Error("后端任务结果格式错误");
return result;
}

View File

@@ -177,6 +177,8 @@ export function uploadAgentFile(sessionId: string, file: File) {
export function createGenerationTask(input: CreateTaskInput) {
return request<GenerationTask>(api.post("/tasks", input)).then((task) => {
notifyCanvasTaskCreated(task);
// 创建任务时积分已被预占,不能等任务结束后才刷新可用余额。
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
});
}
@@ -210,31 +212,37 @@ export async function waitForGenerationTask(id: string, options?: { signal?: Abo
const intervalMs = options?.intervalMs || 2000;
let lastTask = options?.initialTask;
let lastQueryError: unknown;
while (Date.now() - startedAt < (options?.timeoutMs || taskWaitTimeoutMs(lastTask))) {
try {
while (Date.now() - startedAt < (options?.timeoutMs || taskWaitTimeoutMs(lastTask))) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
let task: GenerationTask;
try {
task = await queryGenerationTask(id);
lastTask = task;
lastQueryError = undefined;
options?.onTaskUpdate?.(task);
} catch (error) {
lastQueryError = error;
await delay(intervalMs, options?.signal);
continue;
}
if (task.status === "succeeded") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
}
if (task.status === "failed" || task.status === "cancelled") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
throw new Error(task.error ? generationErrorMessage(task.error) : `任务${task.status === "cancelled" ? "已取消" : "失败"}`);
}
await delay(intervalMs, options?.signal);
}
} catch (error) {
if (options?.signal?.aborted) {
await cancelGenerationTask(id).catch(() => undefined);
window.dispatchEvent(new CustomEvent("wallet:updated"));
throw new DOMException("Aborted", "AbortError");
}
let task: GenerationTask;
try {
task = await queryGenerationTask(id);
lastTask = task;
lastQueryError = undefined;
options?.onTaskUpdate?.(task);
} catch (error) {
lastQueryError = error;
await delay(intervalMs, options?.signal);
continue;
}
if (task.status === "succeeded") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
return task;
}
if (task.status === "failed" || task.status === "cancelled") {
window.dispatchEvent(new CustomEvent("wallet:updated"));
throw new Error(task.error ? generationErrorMessage(task.error) : `任务${task.status === "cancelled" ? "已取消" : "失败"}`);
}
await delay(intervalMs, options?.signal);
throw error;
}
throw new Error(lastQueryError instanceof Error ? `任务状态同步失败:${lastQueryError.message}` : "任务执行超时,请稍后重试");
}

View File

@@ -4156,6 +4156,28 @@ html[data-theme-switching="true"] :where(.canvas-node-shell, .aceternity-comet-c
color: var(--creation-text);
}
.creation-scrollbar {
scrollbar-color: rgba(255, 255, 255, .22) transparent;
scrollbar-gutter: stable;
scrollbar-width: auto;
}
.creation-scrollbar::-webkit-scrollbar { width: 10px; }
.creation-scrollbar::-webkit-scrollbar-track { background: transparent; }
.creation-scrollbar::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: rgba(255, 255, 255, .22);
background-clip: content-box;
}
.creation-scrollbar::-webkit-scrollbar-thumb:hover { background-color: rgba(255, 255, 255, .34); }
html:not(.dark) .creation-scrollbar {
scrollbar-color: rgba(35, 41, 49, .24) transparent;
}
html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb { background-color: rgba(35, 41, 49, .24); }
html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb:hover { background-color: rgba(35, 41, 49, .38); }
html:not(.dark) .creation-home {
--creation-bg: #f7f7f5;
--creation-surface: #ffffff;