merge: 合并 master,保留拖拽上传样式和工具面板样式

This commit is contained in:
OmniAI Developer
2026-06-05 20:04:48 +08:00
23 changed files with 3331 additions and 152 deletions
+58 -9
View File
@@ -182,6 +182,7 @@ import {
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -336,6 +337,7 @@ function CanvasPage({
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false);
@@ -3871,12 +3873,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
@@ -4391,7 +4393,7 @@ function CanvasPage({
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4697,16 +4699,42 @@ function CanvasPage({
)}
<button
type="button"
className={imageNodeFocusActive ? "is-active" : ""}
title="框选聚焦区域"
title="多宫格生成"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openImageFocusMode(imageNode);
setCanvasToolModal({ tool: "multiGrid", imageNode });
}}
>
<BarsOutlined /><span></span>
<BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
@@ -5856,6 +5884,27 @@ function CanvasPage({
</section>
</div>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
</WorkspacePageShell>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -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"),
};
}
}
+197 -48
View File
@@ -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 = "";
};
@@ -1328,8 +1424,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 = "";
};
@@ -1341,8 +1444,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 = "";
};
@@ -1384,11 +1494,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.`);
}
@@ -1400,13 +1531,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.");
@@ -1419,6 +1551,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.");
@@ -1492,8 +1625,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: "生成未返回结果" });
@@ -1531,7 +1665,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> => {
@@ -1578,9 +1712,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: "生成未返回结果" });
@@ -1684,10 +1819,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();
@@ -1767,7 +1916,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),
);
@@ -1846,7 +1995,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++;
@@ -1861,7 +2010,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),
});
+66 -40
View File
@@ -260,6 +260,30 @@ function ProfilePage({
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
const activePanelTitle =
activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核";
const activePanelDescription =
activePanel === "works"
? "最近完成的高质量生成内容"
: activePanel === "projects"
? "云端同步的创作项目"
: activePanel === "assets"
? "可复用的图片、视频与素材"
: "已提交社区的案例状态";
const activePanelCount =
activePanel === "works"
? visibleWorks.length
: activePanel === "projects"
? projects.length
: activePanel === "assets"
? savedAssets.length
: communityCases.length;
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
@@ -765,9 +789,9 @@ function ProfilePage({
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
<CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</button>
<div className="profile-page__banner-overlay" />
</header>
@@ -847,35 +871,39 @@ function ProfilePage({
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
<span></span>
<strong>{(totalBalance / 100).toFixed(2)}</strong>
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
<span></span>
<strong>{tasks.length}</strong>
</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>
<em>{packageLabel}</em>
</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>
<em>{completedTasks.length} </em>
</span>
<span className="profile-page__meta-item">
<span className="profile-page__account-summary-metric">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
@@ -884,51 +912,49 @@ function ProfilePage({
</div>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<div className="profile-page__actions">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
退
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
退
</button>
</div>
</aside>
<main className="profile-page__main">
<div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
<span></span>
</button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
<span></span>
</button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
<span></span>
</button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
<span></span>
</button>
</div>
<div className="profile-page__section">
<span className="profile-page__section-label">
{activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
<div className="profile-page__section-head">
<span className="profile-page__section-label">{activePanelTitle}</span>
<span className="profile-page__section-desc">{activePanelDescription}</span>
<span className="profile-page__section-meta">{activePanelCount} </span>
</div>
{renderActivePanel()}
</div>
</main>
+110 -2
View File
@@ -26,6 +26,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[];
@@ -193,6 +195,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(`# 剧本评测报告`);
@@ -204,9 +260,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("");
@@ -683,7 +746,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;
@@ -723,6 +786,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">
+85 -3
View File
@@ -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,
@@ -106,6 +112,7 @@ import {
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
@@ -872,6 +879,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) {
@@ -918,6 +928,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 =
@@ -942,6 +955,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
@@ -1989,6 +2024,7 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask);
} else {
let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36);
setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, {
@@ -2021,6 +2057,9 @@ function WorkbenchPage({
});
},
abortController.signal,
(usage) => {
chatUsage = usage;
},
);
if (abortController.signal.aborted) return;
@@ -2029,6 +2068,7 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed",
taskUsage: chatUsage,
});
if (!conversationId) {
const conv = await conversationClient.create(
@@ -2156,6 +2196,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("仅支持对视频结果进行超分");
@@ -2705,7 +2777,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus}
onChange={setImageModel}
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection}
/>
<CompoundSelectChip
@@ -2717,6 +2789,7 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
@@ -2728,6 +2801,7 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
)}
</>
)}
{activeMode === "video" && (
@@ -3054,7 +3128,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 />
@@ -3062,9 +3136,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" && (
@@ -3072,6 +3149,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}
+6 -1
View File
@@ -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;
+19 -3
View File
@@ -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";
@@ -74,7 +75,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;
@@ -253,6 +257,13 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
{ value: "grid-25", label: "25 宫格" },
];
export const GRID_SUPPORTED_MODELS = new Set([
"wan2.7-image-pro",
"wan2.7-image",
"gpt-image-2",
"gpt-image-2-vip",
]);
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
{ value: "omni", label: "全能参考" },
{ value: "start-end", label: "首尾帧" },
@@ -386,11 +397,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"
);
}
@@ -421,4 +437,4 @@ export function buildAssistantResult(
summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。",
specs: [model, ...specs],
};
}
}