From 178a2c47da8932030df901b978707c517818a4b7 Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Fri, 5 Jun 2026 01:00:33 +0800 Subject: [PATCH] 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 --- src/api/aiGenerationClient.ts | 25 +- src/api/scriptEvalClient.ts | 84 ++- src/api/taskSubscription.ts | 42 +- src/features/canvas/CanvasPage.tsx | 8 +- src/features/canvas/canvasUtils.ts | 6 +- src/features/ecommerce/EcommercePage.tsx | 245 +++++-- .../ecommerce/ecommerceVideoService.ts | 4 + src/features/profile/ProfilePage.tsx | 14 +- .../script-tokens/ScriptTokensPage.tsx | 112 ++- src/features/workbench/WorkbenchPage.tsx | 83 ++- src/features/workbench/workbenchChatTypes.ts | 7 +- src/features/workbench/workbenchConstants.ts | 15 +- src/services/backgroundTaskRunner.ts | 51 +- src/styles/pages/script-tokens-v5.css | 152 ++++ src/styles/themes/dark-green.css | 694 +++++++++++++++++- src/utils/taskLifecycle.ts | 160 ++++ 16 files changed, 1607 insertions(+), 95 deletions(-) create mode 100644 src/utils/taskLifecycle.ts diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index 0afa038..accb697 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -134,6 +134,12 @@ export interface ChatInput { temperature?: number; } +export interface ChatUsage { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +} + export interface AiTaskStatus { taskId: string; projectId?: string; @@ -500,6 +506,7 @@ export const aiGenerationClient = { input: ChatInput, onChunk: (text: string) => void, signal?: AbortSignal, + onUsage?: (usage: ChatUsage) => void, ): Promise { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", @@ -529,8 +536,24 @@ export const aiGenerationClient = { const payload = line.slice(6).trim(); if (!payload) continue; 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.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.done) return; } catch (e) { diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index 9a8f836..d593541 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -4,6 +4,8 @@ export interface ScriptEvalResult { totalScore: number; grade: string; dimensionScores: Record; + subScores?: Record>; + evidence?: Record; summary: string; issues: string[]; highlights: string[]; @@ -12,6 +14,33 @@ export interface ScriptEvalResult { 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短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 【剧本类型识别】 @@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有 const DIMENSION_WEIGHTS: Record = { hook: { maxScore: 20 }, plot: { maxScore: 20 }, - character: { maxScore: 18 }, - dialogue: { maxScore: 15 }, + character: { maxScore: 15 }, + logic: { maxScore: 15 }, visual: { maxScore: 15 }, - content: { maxScore: 12 }, + content: { maxScore: 15 }, }; function computeTotalAndGrade(scores: Record): { totalScore: number; grade: string } { @@ -68,6 +97,48 @@ function extractJson(text: string): unknown { 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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeNestedScores(value: unknown): Record> { + if (!isRecord(value)) return {}; + + const normalized: Record> = {}; + 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 { + if (!isRecord(value)) return {}; + + const normalized: Record = {}; + 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 { const res = await fetch(buildApiUrl("ai/chat"), { method: "POST", @@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom model: MODEL, messages: [ { role: "system", content: EVAL_SYSTEM_PROMPT }, + { role: "system", content: EVAL_OUTPUT_CONTRACT }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, ], stream: false, @@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); for (const key of Object.keys(DIMENSION_WEIGHTS)) { - const val = Number(rawScores[key] ?? 0); - dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val)); + const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key]; + dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore); } const { totalScore, grade } = computeTotalAndGrade(dimensionScores); @@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom totalScore, grade, dimensionScores, + subScores: normalizeNestedScores(parsed.subScores), + evidence: normalizeEvidence(parsed.evidence), summary: String(parsed.summary || ""), issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts index dc599de..ae702ec 100644 --- a/src/api/taskSubscription.ts +++ b/src/api/taskSubscription.ts @@ -1,4 +1,9 @@ import { aiGenerationClient } from "./aiGenerationClient"; +import { + buildLocalTimeoutMessage, + getTaskTimeoutPolicy, + isTaskLocallyTimedOut, +} from "../utils/taskLifecycle"; export interface TaskProgressEvent { taskId: string; @@ -12,16 +17,28 @@ export interface WaitForTaskOptions { onProgress?: (event: TaskProgressEvent) => void; abortRef?: { current: boolean }; timeoutMs?: number; + noProgressTimeoutMs?: number; + startedAt?: number; + kind?: "image" | "video" | "text"; + model?: string | null; + operation?: string | null; } const POLL_INTERVAL = 3000; -const DEFAULT_TIMEOUT = 30 * 60 * 1000; export function waitForTask( taskId: string, options: WaitForTaskOptions = {}, ): Promise { - 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) => { let settled = false; @@ -29,6 +46,8 @@ export function waitForTask( let timeoutId: ReturnType | null = null; let sseConnected = false; let fallbackTimerId: ReturnType | null = null; + let lastProgress = 0; + let lastProgressAt = startedAt; const settle = (fn: () => void) => { if (settled) return; @@ -40,7 +59,7 @@ export function waitForTask( }; timeoutId = setTimeout( - () => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))), + () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))), timeoutMs, ); @@ -50,6 +69,11 @@ export function waitForTask( settle(() => resolve(null)); return; } + const progress = Number(event.progress || 0); + if (progress > lastProgress || event.status === "completed") { + lastProgress = Math.max(lastProgress, progress); + lastProgressAt = Date.now(); + } onProgress?.(event); if (event.status === "completed") { settle(() => resolve(event.resultUrl || null)); @@ -76,6 +100,16 @@ export function waitForTask( } await new Promise((r) => setTimeout(r, POLL_INTERVAL)); 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 { const task = await aiGenerationClient.getTaskStatus(taskId); handleUpdate({ @@ -90,7 +124,7 @@ export function waitForTask( } } }; - poll(); + void poll(); } }); } diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 5f405ff..1ca62a2 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -3750,12 +3750,12 @@ function CanvasPage({ onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} />
e.stopPropagation()}> - - + - - + +
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
void) { const resultUrl = await waitForTask(taskId, { - timeoutMs: 10 * 60 * 1000, + kind: "image", onProgress: (e) => { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); }, @@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status: export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { const resultUrl = await waitForTask(taskId, { - timeoutMs: 30 * 60 * 1000, + kind: "video", onProgress: (e) => { onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); }, @@ -495,4 +495,4 @@ export function getWorkflowNodeFocusSelection(node: WebCanvasWorkflow["nodes"][n height: clampCanvasPercent(height), ratio: toCanvasStyleString(selection.ratio, "16:9"), }; -} \ No newline at end of file +} diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index 670542f..cf61780 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -63,6 +63,8 @@ interface CloneImageItem { width?: number; height?: number; format?: string; + mimeType?: string; + ossKey?: string; } interface CloneResult { @@ -99,6 +101,18 @@ interface CloneSavedSetting { requirement: string; } +interface EcommerceImagePromptOptions { + gender?: string; + age?: string; + ethnicity?: string; + body?: string; + appearance?: string; + scenes?: string[]; + customScene?: string; + smartScene?: boolean; + detailModules?: string[]; +} + type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit"; interface PlatformRatioGroup { @@ -672,16 +686,85 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb }); } -function createObjectImageItems(files: File[], limit: number, prefix: string) { - return Array.from(files) - .slice(0, limit) - .map((file, index) => ({ - id: `${prefix}-${Date.now()}-${index}`, - src: URL.createObjectURL(file), +const blobToDataUrl = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(blob); + }); + +async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise { + const selectedFiles = Array.from(files).slice(0, limit); + const stamp = Date.now(); + + const items = await Promise.all(selectedFiles.map(async (file, index) => { + const localPreviewUrl = URL.createObjectURL(file); + let dimensions: { width?: number; height?: number } = {}; + try { + dimensions = await readImageDimensions(localPreviewUrl); + } catch { + dimensions = {}; + } finally { + URL.revokeObjectURL(localPreviewUrl); + } + + const mimeType = normalizeEcommerceImageMime(file.type); + const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType }); + const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, { + name: file.name, + mimeType, + scope: "ecommerce-product", + }); + + return { + id: `${prefix}-${stamp}-${index}`, + src: url, name: file.name, file, format: getImageFileFormat(file), - })); + mimeType, + ossKey, + ...dimensions, + }; + })); + + return items; +} + +async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise { + if (!sourceUrl) return sourceUrl; + try { + if (sourceUrl.startsWith("data:")) { + const { url } = await aiGenerationClient.uploadAsset({ + dataUrl: sourceUrl, + name: `${namePrefix}-${Date.now()}.png`, + scope, + }); + return url || sourceUrl; + } + + if (sourceUrl.startsWith("blob:")) { + const rawBlob = await fetch(sourceUrl).then((res) => res.blob()); + const mimeType = normalizeEcommerceImageMime(rawBlob.type); + const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType }); + const { url } = await aiGenerationClient.uploadAssetBinary(blob, { + name: `${namePrefix}-${Date.now()}.png`, + mimeType, + scope, + }); + return url; + } + + const { url } = await aiGenerationClient.uploadAssetByUrl({ + sourceUrl, + name: `${namePrefix}-${Date.now()}`, + scope, + }); + return url || sourceUrl; + } catch { + return sourceUrl; + } } function notifyRejectedImages(files: File[]): File[] { @@ -888,21 +971,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addSetImages = (files: File[]) => { + const addSetImages = async (files: File[]) => { if (setImages.length >= 3) return; const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; - setSetImages((current) => { - const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set"); - return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; - }); - setProductSetStatus("ready"); + try { + const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set"); + setSetImages((current) => { + if (current.length >= 3) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; + }); + setProductSetStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "商品图上传失败"); + } }; const handleSetUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addSetImages(Array.from(files)); + void addSetImages(Array.from(files)); event.target.value = ""; }; @@ -910,7 +998,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.preventDefault(); setIsSetUploadDragging(false); const files = Array.from(event.dataTransfer.files); - if (files.length) addSetImages(files); + if (files.length) void addSetImages(files); }; const removeSetImage = (imageId: string) => { @@ -921,22 +1009,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addProductImages = (files: File[]) => { + const addProductImages = async (files: File[]) => { const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; - setProductImages((current) => { - if (current.length >= maxCloneProductImages) return current; - const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product"); - return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; - }); - setStatus("ready"); - setResults([]); + try { + const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product"); + setProductImages((current) => { + if (current.length >= maxCloneProductImages) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; + }); + setStatus("ready"); + setResults([]); + } catch (err) { + toast.error(err instanceof Error ? err.message : "商品图上传失败"); + } }; const handleProductUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addProductImages(Array.from(files)); + void addProductImages(Array.from(files)); event.target.value = ""; }; @@ -944,7 +1036,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.preventDefault(); setIsProductUploadDragging(false); const files = Array.from(event.dataTransfer.files); - if (files.length) addProductImages(files); + if (files.length) void addProductImages(files); }; const removeProductImage = (imageId: string) => { @@ -970,24 +1062,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }); }; - const addCloneReferenceImages = (files: File[]) => { + const addCloneReferenceImages = async (files: File[]) => { const imageFiles = notifyRejectedImages(files); if (!imageFiles.length) return; const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length; if (remainingSlots <= 0) return; - const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference"); - if (!nextImages.length) return; - setCloneReferenceImages((current) => { - if (current.length >= maxCloneReferenceImages) return current; - return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current; - }); - hydrateCloneReferenceImageMeta(nextImages); + try { + const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference"); + if (!nextImages.length) return; + setCloneReferenceImages((current) => { + if (current.length >= maxCloneReferenceImages) return current; + return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current; + }); + hydrateCloneReferenceImageMeta(nextImages); + } catch (err) { + toast.error(err instanceof Error ? err.message : "参考图上传失败"); + } }; const handleCloneReferenceUpload = (event: ChangeEvent) => { const files = event.target.files; if (!files?.length) return; - addCloneReferenceImages(Array.from(files)); + void addCloneReferenceImages(Array.from(files)); event.target.value = ""; }; @@ -1302,8 +1398,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.target.value = ""; return; } - setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5)); - setTryOnStatus("ready"); + void (async () => { + try { + const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment"); + setGarmentImages((current) => [...current, ...nextImages].slice(0, 5)); + setTryOnStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "服饰图上传失败"); + } + })(); event.target.value = ""; }; @@ -1315,8 +1418,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { event.target.value = ""; return; } - setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3)); - setDetailStatus("ready"); + void (async () => { + try { + const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail"); + setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3)); + setDetailStatus("ready"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "详情图上传失败"); + } + })(); event.target.value = ""; }; @@ -1358,11 +1468,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" }, }; + const buildDetailModulePrompt = (moduleIds: string[]): string => { + if (!moduleIds.length) { + return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules."; + } + + const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id)); + if (!selectedModules.length) return ""; + + const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; "); + return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`; + }; + const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => { const info = setCountLabels[countKey]; const parts: string[] = []; parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`); parts.push(info.promptDesc); + if (countKey === "white") { + parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people."); + } + if (countKey === "scene") { + parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details."); + } + if (countKey === "selling") { + parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts."); + } if (totalCount > 1) { parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`); } @@ -1374,13 +1505,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const buildEcommerceImagePrompt = ( outputKey: CloneOutputKey, userText: string, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string, - tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean }, + tryOnOptions?: EcommerceImagePromptOptions, ): string => { const parts: string[] = []; if (outputKey === "detail") { parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing."); parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout."); parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules)); parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact."); } else if (outputKey === "model") { parts.push("Generate model/try-on lifestyle images for an e-commerce product listing."); @@ -1393,6 +1525,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`); if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`); if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`); + if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`); if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context."); } parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards."); @@ -1466,8 +1599,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (imageAbortRef.current.current) break; if (resultUrl) { - generatedUrls.push(resultUrl); - imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); + const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`); + generatedUrls.push(persistedUrl); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { generatedUrls.push(""); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); @@ -1505,7 +1639,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pRatio: string, pLanguage: string, pMarket: string, - tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean }, + tryOnOptions?: EcommerceImagePromptOptions, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, resultFn?: (results: CloneResult[]) => void, ): Promise => { @@ -1552,9 +1686,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } if (resultUrl) { - resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]); + const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`); + resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]); statusFn?.("done"); - imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl }); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { statusFn?.("idle"); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); @@ -1658,10 +1793,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { (urls) => setProductSetResultImages(urls), ); } else { + const clonePromptOptions: EcommerceImagePromptOptions | undefined = + cloneOutput === "model" + ? { + gender: cloneModelGender, + age: cloneModelAge, + ethnicity: cloneModelEthnicity, + body: cloneModelBody, + appearance: cloneModelAppearance, + scenes: selectedCloneModelScenes, + customScene: cloneModelCustomScene, + } + : cloneOutput === "detail" + ? { detailModules: selectedCloneDetailModules } + : undefined; void generateEcommerceImage( cloneOutput, productImages, requirement, platform, ratio, language, market, - undefined, + clonePromptOptions, (s: string) => setStatus(s as ProductCloneStatus), setResults, ); lastFailedActionRef.current = () => handleGenerate(); @@ -1741,7 +1890,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { void generateEcommerceImage( "detail", detailProductImages, detailRequirement, detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket, - undefined, + { detailModules: selectedDetailModules }, (s: string) => setDetailStatus(s as DetailStatus), (res) => setDetailResultUrl(res[0]?.src ?? null), ); @@ -1820,7 +1969,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { for (let i = 0; i < count; i++) { setPreviewCards.push({ id: `${countKey}-${i}`, - src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "", + src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "", label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`, }); setIndex++; @@ -1835,7 +1984,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { for (let i = 0; i < count; i++) { clonePreviewCards.push({ id: `${countKey}-${i}`, - src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "", + src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "", label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`, }); cloneIndex++; diff --git a/src/features/ecommerce/ecommerceVideoService.ts b/src/features/ecommerce/ecommerceVideoService.ts index acfa5ea..e06f82e 100644 --- a/src/features/ecommerce/ecommerceVideoService.ts +++ b/src/features/ecommerce/ecommerceVideoService.ts @@ -312,6 +312,8 @@ export async function renderSceneImage( const resultUrl = await waitForTask(taskId, { abortRef, + kind: "image", + model: "gpt-image-2", onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress), }); @@ -367,6 +369,8 @@ export async function renderScene( const resultUrl = await waitForTask(taskId, { abortRef, + kind: "video", + model, onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress), }); diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx index 8c40ded..f0a14a6 100644 --- a/src/features/profile/ProfilePage.tsx +++ b/src/features/profile/ProfilePage.tsx @@ -857,25 +857,25 @@ function ProfilePage({ 任务 {tasks.length}
-
+
{accountPanel === "credits" ? ( <> - + 当前账号 {displayName} - + 积分剩余 {(usage.balanceCents / 100).toFixed(2)} ) : ( <> - - 任务总数 - {tasks.length} + + 任务概览 + {tasks.length} 个任务 - + 已完成 {completedTasks.length} diff --git a/src/features/script-tokens/ScriptTokensPage.tsx b/src/features/script-tokens/ScriptTokensPage.tsx index c59cbb0..3c8a53e 100644 --- a/src/features/script-tokens/ScriptTokensPage.tsx +++ b/src/features/script-tokens/ScriptTokensPage.tsx @@ -25,6 +25,8 @@ interface EvalResult { totalScore: number; grade: string; dimensionScores: Record; + subScores?: Record>; + evidence?: Record; summary: string; issues: string[]; highlights: string[]; @@ -192,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [ { key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" }, ]; +const SUB_SCORE_LABELS: Record = { + openingImpact: "开篇冲击", + suspenseChain: "悬念链", + sceneHook: "场内钩子", + structure: "结构完整", + rhythm: "节奏推进", + conflict: "冲突强度", + reversal: "反转效率", + motivation: "动机清晰", + arc: "人物弧光", + voice: "语言辨识", + relationship: "关系张力", + causality: "因果链", + worldRules: "世界规则", + foreshadowing: "伏笔回收", + continuity: "连续性", + sceneDetail: "场景细节", + shotPotential: "镜头潜力", + aigcFeasibility: "AIGC 可实现", + theme: "主题表达", + emotion: "情感共鸣", + marketFit: "市场匹配", + originality: "原创性", +}; + +function clampScore(score: unknown, maxScore: number): number { + const numeric = Number(score); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(maxScore, numeric)); +} + +function getDimensionScore(result: EvalResult, dim: ScoreDimension): number { + const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined); + return clampScore(value, dim.maxScore); +} + +function formatSubScoreLabel(key: string): string { + return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim(); +} + +function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> { + const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined); + if (!scores) return []; + return Object.entries(scores) + .map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number]) + .filter(([, value]) => value > 0) + .slice(0, 5); +} + +function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] { + const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined); + return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : []; +} + function formatReportMarkdown(result: EvalResult, script: string): string { const lines: string[] = []; lines.push(`# 剧本评测报告`); @@ -203,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string { lines.push(""); lines.push(`## 六维评分`); for (const dim of SCORE_DIMENSIONS) { - const score = result.dimensionScores[dim.key] ?? 0; + const score = getDimensionScore(result, dim); const pct = Math.round((score / dim.maxScore) * 100); + const subScores = getDimensionSubScores(result, dim); + const evidence = getDimensionEvidence(result, dim); + const nestedReportLines = [ + ...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`), + ...evidence.map((item) => ` - 证据: ${item}`), + ]; lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`); + lines.push(...nestedReportLines); } if (result.highlights.length > 0) { lines.push(""); @@ -636,7 +699,7 @@ function ScriptTokensPage() {
{SCORE_DIMENSIONS.map((dim, dimIndex) => { - const score = result.dimensionScores[dim.key] ?? 0; + const score = getDimensionScore(result, dim); const pct = Math.max(0, Math.min(1, score / dim.maxScore)); const lossPct = 1 - pct; const isPerfect = score === dim.maxScore; @@ -676,6 +739,51 @@ function ScriptTokensPage() {
+
+ {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 ( +
+
+
+ {dim.label} + {score}/{dim.maxScore} +
+ {pct}% +
+

{dim.hint}

+ {subScores.length > 0 ? ( +
+ {subScores.map(([key, value]) => { + const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100))); + return ( +
+ {formatSubScoreLabel(key)} + + {value} +
+ ); + })} +
+ ) : ( +

等待模型返回更细的子项评分;当前先按主维度分数展示。

+ )} + {evidence.length > 0 ? ( +
    + {evidence.map((item, index) =>
  • {item}
  • )} +
+ ) : null} +
+ ); + })} +
+
{result.highlights.length > 0 ? (
diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 140c0e9..a99ea1d 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -64,6 +64,12 @@ import { import { renderMarkdownBlocks } from "./markdownRenderer"; import { downloadResultAsset } from "./workbenchDownload"; import { translateTaskError } from "../../utils/translateTaskError"; +import { + buildLocalTimeoutMessage, + formatTextTokenUsage, + getTaskTimeoutPolicy, + isTaskLocallyTimedOut, +} from "../../utils/taskLifecycle"; import { detectMentionTrigger } from "../../utils/mentionTrigger"; import { isHappyHorseModel, @@ -865,6 +871,9 @@ function WorkbenchPage({ let lastKnownProgress = Math.max(0, Number(task.progress || 0)); let taskPollFailures = 0; + let lastProgressAt = task.startedAt || Date.now(); + const taskKind = task.mode === "image" ? "image" : "video"; + const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation }); const abortController = new AbortController(); taskAbortControllersRef.current.set(task.taskId, abortController); if (activeConversationIdRef.current === task.conversationId) { @@ -911,6 +920,9 @@ function WorkbenchPage({ const progress = status.status === "completed" ? 100 : Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress))); + if (progress > lastKnownProgress || status.status === "completed") { + lastProgressAt = Date.now(); + } lastKnownProgress = Math.max(lastKnownProgress, progress); const isSuperResolveTask = task.operation === "video-super-resolution"; const statusLabel = @@ -935,6 +947,28 @@ function WorkbenchPage({ setGenerationProgress(progress); } + const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled" + ? isTaskLocallyTimedOut({ + startedAt: task.startedAt || Date.now(), + lastProgressAt, + progress, + policy: timeoutPolicy, + }) + : null; + if (localTimeoutReason) { + await patchConversationMessage(task.conversationId, task.assistantMessageId, { + body: buildLocalTimeoutMessage(taskKind), + status: "local_timeout", + taskLifecycleStatus: "local_timeout", + taskRefundStatus: "unknown", + taskProgress: progress, + taskStatusLabel: "本地等待超时", + }); + removeKeepaliveTask(task.taskId); + onRefreshUsage?.(); + return; + } + if (status.status === "completed" && status.resultUrl) { const completedPatch: Partial = { body: isSuperResolveTask @@ -1982,6 +2016,7 @@ function WorkbenchPage({ runKeepalivePoll(keepaliveTask); } else { let streamedText = ""; + let chatUsage: ChatMessage["taskUsage"] | undefined; setGenerationProgress(36); setGenerationStatus("正在回复"); updateAssistantMessage(assistantMessageId, { @@ -2014,6 +2049,9 @@ function WorkbenchPage({ }); }, abortController.signal, + (usage) => { + chatUsage = usage; + }, ); if (abortController.signal.aborted) return; @@ -2022,6 +2060,7 @@ function WorkbenchPage({ const completedMessages = updateAssistantMessage(assistantMessageId, { body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。", status: "completed", + taskUsage: chatUsage, }); if (!conversationId) { const conv = await conversationClient.create( @@ -2149,6 +2188,38 @@ function WorkbenchPage({ } }; + const handleReleaseStuckTask = (message: ChatMessage) => { + if (message.taskId) { + taskAbortControllersRef.current.get(message.taskId)?.abort(); + taskAbortControllersRef.current.delete(message.taskId); + removeKeepaliveTask(message.taskId); + } + if (message.conversationId) { + void patchConversationMessage(message.conversationId, message.id, { + body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"), + status: "local_timeout", + taskLifecycleStatus: "local_timeout", + taskRefundStatus: message.taskRefundStatus || "unknown", + taskStatusLabel: "本地占用已释放", + }); + } + setMessages((current) => + current.map((item) => + item.id === message.id + ? { + ...item, + body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"), + status: "local_timeout", + taskLifecycleStatus: "local_timeout", + taskRefundStatus: item.taskRefundStatus || "unknown", + taskStatusLabel: "本地占用已释放", + } + : item, + ), + ); + syncActiveGenerationUi(); + }; + const handleSuperResolveVideo = async (message: ChatMessage) => { if (!message.resultUrl || message.resultType !== "video") { setProjectError("仅支持对视频结果进行超分"); @@ -3007,7 +3078,7 @@ function WorkbenchPage({ ))}
)} - {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") && (
+
)} - {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") && ( handleStopSingleTask(message.id)} /> )} {message.status === "thinking" && message.mode === "chat" && ( @@ -3025,6 +3099,11 @@ function WorkbenchPage({ {message.taskStatusLabel || generationStatus}
)} + {message.role === "assistant" && message.mode === "chat" && message.status === "completed" && ( +
+ {formatTextTokenUsage(message.taskUsage)} +
+ )} {(message.resultUrl || (message.result && message.status !== "thinking")) && ( ): boolean { return ( patch.status === "completed" || patch.status === "failed" || + patch.status === "local_timeout" || + patch.status === "stopping" || typeof patch.taskId === "string" || typeof patch.resultUrl === "string" || typeof patch.resultOssKey === "string" || typeof patch.resultOriginalUrl === "string" || - typeof patch.resultMimeType === "string" + typeof patch.resultMimeType === "string" || + typeof patch.taskRefundStatus === "string" || + typeof patch.taskLifecycleStatus === "string" || + typeof patch.taskUsage === "object" ); } @@ -401,4 +410,4 @@ export function buildAssistantResult( summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。", specs: [model, ...specs], }; -} \ No newline at end of file +} diff --git a/src/services/backgroundTaskRunner.ts b/src/services/backgroundTaskRunner.ts index 8314259..15b235f 100644 --- a/src/services/backgroundTaskRunner.ts +++ b/src/services/backgroundTaskRunner.ts @@ -1,5 +1,11 @@ import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore"; import { aiGenerationClient } from "../api/aiGenerationClient"; +import { + buildLocalTimeoutMessage, + buildTaskFailureInfo, + getTaskTimeoutPolicy, + isTaskLocallyTimedOut, +} from "../utils/taskLifecycle"; type PollCallback = (item: GenerationQueueItem) => void; @@ -7,7 +13,7 @@ const activePollers = new Map>(); const pollCallbacks = new Set(); 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 { pollCallbacks.add(callback); @@ -18,10 +24,25 @@ function notifyCallbacks(item: GenerationQueueItem): void { 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 { const key = `poll-${item.id}`; 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 current = useGenerationStore.getState().queue.find((i) => i.id === item.id); if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") { @@ -30,18 +51,31 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): } 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, { status: "failed", - error: "任务超时,请重新提交", + error, }); - notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" }); + notifyCallbacks({ ...item, status: "failed", error }); cleanupPoll(key); return; } try { 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 = { progress: status.progress, resultUrl: status.resultUrl || current.resultUrl, @@ -55,6 +89,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): cleanupPoll(key); } else if (status.status === "failed" || status.status === "cancelled") { patch.status = "failed"; + patch.error = buildTaskFailureInfo(status.error).message; useGenerationStore.getState().updateTask(item.id, patch); notifyCallbacks({ ...item, ...patch, status: "failed" }); cleanupPoll(key); @@ -64,7 +99,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): notifyCallbacks({ ...item, ...patch, status: "running" }); } } catch { - // Network error during poll — keep trying + // Network errors during polling are retried until the lifecycle guard trips. } }, POLL_INTERVAL); @@ -105,24 +140,20 @@ export function stopAllPolling(): void { activePollers.clear(); } -// ── Recovery on page load ────────────────────────── export function recoverAndResumeTasks(): void { const pendingTasks = useGenerationStore.getState().getRunningTasks(); if (!pendingTasks.length) return; pendingTasks.forEach((task) => { if (task.taskId) { - // Mark as pending so the workbench/ecommerce can re-submit to polling useGenerationStore.getState().updateTask(task.id, { status: "pending" }); } else { - // No taskId means it was queued but never submitted — mark failed useGenerationStore.getState().updateTask(task.id, { status: "failed", - error: "页面刷新后任务丢失,请重新提交", + error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交。", }); } }); - // Start polling recovered tasks setTimeout(() => startBackgroundPolling(), 500); } diff --git a/src/styles/pages/script-tokens-v5.css b/src/styles/pages/script-tokens-v5.css index 666a3c0..ff96821 100644 --- a/src/styles/pages/script-tokens-v5.css +++ b/src/styles/pages/script-tokens-v5.css @@ -3234,6 +3234,141 @@ 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 { gap: 18px; } @@ -3282,6 +3417,10 @@ .script-eval-report--inside .script-eval-report__body { padding-inline: 24px; } + + .script-eval-report__detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 900px) { @@ -3301,6 +3440,19 @@ } @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 { overflow: hidden; } diff --git a/src/styles/themes/dark-green.css b/src/styles/themes/dark-green.css index bcf25f0..df0ad2a 100644 --- a/src/styles/themes/dark-green.css +++ b/src/styles/themes/dark-green.css @@ -2376,6 +2376,48 @@ 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.is-video .ai-generation-pending-card__bar { position: absolute; @@ -4917,6 +4959,332 @@ 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 { height: 100%; overflow-y: auto; @@ -6336,6 +6704,8 @@ .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { width: 82px; height: 82px; + justify-content: flex-start; + padding-left: 30px; } .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge { @@ -6404,6 +6774,75 @@ 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 { grid-template-columns: 1fr; gap: 8px; @@ -6705,6 +7144,7 @@ .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { width: 72px; height: 72px; + padding-left: 24px; } .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts { @@ -8282,6 +8722,134 @@ 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. */ .web-shell[data-ui-theme="dark-green"] .floating-nav { flex-shrink: 0; @@ -8712,21 +9280,43 @@ @media (max-width: 900px) { .web-shell[data-ui-theme="dark-green"] .auth-page { 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 { - padding: 24px 20px; + align-items: flex-start; + padding: 22px 20px 36px; border-top: 1px solid var(--border-weak); 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 { - font-size: 32px; + font-size: 34px; } .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 { @@ -8736,9 +9326,82 @@ } @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 { 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 { @@ -8862,6 +9525,27 @@ } /* 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 { background-image: radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%), diff --git a/src/utils/taskLifecycle.ts b/src/utils/taskLifecycle.ts new file mode 100644 index 0000000..ae3a850 --- /dev/null +++ b/src/utils/taskLifecycle.ts @@ -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}`; +}