Merge branch 'master' into feat/ui-polish-and-skills
Web Quality / verify (pull_request) Has been cancelled
Web Quality / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -371,12 +371,19 @@ function CanvasPage({
|
||||
const textNodeIdRef = useRef(9);
|
||||
const imageNodeIdRef = useRef(1);
|
||||
const videoNodeIdRef = useRef(1);
|
||||
const objectUrlsRef = useRef(new Set<string>());
|
||||
const trackObjectUrl = (file: Blob) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
objectUrlsRef.current.add(url);
|
||||
return url;
|
||||
};
|
||||
|
||||
const { pushSnapshot, undo, redo } = useCanvasHistory();
|
||||
const {
|
||||
textGenerationState, imageGenerationState, videoGenerationState,
|
||||
generationToast, setGenerationToast,
|
||||
imageGenerationInFlightRef, videoGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef,
|
||||
imageGenerationAbortRef, videoGenerationAbortRef,
|
||||
canvasGenKeepaliveRestoredRef,
|
||||
setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus,
|
||||
restoreKeepaliveTasks, resetGenerationState,
|
||||
@@ -527,6 +534,7 @@ function CanvasPage({
|
||||
const autoSaveStatusTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrls = objectUrlsRef.current;
|
||||
return () => {
|
||||
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
|
||||
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
|
||||
@@ -535,6 +543,8 @@ function CanvasPage({
|
||||
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
|
||||
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
|
||||
}
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -1691,12 +1701,15 @@ function CanvasPage({
|
||||
const quality = resolveImageQuality(model, imageNode.imageSize || "");
|
||||
|
||||
imageGenerationInFlightRef.current.add(nodeId);
|
||||
const abortRef = { current: false };
|
||||
imageGenerationAbortRef.current.set(nodeId, abortRef);
|
||||
setImageGenerationStatus(nodeId, { status: "submitting", message: "正在提交生成", progress: 8 });
|
||||
setGenerationToast("图片正在生成");
|
||||
|
||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||
try {
|
||||
const referenceUrls = await resolveConnectedImageReferenceUrls("image", nodeId, imageNode);
|
||||
if (abortRef.current) return;
|
||||
const taskInput: CreatePreviewTaskInput = {
|
||||
title: imageNode.title || "图片节点生成",
|
||||
type: "image",
|
||||
@@ -1732,7 +1745,8 @@ function CanvasPage({
|
||||
? "图片生成完成"
|
||||
: "图片生成失败";
|
||||
setImageGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||
}));
|
||||
}, abortRef));
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
setImageGenerationStatus(nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
removeCanvasGenKeepalive(task.id);
|
||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -1794,13 +1808,15 @@ function CanvasPage({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortRef.current) return;
|
||||
setImageGenerationStatus(nodeId, {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "图片生成失败",
|
||||
});
|
||||
} finally {
|
||||
imageGenerationInFlightRef.current.delete(nodeId);
|
||||
if (task?.id) removeCanvasGenKeepalive(task.id);
|
||||
imageGenerationAbortRef.current.delete(nodeId);
|
||||
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1843,12 +1859,15 @@ function CanvasPage({
|
||||
const duration = Number(videoNode.duration) || 4;
|
||||
|
||||
videoGenerationInFlightRef.current.add(nodeId);
|
||||
const abortRef = { current: false };
|
||||
videoGenerationAbortRef.current.set(nodeId, abortRef);
|
||||
setVideoGenerationStatus(nodeId, { status: "submitting", message: "正在提交视频生成", progress: 8 });
|
||||
setGenerationToast("视频正在生成");
|
||||
|
||||
let task: Awaited<ReturnType<typeof onCreateTask>> | null = null;
|
||||
try {
|
||||
const referenceUrls = await resolveConnectedImageReferenceUrls("video", nodeId);
|
||||
if (abortRef.current) return;
|
||||
if (videoNode.videoMode === "img2video" && referenceUrls.length === 0) {
|
||||
throw new Error("图生视频需要先连接至少一个可用的图片节点");
|
||||
}
|
||||
@@ -1892,7 +1911,8 @@ function CanvasPage({
|
||||
? "视频生成完成"
|
||||
: "视频生成失败";
|
||||
setVideoGenerationStatus(nodeId, { status: "running", message: statusLabel, progress });
|
||||
}));
|
||||
}, abortRef));
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
setVideoGenerationStatus(nodeId, { status: "success", message: "视频生成完成", progress: 100 });
|
||||
removeCanvasGenKeepalive(taskId);
|
||||
const immediateAssetRef = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -1948,13 +1968,15 @@ function CanvasPage({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortRef.current) return;
|
||||
setVideoGenerationStatus(nodeId, {
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "视频生成失败",
|
||||
});
|
||||
} finally {
|
||||
videoGenerationInFlightRef.current.delete(nodeId);
|
||||
if (task?.id) removeCanvasGenKeepalive(task.id);
|
||||
videoGenerationAbortRef.current.delete(nodeId);
|
||||
if (task?.id && !abortRef.current) removeCanvasGenKeepalive(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1965,7 +1987,7 @@ function CanvasPage({
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
if (pendingImageToImageNodeId) {
|
||||
const sourceNode = imageNodes.find((node) => node.id === pendingImageToImageNodeId);
|
||||
if (sourceNode) {
|
||||
@@ -2047,7 +2069,7 @@ function CanvasPage({
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
for (const file of files) {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
addImageNode(imageUrl, file.name, {
|
||||
x: dropPosition.x + offsetX,
|
||||
y: dropPosition.y + offsetY,
|
||||
@@ -2103,7 +2125,7 @@ function CanvasPage({
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
for (const file of files) {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
const imageUrl = trackObjectUrl(file);
|
||||
addImageNode(imageUrl, file.name, {
|
||||
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
|
||||
y: sourceNode.position.y + offsetY,
|
||||
@@ -5279,7 +5301,7 @@ function CanvasPage({
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAssetCoverUrl(URL.createObjectURL(file));
|
||||
setAssetCoverUrl(trackObjectUrl(file));
|
||||
setCoverSourceOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -252,28 +252,40 @@ export function blobToDataUrl(blob: Blob) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
export async function waitForImageTaskResult(
|
||||
taskId: string,
|
||||
onStatus?: (status: AiTaskStatus) => void,
|
||||
abortRef?: { current: boolean },
|
||||
) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "image",
|
||||
abortRef,
|
||||
onProgress: (e) => {
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (abortRef?.current) return "";
|
||||
if (!resultUrl) throw new Error("生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
export async function waitForVideoTaskResult(
|
||||
taskId: string,
|
||||
onStatus?: (status: AiTaskStatus) => void,
|
||||
abortRef?: { current: boolean },
|
||||
) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
kind: "video",
|
||||
abortRef,
|
||||
onProgress: (e) => {
|
||||
if (onStatus) {
|
||||
onStatus({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (abortRef?.current) return "";
|
||||
if (!resultUrl) throw new Error("视频生成任务已完成,但服务器没有返回结果地址,请稍后重试");
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
CanvasImageGenerationState,
|
||||
CanvasImageNode,
|
||||
@@ -66,6 +66,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
const videoGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationInFlightRef = useRef(new Set<string>());
|
||||
const textGenerationAbortControllersRef = useRef(new Map<string, AbortController>());
|
||||
const imageGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
|
||||
const videoGenerationAbortRef = useRef(new Map<string, { current: boolean }>());
|
||||
const canvasGenKeepaliveRestoredRef = useRef(false);
|
||||
|
||||
const setTextGenerationStatus = (nodeId: string, state: CanvasTextGenerationState) => {
|
||||
@@ -80,6 +82,15 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
setVideoGenerationState((current) => ({ ...current, [nodeId]: state }));
|
||||
};
|
||||
|
||||
const abortAllGenerationPollers = useCallback(() => {
|
||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
imageGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
|
||||
imageGenerationAbortRef.current.clear();
|
||||
videoGenerationAbortRef.current.forEach((ref) => { ref.current = true; });
|
||||
videoGenerationAbortRef.current.clear();
|
||||
}, []);
|
||||
|
||||
// Toast auto-dismiss
|
||||
useEffect(() => {
|
||||
if (!generationToast) return undefined;
|
||||
@@ -103,11 +114,14 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
}
|
||||
if (entry.nodeKind === "image") {
|
||||
imageGenerationInFlightRef.current.add(entry.nodeId);
|
||||
const abortRef = { current: false };
|
||||
imageGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复图片生成", progress: 20 });
|
||||
void waitForImageTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setImageGenerationStatus(entry.nodeId, { status: "running", message: "图片生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
}, abortRef).then(async (outputUrl) => {
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -128,18 +142,23 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortRef.current) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setImageGenerationStatus(entry.nodeId, { status: "error", message: "图片生成失败" });
|
||||
}).finally(() => {
|
||||
imageGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
imageGenerationAbortRef.current.delete(entry.nodeId);
|
||||
});
|
||||
} else if (entry.nodeKind === "video") {
|
||||
videoGenerationInFlightRef.current.add(entry.nodeId);
|
||||
const abortRef = { current: false };
|
||||
videoGenerationAbortRef.current.set(entry.nodeId, abortRef);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "正在恢复视频生成", progress: 20 });
|
||||
void waitForVideoTaskResult(entry.taskId, (status) => {
|
||||
const progress = Math.max(18, Math.min(status.status === "completed" ? 100 : 96, Math.trunc(status.progress || 0)));
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "running", message: "视频生成中", progress });
|
||||
}).then(async (outputUrl) => {
|
||||
}, abortRef).then(async (outputUrl) => {
|
||||
if (abortRef.current || !outputUrl) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "success", message: "生成完成", progress: 100 });
|
||||
const ref = createCanvasAssetRefFromGeneratedResult({
|
||||
@@ -160,18 +179,19 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
));
|
||||
}
|
||||
}).catch(() => {
|
||||
if (abortRef.current) return;
|
||||
removeCanvasGenKeepalive(entry.taskId);
|
||||
setVideoGenerationStatus(entry.nodeId, { status: "error", message: "视频生成失败" });
|
||||
}).finally(() => {
|
||||
videoGenerationInFlightRef.current.delete(entry.nodeId);
|
||||
videoGenerationAbortRef.current.delete(entry.nodeId);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetGenerationState = () => {
|
||||
textGenerationAbortControllersRef.current.forEach((c) => c.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
abortAllGenerationPollers();
|
||||
textGenerationInFlightRef.current.clear();
|
||||
imageGenerationInFlightRef.current.clear();
|
||||
videoGenerationInFlightRef.current.clear();
|
||||
@@ -180,11 +200,18 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
setVideoGenerationState({});
|
||||
};
|
||||
|
||||
// Stop all in-flight front-end polling/setState when the canvas unmounts (route change).
|
||||
// Keepalive records are intentionally preserved so restoreKeepaliveTasks can resume on return.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortAllGenerationPollers();
|
||||
};
|
||||
}, [abortAllGenerationPollers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePageHide = () => {
|
||||
cancelCanvasGenKeepaliveOnUnload();
|
||||
textGenerationAbortControllersRef.current.forEach((controller) => controller.abort());
|
||||
textGenerationAbortControllersRef.current.clear();
|
||||
abortAllGenerationPollers();
|
||||
textGenerationInFlightRef.current.clear();
|
||||
imageGenerationInFlightRef.current.clear();
|
||||
videoGenerationInFlightRef.current.clear();
|
||||
@@ -202,7 +229,7 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
};
|
||||
}, []);
|
||||
}, [abortAllGenerationPollers]);
|
||||
|
||||
return {
|
||||
textGenerationState,
|
||||
@@ -214,6 +241,8 @@ export function useCanvasGeneration(params: UseCanvasGenerationParams) {
|
||||
videoGenerationInFlightRef,
|
||||
textGenerationInFlightRef,
|
||||
textGenerationAbortControllersRef,
|
||||
imageGenerationAbortRef,
|
||||
videoGenerationAbortRef,
|
||||
canvasGenKeepaliveRestoredRef,
|
||||
setTextGenerationStatus,
|
||||
setImageGenerationStatus,
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useGenerationTasks } from "../../hooks/useGenerationTasks";
|
||||
|
||||
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
|
||||
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
|
||||
import { publicPricingClient, type PublicModelPrice } from "../../api/publicPricingClient";
|
||||
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
|
||||
import type { WebProjectSummary } from "../../types";
|
||||
import {
|
||||
@@ -58,6 +59,8 @@ import {
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
formatTextTokenCreditRule,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
@@ -69,7 +72,12 @@ import {
|
||||
import { isViduModel } from "../../utils/viduRouting";
|
||||
import { isPixverseModel } from "../../utils/pixverseRouting";
|
||||
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
|
||||
import { calculateEnterpriseVideoCredits, ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
|
||||
import {
|
||||
calculateEnterpriseVideoCredits,
|
||||
ENTERPRISE_DEFAULT_VIDEO_MODEL,
|
||||
type EnterpriseVideoPricingConfig,
|
||||
} from "../../utils/enterpriseVideoPolicy";
|
||||
import { resolveTextTokenCreditRate } from "../../utils/modelPricing";
|
||||
import {
|
||||
getImageQualityOptionsForContext,
|
||||
getDefaultImageQuality,
|
||||
@@ -404,9 +412,32 @@ function WorkbenchPage({
|
||||
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
|
||||
|
||||
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
|
||||
const [modelPrices, setModelPrices] = useState<PublicModelPrice[]>([]);
|
||||
const [enterpriseVideoPricing, setEnterpriseVideoPricing] = useState<EnterpriseVideoPricingConfig | null>(null);
|
||||
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
|
||||
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
publicPricingClient
|
||||
.getPricing()
|
||||
.then((pricing) => {
|
||||
if (cancelled) return;
|
||||
setModelPrices(pricing.modelPrices);
|
||||
setEnterpriseVideoPricing(pricing.enterpriseVideoPricing);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setModelPrices([]);
|
||||
setEnterpriseVideoPricing(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -525,6 +556,10 @@ function WorkbenchPage({
|
||||
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
|
||||
|
||||
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
|
||||
const selectedChatTokenRate = useMemo(
|
||||
() => resolveTextTokenCreditRate(modelPrices, chatModel) || FALLBACK_TEXT_TOKEN_CREDIT_RATE,
|
||||
[chatModel, modelPrices],
|
||||
);
|
||||
const billingEstimate = useMemo(() => {
|
||||
if (activeMode === "image") {
|
||||
return {
|
||||
@@ -541,7 +576,7 @@ function WorkbenchPage({
|
||||
durationSeconds,
|
||||
muted: false,
|
||||
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
|
||||
});
|
||||
}, enterpriseVideoPricing || undefined);
|
||||
return {
|
||||
label: `预计 ${formatCreditValue(credits)} 积分`,
|
||||
title: `${activeModel},${videoQualityLabel},${durationSeconds} 秒,预计 ${formatCreditValue(credits)} 积分`,
|
||||
@@ -553,16 +588,20 @@ function WorkbenchPage({
|
||||
};
|
||||
}
|
||||
}
|
||||
const textBillingPrefix =
|
||||
selectedChatTokenRate.source === "server" ? "文本计费" : "服务端价格暂不可用,按默认预估";
|
||||
return {
|
||||
label: "按 Token 结算",
|
||||
title: "文本对话按输入、输出 Token 实际用量结算,完成后显示本次积分",
|
||||
title: `${textBillingPrefix}:${activeModel},${formatTextTokenCreditRule(selectedChatTokenRate)}`,
|
||||
};
|
||||
}, [
|
||||
activeMode,
|
||||
activeModel,
|
||||
activeModelValue,
|
||||
imageSettingsSummary,
|
||||
enterpriseVideoPricing,
|
||||
referenceItems,
|
||||
selectedChatTokenRate,
|
||||
videoDuration,
|
||||
videoQuality,
|
||||
videoQualityLabel,
|
||||
|
||||
Reference in New Issue
Block a user