merge: resolve EcommercePage.tsx conflict, integrate master into profile-account-polish

Keep master's EcommercePage.tsx (has more complete upload logic from prior conflict resolution). Accept all other master changes including canvas tool panels, task lifecycle, and workbench updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:05:39 +08:00
37 changed files with 3182 additions and 130 deletions
+72
View File
@@ -0,0 +1,72 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const repoRoot = process.cwd();
const failures = [];
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function assertMatch(label, content, pattern) {
if (!pattern.test(content)) {
failures.push(label);
}
}
function assertNoMatch(label, content, pattern) {
if (pattern.test(content)) {
failures.push(label);
}
}
const serverConnection = read("src/api/serverConnection.ts");
const generationClient = read("src/api/aiGenerationClient.ts");
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
assertMatch(
"serverConnection must build same-origin /api URLs",
serverConnection,
/return\s+`\/api\/\$\{cleanPath\}`;/,
);
assertNoMatch(
"frontend generation flow must not use fixed VITE environment config",
`${serverConnection}\n${generationClient}`,
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
);
assertNoMatch(
"frontend generation flow must not call provider hosts directly",
generationClient,
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
);
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch(
"ecommerce video history must durable-copy media before saving",
ecommerceVideoService,
/buildDurableVideoHistoryPayload\(payload\)/,
);
assertMatch(
"ecommerce video history must filter temporary provider URLs on read",
ecommerceVideoService,
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
);
assertMatch(
"workbench results must persist generated media through OSS",
workbenchPersistence,
/uploadAssetByUrl\(/,
);
if (failures.length) {
console.error("Mocked generation smoke check failed:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log("Mocked generation smoke check passed.");
+5 -1
View File
@@ -48,6 +48,7 @@ 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"));
@@ -110,6 +111,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale", "resolutionUpscale",
"watermarkRemoval", "watermarkRemoval",
"subtitleRemoval", "subtitleRemoval",
"dialogGenerator",
"digitalHuman", "digitalHuman",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
@@ -123,7 +125,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found", "not-found",
]); ]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey { function normalizeViewKey(rawView: string): WebViewKey {
const normalized = const normalized =
@@ -1159,6 +1161,8 @@ function App() {
onSelectView={handleSetView} onSelectView={handleSetView}
/> />
); );
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report": case "report":
return <ReportPage />; return <ReportPage />;
case "providerHealth": case "providerHealth":
+26 -3
View File
@@ -134,6 +134,12 @@ export interface ChatInput {
temperature?: number; temperature?: number;
} }
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus { export interface AiTaskStatus {
taskId: string; taskId: string;
projectId?: string; projectId?: string;
@@ -159,7 +165,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi
function taskTitle(task: AiTaskStatus): string { function taskTitle(task: AiTaskStatus): string {
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
return task.type === "video" ? "视频生成任务" : "图像生成任务"; return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
} }
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
@@ -500,6 +506,7 @@ export const aiGenerationClient = {
input: ChatInput, input: ChatInput,
onChunk: (text: string) => void, onChunk: (text: string) => void,
signal?: AbortSignal, signal?: AbortSignal,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> { ): Promise<void> {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
@@ -512,7 +519,7 @@ export const aiGenerationClient = {
} }
const reader = res.body?.getReader(); const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取响应流"); if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
@@ -529,8 +536,24 @@ export const aiGenerationClient = {
const payload = line.slice(6).trim(); const payload = line.slice(6).trim();
if (!payload) continue; if (!payload) continue;
try { try {
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string }; const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
if (chunk.error) throw new Error(chunk.error); if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta); if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return; if (chunk.done) return;
} catch (e) { } catch (e) {
+2 -2
View File
@@ -913,7 +913,7 @@ export const keyServerClient = {
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> { async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const safeProjectId = encodeURIComponent(projectId.trim()); const safeProjectId = encodeURIComponent(projectId.trim());
@@ -1000,7 +1000,7 @@ export const keyServerClient = {
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> { async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
const stored = readStoredSession(); const stored = readStoredSession();
if (!stored) { if (!stored) {
throw new Error("闇€瑕佸厛鐧诲綍"); throw new Error("需要先登录");
} }
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
+1 -1
View File
@@ -71,7 +71,7 @@ export const modelCapabilitiesClient = {
let payload: unknown; let payload: unknown;
try { try {
payload = await serverRequest<unknown>(`config/profile?name=${encodeURIComponent(name)}`); payload = await serverRequest<unknown>(`public/config/profile?name=${encodeURIComponent(name)}`);
} catch (error) { } catch (error) {
if (isOptionalApiRouteMissing(error)) { if (isOptionalApiRouteMissing(error)) {
modelCapabilitiesRouteMissing = true; modelCapabilitiesRouteMissing = true;
+79 -5
View File
@@ -4,6 +4,8 @@ export interface ScriptEvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
dimensionScores: Record<string, number>; dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string; summary: string;
issues: string[]; issues: string[];
highlights: string[]; highlights: string[];
@@ -12,6 +14,33 @@ export interface ScriptEvalResult {
const MODEL = "qwen3.7-max"; const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】 【剧本类型识别】
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = { const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 }, hook: { maxScore: 20 },
plot: { maxScore: 20 }, plot: { maxScore: 20 },
character: { maxScore: 18 }, character: { maxScore: 15 },
dialogue: { maxScore: 15 }, logic: { maxScore: 15 },
visual: { maxScore: 15 }, visual: { maxScore: 15 },
content: { maxScore: 12 }, content: { maxScore: 15 },
}; };
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } { function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -68,6 +97,48 @@ function extractJson(text: string): unknown {
return JSON.parse(raw); return JSON.parse(raw);
} }
function normalizeScoreValue(value: unknown, maxScore: number): number {
const score = Number(value);
if (!Number.isFinite(score)) return 0;
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function normalizeNestedScores(value: unknown): Record<string, Record<string, number>> {
if (!isRecord(value)) return {};
const normalized: Record<string, Record<string, number>> = {};
for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!isRecord(source)) continue;
const entries = Object.entries(source)
.map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const)
.filter(([, score]) => score > 0);
if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries);
}
return normalized;
}
function normalizeEvidence(value: unknown): Record<string, string[]> {
if (!isRecord(value)) return {};
const normalized: Record<string, string[]> = {};
for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) {
const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined);
if (!Array.isArray(source)) continue;
const items = source.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3);
if (items.length > 0) normalized[dimensionKey] = items;
}
return normalized;
}
export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> { export async function evaluateScript(script: string, signal?: AbortSignal): Promise<ScriptEvalResult> {
const res = await fetch(buildApiUrl("ai/chat"), { const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST", method: "POST",
@@ -76,6 +147,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
model: MODEL, model: MODEL,
messages: [ messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
], ],
stream: false, stream: false,
@@ -101,8 +173,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) { for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const val = Number(rawScores[key] ?? 0); const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val)); dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
} }
const { totalScore, grade } = computeTotalAndGrade(dimensionScores); const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -111,6 +183,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
totalScore, totalScore,
grade, grade,
dimensionScores, dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""), summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
+43 -8
View File
@@ -1,4 +1,9 @@
import { aiGenerationClient } from "./aiGenerationClient"; import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent { export interface TaskProgressEvent {
taskId: string; taskId: string;
@@ -12,16 +17,28 @@ export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void; onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean }; abortRef?: { current: boolean };
timeoutMs?: number; timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
} }
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
export function waitForTask( export function waitForTask(
taskId: string, taskId: string,
options: WaitForTaskOptions = {}, options: WaitForTaskOptions = {},
): Promise<string | null> { ): Promise<string | null> {
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options; const { onProgress, abortRef } = options;
const timeoutPolicy = getTaskTimeoutPolicy({
kind: options.kind,
model: options.model,
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false; let settled = false;
@@ -29,6 +46,8 @@ export function waitForTask(
let timeoutId: ReturnType<typeof setTimeout> | null = null; let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false; let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null; let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
const settle = (fn: () => void) => { const settle = (fn: () => void) => {
if (settled) return; if (settled) return;
@@ -40,7 +59,7 @@ export function waitForTask(
}; };
timeoutId = setTimeout( timeoutId = setTimeout(
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))), () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
timeoutMs, timeoutMs,
); );
@@ -50,19 +69,22 @@ 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));
} else if (event.status === "failed" || event.status === "cancelled") { } else if (event.status === "failed" || event.status === "cancelled") {
settle(() => reject(new Error(event.error || "任务失败"))); settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
} }
}; };
// Try SSE first
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
sseConnected = true; sseConnected = true;
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
fallbackTimerId = setTimeout(() => { fallbackTimerId = setTimeout(() => {
if (settled || !sseConnected) return; if (settled || !sseConnected) return;
if (cleanup) cleanup(); if (cleanup) cleanup();
@@ -72,9 +94,22 @@ export function waitForTask(
function startPolling() { function startPolling() {
const poll = async () => { const poll = async () => {
while (!settled) { while (!settled) {
if (abortRef?.current) { settle(() => resolve(null)); return; } if (abortRef?.current) {
settle(() => resolve(null));
return;
}
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({
@@ -89,7 +124,7 @@ export function waitForTask(
} }
} }
}; };
poll(); void poll();
} }
}); });
} }
+1 -1
View File
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
prompt, prompt,
createdAt, createdAt,
source: "server", source: "server",
errorMessage: err instanceof Error ? err.message : "请求失败", errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
}; };
} }
}, },
+3 -1
View File
@@ -13,6 +13,7 @@ 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";
@@ -86,6 +87,7 @@ function AppShell({
"imageWorkbench", "imageWorkbench",
"resolutionUpscale", "resolutionUpscale",
"digitalHuman", "digitalHuman",
"dialogGenerator",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
] as WebViewKey[]; ] as WebViewKey[];
@@ -369,7 +371,7 @@ function AppShell({
className="member-button" className="member-button"
type="button" type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`} aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => setRechargeOpen(true)} onClick={() => toast.info("充值功能即将开放,敬请期待")}
> >
<WalletOutlined /> <WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span> <span className="member-button__label">{displayedBalanceLabel}</span>
+2 -1
View File
@@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [
"resolutionUpscale", "resolutionUpscale",
"watermarkRemoval", "watermarkRemoval",
"subtitleRemoval", "subtitleRemoval",
"dialogGenerator",
"digitalHuman", "digitalHuman",
"avatarConsole", "avatarConsole",
"characterMix", "characterMix",
@@ -87,4 +88,4 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp
{displayedChildren} {displayedChildren}
</div> </div>
); );
} }
+1 -1
View File
@@ -21,7 +21,7 @@ export const ossAssets = {
}, },
home: { home: {
backgroundVideo: muban("hero-bg.mp4"), backgroundVideo: muban("hero-bg.mp4"),
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")], heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
features: { features: {
ecommerce: muban("feature-ecommerce.jpg"), ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"), script: muban("feature-script.jpg"),
+59 -10
View File
@@ -182,6 +182,7 @@ 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) => ({
@@ -336,6 +337,7 @@ 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);
@@ -2824,7 +2826,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, 0); const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
setConnectionDropMenu({ setConnectionDropMenu({
...menuPosition, ...menuPosition,
originLeft: event.clientX, originLeft: event.clientX,
@@ -3750,12 +3752,12 @@ function CanvasPage({
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu} onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/> />
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}> <div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button> <button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}> <button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}% {Math.round(canvasViewport.zoom * 100)}%
</button> </button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button> <button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button> <button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
</div> </div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? ( {(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div <div
@@ -4264,7 +4266,7 @@ function CanvasPage({
setSelectedExistingCategory(""); setSelectedExistingCategory("");
setSaveAssetOpen(true); setSaveAssetOpen(true);
} }
if (key === "upscale") void handleGenerateImageNode(imageNode.id); if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
}} }}
moreActions={[ moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl }, { key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4570,16 +4572,42 @@ function CanvasPage({
)} )}
<button <button
type="button" type="button"
className={imageNodeFocusActive ? "is-active" : ""} title="多宫格生成"
title="框选聚焦区域" disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
openImageFocusMode(imageNode); setCanvasToolModal({ tool: "multiGrid", imageNode });
}} }}
> >
<BarsOutlined /><span></span> <BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
</button> </button>
<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>
@@ -5729,6 +5757,27 @@ 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
@@ -0,0 +1,221 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+3 -3
View File
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
timeoutMs: 10 * 60 * 1000, kind: "image",
onProgress: (e) => { onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}, },
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) { export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, { const resultUrl = await waitForTask(taskId, {
timeoutMs: 30 * 60 * 1000, kind: "video",
onProgress: (e) => { onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus); onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
}, },
@@ -495,4 +495,4 @@ export function getWorkflowNodeFocusSelection(node: WebCanvasWorkflow["nodes"][n
height: clampCanvasPercent(height), height: clampCanvasPercent(height),
ratio: toCanvasStyleString(selection.ratio, "16:9"), ratio: toCanvasStyleString(selection.ratio, "16:9"),
}; };
} }
@@ -0,0 +1,290 @@
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;
@@ -34,6 +34,7 @@ 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;
@@ -97,6 +98,7 @@ 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,
@@ -376,8 +378,9 @@ export default function EcommerceVideoWorkspace({
}); });
}; };
try { try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan( const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(), productImageSources, requirement, buildConfig(),
{ {
onStepStart: (step) => setCurrentStep(step), onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => { onStepDone: (step) => {
+242 -40
View File
@@ -19,6 +19,102 @@ import type {
PlanStep, PlanStep,
} from "./ecommerceVideoTypes"; } from "./ecommerceVideoTypes";
type UploadAssetByUrl = typeof aiGenerationClient.uploadAssetByUrl;
interface DurableMediaUrl {
url: string | null;
originalUrl?: string | null;
ossKey?: string | null;
}
const TEMP_MEDIA_HOST_RE = /^file\d*\.aitohumanize\.com$/i;
const OSS_MEDIA_HOST_RE = /\.oss-[^.]+\.aliyuncs\.com$/i;
function isTemporaryProviderUrl(url: string): boolean {
try {
return TEMP_MEDIA_HOST_RE.test(new URL(url).hostname);
} catch {
return false;
}
}
function isDurableOssUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && OSS_MEDIA_HOST_RE.test(parsed.hostname);
} catch {
return false;
}
}
function getMediaExtension(url: string, mimeType: string): string {
const normalizedMime = mimeType.split(";")[0]?.trim().toLowerCase();
if (normalizedMime === "image/jpeg") return "jpg";
if (normalizedMime === "image/png") return "png";
if (normalizedMime === "image/webp") return "webp";
if (normalizedMime === "image/gif") return "gif";
if (normalizedMime === "video/mp4") return "mp4";
if (normalizedMime === "video/webm") return "webm";
if (normalizedMime === "video/quicktime") return "mov";
try {
const matched = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
if (matched?.[1]) return matched[1].toLowerCase();
} catch {
// Keep mime fallback below.
}
return mimeType.startsWith("video/") ? "mp4" : "png";
}
function buildDurableMediaName(prefix: string, url: string, mimeType: string): string {
const normalized = prefix
.trim()
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, " ")
.slice(0, 80)
.trim();
return `${normalized || "ecommerce-video-media"}.${getMediaExtension(url, mimeType)}`;
}
export async function resolveDurableMediaUrl(
url: string | null | undefined,
options: {
mediaType: "image" | "video";
namePrefix: string;
scope?: string;
uploadAssetByUrl?: UploadAssetByUrl;
},
): Promise<DurableMediaUrl> {
const sourceUrl = String(url || "").trim();
if (!sourceUrl) return { url: null };
if (isDurableOssUrl(sourceUrl)) return { url: sourceUrl };
const mimeType = options.mediaType === "video" ? "video/mp4" : "image/png";
const uploadAssetByUrl = options.uploadAssetByUrl || aiGenerationClient.uploadAssetByUrl.bind(aiGenerationClient);
try {
const uploaded = await uploadAssetByUrl({
sourceUrl,
name: buildDurableMediaName(options.namePrefix, sourceUrl, mimeType),
mimeType,
scope: options.scope || "ecommerce-video-history",
});
return {
url: uploaded.url || null,
originalUrl: sourceUrl,
ossKey: uploaded.ossKey || null,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error || "");
console.warn("[ecommerce-video] history media persistence failed:", message);
if (isTemporaryProviderUrl(sourceUrl)) {
return { url: null, originalUrl: sourceUrl };
}
return { url: sourceUrl };
}
}
export interface PlanCallbacks { export interface PlanCallbacks {
onStepStart: (step: PlanStep) => void; onStepStart: (step: PlanStep) => void;
onStepDone: (step: PlanStep) => void; onStepDone: (step: PlanStep) => void;
@@ -30,13 +126,61 @@ 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(
imageDataUrls: string[], imageSources: Array<string | Blob>,
manualText: string, manualText: string,
config: AdVideoUserConfig, config: AdVideoUserConfig,
callbacks: PlanCallbacks, callbacks: PlanCallbacks,
@@ -45,41 +189,30 @@ 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 srcUrl of imageDataUrls) { for (const source of imageSources) {
try { try {
const resp = await fetch(srcUrl); imageUrls.push(await uploadProductImageSource(source));
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 : "图片上传失败"); rejected.push(err instanceof Error ? err.message : "Image upload failed");
} }
} }
if (rejected.length) { if (rejected.length) {
progress.uploadWarnings = rejected; progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected); callbacks.onUploadRejected?.(rejected);
} }
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试"); if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
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);
@@ -87,7 +220,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);
@@ -95,7 +228,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);
@@ -103,16 +236,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("未能生成有效的广告创意"); if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
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);
@@ -120,7 +253,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);
@@ -128,7 +261,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);
@@ -179,13 +312,15 @@ 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, "图片生成未返回结果"); callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
} }
} }
@@ -234,13 +369,15 @@ 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, "任务未返回结果"); callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
} }
} }
@@ -259,7 +396,7 @@ export function buildSceneTasks(
}); });
} }
// ── Video History API ────────────────────────────────── // Video History API
export interface VideoHistoryScene { export interface VideoHistoryScene {
sceneId: number; sceneId: number;
@@ -268,6 +405,15 @@ export interface VideoHistoryScene {
videoUrl?: string | null; videoUrl?: string | null;
} }
interface SaveVideoHistoryPayload {
title: string;
config: Record<string, unknown>;
plan: Record<string, unknown>;
scenes: VideoHistoryScene[];
sourceImageUrls: string[];
uploadAssetByUrl?: UploadAssetByUrl;
}
export interface VideoHistoryItem { export interface VideoHistoryItem {
id: number; id: number;
title: string; title: string;
@@ -293,22 +439,74 @@ function getAuthHeaders(): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
export async function saveVideoHistory(payload: { export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
title: string; const uploadAssetByUrl = payload.uploadAssetByUrl;
config: Record<string, unknown>; const scenes = await Promise.all(
plan: Record<string, unknown>; payload.scenes.map(async (scene) => {
scenes: VideoHistoryScene[]; const [image, video] = await Promise.all([
sourceImageUrls: string[]; resolveDurableMediaUrl(scene.imageUrl, {
}): Promise<{ id: number; createdAt: string }> { mediaType: "image",
namePrefix: `ecommerce-scene-${scene.sceneId}-image`,
uploadAssetByUrl,
}),
resolveDurableMediaUrl(scene.videoUrl, {
mediaType: "video",
namePrefix: `ecommerce-scene-${scene.sceneId}-video`,
uploadAssetByUrl,
}),
]);
return {
...scene,
imageUrl: image.url,
videoUrl: video.url,
};
}),
);
const sourceImageUrls = (
await Promise.all(
payload.sourceImageUrls.map((url, index) =>
resolveDurableMediaUrl(url, {
mediaType: "image",
namePrefix: `ecommerce-source-${index + 1}`,
uploadAssetByUrl,
}),
),
)
)
.map((item) => item.url)
.filter((url): url is string => Boolean(url));
return {
...payload,
scenes,
sourceImageUrls,
};
}
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, { const res = await fetch(API_BASE, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() }, headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(payload), body: JSON.stringify(historyPayload),
}); });
if (!res.ok) throw new Error("保存历史记录失败"); if (!res.ok) throw new Error("Failed to save video history");
return res.json(); return res.json();
} }
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
return {
...item,
scenes: item.scenes.map((scene) => ({
...scene,
imageUrl: scene.imageUrl && !isTemporaryProviderUrl(scene.imageUrl) ? scene.imageUrl : null,
videoUrl: scene.videoUrl && !isTemporaryProviderUrl(scene.videoUrl) ? scene.videoUrl : null,
})),
sourceImageUrls: item.sourceImageUrls.filter((url) => !isTemporaryProviderUrl(url)),
};
}
export async function fetchVideoHistory( export async function fetchVideoHistory(
limit = 20, limit = 20,
offset = 0, offset = 0,
@@ -317,8 +515,12 @@ 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("获取历史记录失败"); if (!res.ok) throw new Error("Failed to fetch video history");
return res.json(); const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
};
} }
export async function deleteVideoHistory(id: number): Promise<void> { export async function deleteVideoHistory(id: number): Promise<void> {
@@ -326,5 +528,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE", method: "DELETE",
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
if (!res.ok) throw new Error("删除失败"); if (!res.ok) throw new Error("Failed to delete video history");
} }
@@ -133,6 +133,7 @@ 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;
@@ -200,6 +201,7 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration, clampCloneVideoDuration,
setCloneVideoSmart, setCloneVideoSmart,
handleGenerate, handleGenerate,
onCancelGenerate,
formatRatioDisplayValue, formatRatioDisplayValue,
setVideoOutfitFiles, setVideoOutfitFiles,
onStartVideoPlan, onStartVideoPlan,
@@ -746,6 +748,11 @@ 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,6 +28,7 @@ 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({
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite, handleDetailAiWrite,
toggleDetailModule, toggleDetailModule,
handleDetailGenerate, handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) { }: EcommerceDetailPanelProps) {
return ( return (
<> <>
@@ -162,6 +164,11 @@ 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,6 +35,7 @@ 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({
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
setSmartScene, setSmartScene,
setTryOnRatio, setTryOnRatio,
handleTryOnGenerate, handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) { }: EcommerceTryOnPanelProps) {
return ( return (
<> <>
@@ -213,6 +215,11 @@ 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,6 +7,7 @@ import {
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
HighlightOutlined, HighlightOutlined,
MessageOutlined,
SwapOutlined, SwapOutlined,
ThunderboltOutlined, ThunderboltOutlined,
VideoCameraOutlined, VideoCameraOutlined,
@@ -42,6 +43,7 @@ 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 },
+110 -2
View File
@@ -25,6 +25,8 @@ interface EvalResult {
totalScore: number; totalScore: number;
grade: string; grade: string;
dimensionScores: Record<string, number>; dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string; summary: string;
issues: string[]; issues: string[];
highlights: string[]; highlights: string[];
@@ -192,6 +194,60 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" }, { key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
]; ];
const SUB_SCORE_LABELS: Record<string, string> = {
openingImpact: "开篇冲击",
suspenseChain: "悬念链",
sceneHook: "场内钩子",
structure: "结构完整",
rhythm: "节奏推进",
conflict: "冲突强度",
reversal: "反转效率",
motivation: "动机清晰",
arc: "人物弧光",
voice: "语言辨识",
relationship: "关系张力",
causality: "因果链",
worldRules: "世界规则",
foreshadowing: "伏笔回收",
continuity: "连续性",
sceneDetail: "场景细节",
shotPotential: "镜头潜力",
aigcFeasibility: "AIGC 可实现",
theme: "主题表达",
emotion: "情感共鸣",
marketFit: "市场匹配",
originality: "原创性",
};
function clampScore(score: unknown, maxScore: number): number {
const numeric = Number(score);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(maxScore, numeric));
}
function getDimensionScore(result: EvalResult, dim: ScoreDimension): number {
const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined);
return clampScore(value, dim.maxScore);
}
function formatSubScoreLabel(key: string): string {
return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim();
}
function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> {
const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined);
if (!scores) return [];
return Object.entries(scores)
.map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number])
.filter(([, value]) => value > 0)
.slice(0, 5);
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
}
function formatReportMarkdown(result: EvalResult, script: string): string { function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`# 剧本评测报告`); lines.push(`# 剧本评测报告`);
@@ -203,9 +259,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
lines.push(""); lines.push("");
lines.push(`## 六维评分`); lines.push(`## 六维评分`);
for (const dim of SCORE_DIMENSIONS) { for (const dim of SCORE_DIMENSIONS) {
const score = result.dimensionScores[dim.key] ?? 0; const score = getDimensionScore(result, dim);
const pct = Math.round((score / dim.maxScore) * 100); const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
const nestedReportLines = [
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
...evidence.map((item) => ` - 证据: ${item}`),
];
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`); lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
lines.push(...nestedReportLines);
} }
if (result.highlights.length > 0) { if (result.highlights.length > 0) {
lines.push(""); lines.push("");
@@ -636,7 +699,7 @@ function ScriptTokensPage() {
</div> </div>
<div className="script-eval-report__chart-grid"> <div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim, dimIndex) => { {SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = result.dimensionScores[dim.key] ?? 0; const score = getDimensionScore(result, dim);
const pct = Math.max(0, Math.min(1, score / dim.maxScore)); const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct; const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore; const isPerfect = score === dim.maxScore;
@@ -676,6 +739,51 @@ function ScriptTokensPage() {
</div> </div>
</section> </section>
<div className="script-eval-report__detail-grid">
{SCORE_DIMENSIONS.map((dim) => {
const score = getDimensionScore(result, dim);
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
return (
<section className="script-eval-report__detail-card" key={dim.key}>
<header className="script-eval-report__detail-head">
<div>
<span>{dim.label}</span>
<strong>{score}<small>/{dim.maxScore}</small></strong>
</div>
<em>{pct}%</em>
</header>
<p className="script-eval-report__detail-hint">{dim.hint}</p>
{subScores.length > 0 ? (
<div className="script-eval-report__subscore-list">
{subScores.map(([key, value]) => {
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
return (
<div className="script-eval-report__subscore-row" key={key}>
<span>{formatSubScoreLabel(key)}</span>
<div className="script-eval-report__subscore-bar" aria-hidden="true">
<i style={{ width: `${subPct}%` }} />
</div>
<b>{value}</b>
</div>
);
})}
</div>
) : (
<p className="script-eval-report__detail-empty"></p>
)}
{evidence.length > 0 ? (
<ul className="script-eval-report__evidence-list">
{evidence.map((item, index) => <li key={index}>{item}</li>)}
</ul>
) : null}
</section>
);
})}
</div>
<div className="script-eval-report__findings"> <div className="script-eval-report__findings">
{result.highlights.length > 0 ? ( {result.highlights.length > 0 ? (
<section className="script-eval-report__finding-group is-highlight"> <section className="script-eval-report__finding-group is-highlight">
+162 -20
View File
@@ -64,6 +64,12 @@ import {
import { renderMarkdownBlocks } from "./markdownRenderer"; import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload"; import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError"; import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
import { detectMentionTrigger } from "../../utils/mentionTrigger"; import { detectMentionTrigger } from "../../utils/mentionTrigger";
import { import {
isHappyHorseModel, isHappyHorseModel,
@@ -103,6 +109,7 @@ 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,
@@ -250,6 +257,8 @@ 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[]>([]);
@@ -863,6 +872,9 @@ function WorkbenchPage({
let lastKnownProgress = Math.max(0, Number(task.progress || 0)); let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 0; let taskPollFailures = 0;
let lastProgressAt = task.startedAt || Date.now();
const taskKind = task.mode === "image" ? "image" : "video";
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
const abortController = new AbortController(); const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController); taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) { if (activeConversationIdRef.current === task.conversationId) {
@@ -909,6 +921,9 @@ function WorkbenchPage({
const progress = status.status === "completed" const progress = status.status === "completed"
? 100 ? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress))); : Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
if (progress > lastKnownProgress || status.status === "completed") {
lastProgressAt = Date.now();
}
lastKnownProgress = Math.max(lastKnownProgress, progress); lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution"; const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel = const statusLabel =
@@ -933,6 +948,28 @@ function WorkbenchPage({
setGenerationProgress(progress); setGenerationProgress(progress);
} }
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
? isTaskLocallyTimedOut({
startedAt: task.startedAt || Date.now(),
lastProgressAt,
progress,
policy: timeoutPolicy,
})
: null;
if (localTimeoutReason) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: buildLocalTimeoutMessage(taskKind),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: "unknown",
taskProgress: progress,
taskStatusLabel: "本地等待超时",
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return;
}
if (status.status === "completed" && status.resultUrl) { if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = { const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask body: isSuperResolveTask
@@ -1459,9 +1496,22 @@ function WorkbenchPage({
setReferenceItems(nextItems); setReferenceItems(nextItems);
}; };
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleReferenceUploadClick = () => {
const files = Array.from(event.target.files || []); if (referenceItems.length > 0) {
event.target.value = ""; setToolbarMenuId(null);
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(
@@ -1548,20 +1598,46 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus()); window.requestAnimationFrame(() => textareaRef.current?.focus());
}; };
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); await processReferenceFiles(files);
return;
}
referenceInputRef.current?.click();
}; };
const handleReferenceAddMore = () => { const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
setToolbarMenuId(null); e.preventDefault();
setReferencePreviewOpen(true); e.stopPropagation();
referenceInputRef.current?.click(); composerDragCounterRef.current += 1;
}; 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);
@@ -1941,6 +2017,7 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask); runKeepalivePoll(keepaliveTask);
} else { } else {
let streamedText = ""; let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36); setGenerationProgress(36);
setGenerationStatus("正在回复"); setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, { updateAssistantMessage(assistantMessageId, {
@@ -1973,6 +2050,9 @@ function WorkbenchPage({
}); });
}, },
abortController.signal, abortController.signal,
(usage) => {
chatUsage = usage;
},
); );
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
@@ -1981,6 +2061,7 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, { const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。", body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed", status: "completed",
taskUsage: chatUsage,
}); });
if (!conversationId) { if (!conversationId) {
const conv = await conversationClient.create( const conv = await conversationClient.create(
@@ -2108,6 +2189,38 @@ function WorkbenchPage({
} }
}; };
const handleReleaseStuckTask = (message: ChatMessage) => {
if (message.taskId) {
taskAbortControllersRef.current.get(message.taskId)?.abort();
taskAbortControllersRef.current.delete(message.taskId);
removeKeepaliveTask(message.taskId);
}
if (message.conversationId) {
void patchConversationMessage(message.conversationId, message.id, {
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: message.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
});
}
setMessages((current) =>
current.map((item) =>
item.id === message.id
? {
...item,
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: item.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
}
: item,
),
);
syncActiveGenerationUi();
};
const handleSuperResolveVideo = async (message: ChatMessage) => { const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") { if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分"); setProjectError("仅支持对视频结果进行超分");
@@ -2561,6 +2674,11 @@ 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"
@@ -2612,7 +2730,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"} isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")} onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus} onClose={closeToolbarMenus}
onChange={setImageModel} onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection} direction={dropdownDirection}
/> />
<CompoundSelectChip <CompoundSelectChip
@@ -2624,6 +2742,7 @@ 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}
@@ -2635,6 +2754,7 @@ function WorkbenchPage({
onChange={setImageGridMode} onChange={setImageGridMode}
direction={dropdownDirection} direction={dropdownDirection}
/> />
)}
</> </>
)} )}
{activeMode === "video" && ( {activeMode === "video" && (
@@ -2818,7 +2938,14 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1> <h1 className="wb-home__title"></h1>
</div> </div>
<div className="wb-home__composer" ref={toolbarRef}> <div
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)}
@@ -2954,7 +3081,7 @@ function WorkbenchPage({
))} ))}
</div> </div>
)} )}
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && ( {(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
<div className="ai-chat-failed-actions"> <div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}> <button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined /> <ReloadOutlined />
@@ -2962,9 +3089,12 @@ function WorkbenchPage({
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}> <button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
<AppstoreOutlined /> <AppstoreOutlined />
</button> </button>
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
<StopOutlined />
</button>
</div> </div>
)} )}
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && ( {(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} /> <GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)} )}
{message.status === "thinking" && message.mode === "chat" && ( {message.status === "thinking" && message.mode === "chat" && (
@@ -2972,6 +3102,11 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span> <span>{message.taskStatusLabel || generationStatus}</span>
</div> </div>
)} )}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && ( {(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard <ResultCard
message={message} message={message}
@@ -2993,7 +3128,14 @@ function WorkbenchPage({
</div> </div>
</section> </section>
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}> <section
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)}
+6 -1
View File
@@ -1,3 +1,5 @@
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
export type WorkbenchMode = "chat" | "image" | "video"; export type WorkbenchMode = "chat" | "image" | "video";
export interface WorkbenchChatAttachment { export interface WorkbenchChatAttachment {
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed"; status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
+19 -3
View File
@@ -1,6 +1,7 @@
import { isServerRequestError } from "../../api/serverConnection"; import { isServerRequestError } from "../../api/serverConnection";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy"; import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import type { WebGenerationPreviewTask } from "../../types"; import type { WebGenerationPreviewTask } from "../../types";
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export type WorkbenchMode = "chat" | "image" | "video"; export type WorkbenchMode = "chat" | "image" | "video";
@@ -71,7 +72,10 @@ export interface ChatMessage {
body: string; body: string;
prompt?: string; prompt?: string;
createdAt: string; createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed"; status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string; taskId?: string;
conversationId?: number; conversationId?: number;
taskProgress?: number; taskProgress?: number;
@@ -232,6 +236,13 @@ 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: "首尾帧" },
@@ -366,11 +377,16 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
return ( return (
patch.status === "completed" || patch.status === "completed" ||
patch.status === "failed" || patch.status === "failed" ||
patch.status === "local_timeout" ||
patch.status === "stopping" ||
typeof patch.taskId === "string" || typeof patch.taskId === "string" ||
typeof patch.resultUrl === "string" || typeof patch.resultUrl === "string" ||
typeof patch.resultOssKey === "string" || typeof patch.resultOssKey === "string" ||
typeof patch.resultOriginalUrl === "string" || typeof patch.resultOriginalUrl === "string" ||
typeof patch.resultMimeType === "string" typeof patch.resultMimeType === "string" ||
typeof patch.taskRefundStatus === "string" ||
typeof patch.taskLifecycleStatus === "string" ||
typeof patch.taskUsage === "object"
); );
} }
@@ -401,4 +417,4 @@ export function buildAssistantResult(
summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。", summary: "我会把当前输入整理成脚本、分镜、素材需求和可复制的工作流节点。",
specs: [model, ...specs], specs: [model, ...specs],
}; };
} }
+41 -10
View File
@@ -1,5 +1,11 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore"; import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient"; import { aiGenerationClient } from "../api/aiGenerationClient";
import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void; type PollCallback = (item: GenerationQueueItem) => void;
@@ -7,7 +13,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>(); const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000; const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
export function subscribeToTaskUpdates(callback: PollCallback): () => void { export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback); pollCallbacks.add(callback);
@@ -18,10 +24,25 @@ function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item)); pollCallbacks.forEach((cb) => cb(item));
} }
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
if (item.type === "image") return "image";
if (item.type === "video" || item.type === "ecommerce-video") return "video";
return "text";
}
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void { function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`; const key = `poll-${item.id}`;
if (activePollers.has(key)) return; if (activePollers.has(key)) return;
const kind = getQueueItemKind(item);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const interval = setInterval(async () => { const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id); const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") { if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
@@ -30,18 +51,31 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
} }
attemptsRef.current++; attemptsRef.current++;
if (attemptsRef.current > MAX_POLL_ATTEMPTS) { const timeoutReason = isTaskLocallyTimedOut({
startedAt: current.createdAt || item.createdAt || Date.now(),
lastProgressAt,
progress: lastProgress,
policy: timeoutPolicy,
});
if (timeoutReason || attemptsRef.current > MAX_POLL_ATTEMPTS) {
const error = buildLocalTimeoutMessage(kind);
useGenerationStore.getState().updateTask(item.id, { useGenerationStore.getState().updateTask(item.id, {
status: "failed", status: "failed",
error: "任务超时,请重新提交", error,
}); });
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" }); notifyCallbacks({ ...item, status: "failed", error });
cleanupPoll(key); cleanupPoll(key);
return; return;
} }
try { try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || ""); const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = { const patch: Partial<GenerationQueueItem> = {
progress: status.progress, progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl, resultUrl: status.resultUrl || current.resultUrl,
@@ -55,6 +89,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
cleanupPoll(key); cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") { } else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed"; patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch); useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" }); notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key); cleanupPoll(key);
@@ -64,7 +99,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
notifyCallbacks({ ...item, ...patch, status: "running" }); notifyCallbacks({ ...item, ...patch, status: "running" });
} }
} catch { } catch {
// Network error during poll — keep trying // Network errors during polling are retried until the lifecycle guard trips.
} }
}, POLL_INTERVAL); }, POLL_INTERVAL);
@@ -105,24 +140,20 @@ export function stopAllPolling(): void {
activePollers.clear(); activePollers.clear();
} }
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void { export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks(); const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return; if (!pendingTasks.length) return;
pendingTasks.forEach((task) => { pendingTasks.forEach((task) => {
if (task.taskId) { if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" }); useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else { } else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, { useGenerationStore.getState().updateTask(task.id, {
status: "failed", status: "failed",
error: "页面刷新后任务丢失,请重新提交", error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交",
}); });
} }
}); });
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500); setTimeout(() => startBackgroundPolling(), 500);
} }
+1
View File
@@ -20,6 +20,7 @@
@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,3 +722,191 @@
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
@@ -0,0 +1,580 @@
.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,6 +418,15 @@
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;
} }
@@ -4255,6 +4264,7 @@
.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;
} }
@@ -4279,6 +4289,11 @@
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;
@@ -5209,6 +5224,7 @@
} }
.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;
@@ -9045,6 +9061,17 @@
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);
@@ -9270,6 +9297,17 @@
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%),
+156
View File
@@ -2802,6 +2802,10 @@
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 {
@@ -3230,6 +3234,141 @@
color: var(--report-green); color: var(--report-green);
} }
.script-eval-report__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 0 18px 18px;
}
.script-eval-report__detail-card {
min-width: 0;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: var(--v5-radius-md);
background: linear-gradient(180deg, rgb(255 255 255 / 3.4%), transparent), var(--report-row);
padding: 14px;
}
.script-eval-report__detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.script-eval-report__detail-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.script-eval-report__detail-head span {
color: #dfe8e4;
font-size: 14px;
font-weight: 800;
}
.script-eval-report__detail-head strong {
color: var(--report-green);
font-size: 22px;
line-height: 1;
}
.script-eval-report__detail-head small {
color: #7e8a86;
font-size: 12px;
}
.script-eval-report__detail-head em {
flex-shrink: 0;
border: 1px solid rgb(0 255 136 / 18%);
border-radius: 999px;
background: rgb(0 255 136 / 7%);
color: #98e8bd;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.script-eval-report__detail-hint,
.script-eval-report__detail-empty {
margin: 10px 0 0;
color: #7f8c88;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.script-eval-report__subscore-list {
display: grid;
gap: 9px;
margin-top: 13px;
}
.script-eval-report__subscore-row {
display: grid;
grid-template-columns: minmax(62px, 86px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
color: #bdcbc6;
font-size: 12px;
font-weight: 750;
}
.script-eval-report__subscore-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-eval-report__subscore-row b {
color: #dfe8e4;
text-align: right;
}
.script-eval-report__subscore-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 5%);
}
.script-eval-report__subscore-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #18de8a, #a3f7c1);
}
.script-eval-report__evidence-list {
display: grid;
gap: 7px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.script-eval-report__evidence-list li {
position: relative;
padding-left: 13px;
color: #aebcb7;
font-size: 12px;
font-weight: 650;
line-height: 1.55;
}
.script-eval-report__evidence-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--report-green);
}
.script-eval-report__findings { .script-eval-report__findings {
gap: 18px; gap: 18px;
} }
@@ -3278,6 +3417,10 @@
.script-eval-report--inside .script-eval-report__body { .script-eval-report--inside .script-eval-report__body {
padding-inline: 24px; padding-inline: 24px;
} }
.script-eval-report__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -3297,6 +3440,19 @@
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.script-eval-report__detail-grid {
grid-template-columns: 1fr;
margin-inline: 0;
}
.script-eval-report__detail-card {
padding: 12px;
}
.script-eval-report__subscore-row {
grid-template-columns: minmax(58px, 78px) minmax(0, 1fr) 30px;
}
.script-eval-v5 { .script-eval-v5 {
overflow: hidden; overflow: hidden;
} }
+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: 1fr 1fr; grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 16px; gap: 16px;
min-height: clamp(360px, 40vw, 520px); min-height: clamp(560px, 52vw, 760px);
} }
/* ===== 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(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-auto-flow: dense; grid-auto-flow: dense;
grid-auto-rows: 10px; grid-auto-rows: 10px;
gap: 10px; gap: 8px;
} }
.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 8; grid-row: span 13;
} }
.wb-prompt-case-card--ratio-tall { .wb-prompt-case-card--ratio-tall {
grid-column: span 1; grid-column: span 1;
grid-row: span 23; grid-row: span 30;
} }
.wb-prompt-case-card--ratio-square { .wb-prompt-case-card--ratio-square {
grid-column: span 1; grid-column: span 1;
grid-row: span 13; grid-row: span 18;
} }
.wb-prompt-case-card--ratio-portrait { .wb-prompt-case-card--ratio-portrait {
grid-column: span 1; grid-column: span 1;
grid-row: span 16; grid-row: span 24;
} }
.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(170px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 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(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 8px; grid-auto-rows: 8px;
gap: 8px; gap: 8px;
} }
+632 -5
View File
@@ -2410,6 +2410,48 @@
border-color: rgba(239, 68, 68, 0.6); border-color: rgba(239, 68, 68, 0.6);
} }
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 6px 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: rgba(15, 23, 42, 0.62);
color: rgba(226, 232, 240, 0.92);
font-size: 12px;
cursor: pointer;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions button:hover {
border-color: rgba(56, 189, 248, 0.42);
background: rgba(8, 47, 73, 0.45);
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-failed-actions__release {
color: #fbbf24;
}
.web-shell[data-ui-theme="dark-green"] .ai-chat-task-billing-note {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid rgba(45, 212, 191, 0.22);
border-radius: 6px;
background: rgba(13, 148, 136, 0.08);
color: rgba(204, 251, 241, 0.86);
font-size: 12px;
line-height: 1.6;
white-space: pre-line;
}
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card__bar, .web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card__bar,
.web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .ai-generation-pending-card__bar { .web-shell[data-ui-theme="dark-green"] .ai-generation-pending-card.is-video .ai-generation-pending-card__bar {
position: absolute; position: absolute;
@@ -4951,6 +4993,332 @@
color: var(--accent); color: var(--accent);
} }
/* Auth page: refined SaaS entry surface, preserving current auth behavior and OSS assets. */
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: minmax(0, 1.55fr) minmax(400px, 0.9fr);
background: var(--dg-page);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase {
background: #0d0d0f;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video {
opacity: 0.48;
filter: saturate(1.08) contrast(1.04);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: center;
justify-content: center;
padding: clamp(36px, 6vw, 76px);
background:
linear-gradient(90deg, rgba(13, 13, 15, 0.86), rgba(13, 13, 15, 0.46) 58%, rgba(13, 13, 15, 0.72)),
linear-gradient(180deg, rgba(13, 13, 15, 0.28), rgba(13, 13, 15, 0.92));
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
display: grid;
gap: 14px;
width: min(620px, 100%);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
color: var(--accent);
font-size: 64px;
line-height: 0.96;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
max-width: 480px;
color: var(--fg-muted);
font-size: 22px;
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
justify-content: flex-start;
gap: 10px;
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: var(--fg-body);
backdrop-filter: blur(12px);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
width: min(520px, 100%);
margin-top: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats span {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
background: rgba(16, 18, 20, 0.58);
color: var(--fg-soft);
backdrop-filter: blur(14px);
font-size: 12px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats strong {
color: var(--fg-body);
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
align-items: center;
padding: clamp(28px, 4vw, 48px);
border-left-color: rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent),
var(--bg-surface);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 20px;
max-width: 420px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-head {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 54px;
height: 54px;
margin-bottom: 2px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.035);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 24px;
line-height: 1.2;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__subtitle {
color: var(--fg-soft);
line-height: 1.5;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs {
gap: 4px;
padding: 4px;
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button {
min-height: 38px;
padding: 0 12px;
border: 0;
border-radius: 9px;
font-weight: 700;
transition: background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__mode-tabs button.is-active {
background: rgba(var(--accent-rgb), 0.13);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 42px;
padding: 0 8px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.018);
font-size: 12px;
font-weight: 600;
transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button.is-active {
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.1);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form {
gap: 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field {
gap: 7px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field > span {
color: var(--fg-muted);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row {
border-color: rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input {
min-height: 44px;
padding: 0 14px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input:focus,
.web-shell[data-ui-theme="dark-green"] .auth-page__phone-row:focus-within {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error input,
.web-shell[data-ui-theme="dark-green"] .auth-page__field--error .auth-page__phone-row {
border-color: rgba(255, 90, 95, 0.64);
box-shadow: 0 0 0 3px rgba(255, 90, 95, 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 44px;
border-color: rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.08);
font-weight: 700;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn:disabled {
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit {
min-height: 46px;
padding: 0 16px;
border-radius: var(--radius-sm);
font-size: 15px;
font-weight: 800;
transition: background var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__submit:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(var(--accent-rgb), 0.16);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__notice {
border-color: rgba(255, 90, 95, 0.28);
background: rgba(255, 90, 95, 0.09);
line-height: 1.45;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-box {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.022);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-title {
margin: 0;
color: var(--fg-body);
font-size: 13px;
font-weight: 800;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input {
width: 100%;
min-height: 44px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-body);
outline: none;
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-input:focus {
border-color: rgba(var(--accent-rgb), 0.55);
background: var(--bg-elevated);
box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel,
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
min-height: 38px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 750;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-cancel {
border: 1px solid rgba(255, 255, 255, 0.075);
background: rgba(255, 255, 255, 0.026);
color: var(--fg-muted);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__forgot-confirm {
border: 1px solid rgba(var(--accent-rgb), 0.42);
background: rgba(var(--accent-rgb), 0.12);
color: var(--accent);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__agreement {
line-height: 1.55;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__divider {
margin-top: 2px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__social-btn {
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.018);
}
.web-shell[data-ui-theme="dark-green"] .profile-page { .web-shell[data-ui-theme="dark-green"] .profile-page {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@@ -5077,6 +5445,12 @@
color: #fff; color: #fff;
box-shadow: none; box-shadow: none;
font-size: 11px; font-size: 11px;
opacity: 0;
transition: opacity 0.2s;
}
.web-shell[data-ui-theme="dark-green"] .profile-page__avatar-ring:hover .profile-page__avatar-edit {
opacity: 1;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page__avatar-edit:hover { .web-shell[data-ui-theme="dark-green"] .profile-page__avatar-edit:hover {
@@ -6370,6 +6744,14 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 82px; width: 82px;
height: 82px; height: 82px;
justify-content: flex-start;
padding-left: 30px;
opacity: 0;
transition: opacity 0.2s;
}
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-ring:hover .profile-page__avatar-edit {
opacity: 1;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-badge {
@@ -6808,6 +7190,7 @@
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__avatar-edit {
width: 72px; width: 72px;
height: 72px; height: 72px;
padding-left: 24px;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts { .web-shell[data-ui-theme="dark-green"] .profile-page--dashboard .profile-page__counts {
@@ -8472,6 +8855,134 @@
color: var(--accent); color: var(--accent);
} }
@media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
top: 70px;
right: 12px;
left: 12px;
max-width: none;
overflow-x: auto;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-zoom-controls {
bottom: 14px;
left: 12px;
}
}
@media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
gap: 7px;
min-height: 82px;
padding: 8px;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__identity {
grid-column: 1 / 4;
max-width: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__status,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__autosave-status {
display: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent {
grid-column: 1;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export {
grid-column: 2;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save {
grid-column: 3;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish {
grid-column: 4;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar :is(
.studio-canvas-project-bar__rename,
.studio-canvas-project-bar__recent,
.studio-canvas-project-bar__export,
.studio-canvas-project-bar__save,
.studio-canvas-project-bar__publish
) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 100%;
min-width: 0;
min-height: 32px;
padding: 0 7px;
border-radius: 10px;
font-size: 11px;
font-weight: 780;
line-height: 1;
white-space: nowrap;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__rename::after {
content: "编辑";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__save span {
display: inline;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export span,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish span {
width: 0;
max-width: 0;
overflow: hidden;
font-size: 0;
opacity: 0;
pointer-events: none;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent::after {
font-size: 11px;
content: "最近";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__export::after {
font-size: 11px;
content: "导出";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__publish::after {
font-size: 11px;
content: "提交";
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .studio-canvas-project-bar__recent em {
display: inline-flex;
min-width: 16px;
height: 16px;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.16);
color: var(--accent);
font-size: 10px;
}
}
/* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */ /* Responsive floating navigation: prevent squeeze/warp on narrow workspaces. */
.web-shell[data-ui-theme="dark-green"] .floating-nav { .web-shell[data-ui-theme="dark-green"] .floating-nav {
flex-shrink: 0; flex-shrink: 0;
@@ -8902,21 +9413,43 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.web-shell[data-ui-theme="dark-green"] .auth-page { .web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 200px 1fr; grid-template-rows: 180px minmax(0, 1fr);
overflow-y: auto;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel { .web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
padding: 24px 20px; align-items: flex-start;
padding: 22px 20px 36px;
border-top: 1px solid var(--border-weak); border-top: 1px solid var(--border-weak);
border-left: 0; border-left: 0;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
align-items: flex-start;
justify-content: center;
padding: 22px 24px;
text-align: left;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-content {
gap: 9px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand { .web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 32px; font-size: 34px;
} }
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline { .web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 16px; font-size: 15px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__showcase-stats {
display: none;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
max-width: 520px;
margin: 0 auto;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page__body { .web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -8926,9 +9459,82 @@
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.web-shell[data-ui-theme="dark-green"] .auth-page {
grid-template-rows: 132px minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"] .auth-page__video-overlay {
padding: 16px 18px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand-row {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__brand {
font-size: 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__tagline {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features {
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__features span {
padding: 4px 8px;
font-size: 11px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-panel {
padding: 14px 14px 28px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__form-inner {
gap: 16px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__logo {
width: 46px;
height: 46px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__title {
font-size: 21px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs { .web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button {
min-height: 38px;
padding: 0 4px;
font-size: 10px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__auth-tabs button .anticon {
font-size: 13px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__field input,
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
min-height: 42px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-row {
gap: 8px;
}
.web-shell[data-ui-theme="dark-green"] .auth-page__sms-btn {
padding: 0 10px;
font-size: 12px;
} }
.web-shell[data-ui-theme="dark-green"] .profile-page__body { .web-shell[data-ui-theme="dark-green"] .profile-page__body {
@@ -9052,6 +9658,27 @@
} }
/* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */ /* Canvas SaaS polish: refined production-tool surfaces without changing canvas behavior. */
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
padding-bottom: 0;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .web-shell__page {
overflow: hidden;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .workspace-page-shell__content,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas,
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
min-height: 0;
height: 100%;
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-tool-layout--canvas-empty {
grid-template-rows: minmax(0, 1fr);
}
.web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas { .web-shell[data-ui-theme="dark-green"][data-view="canvas"] .canvas-page .studio-canvas {
background-image: background-image:
radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%), radial-gradient(circle at 18% 8%, rgba(var(--accent-rgb), 0.055), transparent 30%),
+1
View File
@@ -22,6 +22,7 @@ export type WebViewKey =
| "more" | "more"
| "watermarkRemoval" | "watermarkRemoval"
| "subtitleRemoval" | "subtitleRemoval"
| "dialogGenerator"
| "communityReview" | "communityReview"
| "communityCaseAdd" | "communityCaseAdd"
| "report" | "report"
+160
View File
@@ -0,0 +1,160 @@
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
export type GenerationLifecycleStatus =
| "creating"
| "queued"
| "running"
| "stopping"
| "failed"
| "completed"
| "local_timeout";
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
export interface TaskTimeoutPolicy {
submitTimeoutMs: number;
noProgressTimeoutMs: number;
maxRuntimeMs: number;
}
export interface TaskFailureInfo {
category: TaskErrorCategory;
message: string;
actionLabel: string;
retryable: boolean;
refundStatus: TaskRefundStatus;
refundHint: string;
}
export interface TextTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 10 * 60_000,
};
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 20 * 60_000,
};
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 30 * 60_000,
};
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 15 * 60_000,
};
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 30_000,
noProgressTimeoutMs: 60_000,
maxRuntimeMs: 5 * 60_000,
};
export function getTaskTimeoutPolicy(input: {
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}): TaskTimeoutPolicy {
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
const model = String(input.model || "").toLowerCase();
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
return VIDEO_TIMEOUT_POLICY;
}
export function isTaskLocallyTimedOut(input: {
startedAt: number;
lastProgressAt: number;
now?: number;
policy: TaskTimeoutPolicy;
progress?: number;
}): "no_progress" | "max_runtime" | null {
const now = input.now || Date.now();
const progress = Number(input.progress || 0);
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
return "no_progress";
}
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
return null;
}
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
if (kind === "text") {
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
}
const label = kind === "image" ? "图片" : "视频";
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
}
export function buildTaskFailureInfo(
error: string | undefined | null,
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
): TaskFailureInfo {
const classified = classifyTaskError(error);
const submitted = options.submitted !== false;
const refundStatus: TaskRefundStatus =
options.refundStatus ||
(submitted
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
? "not_charged"
: "unknown"
: "not_charged");
const refundHint = getRefundHint(refundStatus);
return {
category: classified.category,
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
actionLabel: classified.action,
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
refundStatus,
refundHint,
};
}
export function getRefundHint(status: TaskRefundStatus): string {
switch (status) {
case "not_charged":
return "提交未进入扣费结算,未产生积分消耗。";
case "pending_refund":
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
case "refunded":
return "失败扣费已退回,请在积分流水中核对。";
case "manual_review":
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
default:
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default defineConfig(() => ({
drop: ["console", "debugger"], drop: ["console", "debugger"],
}, },
build: { build: {
sourcemap: "hidden", sourcemap: false,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id: string) { manualChunks(id: string) {