From 247af966b700a528fdd8bde7afc05903073783c7 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 2 Aug 2026 17:08:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E5=88=9B=E4=BD=9C=E5=8C=BA=20-?= =?UTF-8?q?=20=E6=8E=A5=E9=80=9A=E5=AA=92=E4=BD=93=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E5=B9=B6=E6=81=A2=E5=A4=8D=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E8=B7=9F=E9=9A=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/canvas/canvas-generation-task-sync.ts | 17 +- .../lib/canvas/canvas-project-generation.ts | 102 ++--------- web/src/pages/create/index.tsx | 63 ++++++- web/src/services/api/generation-task.ts | 168 ++++++++++++++++++ web/src/services/api/task-center.ts | 50 +++--- web/src/styles/globals.css | 22 +++ 6 files changed, 292 insertions(+), 130 deletions(-) create mode 100644 web/src/services/api/generation-task.ts diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index a2f5b202f..52508e246 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -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 { diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index a2f1ef741..badaccbad 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -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; 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); diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 2876a8f46..bb8757260 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -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(null); const fileInputRef = useRef(null); + const threadScrollRef = useRef(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
; + 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 <> -
+
{!isEmpty ? : null} diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts new file mode 100644 index 000000000..9de4455a6 --- /dev/null +++ b/web/src/services/api/generation-task.ts @@ -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; + onTaskUpdate?: (task: GenerationTask) => void; +}; + +type PreparedGenerationReferences = { + referenceImages: Awaited>[]; + referenceVideos: Awaited>[]; + referenceAudios: Awaited>[]; + mask?: Awaited>; +}; + +// 生成、计费、取消和任务记录必须共用后端任务生命周期,页面层不能再直连供应商。 +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): Promise { + 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; +} diff --git a/web/src/services/api/task-center.ts b/web/src/services/api/task-center.ts index 05139237c..3a7db7955 100644 --- a/web/src/services/api/task-center.ts +++ b/web/src/services/api/task-center.ts @@ -177,6 +177,8 @@ export function uploadAgentFile(sessionId: string, file: File) { export function createGenerationTask(input: CreateTaskInput) { return request(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}` : "任务执行超时,请稍后重试"); } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index c877c9036..355de5c1f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -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;