feat: add task lifecycle management and improve generation reliability
Centralize timeout policies, stall detection, and error classification for image/video/text generation tasks. Improve ecommerce OSS upload flow and add script evaluation enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3750,12 +3750,12 @@ function CanvasPage({
|
||||
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
|
||||
/>
|
||||
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button type="button" title="缩小" onClick={zoomCanvasOut}>−</button>
|
||||
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
|
||||
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}>−</button>
|
||||
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
|
||||
{Math.round(canvasViewport.zoom * 100)}%
|
||||
</button>
|
||||
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
|
||||
<button type="button" title="适应视图" onClick={fitCanvasView}>⊡</button>
|
||||
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
|
||||
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}>⊡</button>
|
||||
</div>
|
||||
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
|
||||
<div
|
||||
|
||||
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
|
||||
|
||||
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
kind: "image",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
|
||||
|
||||
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
kind: "video",
|
||||
onProgress: (e) => {
|
||||
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
|
||||
},
|
||||
@@ -495,4 +495,4 @@ export function getWorkflowNodeFocusSelection(node: WebCanvasWorkflow["nodes"][n
|
||||
height: clampCanvasPercent(height),
|
||||
ratio: toCanvasStyleString(selection.ratio, "16:9"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ interface CloneImageItem {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
mimeType?: string;
|
||||
ossKey?: string;
|
||||
}
|
||||
|
||||
interface CloneResult {
|
||||
@@ -99,6 +101,18 @@ interface CloneSavedSetting {
|
||||
requirement: string;
|
||||
}
|
||||
|
||||
interface EcommerceImagePromptOptions {
|
||||
gender?: string;
|
||||
age?: string;
|
||||
ethnicity?: string;
|
||||
body?: string;
|
||||
appearance?: string;
|
||||
scenes?: string[];
|
||||
customScene?: string;
|
||||
smartScene?: boolean;
|
||||
detailModules?: string[];
|
||||
}
|
||||
|
||||
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
|
||||
|
||||
interface PlatformRatioGroup {
|
||||
@@ -672,16 +686,85 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
|
||||
});
|
||||
}
|
||||
|
||||
function createObjectImageItems(files: File[], limit: number, prefix: string) {
|
||||
return Array.from(files)
|
||||
.slice(0, limit)
|
||||
.map<CloneImageItem>((file, index) => ({
|
||||
id: `${prefix}-${Date.now()}-${index}`,
|
||||
src: URL.createObjectURL(file),
|
||||
const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
|
||||
const selectedFiles = Array.from(files).slice(0, limit);
|
||||
const stamp = Date.now();
|
||||
|
||||
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
let dimensions: { width?: number; height?: number } = {};
|
||||
try {
|
||||
dimensions = await readImageDimensions(localPreviewUrl);
|
||||
} catch {
|
||||
dimensions = {};
|
||||
} finally {
|
||||
URL.revokeObjectURL(localPreviewUrl);
|
||||
}
|
||||
|
||||
const mimeType = normalizeEcommerceImageMime(file.type);
|
||||
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
|
||||
const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
|
||||
name: file.name,
|
||||
mimeType,
|
||||
scope: "ecommerce-product",
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${prefix}-${stamp}-${index}`,
|
||||
src: url,
|
||||
name: file.name,
|
||||
file,
|
||||
format: getImageFileFormat(file),
|
||||
}));
|
||||
mimeType,
|
||||
ossKey,
|
||||
...dimensions,
|
||||
};
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
|
||||
if (!sourceUrl) return sourceUrl;
|
||||
try {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const { url } = await aiGenerationClient.uploadAsset({
|
||||
dataUrl: sourceUrl,
|
||||
name: `${namePrefix}-${Date.now()}.png`,
|
||||
scope,
|
||||
});
|
||||
return url || sourceUrl;
|
||||
}
|
||||
|
||||
if (sourceUrl.startsWith("blob:")) {
|
||||
const rawBlob = await fetch(sourceUrl).then((res) => res.blob());
|
||||
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
|
||||
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
|
||||
const { url } = await aiGenerationClient.uploadAssetBinary(blob, {
|
||||
name: `${namePrefix}-${Date.now()}.png`,
|
||||
mimeType,
|
||||
scope,
|
||||
});
|
||||
return url;
|
||||
}
|
||||
|
||||
const { url } = await aiGenerationClient.uploadAssetByUrl({
|
||||
sourceUrl,
|
||||
name: `${namePrefix}-${Date.now()}`,
|
||||
scope,
|
||||
});
|
||||
return url || sourceUrl;
|
||||
} catch {
|
||||
return sourceUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function notifyRejectedImages(files: File[]): File[] {
|
||||
@@ -888,21 +971,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addSetImages = (files: File[]) => {
|
||||
const addSetImages = async (files: File[]) => {
|
||||
if (setImages.length >= 3) return;
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
setSetImages((current) => {
|
||||
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
|
||||
});
|
||||
setProductSetStatus("ready");
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
|
||||
setSetImages((current) => {
|
||||
if (current.length >= 3) return current;
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
|
||||
});
|
||||
setProductSetStatus("ready");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "商品图上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) return;
|
||||
addSetImages(Array.from(files));
|
||||
void addSetImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -910,7 +998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.preventDefault();
|
||||
setIsSetUploadDragging(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length) addSetImages(files);
|
||||
if (files.length) void addSetImages(files);
|
||||
};
|
||||
|
||||
const removeSetImage = (imageId: string) => {
|
||||
@@ -921,22 +1009,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addProductImages = (files: File[]) => {
|
||||
const addProductImages = async (files: File[]) => {
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
setProductImages((current) => {
|
||||
if (current.length >= maxCloneProductImages) return current;
|
||||
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
|
||||
});
|
||||
setStatus("ready");
|
||||
setResults([]);
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
|
||||
setProductImages((current) => {
|
||||
if (current.length >= maxCloneProductImages) return current;
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
|
||||
});
|
||||
setStatus("ready");
|
||||
setResults([]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "商品图上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) return;
|
||||
addProductImages(Array.from(files));
|
||||
void addProductImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -944,7 +1036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.preventDefault();
|
||||
setIsProductUploadDragging(false);
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length) addProductImages(files);
|
||||
if (files.length) void addProductImages(files);
|
||||
};
|
||||
|
||||
const removeProductImage = (imageId: string) => {
|
||||
@@ -970,24 +1062,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
});
|
||||
};
|
||||
|
||||
const addCloneReferenceImages = (files: File[]) => {
|
||||
const addCloneReferenceImages = async (files: File[]) => {
|
||||
const imageFiles = notifyRejectedImages(files);
|
||||
if (!imageFiles.length) return;
|
||||
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
|
||||
if (remainingSlots <= 0) return;
|
||||
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
|
||||
if (!nextImages.length) return;
|
||||
setCloneReferenceImages((current) => {
|
||||
if (current.length >= maxCloneReferenceImages) return current;
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
|
||||
});
|
||||
hydrateCloneReferenceImageMeta(nextImages);
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
|
||||
if (!nextImages.length) return;
|
||||
setCloneReferenceImages((current) => {
|
||||
if (current.length >= maxCloneReferenceImages) return current;
|
||||
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
|
||||
});
|
||||
hydrateCloneReferenceImageMeta(nextImages);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "参考图上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files?.length) return;
|
||||
addCloneReferenceImages(Array.from(files));
|
||||
void addCloneReferenceImages(Array.from(files));
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -1302,8 +1398,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
|
||||
setTryOnStatus("ready");
|
||||
void (async () => {
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment");
|
||||
setGarmentImages((current) => [...current, ...nextImages].slice(0, 5));
|
||||
setTryOnStatus("ready");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "服饰图上传失败");
|
||||
}
|
||||
})();
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -1315,8 +1418,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
|
||||
setDetailStatus("ready");
|
||||
void (async () => {
|
||||
try {
|
||||
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
|
||||
setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3));
|
||||
setDetailStatus("ready");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "详情图上传失败");
|
||||
}
|
||||
})();
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
@@ -1358,11 +1468,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
|
||||
};
|
||||
|
||||
const buildDetailModulePrompt = (moduleIds: string[]): string => {
|
||||
if (!moduleIds.length) {
|
||||
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
|
||||
}
|
||||
|
||||
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
|
||||
if (!selectedModules.length) return "";
|
||||
|
||||
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
|
||||
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
|
||||
};
|
||||
|
||||
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
|
||||
const info = setCountLabels[countKey];
|
||||
const parts: string[] = [];
|
||||
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
|
||||
parts.push(info.promptDesc);
|
||||
if (countKey === "white") {
|
||||
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
|
||||
}
|
||||
if (countKey === "scene") {
|
||||
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
|
||||
}
|
||||
if (countKey === "selling") {
|
||||
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
|
||||
}
|
||||
if (totalCount > 1) {
|
||||
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
|
||||
}
|
||||
@@ -1374,13 +1505,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
const buildEcommerceImagePrompt = (
|
||||
outputKey: CloneOutputKey, userText: string,
|
||||
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
|
||||
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
|
||||
tryOnOptions?: EcommerceImagePromptOptions,
|
||||
): string => {
|
||||
const parts: string[] = [];
|
||||
if (outputKey === "detail") {
|
||||
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
|
||||
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
|
||||
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
|
||||
if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules));
|
||||
parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
|
||||
} else if (outputKey === "model") {
|
||||
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
|
||||
@@ -1393,6 +1525,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
|
||||
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
|
||||
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`);
|
||||
if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`);
|
||||
if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context.");
|
||||
}
|
||||
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
|
||||
@@ -1466,8 +1599,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
if (imageAbortRef.current.current) break;
|
||||
|
||||
if (resultUrl) {
|
||||
generatedUrls.push(resultUrl);
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
|
||||
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
|
||||
generatedUrls.push(persistedUrl);
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
||||
} else {
|
||||
generatedUrls.push("");
|
||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||
@@ -1505,7 +1639,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
pRatio: string,
|
||||
pLanguage: string,
|
||||
pMarket: string,
|
||||
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
|
||||
tryOnOptions?: EcommerceImagePromptOptions,
|
||||
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
|
||||
resultFn?: (results: CloneResult[]) => void,
|
||||
): Promise<void> => {
|
||||
@@ -1552,9 +1686,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
}
|
||||
|
||||
if (resultUrl) {
|
||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
|
||||
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`);
|
||||
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
|
||||
statusFn?.("done");
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
|
||||
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
|
||||
} else {
|
||||
statusFn?.("idle");
|
||||
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
|
||||
@@ -1658,10 +1793,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
(urls) => setProductSetResultImages(urls),
|
||||
);
|
||||
} else {
|
||||
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
|
||||
cloneOutput === "model"
|
||||
? {
|
||||
gender: cloneModelGender,
|
||||
age: cloneModelAge,
|
||||
ethnicity: cloneModelEthnicity,
|
||||
body: cloneModelBody,
|
||||
appearance: cloneModelAppearance,
|
||||
scenes: selectedCloneModelScenes,
|
||||
customScene: cloneModelCustomScene,
|
||||
}
|
||||
: cloneOutput === "detail"
|
||||
? { detailModules: selectedCloneDetailModules }
|
||||
: undefined;
|
||||
void generateEcommerceImage(
|
||||
cloneOutput, productImages, requirement,
|
||||
platform, ratio, language, market,
|
||||
undefined,
|
||||
clonePromptOptions,
|
||||
(s: string) => setStatus(s as ProductCloneStatus), setResults,
|
||||
);
|
||||
lastFailedActionRef.current = () => handleGenerate();
|
||||
@@ -1741,7 +1890,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
void generateEcommerceImage(
|
||||
"detail", detailProductImages, detailRequirement,
|
||||
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
|
||||
undefined,
|
||||
{ detailModules: selectedDetailModules },
|
||||
(s: string) => setDetailStatus(s as DetailStatus),
|
||||
(res) => setDetailResultUrl(res[0]?.src ?? null),
|
||||
);
|
||||
@@ -1820,7 +1969,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
setPreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
|
||||
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
setIndex++;
|
||||
@@ -1835,7 +1984,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
clonePreviewCards.push({
|
||||
id: `${countKey}-${i}`,
|
||||
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
|
||||
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
|
||||
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
|
||||
});
|
||||
cloneIndex++;
|
||||
|
||||
@@ -312,6 +312,8 @@ export async function renderSceneImage(
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "image",
|
||||
model: "gpt-image-2",
|
||||
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
@@ -367,6 +369,8 @@ export async function renderScene(
|
||||
|
||||
const resultUrl = await waitForTask(taskId, {
|
||||
abortRef,
|
||||
kind: "video",
|
||||
model,
|
||||
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
|
||||
});
|
||||
|
||||
|
||||
@@ -857,25 +857,25 @@ function ProfilePage({
|
||||
任务 {tasks.length}
|
||||
</button>
|
||||
</div>
|
||||
<div className="profile-page__upload-card profile-page__upload-card--meta">
|
||||
<div className="profile-page__account-summary">
|
||||
{accountPanel === "credits" ? (
|
||||
<>
|
||||
<span className="profile-page__meta-item">
|
||||
<span className="profile-page__account-summary-main">
|
||||
<small>当前账号</small>
|
||||
<strong>{displayName}</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<span className="profile-page__account-summary-metric">
|
||||
<small>积分剩余</small>
|
||||
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="profile-page__meta-item">
|
||||
<small>任务总数</small>
|
||||
<strong>{tasks.length}</strong>
|
||||
<span className="profile-page__account-summary-main">
|
||||
<small>任务概览</small>
|
||||
<strong>{tasks.length} 个任务</strong>
|
||||
</span>
|
||||
<span className="profile-page__meta-item">
|
||||
<span className="profile-page__account-summary-metric">
|
||||
<small>已完成</small>
|
||||
<strong>{completedTasks.length}</strong>
|
||||
</span>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface EvalResult {
|
||||
totalScore: number;
|
||||
grade: string;
|
||||
dimensionScores: Record<string, number>;
|
||||
subScores?: Record<string, Record<string, number>>;
|
||||
evidence?: Record<string, string[]>;
|
||||
summary: string;
|
||||
issues: string[];
|
||||
highlights: string[];
|
||||
@@ -192,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
|
||||
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
|
||||
];
|
||||
|
||||
const SUB_SCORE_LABELS: Record<string, string> = {
|
||||
openingImpact: "开篇冲击",
|
||||
suspenseChain: "悬念链",
|
||||
sceneHook: "场内钩子",
|
||||
structure: "结构完整",
|
||||
rhythm: "节奏推进",
|
||||
conflict: "冲突强度",
|
||||
reversal: "反转效率",
|
||||
motivation: "动机清晰",
|
||||
arc: "人物弧光",
|
||||
voice: "语言辨识",
|
||||
relationship: "关系张力",
|
||||
causality: "因果链",
|
||||
worldRules: "世界规则",
|
||||
foreshadowing: "伏笔回收",
|
||||
continuity: "连续性",
|
||||
sceneDetail: "场景细节",
|
||||
shotPotential: "镜头潜力",
|
||||
aigcFeasibility: "AIGC 可实现",
|
||||
theme: "主题表达",
|
||||
emotion: "情感共鸣",
|
||||
marketFit: "市场匹配",
|
||||
originality: "原创性",
|
||||
};
|
||||
|
||||
function clampScore(score: unknown, maxScore: number): number {
|
||||
const numeric = Number(score);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.min(maxScore, numeric));
|
||||
}
|
||||
|
||||
function getDimensionScore(result: EvalResult, dim: ScoreDimension): number {
|
||||
const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined);
|
||||
return clampScore(value, dim.maxScore);
|
||||
}
|
||||
|
||||
function formatSubScoreLabel(key: string): string {
|
||||
return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim();
|
||||
}
|
||||
|
||||
function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> {
|
||||
const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined);
|
||||
if (!scores) return [];
|
||||
return Object.entries(scores)
|
||||
.map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number])
|
||||
.filter(([, value]) => value > 0)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
|
||||
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
|
||||
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
|
||||
}
|
||||
|
||||
function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# 剧本评测报告`);
|
||||
@@ -203,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
|
||||
lines.push("");
|
||||
lines.push(`## 六维评分`);
|
||||
for (const dim of SCORE_DIMENSIONS) {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.round((score / dim.maxScore) * 100);
|
||||
const subScores = getDimensionSubScores(result, dim);
|
||||
const evidence = getDimensionEvidence(result, dim);
|
||||
const nestedReportLines = [
|
||||
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
|
||||
...evidence.map((item) => ` - 证据: ${item}`),
|
||||
];
|
||||
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
|
||||
lines.push(...nestedReportLines);
|
||||
}
|
||||
if (result.highlights.length > 0) {
|
||||
lines.push("");
|
||||
@@ -636,7 +699,7 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
<div className="script-eval-report__chart-grid">
|
||||
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
|
||||
const score = result.dimensionScores[dim.key] ?? 0;
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
|
||||
const lossPct = 1 - pct;
|
||||
const isPerfect = score === dim.maxScore;
|
||||
@@ -676,6 +739,51 @@ function ScriptTokensPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="script-eval-report__detail-grid">
|
||||
{SCORE_DIMENSIONS.map((dim) => {
|
||||
const score = getDimensionScore(result, dim);
|
||||
const pct = Math.round((score / dim.maxScore) * 100);
|
||||
const subScores = getDimensionSubScores(result, dim);
|
||||
const evidence = getDimensionEvidence(result, dim);
|
||||
|
||||
return (
|
||||
<section className="script-eval-report__detail-card" key={dim.key}>
|
||||
<header className="script-eval-report__detail-head">
|
||||
<div>
|
||||
<span>{dim.label}</span>
|
||||
<strong>{score}<small>/{dim.maxScore}</small></strong>
|
||||
</div>
|
||||
<em>{pct}%</em>
|
||||
</header>
|
||||
<p className="script-eval-report__detail-hint">{dim.hint}</p>
|
||||
{subScores.length > 0 ? (
|
||||
<div className="script-eval-report__subscore-list">
|
||||
{subScores.map(([key, value]) => {
|
||||
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
|
||||
return (
|
||||
<div className="script-eval-report__subscore-row" key={key}>
|
||||
<span>{formatSubScoreLabel(key)}</span>
|
||||
<div className="script-eval-report__subscore-bar" aria-hidden="true">
|
||||
<i style={{ width: `${subPct}%` }} />
|
||||
</div>
|
||||
<b>{value}</b>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="script-eval-report__detail-empty">等待模型返回更细的子项评分;当前先按主维度分数展示。</p>
|
||||
)}
|
||||
{evidence.length > 0 ? (
|
||||
<ul className="script-eval-report__evidence-list">
|
||||
{evidence.map((item, index) => <li key={index}>{item}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="script-eval-report__findings">
|
||||
{result.highlights.length > 0 ? (
|
||||
<section className="script-eval-report__finding-group is-highlight">
|
||||
|
||||
@@ -64,6 +64,12 @@ import {
|
||||
import { renderMarkdownBlocks } from "./markdownRenderer";
|
||||
import { downloadResultAsset } from "./workbenchDownload";
|
||||
import { translateTaskError } from "../../utils/translateTaskError";
|
||||
import {
|
||||
buildLocalTimeoutMessage,
|
||||
formatTextTokenUsage,
|
||||
getTaskTimeoutPolicy,
|
||||
isTaskLocallyTimedOut,
|
||||
} from "../../utils/taskLifecycle";
|
||||
import { detectMentionTrigger } from "../../utils/mentionTrigger";
|
||||
import {
|
||||
isHappyHorseModel,
|
||||
@@ -865,6 +871,9 @@ function WorkbenchPage({
|
||||
|
||||
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
|
||||
let taskPollFailures = 0;
|
||||
let lastProgressAt = task.startedAt || Date.now();
|
||||
const taskKind = task.mode === "image" ? "image" : "video";
|
||||
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
|
||||
const abortController = new AbortController();
|
||||
taskAbortControllersRef.current.set(task.taskId, abortController);
|
||||
if (activeConversationIdRef.current === task.conversationId) {
|
||||
@@ -911,6 +920,9 @@ function WorkbenchPage({
|
||||
const progress = status.status === "completed"
|
||||
? 100
|
||||
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
|
||||
if (progress > lastKnownProgress || status.status === "completed") {
|
||||
lastProgressAt = Date.now();
|
||||
}
|
||||
lastKnownProgress = Math.max(lastKnownProgress, progress);
|
||||
const isSuperResolveTask = task.operation === "video-super-resolution";
|
||||
const statusLabel =
|
||||
@@ -935,6 +947,28 @@ function WorkbenchPage({
|
||||
setGenerationProgress(progress);
|
||||
}
|
||||
|
||||
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
|
||||
? isTaskLocallyTimedOut({
|
||||
startedAt: task.startedAt || Date.now(),
|
||||
lastProgressAt,
|
||||
progress,
|
||||
policy: timeoutPolicy,
|
||||
})
|
||||
: null;
|
||||
if (localTimeoutReason) {
|
||||
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
|
||||
body: buildLocalTimeoutMessage(taskKind),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: "unknown",
|
||||
taskProgress: progress,
|
||||
taskStatusLabel: "本地等待超时",
|
||||
});
|
||||
removeKeepaliveTask(task.taskId);
|
||||
onRefreshUsage?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === "completed" && status.resultUrl) {
|
||||
const completedPatch: Partial<ChatMessage> = {
|
||||
body: isSuperResolveTask
|
||||
@@ -1982,6 +2016,7 @@ function WorkbenchPage({
|
||||
runKeepalivePoll(keepaliveTask);
|
||||
} else {
|
||||
let streamedText = "";
|
||||
let chatUsage: ChatMessage["taskUsage"] | undefined;
|
||||
setGenerationProgress(36);
|
||||
setGenerationStatus("正在回复");
|
||||
updateAssistantMessage(assistantMessageId, {
|
||||
@@ -2014,6 +2049,9 @@ function WorkbenchPage({
|
||||
});
|
||||
},
|
||||
abortController.signal,
|
||||
(usage) => {
|
||||
chatUsage = usage;
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
@@ -2022,6 +2060,7 @@ function WorkbenchPage({
|
||||
const completedMessages = updateAssistantMessage(assistantMessageId, {
|
||||
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
|
||||
status: "completed",
|
||||
taskUsage: chatUsage,
|
||||
});
|
||||
if (!conversationId) {
|
||||
const conv = await conversationClient.create(
|
||||
@@ -2149,6 +2188,38 @@ function WorkbenchPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReleaseStuckTask = (message: ChatMessage) => {
|
||||
if (message.taskId) {
|
||||
taskAbortControllersRef.current.get(message.taskId)?.abort();
|
||||
taskAbortControllersRef.current.delete(message.taskId);
|
||||
removeKeepaliveTask(message.taskId);
|
||||
}
|
||||
if (message.conversationId) {
|
||||
void patchConversationMessage(message.conversationId, message.id, {
|
||||
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: message.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
});
|
||||
}
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
|
||||
status: "local_timeout",
|
||||
taskLifecycleStatus: "local_timeout",
|
||||
taskRefundStatus: item.taskRefundStatus || "unknown",
|
||||
taskStatusLabel: "本地占用已释放",
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
syncActiveGenerationUi();
|
||||
};
|
||||
|
||||
const handleSuperResolveVideo = async (message: ChatMessage) => {
|
||||
if (!message.resultUrl || message.resultType !== "video") {
|
||||
setProjectError("仅支持对视频结果进行超分");
|
||||
@@ -3007,7 +3078,7 @@ function WorkbenchPage({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
|
||||
<div className="ai-chat-failed-actions">
|
||||
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
|
||||
<ReloadOutlined /> 重试
|
||||
@@ -3015,9 +3086,12 @@ function WorkbenchPage({
|
||||
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
|
||||
<AppstoreOutlined /> 切换模型
|
||||
</button>
|
||||
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
|
||||
<StopOutlined /> 释放卡住任务
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
|
||||
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
|
||||
)}
|
||||
{message.status === "thinking" && message.mode === "chat" && (
|
||||
@@ -3025,6 +3099,11 @@ function WorkbenchPage({
|
||||
<span>{message.taskStatusLabel || generationStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
|
||||
<div className="ai-chat-task-billing-note">
|
||||
{formatTextTokenUsage(message.taskUsage)}
|
||||
</div>
|
||||
)}
|
||||
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
|
||||
<ResultCard
|
||||
message={message}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
|
||||
export interface WorkbenchChatAttachment {
|
||||
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isServerRequestError } from "../../api/serverConnection";
|
||||
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
|
||||
import type { WebGenerationPreviewTask } from "../../types";
|
||||
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type WorkbenchMode = "chat" | "image" | "video";
|
||||
@@ -71,7 +72,10 @@ export interface ChatMessage {
|
||||
body: string;
|
||||
prompt?: string;
|
||||
createdAt: string;
|
||||
status?: "thinking" | "queued" | "completed" | "failed";
|
||||
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
|
||||
taskLifecycleStatus?: GenerationLifecycleStatus;
|
||||
taskRefundStatus?: TaskRefundStatus;
|
||||
taskUsage?: TextTokenUsage;
|
||||
taskId?: string;
|
||||
conversationId?: number;
|
||||
taskProgress?: number;
|
||||
@@ -366,11 +370,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
|
||||
return (
|
||||
patch.status === "completed" ||
|
||||
patch.status === "failed" ||
|
||||
patch.status === "local_timeout" ||
|
||||
patch.status === "stopping" ||
|
||||
typeof patch.taskId === "string" ||
|
||||
typeof patch.resultUrl === "string" ||
|
||||
typeof patch.resultOssKey === "string" ||
|
||||
typeof patch.resultOriginalUrl === "string" ||
|
||||
typeof patch.resultMimeType === "string"
|
||||
typeof patch.resultMimeType === "string" ||
|
||||
typeof patch.taskRefundStatus === "string" ||
|
||||
typeof patch.taskLifecycleStatus === "string" ||
|
||||
typeof patch.taskUsage === "object"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,4 +410,4 @@ export function buildAssistantResult(
|
||||
summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。",
|
||||
specs: [model, ...specs],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user