Compare commits

..

1 Commits

Author SHA1 Message Date
stringadmin 6cc81e3804 Improve generation task client errors 2026-06-04 21:07:48 +08:00
35 changed files with 214 additions and 4530 deletions
+1 -5
View File
@@ -48,7 +48,6 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const HomePage = lazy(() => import("./features/home/HomePage")); const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
@@ -111,7 +110,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale", "resolutionUpscale",
"watermarkRemoval", "watermarkRemoval",
"subtitleRemoval", "subtitleRemoval",
"dialogGenerator",
"digitalHuman", "digitalHuman",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
@@ -125,7 +123,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found", "not-found",
]); ]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
@@ -1161,8 +1159,6 @@ function App() {
onSelectView={handleSetView} onSelectView={handleSetView}
/> />
); );
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report": case "report":
return <ReportPage />; return <ReportPage />;
case "providerHealth": case "providerHealth":
+1 -24
View File
@@ -134,12 +134,6 @@ 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;
@@ -506,7 +500,6 @@ 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",
@@ -536,24 +529,8 @@ 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 { const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
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) {
+5 -79
View File
@@ -4,8 +4,6 @@ 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[];
@@ -14,33 +12,6 @@ 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短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】 【剧本类型识别】
@@ -75,10 +46,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: 15 }, character: { maxScore: 18 },
logic: { maxScore: 15 }, dialogue: { maxScore: 15 },
visual: { maxScore: 15 }, visual: { maxScore: 15 },
content: { maxScore: 15 }, content: { maxScore: 12 },
}; };
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } { function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -97,48 +68,6 @@ 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",
@@ -147,7 +76,6 @@ 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,
@@ -173,8 +101,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 rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key]; const val = Number(rawScores[key] ?? 0);
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore); dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -183,8 +111,6 @@ 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) : [],
+4 -38
View File
@@ -1,9 +1,4 @@
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;
@@ -17,28 +12,16 @@ 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 } = options; const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = 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;
@@ -46,8 +29,6 @@ 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;
@@ -59,7 +40,7 @@ export function waitForTask(
}; };
timeoutId = setTimeout( timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))), () => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
timeoutMs, timeoutMs,
); );
@@ -69,11 +50,6 @@ 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));
@@ -100,16 +76,6 @@ 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({
@@ -124,7 +90,7 @@ export function waitForTask(
} }
} }
}; };
void poll(); poll();
} }
}); });
} }
+2 -4
View File
@@ -13,7 +13,6 @@ import {
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore";
import type { ServerConnectionHealth } from "../api/serverConnection"; import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets"; import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
@@ -75,7 +74,7 @@ function AppShell({
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null); const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login"; const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
const toolSurfaceViews = [ const toolSurfaceViews = [
"workbench", "workbench",
"canvas", "canvas",
@@ -87,7 +86,6 @@ function AppShell({
"imageWorkbench", "imageWorkbench",
"resolutionUpscale", "resolutionUpscale",
"digitalHuman", "digitalHuman",
"dialogGenerator",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
] as WebViewKey[]; ] as WebViewKey[];
@@ -371,7 +369,7 @@ function AppShell({
className="member-button" className="member-button"
type="button" type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`} aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => toast.info("充值功能即将开放,敬请期待")} onClick={() => setRechargeOpen(true)}
> >
<WalletOutlined /> <WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span> <span className="member-button__label">{displayedBalanceLabel}</span>
-1
View File
@@ -23,7 +23,6 @@ const NAV_ORDER: string[] = [
"resolutionUpscale", "resolutionUpscale",
"watermarkRemoval", "watermarkRemoval",
"subtitleRemoval", "subtitleRemoval",
"dialogGenerator",
"digitalHuman", "digitalHuman",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
+1 -1
View File
@@ -21,7 +21,7 @@ export const ossAssets = {
}, },
home: { home: {
backgroundVideo: muban("hero-bg.mp4"), backgroundVideo: muban("hero-bg.mp4"),
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")], heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
features: { features: {
ecommerce: muban("feature-ecommerce.jpg"), ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"), script: muban("feature-script.jpg"),
+10 -59
View File
@@ -182,7 +182,6 @@ import {
} from "./canvasWorkflowDeserialize"; } from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -337,7 +336,6 @@ function CanvasPage({
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null); const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null); const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null); const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null); const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]); const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false); const [stylePickerLoading, setStylePickerLoading] = useState(false);
@@ -2826,7 +2824,7 @@ function CanvasPage({
if (targetPort) { if (targetPort) {
connectCanvasPorts(connectorDrag.port, targetPort); connectCanvasPorts(connectorDrag.port, targetPort);
} else { } else {
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40); const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
setConnectionDropMenu({ setConnectionDropMenu({
...menuPosition, ...menuPosition,
originLeft: event.clientX, originLeft: event.clientX,
@@ -3752,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="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}% {Math.round(canvasViewport.zoom * 100)}%
</button> </button>
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button> <button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button> <button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div> </div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? ( {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div <div
@@ -4266,7 +4264,7 @@ function CanvasPage({
setSelectedExistingCategory(""); setSelectedExistingCategory("");
setSaveAssetOpen(true); setSaveAssetOpen(true);
} }
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode }); if (key === "upscale") void handleGenerateImageNode(imageNode.id);
}} }}
moreActions={[ moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl }, { key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4572,42 +4570,16 @@ function CanvasPage({
)} )}
<button <button
type="button" type="button"
title="多宫格生成" className={imageNodeFocusActive ? "is-active" : ""}
disabled={!imageNode.imageUrl} title="框选聚焦区域"
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setCanvasToolModal({ tool: "multiGrid", imageNode }); openImageFocusMode(imageNode);
}} }}
> >
<BarsOutlined /><span></span> <BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
</button> </button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button> <button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div> </div>
@@ -5757,27 +5729,6 @@ function CanvasPage({
</section> </section>
</div> </div>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
</WorkspacePageShell> </WorkspacePageShell>
); );
} }
-221
View File
@@ -1,221 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+2 -2
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, {
kind: "image", timeoutMs: 10 * 60 * 1000,
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, {
kind: "video", timeoutMs: 30 * 60 * 1000,
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);
}, },
@@ -1,290 +0,0 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
interface DialogItem {
id: number;
style: DialogStyle;
x: number;
y: number;
text: string;
color: string;
confirmed: boolean;
}
interface DragState {
id: number;
offsetX: number;
offsetY: number;
}
const dialogStyles: Array<{
key: DialogStyle;
label: string;
description: string;
swatchClass: string;
}> = [
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
];
const textColorOptions = [
{ value: "#ffffff", label: "白色" },
{ value: "#111827", label: "黑色" },
{ value: "#ef4444", label: "红色" },
{ value: "#f59e0b", label: "黄色" },
{ value: "#165dff", label: "蓝色" },
{ value: "#00ff88", label: "绿色" },
];
function DialogGeneratorPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const nextIdRef = useRef(0);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const handleFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setBackgroundUrl(reader.result);
}
};
reader.readAsDataURL(file);
}, []);
const addDialog = useCallback((style: DialogStyle) => {
nextIdRef.current += 1;
const id = nextIdRef.current;
setDialogs((current) => [
...current,
{
id,
style,
x: 30 + (id * 25) % 200,
y: 30 + (id * 20) % 150,
text: "",
color: selectedTextColor,
confirmed: false,
},
]);
}, [selectedTextColor]);
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}, []);
const deleteDialog = useCallback((id: number) => {
setDialogs((current) => current.filter((item) => item.id !== id));
}, []);
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
dragRef.current = {
id,
offsetX: clientX - rect.left,
offsetY: clientY - rect.top,
};
setActiveDragId(id);
}, []);
const moveDrag = useCallback((clientX: number, clientY: number) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview) return;
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
if (!dialogEl) return;
const bounds = preview.getBoundingClientRect();
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
updateDialog(drag.id, { x: nextX, y: nextY });
}, [updateDialog]);
const endDrag = useCallback(() => {
dragRef.current = null;
setActiveDragId(null);
}, []);
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
moveDrag(event.clientX, event.clientY);
}, [moveDrag]);
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
if (!touch) return;
moveDrag(touch.clientX, touch.clientY);
}, [moveDrag]);
return (
<section className="dialog-generator-page page-motion">
<div className="dialog-generator-shell">
<aside className="dialog-generator-panel">
<div className="dialog-generator-heading">
<span className="dialog-generator-kicker">Interactive Dialog</span>
<h1></h1>
<p></p>
</div>
<div className="dialog-generator-section">
<h2></h2>
<button
type="button"
className="dialog-generator-drop"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
handleFile(event.dataTransfer.files[0]);
}}
>
<span className="dialog-generator-drop-icon">🖼</span>
<strong></strong>
<small> JPGPNGWEBP </small>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => handleFile(event.target.files?.[0])}
/>
</div>
<div className="dialog-generator-section">
<h2></h2>
<p className="dialog-generator-hint"></p>
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
{textColorOptions.map((item) => (
<button
key={item.value}
type="button"
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
style={{ "--text-color": item.value } as CSSProperties}
aria-checked={selectedTextColor === item.value}
role="radio"
onClick={() => setSelectedTextColor(item.value)}
>
<span />
<strong>{item.label}</strong>
</button>
))}
</div>
<div className="dialog-generator-style-list">
{dialogStyles.map((item) => (
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
<span>
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
</button>
))}
</div>
</div>
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
</button>
</aside>
<main className="dialog-generator-preview-card">
<div className="dialog-generator-preview-head">
<div>
<span>Preview</span>
<h2></h2>
</div>
<p></p>
</div>
<div
ref={previewRef}
className="dialog-generator-preview"
onMouseMove={handleCanvasMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchMove={handleCanvasTouchMove}
onTouchEnd={endDrag}
>
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
{!backgroundUrl ? (
<div className="dialog-generator-empty">
<span>🖼</span>
<p></p>
</div>
) : null}
{dialogs.map((dialog) => (
<div
key={dialog.id}
data-dialog-id={dialog.id}
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
onMouseDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
startDrag(dialog.id, event.clientX, event.clientY);
event.preventDefault();
}}
onTouchStart={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
const touch = event.touches[0];
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
}}
onDoubleClick={() => {
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
}}
>
{!dialog.confirmed ? (
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
×
</button>
) : null}
{dialog.confirmed ? (
<div className="dialog-generator-text-display">{dialog.text}</div>
) : (
<textarea
className="dialog-generator-text"
rows={2}
placeholder="输入文本..."
value={dialog.text}
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
/>
)}
{!dialog.confirmed ? (
<div className="dialog-generator-bubble-bottom">
<button
type="button"
className="dialog-generator-confirm"
onClick={() => {
if (dialog.text.trim()) {
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
}
}}
>
</button>
</div>
) : null}
</div>
))}
</div>
</main>
</div>
</section>
);
}
export default DialogGeneratorPage;
+63 -308
View File
@@ -59,12 +59,9 @@ interface CloneImageItem {
id: string; id: string;
src: string; src: string;
name: string; name: string;
file?: File;
width?: number; width?: number;
height?: number; height?: number;
format?: string; format?: string;
mimeType?: string;
ossKey?: string;
} }
interface CloneResult { interface CloneResult {
@@ -101,18 +98,6 @@ 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 {
@@ -686,85 +671,15 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
}); });
} }
const blobToDataUrl = (blob: Blob): Promise<string> => function createObjectImageItems(files: File[], limit: number, prefix: string) {
new Promise((resolve, reject) => { return Array.from(files)
const reader = new FileReader(); .slice(0, limit)
reader.onload = () => resolve(String(reader.result || "")); .map<CloneImageItem>((file, index) => ({
reader.onerror = () => reject(reader.error || new Error("文件读取失败")); id: `${prefix}-${Date.now()}-${index}`,
reader.readAsDataURL(blob); src: URL.createObjectURL(file),
});
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, name: file.name,
mimeType,
scope: "ecommerce-product",
});
return {
id: `${prefix}-${stamp}-${index}`,
src: url,
name: file.name,
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[] {
@@ -876,7 +791,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [status, setStatus] = useState<ProductCloneStatus>("idle"); const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]); const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false }); const imageAbortRef = useRef({ current: false });
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null); const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]); const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai"); const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
@@ -931,30 +845,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`, "--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties; } as CSSProperties;
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => { const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null); setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
}; };
@@ -971,26 +861,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
}; };
const addSetImages = async (files: File[]) => { const addSetImages = (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;
try { setSetImages((current) => {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set"); const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
setSetImages((current) => { return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
if (current.length >= 3) return current; });
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current; setProductSetStatus("ready");
});
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;
void addSetImages(Array.from(files)); addSetImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -998,7 +883,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) void addSetImages(files); if (files.length) addSetImages(files);
}; };
const removeSetImage = (imageId: string) => { const removeSetImage = (imageId: string) => {
@@ -1009,26 +894,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
}; };
const addProductImages = async (files: File[]) => { const addProductImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files); const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return; if (!imageFiles.length) return;
try { setProductImages((current) => {
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product"); if (current.length >= maxCloneProductImages) return current;
setProductImages((current) => { const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
if (current.length >= maxCloneProductImages) return current; return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current; });
}); setStatus("ready");
setStatus("ready"); setResults([]);
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;
void addProductImages(Array.from(files)); addProductImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -1036,7 +917,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) void addProductImages(files); if (files.length) addProductImages(files);
}; };
const removeProductImage = (imageId: string) => { const removeProductImage = (imageId: string) => {
@@ -1062,28 +943,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}); });
}; };
const addCloneReferenceImages = async (files: File[]) => { const addCloneReferenceImages = (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;
try { const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference"); if (!nextImages.length) return;
if (!nextImages.length) return; setCloneReferenceImages((current) => {
setCloneReferenceImages((current) => { if (current.length >= maxCloneReferenceImages) return current;
if (current.length >= maxCloneReferenceImages) return current; return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : 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;
void addCloneReferenceImages(Array.from(files)); addCloneReferenceImages(Array.from(files));
event.target.value = ""; event.target.value = "";
}; };
@@ -1398,15 +1275,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = ""; event.target.value = "";
return; return;
} }
void (async () => { setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
try { setTryOnStatus("ready");
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 = "";
}; };
@@ -1418,15 +1288,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = ""; event.target.value = "";
return; return;
} }
void (async () => { setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
try { setDetailStatus("ready");
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 = "";
}; };
@@ -1442,15 +1305,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const urls: string[] = []; const urls: string[] = [];
for (const item of images) { for (const item of images) {
try { try {
if (!item.file && item.src.startsWith("blob:")) { const resp = await fetch(item.src);
throw new Error("本地预览图缺少原始文件,无法上传"); const rawBlob = await resp.blob();
} const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob()); const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const mimeType = normalizeEcommerceImageMime( const dataUrl = await blobToDataUrl(blob);
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" }); const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url); urls.push(url);
} catch { } catch {
@@ -1468,32 +1327,11 @@ 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.`);
} }
@@ -1505,14 +1343,13 @@ 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?: EcommerceImagePromptOptions, tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
): 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.");
@@ -1525,7 +1362,6 @@ 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.");
@@ -1559,10 +1395,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatusFn("idle"); setStatusFn("idle");
return; return;
} }
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = []; const generatedUrls: string[] = [];
const stamp = Date.now(); const stamp = Date.now();
@@ -1582,26 +1414,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single", gridMode: "single",
referenceUrls, referenceUrls,
}); });
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null; const resultUrl = await waitForTask(taskId, {
try { abortRef: imageAbortRef.current,
resultUrl = await waitForTask(taskId, { onProgress: () => {},
abortRef: imageAbortRef.current, });
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) { if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`); generatedUrls.push(resultUrl);
generatedUrls.push(persistedUrl); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
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: "生成未返回结果" });
@@ -1609,17 +1432,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
} }
} }
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls); setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle"); setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
} catch (err) { } catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) { if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]); setResultFn([]);
toast.error("余额不足,请充值后继续"); toast.error("余额不足,请充值后继续");
@@ -1639,7 +1454,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string, pRatio: string,
pLanguage: string, pLanguage: string,
pMarket: string, pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions, tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
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> => {
@@ -1650,10 +1465,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
statusFn?.("idle"); statusFn?.("idle");
return; return;
} }
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions); const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now(); const stamp = Date.now();
@@ -1666,39 +1477,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single", gridMode: "single",
referenceUrls, referenceUrls,
}); });
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null; const resultUrl = await waitForTask(taskId, {
try { abortRef: imageAbortRef.current,
resultUrl = await waitForTask(taskId, { onProgress: () => {},
abortRef: imageAbortRef.current, });
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) { if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`); resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
statusFn?.("done"); statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else { } else {
statusFn?.("idle"); statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" }); imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
} }
} catch (err) { } catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) { if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]); resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续"); toast.error("余额不足,请充值后继续");
@@ -1732,38 +1527,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
dataUrl: refDataUrl, name: videoOutfitRefFile.name, dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit", mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
}); });
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
const { taskId } = await aiGenerationClient.createVideoEditTask({ const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url, videoUrl: videoAsset.url,
referenceUrls: [refAsset.url], referenceUrls: [refAsset.url],
prompt: requirement || undefined, prompt: requirement || undefined,
}); });
trackEcommerceTask(taskId);
const { waitForTask } = await import("../../api/taskSubscription"); const { waitForTask } = await import("../../api/taskSubscription");
let resultUrl: string | null = null; imageAbortRef.current = { current: false };
try { const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
if (resultUrl) { if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]); setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
} }
setStatus("done"); setStatus("done");
} catch (err) { } catch (err) {
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
setStatus("failed"); setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败"); toast.error(err instanceof Error ? err.message : "视频换装生成失败");
} }
@@ -1793,24 +1571,10 @@ 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,
clonePromptOptions, undefined,
(s: string) => setStatus(s as ProductCloneStatus), setResults, (s: string) => setStatus(s as ProductCloneStatus), setResults,
); );
lastFailedActionRef.current = () => handleGenerate(); lastFailedActionRef.current = () => handleGenerate();
@@ -1890,7 +1654,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,
{ detailModules: selectedDetailModules }, undefined,
(s: string) => setDetailStatus(s as DetailStatus), (s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null), (res) => setDetailResultUrl(res[0]?.src ?? null),
); );
@@ -1969,7 +1733,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++;
@@ -1984,7 +1748,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++;
@@ -2113,7 +1877,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
clampCloneVideoDuration={clampCloneVideoDuration} clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart} setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate} handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue} formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }} setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)} onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
@@ -2147,7 +1910,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleDetailAiWrite={handleDetailAiWrite} handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule} toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate} handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/> />
); );
@@ -2185,7 +1947,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSmartScene={setSmartScene} setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio} setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate} handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/> />
); );
@@ -2261,11 +2022,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{productSetStatus === "generating" ? <LoadingOutlined /> : null} {productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel} {setPrimaryLabel}
</button> </button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section> </section>
<button type="button" className="product-clone-help" aria-label="帮助"> <button type="button" className="product-clone-help" aria-label="帮助">
@@ -2617,7 +2373,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<EcommerceVideoWorkspace <EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)} isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)} productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
requirement={requirement} requirement={requirement}
platform={platform} platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"} aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -34,7 +34,6 @@ import {
interface EcommerceVideoWorkspaceProps { interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean; isAuthenticated: boolean;
productImageDataUrls: string[]; productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string; requirement: string;
platform: string; platform: string;
aspectRatio: string; aspectRatio: string;
@@ -98,7 +97,6 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({ export default function EcommerceVideoWorkspace({
isAuthenticated, isAuthenticated,
productImageDataUrls, productImageDataUrls,
productImageFiles = [],
requirement, requirement,
platform, platform,
aspectRatio, aspectRatio,
@@ -378,9 +376,8 @@ export default function EcommerceVideoWorkspace({
}); });
}; };
try { try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan( const result = await runVideoPlan(
productImageSources, requirement, buildConfig(), productImageDataUrls, requirement, buildConfig(),
{ {
onStepStart: (step) => setCurrentStep(step), onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => { onStepDone: (step) => {
+31 -72
View File
@@ -126,61 +126,13 @@ export interface PlanCallbacks {
resumeFrom?: EcommerceVideoPlanProgress; resumeFrom?: EcommerceVideoPlanProgress;
} }
const LOCAL_PREVIEW_MISSING_FILE_MESSAGE = "Please re-upload the product image before generating the short video.";
function readBlobAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("File read failed"));
reader.readAsDataURL(blob);
});
}
function normalizeRemoteImageUrl(source: string): string | null {
try {
const url = new URL(source, typeof window !== "undefined" ? window.location.href : undefined);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
async function uploadProductImageSource(source: string | Blob): Promise<string> {
if (typeof source === "string") {
if (source.startsWith("blob:")) {
throw new Error(LOCAL_PREVIEW_MISSING_FILE_MESSAGE);
}
if (source.startsWith("data:")) {
const mimeType = normalizeEcommerceImageMime(source.match(/^data:([^;,]+)/)?.[1] || "image/png");
const result = await aiGenerationClient.uploadAsset({ dataUrl: source, mimeType, scope: "ecommerce-product" });
return result.url;
}
const remoteUrl = normalizeRemoteImageUrl(source);
if (remoteUrl) {
const result = await aiGenerationClient.uploadAssetByUrl({ sourceUrl: remoteUrl, scope: "ecommerce-product" });
return result.url;
}
throw new Error("Unsupported product image URL. Please re-upload the product image.");
}
const mimeType = normalizeEcommerceImageMime(source.type || "image/png");
const blob = source.type === mimeType ? source : new Blob([source], { type: mimeType });
const dataUrl = await readBlobAsDataUrl(blob);
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
return result.url;
}
/** /**
* Run the full ad video planning pipeline. * Run the full ad video planning pipeline.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped. * Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state. * After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/ */
export async function runVideoPlan( export async function runVideoPlan(
imageSources: Array<string | Blob>, imageDataUrls: string[],
manualText: string, manualText: string,
config: AdVideoUserConfig, config: AdVideoUserConfig,
callbacks: PlanCallbacks, callbacks: PlanCallbacks,
@@ -189,30 +141,41 @@ export async function runVideoPlan(
const progress: EcommerceVideoPlanProgress = { ...resumeFrom }; const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress }); const emit = () => callbacks.onPartialProgress?.({ ...progress });
// Step: upload // ── Step: upload ──────────────────────────────────────
if (!progress.imageUrls?.length) { if (!progress.imageUrls?.length) {
onStepStart("upload"); onStepStart("upload");
const imageUrls: string[] = []; const imageUrls: string[] = [];
const rejected: string[] = []; const rejected: string[] = [];
for (const source of imageSources) { for (const srcUrl of imageDataUrls) {
try { try {
imageUrls.push(await uploadProductImageSource(source)); const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch (err) { } catch (err) {
rejected.push(err instanceof Error ? err.message : "Image upload failed"); rejected.push(err instanceof Error ? err.message : "图片上传失败");
} }
} }
if (rejected.length) { if (rejected.length) {
progress.uploadWarnings = rejected; progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected); callbacks.onUploadRejected?.(rejected);
} }
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again."); if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
progress.imageUrls = imageUrls; progress.imageUrls = imageUrls;
onStepDone("upload"); onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls); callbacks.onImagesUploaded?.(imageUrls);
emit(); emit();
} }
// Step: analyze // ── Step: analyze ─────────────────────────────────────
if (progress.imageDescription === undefined) { if (progress.imageDescription === undefined) {
onStepStart("analyze"); onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal); progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -220,7 +183,7 @@ export async function runVideoPlan(
emit(); emit();
} }
// Step: summary // ── Step: summary ─────────────────────────────────────
if (!progress.summary) { if (!progress.summary) {
onStepStart("summary"); onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal); progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -228,7 +191,7 @@ export async function runVideoPlan(
emit(); emit();
} }
// Step: selling // ── Step: selling ─────────────────────────────────────
if (!progress.selling) { if (!progress.selling) {
onStepStart("selling"); onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal); progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -236,16 +199,16 @@ export async function runVideoPlan(
emit(); emit();
} }
// Step: creative // ── Step: creative ────────────────────────────────────
if (!progress.creatives?.length) { if (!progress.creatives?.length) {
onStepStart("creative"); onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal); progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives."); if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative"); onStepDone("creative");
emit(); emit();
} }
// Step: storyboard // ── Step: storyboard ──────────────────────────────────
if (!progress.storyboard) { if (!progress.storyboard) {
onStepStart("storyboard"); onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal); progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -253,7 +216,7 @@ export async function runVideoPlan(
emit(); emit();
} }
// Step: prompts // ── Step: prompts ─────────────────────────────────────
if (!progress.videoPrompts) { if (!progress.videoPrompts) {
onStepStart("prompts"); onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal); progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -261,7 +224,7 @@ export async function runVideoPlan(
emit(); emit();
} }
// Step: compliance // ── Step: compliance ──────────────────────────────────
if (!progress.compliance) { if (!progress.compliance) {
onStepStart("compliance"); onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal); progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
@@ -312,15 +275,13 @@ 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),
}); });
if (resultUrl) { if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl); callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else { } else {
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result."); callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
} }
} }
@@ -369,15 +330,13 @@ 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),
}); });
if (resultUrl) { if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl); callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else { } else {
callbacks.onSceneFailed(input.sceneId, "Task returned no result."); callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
} }
} }
@@ -396,7 +355,7 @@ export function buildSceneTasks(
}); });
} }
// Video History API // ── Video History API ──────────────────────────────────
export interface VideoHistoryScene { export interface VideoHistoryScene {
sceneId: number; sceneId: number;
@@ -491,7 +450,7 @@ export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promis
headers: { "Content-Type": "application/json", ...getAuthHeaders() }, headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload), body: JSON.stringify(historyPayload),
}); });
if (!res.ok) throw new Error("Failed to save video history"); if (!res.ok) throw new Error("保存历史记录失败");
return res.json(); return res.json();
} }
@@ -515,7 +474,7 @@ export async function fetchVideoHistory(
`${API_BASE}?limit=${limit}&offset=${offset}`, `${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() }, { headers: getAuthHeaders() },
); );
if (!res.ok) throw new Error("Failed to fetch video history"); if (!res.ok) throw new Error("获取历史记录失败");
const history = (await res.json()) as VideoHistoryListResponse; const history = (await res.json()) as VideoHistoryListResponse;
return { return {
...history, ...history,
@@ -528,5 +487,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE", method: "DELETE",
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
if (!res.ok) throw new Error("Failed to delete video history"); if (!res.ok) throw new Error("删除失败");
} }
@@ -133,7 +133,6 @@ interface EcommerceClonePanelProps {
clampCloneVideoDuration: (value: number) => number; clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void; setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void; handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string; formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void; setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void; onStartVideoPlan?: () => void;
@@ -201,7 +200,6 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration, clampCloneVideoDuration,
setCloneVideoSmart, setCloneVideoSmart,
handleGenerate, handleGenerate,
onCancelGenerate,
formatRatioDisplayValue, formatRatioDisplayValue,
setVideoOutfitFiles, setVideoOutfitFiles,
onStartVideoPlan, onStartVideoPlan,
@@ -748,11 +746,6 @@ export default function EcommerceClonePanel({
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null} {status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"} {status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</button> </button>
{status === "generating" && cloneOutput !== "video" ? (
<button type="button" className="clone-ai-generate clone-ai-generate--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</div> </div>
</> </>
); );
@@ -28,7 +28,6 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void; handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void; toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void; handleDetailGenerate: () => void;
onCancelGenerate: () => void;
} }
export default function EcommerceDetailPanel({ export default function EcommerceDetailPanel({
@@ -57,7 +56,6 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite, handleDetailAiWrite,
toggleDetailModule, toggleDetailModule,
handleDetailGenerate, handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) { }: EcommerceDetailPanelProps) {
return ( return (
<> <>
@@ -164,11 +162,6 @@ export default function EcommerceDetailPanel({
{detailStatus === "generating" ? <LoadingOutlined /> : null} {detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel} {detailPrimaryLabel}
</button> </button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer> </footer>
</> </>
); );
@@ -35,7 +35,6 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void; setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void; setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void; handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
} }
export default function EcommerceTryOnPanel({ export default function EcommerceTryOnPanel({
@@ -71,7 +70,6 @@ export default function EcommerceTryOnPanel({
setSmartScene, setSmartScene,
setTryOnRatio, setTryOnRatio,
handleTryOnGenerate, handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) { }: EcommerceTryOnPanelProps) {
return ( return (
<> <>
@@ -215,11 +213,6 @@ export default function EcommerceTryOnPanel({
{tryOnStatus === "generating" ? <LoadingOutlined /> : null} {tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel} {tryOnPrimaryLabel}
</button> </button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer> </footer>
</> </>
); );
-2
View File
@@ -7,7 +7,6 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
HighlightOutlined, HighlightOutlined,
MessageOutlined,
SwapOutlined, SwapOutlined,
ThunderboltOutlined, ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
@@ -43,7 +42,6 @@ const tools: MoreTool[] = [
{ id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true }, { id: "camera", title: "镜头实验室", text: "角度、焦段和机位控制", icon: <CameraOutlined />, category: "image", imageTool: "camera", ready: true },
{ id: "upscale", title: "分辨率提升", text: "图片与视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true }, { id: "upscale", title: "分辨率提升", text: "图片与视频高清超分", icon: <ColumnWidthOutlined />, category: "image", target: "resolutionUpscale", ready: true },
{ id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印和文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true }, { id: "watermarkRemoval", title: "去水印", text: "AI 智能去除图片水印和文字", icon: <DeleteOutlined />, category: "image", target: "watermarkRemoval", ready: true },
{ id: "dialogGenerator", title: "交互式对话框生成器", text: "上传背景图,添加可拖拽编辑的对话框", icon: <MessageOutlined />, category: "image", target: "dialogGenerator", ready: true },
{ id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true }, { id: "subtitleRemoval", title: "字幕去除", text: "AI 智能擦除视频字幕", icon: <DeleteOutlined />, category: "video", target: "subtitleRemoval", ready: true },
{ id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true }, { id: "digitalHuman", title: "数字人", text: "参考人像与音频生成口播视频", icon: <CustomerServiceOutlined />, category: "video", target: "digitalHuman", ready: true, featured: true },
{ id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true }, { id: "characterMix", title: "角色迁移", text: "人物图迁移到参考视频动作", icon: <SwapOutlined />, category: "video", target: "characterMix", ready: true },
+40 -66
View File
@@ -260,30 +260,6 @@ function ProfilePage({
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分"; const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null; const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名"; const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
const activePanelTitle =
activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核";
const activePanelDescription =
activePanel === "works"
? "最近完成的高质量生成内容"
: activePanel === "projects"
? "云端同步的创作项目"
: activePanel === "assets"
? "可复用的图片、视频与素材"
: "已提交社区的案例状态";
const activePanelCount =
activePanel === "works"
? visibleWorks.length
: activePanel === "projects"
? projects.length
: activePanel === "assets"
? savedAssets.length
: communityCases.length;
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()); const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim()); const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1); const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
@@ -789,9 +765,9 @@ function ProfilePage({
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`} className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined} style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
> >
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景"> <button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<CameraOutlined /> <CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</button> </button>
<div className="profile-page__banner-overlay" /> <div className="profile-page__banner-overlay" />
</header> </header>
@@ -871,39 +847,35 @@ function ProfilePage({
className={accountPanel === "credits" ? "is-active" : ""} className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")} onClick={() => setAccountPanel("credits")}
> >
<span></span> {(totalBalance / 100).toFixed(2)}
<strong>{(totalBalance / 100).toFixed(2)}</strong>
</button> </button>
<button <button
type="button" type="button"
className={accountPanel === "tasks" ? "is-active" : ""} className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")} onClick={() => setAccountPanel("tasks")}
> >
<span></span> {tasks.length}
<strong>{tasks.length}</strong>
</button> </button>
</div> </div>
<div className="profile-page__account-summary"> <div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? ( {accountPanel === "credits" ? (
<> <>
<span className="profile-page__account-summary-main"> <span className="profile-page__meta-item">
<small></small> <small></small>
<strong>{displayName}</strong> <strong>{displayName}</strong>
<em>{packageLabel}</em>
</span> </span>
<span className="profile-page__account-summary-metric"> <span className="profile-page__meta-item">
<small></small> <small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong> <strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span> </span>
</> </>
) : ( ) : (
<> <>
<span className="profile-page__account-summary-main"> <span className="profile-page__meta-item">
<small></small> <small></small>
<strong>{tasks.length} </strong> <strong>{tasks.length}</strong>
<em>{completedTasks.length} </em>
</span> </span>
<span className="profile-page__account-summary-metric"> <span className="profile-page__meta-item">
<small></small> <small></small>
<strong>{completedTasks.length}</strong> <strong>{completedTasks.length}</strong>
</span> </span>
@@ -912,49 +884,51 @@ function ProfilePage({
</div> </div>
</div> </div>
<div className="profile-page__actions"> <button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan"> <ShareAltOutlined />
<ShareAltOutlined /> {packageLabel}
{packageLabel} </button>
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}> <button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined /> <PlusOutlined />
</button> </button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}> <button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined /> <ShareAltOutlined />
</button> </button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}> <button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined /> <LockOutlined />
退 退
</button> </button>
</div>
</aside> </aside>
<main className="profile-page__main"> <main className="profile-page__main">
<div className="profile-page__main-tabs"> <div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}> <button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}> <button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}> <button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
<span></span>
</button> </button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}> <button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
<span></span>
</button> </button>
</div> </div>
<div className="profile-page__section"> <div className="profile-page__section">
<div className="profile-page__section-head"> <span className="profile-page__section-label">
<span className="profile-page__section-label">{activePanelTitle}</span> {activePanel === "works"
<span className="profile-page__section-desc">{activePanelDescription}</span> ? "代表作"
<span className="profile-page__section-meta">{activePanelCount} </span> : activePanel === "projects"
</div> ? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
{renderActivePanel()} {renderActivePanel()}
</div> </div>
</main> </main>
+2 -110
View File
@@ -25,8 +25,6 @@ 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[];
@@ -194,60 +192,6 @@ 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(`# 剧本评测报告`);
@@ -259,16 +203,9 @@ 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 = getDimensionScore(result, dim); const score = result.dimensionScores[dim.key] ?? 0;
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("");
@@ -699,7 +636,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 = getDimensionScore(result, dim); const score = result.dimensionScores[dim.key] ?? 0;
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;
@@ -739,51 +676,6 @@ 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">
+20 -162
View File
@@ -64,12 +64,6 @@ 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,
@@ -109,7 +103,6 @@ import {
VIDEO_MODEL_OPTIONS, VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS, RATIO_OPTIONS,
GRID_MODE_OPTIONS, GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS, VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS, VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY, MESSAGE_STORAGE_KEY,
@@ -257,8 +250,6 @@ function WorkbenchPage({
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null); const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]); const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false); const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
const [isComposerDragging, setIsComposerDragging] = useState(false);
const composerDragCounterRef = useRef(0);
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null); const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null); const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]); const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
@@ -872,9 +863,6 @@ 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) {
@@ -921,9 +909,6 @@ 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 =
@@ -948,28 +933,6 @@ 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
@@ -1496,22 +1459,9 @@ function WorkbenchPage({
setReferenceItems(nextItems); setReferenceItems(nextItems);
}; };
const handleReferenceUploadClick = () => { const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
if (referenceItems.length > 0) { const files = Array.from(event.target.files || []);
setToolbarMenuId(null); event.target.value = "";
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const processReferenceFiles = async (files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
const existingFingerprints = new Set( const existingFingerprints = new Set(
@@ -1598,46 +1548,20 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus()); window.requestAnimationFrame(() => textareaRef.current?.focus());
}; };
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleReferenceUploadClick = () => {
const files = Array.from(event.target.files || []); if (referenceItems.length > 0) {
event.target.value = ""; setToolbarMenuId(null);
await processReferenceFiles(files); setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
}; };
const handleComposerDragEnter = useCallback((e: React.DragEvent) => { const handleReferenceAddMore = () => {
e.preventDefault(); setToolbarMenuId(null);
e.stopPropagation(); setReferencePreviewOpen(true);
composerDragCounterRef.current += 1; referenceInputRef.current?.click();
if (composerDragCounterRef.current === 1) { };
setIsComposerDragging(true);
}
}, []);
const handleComposerDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current -= 1;
if (composerDragCounterRef.current <= 0) {
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
}
}, []);
const handleComposerDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleComposerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processReferenceFiles(files);
}
}, [activeMode]);
const insertPromptMention = (token: string) => { const insertPromptMention = (token: string) => {
const rawBefore = inputValue.slice(0, cursorIndex); const rawBefore = inputValue.slice(0, cursorIndex);
@@ -2017,7 +1941,6 @@ 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, {
@@ -2050,9 +1973,6 @@ function WorkbenchPage({
}); });
}, },
abortController.signal, abortController.signal,
(usage) => {
chatUsage = usage;
},
); );
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -2061,7 +1981,6 @@ 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(
@@ -2189,38 +2108,6 @@ 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("仅支持对视频结果进行超分");
@@ -2674,11 +2561,6 @@ function WorkbenchPage({
> >
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} /> <ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
</button> </button>
{(item.kind === "image" || item.kind === "video") && item.previewUrl ? (
<span className="wb-composer__ref-zoom" aria-hidden="true">
{item.kind === "video" ? <video src={item.previewUrl} muted playsInline /> : <img src={item.previewUrl} alt="" />}
</span>
) : null}
<button <button
type="button" type="button"
className="wb-composer__ref-remove" className="wb-composer__ref-remove"
@@ -2730,7 +2612,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"} isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")} onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus} onClose={closeToolbarMenus}
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }} onChange={setImageModel}
direction={dropdownDirection} direction={dropdownDirection}
/> />
<CompoundSelectChip <CompoundSelectChip
@@ -2742,7 +2624,6 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")} onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection} direction={dropdownDirection}
/> />
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip <SelectChip
chipId="image-grid-mode" chipId="image-grid-mode"
value={imageGridMode} value={imageGridMode}
@@ -2754,7 +2635,6 @@ function WorkbenchPage({
onChange={setImageGridMode} onChange={setImageGridMode}
direction={dropdownDirection} direction={dropdownDirection}
/> />
)}
</> </>
)} )}
{activeMode === "video" && ( {activeMode === "video" && (
@@ -2938,14 +2818,7 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1> <h1 className="wb-home__title"></h1>
</div> </div>
<div <div className="wb-home__composer" ref={toolbarRef}>
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-composer__content"> <div className="wb-composer__content">
<div className="wb-composer__input-row"> <div className="wb-composer__input-row">
{renderComposerReferences(false)} {renderComposerReferences(false)}
@@ -3081,7 +2954,7 @@ function WorkbenchPage({
))} ))}
</div> </div>
)} )}
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && ( {message.status === "failed" && 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 />
@@ -3089,12 +2962,9 @@ 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.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && ( {message.status === "thinking" && !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" && (
@@ -3102,11 +2972,6 @@ 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}
@@ -3128,14 +2993,7 @@ function WorkbenchPage({
</div> </div>
</section> </section>
<section <section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-composer__content"> <div className="wb-composer__content">
<div className="wb-composer__input-row"> <div className="wb-composer__input-row">
{renderComposerReferences(false)} {renderComposerReferences(false)}
+1 -6
View File
@@ -1,5 +1,3 @@
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 {
@@ -18,10 +16,7 @@ export interface WorkbenchChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout"; status?: "thinking" | "queued" | "completed" | "failed";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
+2 -18
View File
@@ -1,7 +1,6 @@
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";
@@ -72,10 +71,7 @@ export interface ChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout"; status?: "thinking" | "queued" | "completed" | "failed";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
@@ -236,13 +232,6 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
{ value: "grid-25", label: "25 宫格" }, { value: "grid-25", label: "25 宫格" },
]; ];
export const GRID_SUPPORTED_MODELS = new Set([
"wan2.7-image-pro",
"wan2.7-image",
"gpt-image-2",
"gpt-image-2-vip",
]);
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [ export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
{ value: "omni", label: "全能参考" }, { value: "omni", label: "全能参考" },
{ value: "start-end", label: "首尾帧" }, { value: "start-end", label: "首尾帧" },
@@ -377,16 +366,11 @@ 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"
); );
} }
+10 -41
View File
@@ -1,11 +1,5 @@
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;
@@ -13,7 +7,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; // Keep the previous 10-minute guard as a fallback. const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
export function subscribeToTaskUpdates(callback: PollCallback): () => void { export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback); pollCallbacks.add(callback);
@@ -24,25 +18,10 @@ 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") {
@@ -51,31 +30,18 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
} }
attemptsRef.current++; attemptsRef.current++;
const timeoutReason = isTaskLocallyTimedOut({ if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
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,
@@ -89,7 +55,6 @@ 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);
@@ -99,7 +64,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
notifyCallbacks({ ...item, ...patch, status: "running" }); notifyCallbacks({ ...item, ...patch, status: "running" });
} }
} catch { } catch {
// Network errors during polling are retried until the lifecycle guard trips. // Network error during poll — keep trying
} }
}, POLL_INTERVAL); }, POLL_INTERVAL);
@@ -140,20 +105,24 @@ 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: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交", error: "页面刷新后任务丢失,请重新提交",
}); });
} }
}); });
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500); setTimeout(() => startBackgroundPolling(), 500);
} }
-1
View File
@@ -20,7 +20,6 @@
@import "./pages/studio-layout.css"; @import "./pages/studio-layout.css";
@import "./pages/image-workbench.css"; @import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css"; @import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css"; @import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css"; @import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css"; @import "./pages/script-tokens.css";
-188
View File
@@ -722,191 +722,3 @@
right: -9999px; right: -9999px;
height: 1px; height: 1px;
} }
/* Tool Modal Overlay */
.studio-canvas-tool-modal-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.studio-canvas-tool-modal {
position: relative;
width: 90vw;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
border-radius: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
padding: 24px;
}
.studio-canvas-tool-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.studio-canvas-tool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-default);
}
.studio-canvas-tool-modal__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: var(--bg-subtle);
color: var(--fg-muted);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.studio-canvas-tool-modal__close:hover {
background: var(--bg-hover);
}
/* Tool Panel Components */
.studio-canvas-tool-panel {
display: flex;
gap: 20px;
min-height: 280px;
}
.studio-canvas-tool-panel--inpaint {
flex-direction: column;
}
.studio-canvas-tool-panel__preview {
flex: 0 0 260px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.studio-canvas-tool-panel__preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.studio-canvas-tool-panel__controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.studio-canvas-tool-panel__label {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.studio-canvas-tool-panel__options {
display: flex;
gap: 8px;
}
.studio-canvas-tool-panel__options button {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.studio-canvas-tool-panel__options button.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.studio-canvas-tool-panel__textarea {
width: 100%;
min-height: 60px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
resize: vertical;
}
.studio-canvas-tool-panel__submit {
margin-top: auto;
padding: 10px 20px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.studio-canvas-tool-panel__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.studio-canvas-tool-panel__actions {
display: flex;
gap: 10px;
margin-top: auto;
}
.studio-canvas-tool-panel__reset {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
cursor: pointer;
}
.studio-canvas-tool-panel__canvas-wrap {
position: relative;
width: 100%;
max-height: 320px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
}
.studio-canvas-tool-panel__canvas-bg {
width: 100%;
height: auto;
display: block;
}
.studio-canvas-tool-panel__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
-580
View File
@@ -1,580 +0,0 @@
.dialog-generator-page {
min-height: 100%;
overflow: auto;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
linear-gradient(180deg, #070b10 0%, #05080d 100%);
color: #e8eaef;
}
.dialog-generator-shell {
display: grid;
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
gap: clamp(18px, 2.8vw, 34px);
min-height: var(--shell-content-height, 100vh);
padding: clamp(24px, 4vw, 52px);
}
.dialog-generator-panel,
.dialog-generator-preview-card {
border: 1px solid rgba(0, 255, 136, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
0 24px 72px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.dialog-generator-panel {
display: grid;
align-content: start;
gap: 24px;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-heading {
display: grid;
gap: 12px;
}
.dialog-generator-kicker {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-heading h1 {
margin: 0;
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: clamp(32px, 3.6vw, 56px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.1;
}
.dialog-generator-heading p,
.dialog-generator-hint,
.dialog-generator-preview-head p {
margin: 0;
color: #9aa1b8;
font-size: 15px;
font-weight: 650;
line-height: 1.7;
}
.dialog-generator-section {
display: grid;
gap: 12px;
}
.dialog-generator-section h2 {
margin: 0;
color: #f6f8fb;
font-size: 18px;
font-weight: 900;
}
.dialog-generator-drop {
display: grid;
justify-items: center;
gap: 8px;
min-height: 168px;
border: 1px dashed rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: rgba(0, 255, 136, 0.035);
color: #e8eaef;
padding: 24px;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-drop:hover {
border-color: rgba(0, 255, 136, 0.5);
background: rgba(0, 255, 136, 0.06);
transform: translateY(-1px);
}
.dialog-generator-drop-icon {
font-size: 42px;
}
.dialog-generator-drop strong {
font-size: 16px;
font-weight: 900;
}
.dialog-generator-drop small,
.dialog-generator-style small {
color: #62697f;
font-size: 13px;
font-weight: 700;
}
.dialog-generator-style-list {
display: grid;
gap: 10px;
}
.dialog-generator-color-picker {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dialog-generator-color {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #dce3ed;
cursor: pointer;
font-size: 13px;
font-weight: 850;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-color:hover,
.dialog-generator-color.is-active {
border-color: var(--text-color);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.dialog-generator-color span {
width: 14px;
height: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 50%;
background: var(--text-color);
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
}
.dialog-generator-color strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-generator-style {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 14px;
border: 1px solid rgba(0, 255, 136, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #e8eaef;
padding: 15px 18px;
text-align: left;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-style:hover {
border-color: rgba(0, 255, 136, 0.28);
background: rgba(255, 255, 255, 0.06);
transform: translateX(3px);
}
.dialog-generator-style span:last-child {
display: grid;
gap: 4px;
min-width: 0;
}
.dialog-generator-style strong {
color: #f7fafc;
font-size: 16px;
font-weight: 900;
}
.dialog-generator-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.dialog-generator-swatch.is-white {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.dialog-generator-swatch.is-blue {
background: #165dff;
}
.dialog-generator-swatch.is-amber {
background: #f59e0b;
}
.dialog-generator-swatch.is-gray {
background: #6b7280;
}
.dialog-generator-clear {
min-height: 48px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #e8eaef;
cursor: pointer;
font-size: 15px;
font-weight: 900;
transition:
border-color 180ms ease,
background 180ms ease;
}
.dialog-generator-clear:hover {
border-color: rgba(255, 77, 103, 0.32);
background: rgba(255, 77, 103, 0.1);
}
.dialog-generator-preview-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-preview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.dialog-generator-preview-head span {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-preview-head h2 {
margin: 4px 0 0;
color: #ffffff;
font-size: clamp(24px, 2vw, 34px);
font-weight: 950;
}
.dialog-generator-preview-head p {
max-width: 440px;
text-align: right;
}
.dialog-generator-preview {
position: relative;
min-height: 520px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
rgba(5, 8, 13, 0.72);
background-size: 32px 32px, 32px 32px, auto;
touch-action: none;
}
.dialog-generator-image {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.dialog-generator-empty {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 12px;
color: #62697f;
text-align: center;
pointer-events: none;
}
.dialog-generator-empty span {
font-size: 52px;
}
.dialog-generator-empty p {
margin: 0;
font-size: 16px;
font-weight: 800;
}
.dialog-generator-bubble {
position: absolute;
z-index: 10;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
padding: 12px 14px;
user-select: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.dialog-generator-bubble.is-confirmed {
min-width: 0;
max-width: min(420px, 80%);
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
cursor: move;
}
.dialog-generator-bubble:hover {
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
}
.dialog-generator-bubble.is-confirmed:hover {
box-shadow: none;
}
.dialog-generator-bubble.is-dragging {
z-index: 20;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
}
.dialog-generator-bubble.is-confirmed.is-dragging {
box-shadow: none;
}
.dialog-generator-bubble.style1 {
border: 2px solid #cbd5e1;
background: rgba(255, 255, 255, 0.97);
}
.dialog-generator-bubble.style2 {
border: 2px solid #4f8aff;
border-radius: 16px 16px 4px 16px;
background: rgba(22, 93, 255, 0.95);
}
.dialog-generator-bubble.style3 {
border: 2px solid #f59e0b;
background: rgba(255, 247, 237, 0.97);
}
.dialog-generator-bubble.style4 {
border: 2px solid #6b7280;
border-radius: 4px;
background: rgba(248, 250, 252, 0.97);
}
.dialog-generator-bubble.is-confirmed.style1,
.dialog-generator-bubble.is-confirmed.style2,
.dialog-generator-bubble.is-confirmed.style3,
.dialog-generator-bubble.is-confirmed.style4 {
border: 0;
background: transparent;
}
.dialog-generator-delete {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid #fff;
border-radius: 50%;
background: #ef4444;
color: #fff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.dialog-generator-bubble:hover .dialog-generator-delete {
opacity: 1;
}
.dialog-generator-text,
.dialog-generator-text-display {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--dialog-text-color, #1e293b);
padding: 0;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-generator-text-display {
width: max-content;
max-width: min(420px, 80vw);
color: var(--dialog-text-color, #ffffff);
font-size: clamp(18px, 2.2vw, 30px);
font-weight: 900;
line-height: 1.35;
letter-spacing: 0;
overflow-wrap: anywhere;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.72),
0 0 1px rgba(0, 0, 0, 0.9);
}
.dialog-generator-text::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.dialog-generator-bubble.style2 .dialog-generator-text,
.dialog-generator-bubble.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #fff);
}
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #7fb4ff);
}
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.dialog-generator-bubble.style3 .dialog-generator-text,
.dialog-generator-bubble.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #92400e);
}
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #ffd76a);
}
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
color: rgba(146, 64, 14, 0.4);
}
.dialog-generator-bubble.style4 .dialog-generator-text,
.dialog-generator-bubble.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #1f2937);
}
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #111827);
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.72),
0 0 8px rgba(255, 255, 255, 0.58);
}
.dialog-generator-bubble-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
}
.dialog-generator-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: #165dff;
color: #fff;
cursor: pointer;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
transition:
filter 0.15s,
transform 0.15s;
}
.dialog-generator-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.dialog-generator-bubble.style2 .dialog-generator-confirm {
background: #fff;
color: #165dff;
}
.dialog-generator-bubble.style3 .dialog-generator-confirm {
background: #f59e0b;
}
.dialog-generator-bubble.style4 .dialog-generator-confirm {
background: #6b7280;
}
.dialog-generator-edit-hint {
display: none;
color: rgba(0, 0, 0, 0.36);
font-size: 10px;
font-weight: 700;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
display: none;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
display: inline-block;
}
@media (max-width: 980px) {
.dialog-generator-shell {
grid-template-columns: 1fr;
}
.dialog-generator-preview-head {
align-items: start;
flex-direction: column;
}
.dialog-generator-preview-head p {
max-width: none;
text-align: left;
}
}
@media (max-width: 560px) {
.dialog-generator-shell {
padding: 18px;
}
.dialog-generator-preview {
min-height: 420px;
}
}
-38
View File
@@ -418,15 +418,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-page[data-tool="set"] .product-set-floating-submit--cancel:hover {
background: #3a4050;
}
.product-clone-page[data-tool="set"] .product-clone-help { .product-clone-page[data-tool="set"] .product-clone-help {
display: none; display: none;
} }
@@ -3985,7 +3976,6 @@
.product-clone-panel__footer { .product-clone-panel__footer {
display: grid; display: grid;
align-items: center; align-items: center;
gap: 8px;
border-top: 1px solid #e5e7eb; border-top: 1px solid #e5e7eb;
padding: 12px 16px; padding: 12px 16px;
} }
@@ -4010,11 +4000,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.product-clone-primary--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-preview { .product-clone-preview {
display: grid; display: grid;
align-content: center; align-content: center;
@@ -4945,7 +4930,6 @@
} }
.product-set-main-card { .product-set-main-card {
position: relative;
height: 380px; height: 380px;
border-radius: 16px; border-radius: 16px;
transition: transform 250ms ease, box-shadow 250ms ease; transition: transform 250ms ease, box-shadow 250ms ease;
@@ -8775,17 +8759,6 @@
filter: none; filter: none;
} }
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-generate--cancel:hover:not(:disabled) {
background: var(--ecm-inset-hover);
}
.product-clone-page[data-tool="clone"] .clone-ai-settings-toggle { .product-clone-page[data-tool="clone"] .clone-ai-settings-toggle {
border-color: var(--ecm-line-strong); border-color: var(--ecm-line-strong);
background: rgba(20, 23, 25, 0.86); background: rgba(20, 23, 25, 0.86);
@@ -9011,17 +8984,6 @@
box-shadow: none; box-shadow: none;
} }
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel) {
border: 1px solid var(--ecm-line);
background: var(--ecm-inset);
color: var(--ecm-text);
box-shadow: none;
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) :is(.product-clone-primary--cancel, .product-set-floating-submit--cancel):hover {
background: var(--ecm-inset-hover);
}
.product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview { .product-clone-page:is([data-tool="set"], [data-tool="detail"], [data-tool="wear"]) .product-clone-preview {
background: background:
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%), radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
-7
View File
@@ -15,10 +15,3 @@
.profile-page__works-scroll .profile-page__list-grid { .profile-page__works-scroll .profile-page__list-grid {
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */ grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
} }
/* Dashboard uses natural page scrolling instead of a nested works scroller. */
.profile-page--dashboard .profile-page__works-scroll {
max-height: none;
overflow: visible;
scrollbar-width: auto;
}
-156
View File
@@ -2802,10 +2802,6 @@
color: #e9fff5; color: #e9fff5;
font-size: 14px; font-size: 14px;
line-height: 1.25; line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8em;
} }
.script-eval-v5-uf-size { .script-eval-v5-uf-size {
@@ -3234,141 +3230,6 @@
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;
} }
@@ -3417,10 +3278,6 @@
.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) {
@@ -3440,19 +3297,6 @@
} }
@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;
} }
+2 -2
View File
@@ -202,9 +202,9 @@
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(2, minmax(0, 1fr)); grid-template-rows: 1fr 1fr;
gap: 16px; gap: 16px;
min-height: clamp(560px, 52vw, 760px); min-height: clamp(360px, 40vw, 520px);
} }
/* ===== Tool Cards ===== */ /* ===== Tool Cards ===== */
+8 -8
View File
@@ -9,10 +9,10 @@
.wb-prompt-cases__grid { .wb-prompt-cases__grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-flow: dense; grid-auto-flow: dense;
grid-auto-rows: 10px; grid-auto-rows: 10px;
gap: 8px; gap: 10px;
} }
.wb-prompt-case-card { .wb-prompt-case-card {
@@ -34,22 +34,22 @@
.wb-prompt-case-card--ratio-wide { .wb-prompt-case-card--ratio-wide {
grid-column: span 1; grid-column: span 1;
grid-row: span 13; grid-row: span 8;
} }
.wb-prompt-case-card--ratio-tall { .wb-prompt-case-card--ratio-tall {
grid-column: span 1; grid-column: span 1;
grid-row: span 30; grid-row: span 23;
} }
.wb-prompt-case-card--ratio-square { .wb-prompt-case-card--ratio-square {
grid-column: span 1; grid-column: span 1;
grid-row: span 18; grid-row: span 13;
} }
.wb-prompt-case-card--ratio-portrait { .wb-prompt-case-card--ratio-portrait {
grid-column: span 1; grid-column: span 1;
grid-row: span 24; grid-row: span 16;
} }
.wb-prompt-case-card img { .wb-prompt-case-card img {
@@ -328,7 +328,7 @@
@media (max-width: 980px) { @media (max-width: 980px) {
.wb-prompt-cases__grid { .wb-prompt-cases__grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-auto-rows: 8px; grid-auto-rows: 8px;
gap: 8px; gap: 8px;
} }
@@ -387,7 +387,7 @@
@media (max-width: 560px) { @media (max-width: 560px) {
.wb-prompt-cases__grid { .wb-prompt-cases__grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 8px; grid-auto-rows: 8px;
gap: 8px; gap: 8px;
} }
File diff suppressed because it is too large Load Diff
-1
View File
@@ -22,7 +22,6 @@ export type WebViewKey =
| "more" | "more"
| "watermarkRemoval" | "watermarkRemoval"
| "subtitleRemoval" | "subtitleRemoval"
| "dialogGenerator"
| "communityReview" | "communityReview"
| "communityCaseAdd" | "communityCaseAdd"
| "report" | "report"
-160
View File
@@ -1,160 +0,0 @@
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}`;
}