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:
2026-06-05 01:00:33 +08:00
parent d36a093159
commit 178a2c47da
16 changed files with 1607 additions and 95 deletions
+24 -1
View File
@@ -134,6 +134,12 @@ export interface ChatInput {
temperature?: number; temperature?: number;
} }
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus { export interface AiTaskStatus {
taskId: string; taskId: string;
projectId?: string; projectId?: string;
@@ -500,6 +506,7 @@ export const aiGenerationClient = {
input: ChatInput, input: ChatInput,
onChunk: (text: string) => void, onChunk: (text: string) => void,
signal?: AbortSignal, signal?: AbortSignal,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> { ): Promise<void> {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
@@ -529,8 +536,24 @@ export const aiGenerationClient = {
const payload = line.slice(6).trim(); const payload = line.slice(6).trim();
if (!payload) continue; if (!payload) continue;
try { try {
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string }; const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
if (chunk.error) throw new Error(chunk.error); if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta); if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return; if (chunk.done) return;
} catch (e) { } catch (e) {
+79 -5
View File
@@ -4,6 +4,8 @@ export interface ScriptEvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
dimensionScores: Record<string, number>; dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string; summary: string;
issues: string[]; issues: string[];
highlights: string[]; highlights: string[];
@@ -12,6 +14,33 @@ export interface ScriptEvalResult {
const MODEL = "qwen3.7-max"; const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】 【剧本类型识别】
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = { const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 }, hook: { maxScore: 20 },
plot: { maxScore: 20 }, plot: { maxScore: 20 },
character: { maxScore: 18 }, character: { maxScore: 15 },
dialogue: { maxScore: 15 }, logic: { maxScore: 15 },
visual: { maxScore: 15 }, visual: { maxScore: 15 },
content: { maxScore: 12 }, content: { maxScore: 15 },
}; };
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } { function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -68,6 +97,48 @@ function extractJson(text: string): unknown {
return JSON.parse(raw); return JSON.parse(raw);
} }
function normalizeScoreValue(value: unknown, maxScore: number): number {
const score = Number(value);
if (!Number.isFinite(score)) return 0;
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {};
const normalized: Record<string, Record<string, number>> = {};
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!isRecord(source)) continue;
const entries = Object.entries(source)
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
.filter(([, score]) => score > 0);
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
}
return normalized;
}
function normalizeEvidence(value: unknown): Record<string, string[]> {
if (!isRecord(value)) return {};
const normalized: Record<string, string[]> = {};
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue;
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
if (items.length > 0) normalized[dimensionKey] = items;
}
return normalized;
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
@@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
model: MODEL, model: MODEL,
messages: [ messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
], ],
stream: false, stream: false,
@@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) { for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const val = Number(rawScores[key] ?? 0); const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val)); dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
totalScore, totalScore,
grade, grade,
dimensionScores, dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""), summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
+38 -4
View File
@@ -1,4 +1,9 @@
import { aiGenerationClient } from "./aiGenerationClient"; import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent { export interface TaskProgressEvent {
taskId: string; taskId: string;
@@ -12,16 +17,28 @@ export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void; onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean }; abortRef?: { current: boolean };
timeoutMs?: number; timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
} }
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
export function waitForTask( export function waitForTask(
taskId: string, taskId: string,
options: WaitForTaskOptions = {}, options: WaitForTaskOptions = {},
): Promise<string | null> { ): Promise<string | null> {
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options; const { onProgress, abortRef } = options;
const timeoutPolicy = getTaskTimeoutPolicy({
kind: options.kind,
model: options.model,
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false; let settled = false;
@@ -29,6 +46,8 @@ export function waitForTask(
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false; let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null; let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
const settle = (fn: () => void) => { const settle = (fn: () => void) => {
if (settled) return; if (settled) return;
@@ -40,7 +59,7 @@ export function waitForTask(
}; };
timeoutId = setTimeout( timeoutId = setTimeout(
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))), () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
timeoutMs, timeoutMs,
); );
@@ -50,6 +69,11 @@ export function waitForTask(
settle(() => resolve(null)); settle(() => resolve(null));
return; return;
} }
const progress = Number(event.progress || 0);
if (progress > lastProgress || event.status === "completed") {
lastProgress = Math.max(lastProgress, progress);
lastProgressAt = Date.now();
}
onProgress?.(event); onProgress?.(event);
if (event.status === "completed") { if (event.status === "completed") {
settle(() => resolve(event.resultUrl || null)); settle(() => resolve(event.resultUrl || null));
@@ -76,6 +100,16 @@ export function waitForTask(
} }
await new Promise((r) => setTimeout(r, POLL_INTERVAL)); await new Promise((r) => setTimeout(r, POLL_INTERVAL));
if (settled || abortRef?.current) return; if (settled || abortRef?.current) return;
const timeoutReason = isTaskLocallyTimedOut({
startedAt,
lastProgressAt,
progress: lastProgress,
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
return;
}
try { try {
const task = await aiGenerationClient.getTaskStatus(taskId); const task = await aiGenerationClient.getTaskStatus(taskId);
handleUpdate({ handleUpdate({
@@ -90,7 +124,7 @@ export function waitForTask(
} }
} }
}; };
poll(); void poll();
} }
}); });
} }
+4 -4
View File
@@ -3750,12 +3750,12 @@ function CanvasPage({
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/> />
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}> <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}% {Math.round(canvasViewport.zoom * 100)}%
</button> </button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button> <button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button> <button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
</div> </div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? ( {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<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) { export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
timeoutMs: 10 * 60 * 1000, kind: "image",
onProgress: (e) => { onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); 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) { export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
timeoutMs: 30 * 60 * 1000, kind: "video",
onProgress: (e) => { onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); 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), height: clampCanvasPercent(height),
ratio: toCanvasStyleString(selection.ratio, "16:9"), ratio: toCanvasStyleString(selection.ratio, "16:9"),
}; };
} }
+197 -48
View File
@@ -63,6 +63,8 @@ interface CloneImageItem {
width?: number; width?: number;
height?: number; height?: number;
format?: string; format?: string;
mimeType?: string;
ossKey?: string;
} }
interface CloneResult { interface CloneResult {
@@ -99,6 +101,18 @@ interface CloneSavedSetting {
requirement: string; 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"; type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
interface PlatformRatioGroup { interface PlatformRatioGroup {
@@ -672,16 +686,85 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
}); });
} }
function createObjectImageItems(files: File[], limit: number, prefix: string) { const blobToDataUrl = (blob: Blob): Promise<string> =>
return Array.from(files) new Promise((resolve, reject) => {
.slice(0, limit) const reader = new FileReader();
.map<CloneImageItem>((file, index) => ({ reader.onload = () => resolve(String(reader.result || ""));
id: `${prefix}-${Date.now()}-${index}`, reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
src: URL.createObjectURL(file), 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, name: file.name,
file, file,
format: getImageFileFormat(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[] { 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; if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files); const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return; if (!imageFiles.length) return;
setSetImages((current) => { try {
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set"); const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; setSetImages((current) => {
}); if (current.length >= 3) return current;
setProductSetStatus("ready"); 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 handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (!files?.length) return; if (!files?.length) return;
addSetImages(Array.from(files)); void addSetImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -910,7 +998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault(); event.preventDefault();
setIsSetUploadDragging(false); setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
if (files.length) addSetImages(files); if (files.length) void addSetImages(files);
}; };
const removeSetImage = (imageId: string) => { 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); const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return; if (!imageFiles.length) return;
setProductImages((current) => { try {
if (current.length >= maxCloneProductImages) return current; const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product"); setProductImages((current) => {
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; if (current.length >= maxCloneProductImages) return current;
}); return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
setStatus("ready"); });
setResults([]); setStatus("ready");
setResults([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
}; };
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => { const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (!files?.length) return; if (!files?.length) return;
addProductImages(Array.from(files)); void addProductImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -944,7 +1036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault(); event.preventDefault();
setIsProductUploadDragging(false); setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
if (files.length) addProductImages(files); if (files.length) void addProductImages(files);
}; };
const removeProductImage = (imageId: string) => { 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); const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return; if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return; if (remainingSlots <= 0) return;
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference"); try {
if (!nextImages.length) return; const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
setCloneReferenceImages((current) => { if (!nextImages.length) return;
if (current.length >= maxCloneReferenceImages) return current; setCloneReferenceImages((current) => {
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current; if (current.length >= maxCloneReferenceImages) return current;
}); return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
hydrateCloneReferenceImageMeta(nextImages); });
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
}; };
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => { const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (!files?.length) return; if (!files?.length) return;
addCloneReferenceImages(Array.from(files)); void addCloneReferenceImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -1302,8 +1398,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = ""; event.target.value = "";
return; return;
} }
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5)); void (async () => {
setTryOnStatus("ready"); 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 = ""; event.target.value = "";
}; };
@@ -1315,8 +1418,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = ""; event.target.value = "";
return; return;
} }
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3)); void (async () => {
setDetailStatus("ready"); 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 = ""; 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" }, 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 buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
const info = setCountLabels[countKey]; const info = setCountLabels[countKey];
const parts: string[] = []; const parts: string[] = [];
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`); parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
parts.push(info.promptDesc); 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) { if (totalCount > 1) {
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`); 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 = ( const buildEcommerceImagePrompt = (
outputKey: CloneOutputKey, userText: string, outputKey: CloneOutputKey, userText: string,
pPlatform: string, pRatio: string, pLanguage: string, pMarket: 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 => { ): string => {
const parts: string[] = []; const parts: string[] = [];
if (outputKey === "detail") { if (outputKey === "detail") {
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); 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("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}.`); 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."); parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
} else if (outputKey === "model") { } else if (outputKey === "model") {
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing."); 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.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); 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."); 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."); 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 (imageAbortRef.current.current) break;
if (resultUrl) { if (resultUrl) {
generatedUrls.push(resultUrl); const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else { } else {
generatedUrls.push(""); generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
@@ -1505,7 +1639,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string, pRatio: string,
pLanguage: string, pLanguage: string,
pMarket: 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, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void, resultFn?: (results: CloneResult[]) => void,
): Promise<void> => { ): Promise<void> => {
@@ -1552,9 +1686,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
} }
if (resultUrl) { 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"); statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else { } else {
statusFn?.("idle"); statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
@@ -1658,10 +1793,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
(urls) => setProductSetResultImages(urls), (urls) => setProductSetResultImages(urls),
); );
} else { } 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( void generateEcommerceImage(
cloneOutput, productImages, requirement, cloneOutput, productImages, requirement,
platform, ratio, language, market, platform, ratio, language, market,
undefined, clonePromptOptions,
(s: string) => setStatus(s as ProductCloneStatus), setResults, (s: string) => setStatus(s as ProductCloneStatus), setResults,
); );
lastFailedActionRef.current = () => handleGenerate(); lastFailedActionRef.current = () => handleGenerate();
@@ -1741,7 +1890,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage( void generateEcommerceImage(
"detail", detailProductImages, detailRequirement, "detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
undefined, { detailModules: selectedDetailModules },
(s: string) => setDetailStatus(s as DetailStatus), (s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null), (res) => setDetailResultUrl(res[0]?.src ?? null),
); );
@@ -1820,7 +1969,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
setPreviewCards.push({ setPreviewCards.push({
id: `${countKey}-${i}`, 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}` : ""}`, label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
}); });
setIndex++; setIndex++;
@@ -1835,7 +1984,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
clonePreviewCards.push({ clonePreviewCards.push({
id: `${countKey}-${i}`, 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}` : ""}`, label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
}); });
cloneIndex++; cloneIndex++;
@@ -312,6 +312,8 @@ export async function renderSceneImage(
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
abortRef, abortRef,
kind: "image",
model: "gpt-image-2",
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress), onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
}); });
@@ -367,6 +369,8 @@ export async function renderScene(
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
abortRef, abortRef,
kind: "video",
model,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress), onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
}); });
+7 -7
View File
@@ -857,25 +857,25 @@ function ProfilePage({
{tasks.length} {tasks.length}
</button> </button>
</div> </div>
<div className="profile-page__upload-card profile-page__upload-card--meta"> <div className="profile-page__account-summary">
{accountPanel === "credits" ? ( {accountPanel === "credits" ? (
<> <>
<span className="profile-page__meta-item"> <span className="profile-page__account-summary-main">
<small></small> <small></small>
<strong>{displayName}</strong> <strong>{displayName}</strong>
</span> </span>
<span className="profile-page__meta-item"> <span className="profile-page__account-summary-metric">
<small></small> <small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong> <strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span> </span>
</> </>
) : ( ) : (
<> <>
<span className="profile-page__meta-item"> <span className="profile-page__account-summary-main">
<small></small> <small></small>
<strong>{tasks.length}</strong> <strong>{tasks.length} </strong>
</span> </span>
<span className="profile-page__meta-item"> <span className="profile-page__account-summary-metric">
<small></small> <small></small>
<strong>{completedTasks.length}</strong> <strong>{completedTasks.length}</strong>
</span> </span>
+110 -2
View File
@@ -25,6 +25,8 @@ interface EvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
dimensionScores: Record<string, number>; dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string; summary: string;
issues: string[]; issues: string[];
highlights: string[]; highlights: string[];
@@ -192,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" }, { 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 { function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`# 剧本评测报告`); lines.push(`# 剧本评测报告`);
@@ -203,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
lines.push(""); lines.push("");
lines.push(`## 六维评分`); lines.push(`## 六维评分`);
for (const dim of SCORE_DIMENSIONS) { 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 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(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
lines.push(...nestedReportLines);
} }
if (result.highlights.length > 0) { if (result.highlights.length > 0) {
lines.push(""); lines.push("");
@@ -636,7 +699,7 @@ function ScriptTokensPage() {
</div> </div>
<div className="script-eval-report__chart-grid"> <div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim, dimIndex) => { {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 pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct; const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore; const isPerfect = score === dim.maxScore;
@@ -676,6 +739,51 @@ function ScriptTokensPage() {
</div> </div>
</section> </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"> <div className="script-eval-report__findings">
{result.highlights.length > 0 ? ( {result.highlights.length > 0 ? (
<section className="script-eval-report__finding-group is-highlight"> <section className="script-eval-report__finding-group is-highlight">
+81 -2
View File
@@ -64,6 +64,12 @@ import {
import { renderMarkdownBlocks } from "./markdownRenderer"; import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload"; import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
import { detectMentionTrigger } from "../../utils/mentionTrigger"; import { detectMentionTrigger } from "../../utils/mentionTrigger";
import { import {
isHappyHorseModel, isHappyHorseModel,
@@ -865,6 +871,9 @@ function WorkbenchPage({
let lastKnownProgress = Math.max(0, Number(task.progress || 0)); let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 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(); const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController); taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) { if (activeConversationIdRef.current === task.conversationId) {
@@ -911,6 +920,9 @@ function WorkbenchPage({
const progress = status.status === "completed" const progress = status.status === "completed"
? 100 ? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress))); : 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); lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution"; const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel = const statusLabel =
@@ -935,6 +947,28 @@ function WorkbenchPage({
setGenerationProgress(progress); 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) { if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = { const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask body: isSuperResolveTask
@@ -1982,6 +2016,7 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask); runKeepalivePoll(keepaliveTask);
} else { } else {
let streamedText = ""; let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36); setGenerationProgress(36);
setGenerationStatus("正在回复"); setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, { updateAssistantMessage(assistantMessageId, {
@@ -2014,6 +2049,9 @@ function WorkbenchPage({
}); });
}, },
abortController.signal, abortController.signal,
(usage) => {
chatUsage = usage;
},
); );
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -2022,6 +2060,7 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, { const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。", body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed", status: "completed",
taskUsage: chatUsage,
}); });
if (!conversationId) { if (!conversationId) {
const conv = await conversationClient.create( 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) => { const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") { if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分"); setProjectError("仅支持对视频结果进行超分");
@@ -3007,7 +3078,7 @@ function WorkbenchPage({
))} ))}
</div> </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"> <div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}> <button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined /> <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"); }}> <button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
<AppstoreOutlined /> <AppstoreOutlined />
</button> </button>
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
<StopOutlined />
</button>
</div> </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)} /> <GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)} )}
{message.status === "thinking" && message.mode === "chat" && ( {message.status === "thinking" && message.mode === "chat" && (
@@ -3025,6 +3099,11 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span> <span>{message.taskStatusLabel || generationStatus}</span>
</div> </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")) && ( {(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard <ResultCard
message={message} 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 type WorkbenchMode = "chat" | "image" | "video";
export interface WorkbenchChatAttachment { export interface WorkbenchChatAttachment {
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed"; status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
+12 -3
View File
@@ -1,6 +1,7 @@
import { isServerRequestError } from "../../api/serverConnection"; import { isServerRequestError } from "../../api/serverConnection";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy"; import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import type { WebGenerationPreviewTask } from "../../types"; import type { WebGenerationPreviewTask } from "../../types";
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export type WorkbenchMode = "chat" | "image" | "video"; export type WorkbenchMode = "chat" | "image" | "video";
@@ -71,7 +72,10 @@ export interface ChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed"; status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
@@ -366,11 +370,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
return ( return (
patch.status === "completed" || patch.status === "completed" ||
patch.status === "failed" || patch.status === "failed" ||
patch.status === "local_timeout" ||
patch.status === "stopping" ||
typeof patch.taskId === "string" || typeof patch.taskId === "string" ||
typeof patch.resultUrl === "string" || typeof patch.resultUrl === "string" ||
typeof patch.resultOssKey === "string" || typeof patch.resultOssKey === "string" ||
typeof patch.resultOriginalUrl === "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: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。", summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。",
specs: [model, ...specs], specs: [model, ...specs],
}; };
} }
+41 -10
View File
@@ -1,5 +1,11 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore"; import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient"; import { aiGenerationClient } from "../api/aiGenerationClient";
import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void; type PollCallback = (item: GenerationQueueItem) => void;
@@ -7,7 +13,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>(); const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
export function subscribeToTaskUpdates(callback: PollCallback): () => void { export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback); pollCallbacks.add(callback);
@@ -18,10 +24,25 @@ function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item)); pollCallbacks.forEach((cb) => cb(item));
} }
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
if (item.type === "image") return "image";
if (item.type === "video" || item.type === "ecommerce-video") return "video";
return "text";
}
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void { function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`; const key = `poll-${item.id}`;
if (activePollers.has(key)) return; if (activePollers.has(key)) return;
const kind = getQueueItemKind(item);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const interval = setInterval(async () => { const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id); const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") { if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
@@ -30,18 +51,31 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
} }
attemptsRef.current++; attemptsRef.current++;
if (attemptsRef.current > MAX_POLL_ATTEMPTS) { const timeoutReason = isTaskLocallyTimedOut({
startedAt: current.createdAt || item.createdAt || Date.now(),
lastProgressAt,
progress: lastProgress,
policy: timeoutPolicy,
});
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
const error = buildLocalTimeoutMessage(kind);
useGenerationStore.getState().updateTask(item.id, { useGenerationStore.getState().updateTask(item.id, {
status: "failed", status: "failed",
error: "任务超时,请重新提交", error,
}); });
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" }); notifyCallbacks({ ...item, status: "failed", error });
cleanupPoll(key); cleanupPoll(key);
return; return;
} }
try { try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || ""); const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = { const patch: Partial<GenerationQueueItem> = {
progress: status.progress, progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl, resultUrl: status.resultUrl || current.resultUrl,
@@ -55,6 +89,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
cleanupPoll(key); cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") { } else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed"; patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch); useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" }); notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key); cleanupPoll(key);
@@ -64,7 +99,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
notifyCallbacks({ ...item, ...patch, status: "running" }); notifyCallbacks({ ...item, ...patch, status: "running" });
} }
} catch { } catch {
// Network error during poll — keep trying // Network errors during polling are retried until the lifecycle guard trips.
} }
}, POLL_INTERVAL); }, POLL_INTERVAL);
@@ -105,24 +140,20 @@ export function stopAllPolling(): void {
activePollers.clear(); activePollers.clear();
} }
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void { export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks(); const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return; if (!pendingTasks.length) return;
pendingTasks.forEach((task) => { pendingTasks.forEach((task) => {
if (task.taskId) { if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" }); useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else { } else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, { useGenerationStore.getState().updateTask(task.id, {
status: "failed", status: "failed",
error: "页面刷新后任务丢失,请重新提交", error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交",
}); });
} }
}); });
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500); setTimeout(() => startBackgroundPolling(), 500);
} }
+152
View File
@@ -3234,6 +3234,141 @@
color: var(--report-green); color: var(--report-green);
} }
.script-eval-report__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 0 18px 18px;
}
.script-eval-report__detail-card {
min-width: 0;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: var(--v5-radius-md);
background: linear-gradient(180deg, rgb(255 255 255 / 3.4%), transparent), var(--report-row);
padding: 14px;
}
.script-eval-report__detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.script-eval-report__detail-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.script-eval-report__detail-head span {
color: #dfe8e4;
font-size: 14px;
font-weight: 800;
}
.script-eval-report__detail-head strong {
color: var(--report-green);
font-size: 22px;
line-height: 1;
}
.script-eval-report__detail-head small {
color: #7e8a86;
font-size: 12px;
}
.script-eval-report__detail-head em {
flex-shrink: 0;
border: 1px solid rgb(0 255 136 / 18%);
border-radius: 999px;
background: rgb(0 255 136 / 7%);
color: #98e8bd;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.script-eval-report__detail-hint,
.script-eval-report__detail-empty {
margin: 10px 0 0;
color: #7f8c88;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.script-eval-report__subscore-list {
display: grid;
gap: 9px;
margin-top: 13px;
}
.script-eval-report__subscore-row {
display: grid;
grid-template-columns: minmax(62px, 86px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
color: #bdcbc6;
font-size: 12px;
font-weight: 750;
}
.script-eval-report__subscore-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-eval-report__subscore-row b {
color: #dfe8e4;
text-align: right;
}
.script-eval-report__subscore-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 5%);
}
.script-eval-report__subscore-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #18de8a, #a3f7c1);
}
.script-eval-report__evidence-list {
display: grid;
gap: 7px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.script-eval-report__evidence-list li {
position: relative;
padding-left: 13px;
color: #aebcb7;
font-size: 12px;
font-weight: 650;
line-height: 1.55;
}
.script-eval-report__evidence-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--report-green);
}
.script-eval-report__findings { .script-eval-report__findings {
gap: 18px; gap: 18px;
} }
@@ -3282,6 +3417,10 @@
.script-eval-report--inside .script-eval-report__body { .script-eval-report--inside .script-eval-report__body {
padding-inline: 24px; padding-inline: 24px;
} }
.script-eval-report__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -3301,6 +3440,19 @@
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.script-eval-report__detail-grid {
grid-template-columns: 1fr;
margin-inline: 0;
}
.script-eval-report__detail-card {
padding: 12px;
}
.script-eval-report__subscore-row {
grid-template-columns: minmax(58px, 78px) minmax(0, 1fr) 30px;
}
.script-eval-v5 { .script-eval-v5 {
overflow: hidden; overflow: hidden;
} }
+689 -5
View File
@@ -2376,6 +2376,48 @@
border-color: rgba(239, 68, 68, 0.6); border-color: rgba(239, 68, 68, 0.6);
} }
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 6px 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: rgba(15, 23, 42, 0.62);
color: rgba(226, 232, 240, 0.92);
font-size: 12px;
cursor: pointer;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button:hover {
border-color: rgba(56, 189, 248, 0.42);
background: rgba(8, 47, 73, 0.45);
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions__release {
color: #fbbf24;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-task-billing-note {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid rgba(45, 212, 191, 0.22);
border-radius: 6px;
background: rgba(13, 148, 136, 0.08);
color: rgba(204, 251, 241, 0.86);
font-size: 12px;
line-height: 1.6;
white-space: pre-line;
}
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card__bar, .web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card__bar,
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .ai-generation-pending-card__bar { .web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .ai-generation-pending-card__bar {
position: absolute; position: absolute;
@@ -4917,6 +4959,332 @@
color: var(--accent); color: var(--accent);
} }
/* Auth page: refined SaaS entry surface, preserving current auth behavior and OSS assets. */
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: minmax(0, 1.55fr) minmax(400px, 0.9fr);
background: var(--dg-page);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase {
background: #0d0d0f;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video {
opacity: 0.48;
filter: saturate(1.08) contrast(1.04);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: center;
justify-content: center;
padding: clamp(36px, 6vw, 76px);
background:
linear-gradient(90deg, rgba(13, 13, 15, 0.86), rgba(13, 13, 15, 0.46) 58%, rgba(13, 13, 15, 0.72)),
linear-gradient(180deg, rgba(13, 13, 15, 0.28), rgba(13, 13, 15, 0.92));
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
display: grid;
gap: 14px;
width: min(620px, 100%);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
color: var(--accent);
font-size: 64px;
line-height: 0.96;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
max-width: 480px;
color: var(--fg-muted);
font-size: 22px;
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
justify-content: flex-start;
gap: 10px;
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--fg-body);
backdrop-filter: blur(12px);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
width: min(520px, 100%);
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats span {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
background: rgba(16, 18, 20, 0.58);
color: var(--fg-soft);
backdrop-filter: blur(14px);
font-size: 12px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats strong {
color: var(--fg-body);
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
align-items: center;
padding: clamp(28px, 4vw, 48px);
border-left-color: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
var(--bg-surface);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 20px;
max-width: 420px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-head {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 54px;
height: 54px;
margin-bottom: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 24px;
line-height: 1.2;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__subtitle {
color: var(--fg-soft);
line-height: 1.5;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs {
gap: 4px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button {
min-height: 38px;
padding: 0 12px;
border: 0;
border-radius: 9px;
font-weight: 700;
transition: background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button.is-active {
background: rgba(var(--accent-rgb), 0.13);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 42px;
padding: 0 8px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.018);
font-size: 12px;
font-weight: 600;
transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button.is-active {
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.1);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form {
gap: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field > span {
color: var(--fg-muted);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row {
border-color: rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input {
min-height: 44px;
padding: 0 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input:focus,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row:focus-within {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error input,
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error .auth-page__phone-row {
border-color: rgba(255, 90, 95, 0.64);
box-shadow: 0 0 0 3px rgba(255, 90, 95, 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 44px;
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.08);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn:disabled {
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit {
min-height: 46px;
padding: 0 16px;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 800;
transition: background var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(var(--accent-rgb), 0.16);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__notice {
border-color: rgba(255, 90, 95, 0.28);
background: rgba(255, 90, 95, 0.09);
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-box {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-title {
margin: 0;
color: var(--fg-body);
font-size: 13px;
font-weight: 800;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input {
width: 100%;
min-height: 44px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-body);
outline: none;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input:focus {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel,
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
min-height: 38px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 750;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel {
border: 1px solid rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-muted);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
border: 1px solid rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__agreement {
line-height: 1.55;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__divider {
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__social-btn {
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .profile-page { .web-shell[data-ui-theme="dark-green"] .profile-page {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@@ -6336,6 +6704,8 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 82px; width: 82px;
height: 82px; height: 82px;
justify-content: flex-start;
padding-left: 30px;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge {
@@ -6404,6 +6774,75 @@
white-space: nowrap; white-space: nowrap;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: stretch;
min-width: 0;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.055);
border-radius: 11px;
background:
linear-gradient(135deg, rgba(var(--accent-rgb), 0.055), transparent 62%),
rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-main,
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric {
display: grid;
min-width: 0;
align-content: center;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-main {
gap: 3px;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric {
min-width: 86px;
justify-items: end;
padding-left: 10px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
text-align: right;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary small {
overflow: hidden;
color: rgba(225, 235, 231, 0.52);
font-size: 10px;
font-weight: 800;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary strong {
overflow: hidden;
color: var(--fg);
font-size: 16px;
font-weight: 850;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary-metric strong {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-summary em {
overflow: hidden;
color: rgba(225, 235, 231, 0.42);
font-size: 10px;
font-style: normal;
font-weight: 650;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__upload-card--meta { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__account-card .profile-page__upload-card--meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
@@ -6705,6 +7144,7 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 72px; width: 72px;
height: 72px; height: 72px;
padding-left: 24px;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
@@ -8282,6 +8722,134 @@
color: var(--accent); color: var(--accent);
} }
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
top: 70px;
right: 12px;
left: 12px;
max-width: none;
overflow-x: auto;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-zoom-controls {
bottom: 14px;
left: 12px;
}
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 7px;
min-height: 82px;
padding: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__identity {
grid-column: 1 / 4;
max-width: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__status,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__autosave-status {
display: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent {
grid-column: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export {
grid-column: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save {
grid-column: 3;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar :is(
.studio-canvas-project-bar__rename,
.studio-canvas-project-bar__recent,
.studio-canvas-project-bar__export,
.studio-canvas-project-bar__save,
.studio-canvas-project-bar__publish
) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
min-width: 0;
min-height: 32px;
padding: 0 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 780;
line-height: 1;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename::after {
content: "编辑";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save span {
display: inline;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span {
width: 0;
max-width: 0;
overflow: hidden;
font-size: 0;
opacity: 0;
pointer-events: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent::after {
font-size: 11px;
content: "最近";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export::after {
font-size: 11px;
content: "导出";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish::after {
font-size: 11px;
content: "提交";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent em {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.16);
color: var(--accent);
font-size: 10px;
}
}
/* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */ /* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */
.web-shell[data-ui-theme="dark-green"] .floating-nav { .web-shell[data-ui-theme="dark-green"] .floating-nav {
flex-shrink: 0; flex-shrink: 0;
@@ -8712,21 +9280,43 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] .auth-page { .web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 200px 1fr; grid-template-rows: 180px minmax(0, 1fr);
overflow-y: auto;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel { .web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
padding: 24px 20px; align-items: flex-start;
padding: 22px 20px 36px;
border-top: 1px solid var(--border-weak); border-top: 1px solid var(--border-weak);
border-left: 0; border-left: 0;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: flex-start;
justify-content: center;
padding: 22px 24px;
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
gap: 9px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand { .web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 32px; font-size: 34px;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline { .web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 16px; font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
max-width: 520px;
margin: 0 auto;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page__body { .web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -8736,9 +9326,82 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-rows: 132px minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
padding: 16px 18px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
padding: 4px 8px;
font-size: 11px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
padding: 14px 14px 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 16px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 46px;
height: 46px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 21px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs { .web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 38px;
padding: 0 4px;
font-size: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 42px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
padding: 0 10px;
font-size: 12px;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page__body { .web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -8862,6 +9525,27 @@
} }
/* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */ /* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
overflow: hidden;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .workspace-page-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
min-height: 0;
height: 100%;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas-empty {
grid-template-rows: minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas { .web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
background-image: background-image:
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%), radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
+160
View File
@@ -0,0 +1,160 @@
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
export type GenerationLifecycleStatus =
| "creating"
| "queued"
| "running"
| "stopping"
| "failed"
| "completed"
| "local_timeout";
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
export interface TaskTimeoutPolicy {
submitTimeoutMs: number;
noProgressTimeoutMs: number;
maxRuntimeMs: number;
}
export interface TaskFailureInfo {
category: TaskErrorCategory;
message: string;
actionLabel: string;
retryable: boolean;
refundStatus: TaskRefundStatus;
refundHint: string;
}
export interface TextTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 10 * 60_000,
};
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 20 * 60_000,
};
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 30 * 60_000,
};
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 15 * 60_000,
};
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 30_000,
noProgressTimeoutMs: 60_000,
maxRuntimeMs: 5 * 60_000,
};
export function getTaskTimeoutPolicy(input: {
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}): TaskTimeoutPolicy {
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
const model = String(input.model || "").toLowerCase();
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
return VIDEO_TIMEOUT_POLICY;
}
export function isTaskLocallyTimedOut(input: {
startedAt: number;
lastProgressAt: number;
now?: number;
policy: TaskTimeoutPolicy;
progress?: number;
}): "no_progress" | "max_runtime" | null {
const now = input.now || Date.now();
const progress = Number(input.progress || 0);
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
return "no_progress";
}
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
return null;
}
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
if (kind === "text") {
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
}
const label = kind === "image" ? "图片" : "视频";
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
}
export function buildTaskFailureInfo(
error: string | undefined | null,
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
): TaskFailureInfo {
const classified = classifyTaskError(error);
const submitted = options.submitted !== false;
const refundStatus: TaskRefundStatus =
options.refundStatus ||
(submitted
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
? "not_charged"
: "unknown"
: "not_charged");
const refundHint = getRefundHint(refundStatus);
return {
category: classified.category,
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
actionLabel: classified.action,
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
refundStatus,
refundHint,
};
}
export function getRefundHint(status: TaskRefundStatus): string {
switch (status) {
case "not_charged":
return "提交未进入扣费结算,未产生积分消耗。";
case "pending_refund":
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
case "refunded":
return "失败扣费已退回,请在积分流水中核对。";
case "manual_review":
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
default:
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}