Compare commits

..

1 Commits

Author SHA1 Message Date
stringadmin 6cc81e3804 Improve generation task client errors 2026-06-04 21:07:48 +08:00
46 changed files with 405 additions and 6326 deletions
@@ -1,408 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>交互式对话框生成器</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#165DFF',
secondary: '#6B7280',
accent: '#F59E0B'
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
}
}
}
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.dialog-item {
@apply p-3 border rounded-lg cursor-pointer transition-all hover:bg-primary/5 hover:border-primary;
}
}
</style>
<style>
.gen-dialog {
position: absolute;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.12);
z-index: 10;
user-select: none;
transition: box-shadow 0.2s;
}
.gen-dialog:hover { box-shadow: 0 6px 32px rgba(0,0,0,0.18); }
.gen-dialog.active-drag { z-index: 20; box-shadow: 0 8px 40px rgba(0,0,0,0.22); }
/* 样式1:白色圆角 */
.gen-dialog.style1 { background: rgba(255,255,255,0.97); border: 2px solid #CBD5E1; }
.gen-dialog.style1 .gen-confirm { background: #165DFF; }
.gen-dialog.style1 .gen-text { color: #1e293b; }
/* 样式2:蓝色气泡 */
.gen-dialog.style2 { background: rgba(22,93,255,0.95); border: 2px solid #4F8AFF; border-radius: 16px 16px 4px 16px; }
.gen-dialog.style2 .gen-confirm { background: #fff; color: #165DFF; }
.gen-dialog.style2 .gen-text { color: #fff; }
.gen-dialog.style2 .gen-text::placeholder { color: rgba(255,255,255,0.6); }
/* 样式3:黄色提示 */
.gen-dialog.style3 { background: rgba(255,247,237,0.97); border: 2px solid #F59E0B; }
.gen-dialog.style3 .gen-confirm { background: #F59E0B; }
.gen-dialog.style3 .gen-text { color: #92400e; }
.gen-dialog.style3 .gen-text::placeholder { color: rgba(146,64,14,0.4); }
/* 样式4:灰色简约 */
.gen-dialog.style4 { background: rgba(248,250,252,0.97); border: 2px solid #6B7280; border-radius: 4px; }
.gen-dialog.style4 .gen-confirm { background: #6B7280; }
.gen-dialog.style4 .gen-text { color: #1f2937; }
.gen-text {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-size: 14px;
line-height: 1.6;
padding: 0;
font-family: inherit;
}
.gen-text::placeholder { color: rgba(0,0,0,0.3); }
.gen-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 6px;
border: none;
color: #fff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
margin-top: 6px;
}
.gen-confirm:hover { filter: brightness(1.1); transform: translateY(-1px); }
.gen-confirm:active { transform: translateY(0); }
/* 已确认状态 */
.gen-dialog.confirmed .gen-text { cursor: default; }
.gen-dialog.confirmed .gen-confirm { display: none; }
.gen-dialog.confirmed .gen-edit-hint { display: inline-block; }
.gen-dialog:not(.confirmed) .gen-edit-hint { display: none; }
.gen-edit-hint {
font-size: 10px;
color: rgba(0,0,0,0.3);
margin-top: 4px;
}
/* 删除按钮 */
.gen-delete {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #EF4444;
color: #fff;
border: 2px solid #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.gen-dialog:hover .gen-delete { opacity: 1; }
#previewContainer { position: relative; }
#previewImage { width: 100%; height: 100%; background-size: contain; background-position: center; background-repeat: no-repeat; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8">
<h1 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-center mb-8 text-gray-800">
交互式对话框生成器
</h1>
<div class="flex flex-col lg:flex-row gap-6">
<!-- 左侧面板 -->
<div class="lg:w-1/3 bg-white rounded-xl shadow-md p-6">
<!-- 文件上传区域 -->
<div class="mb-8">
<h2 class="text-lg font-semibold mb-4 text-gray-700">
<i class="fa fa-upload mr-2 text-primary"></i>上传背景图片
</h2>
<div id="dropArea" class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors">
<i class="fa fa-image text-4xl text-gray-400 mb-3"></i>
<p class="text-gray-500 mb-2">点击或拖拽图片到此处</p>
<p class="text-xs text-gray-400">支持 JPG、PNG、WEBP 格式</p>
<input type="file" id="fileInput" accept="image/*" class="hidden">
</div>
</div>
<!-- 对话框选择区域 -->
<div>
<h2 class="text-lg font-semibold mb-4 text-gray-700">
<i class="fa fa-comment mr-2 text-primary"></i>点击添加对话框
</h2>
<p class="text-xs text-gray-400 mb-3">每点一次即在预览区新增一个对话框</p>
<div class="space-y-3" id="dialogList">
<div class="dialog-item" data-style="style1">
<span class="inline-block w-3 h-3 rounded bg-white border border-gray-300 mr-2 align-middle"></span>白色圆角对话框
</div>
<div class="dialog-item" data-style="style2">
<span class="inline-block w-3 h-3 rounded bg-blue-500 mr-2 align-middle"></span>蓝色气泡对话框
</div>
<div class="dialog-item" data-style="style3">
<span class="inline-block w-3 h-3 rounded bg-amber-400 mr-2 align-middle"></span>黄色提示对话框
</div>
<div class="dialog-item" data-style="style4">
<span class="inline-block w-3 h-3 rounded bg-gray-400 mr-2 align-middle"></span>灰色简约对话框
</div>
</div>
</div>
<div class="mt-8">
<button id="clearBtn" class="w-full bg-gray-200 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 transition-all">
<i class="fa fa-trash mr-1"></i>清空全部对话框
</button>
</div>
</div>
<!-- 右侧预览面板 -->
<div class="lg:w-2/3 bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold mb-4 text-gray-700">
<i class="fa fa-eye mr-2 text-primary"></i>预览区域
</h2>
<div id="previewContainer" class="relative w-full h-[500px] border rounded-lg bg-gray-100 overflow-hidden">
<div id="previewImage"></div>
<div id="dialogContainer" class="absolute inset-0"></div>
<div id="emptyTip" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-center text-gray-400 pointer-events-none">
<i class="fa fa-image text-5xl mb-3"></i>
<p>上传图片后开始编辑</p>
</div>
</div>
<p class="text-sm text-gray-500 mt-3">
<i class="fa fa-info-circle mr-1"></i>提示:对话框可拖动定位,输入文字后点确认即可渲染,双击已确认的框可重新编辑
</p>
</div>
</div>
</div>
<script>
let dialogCount = 0;
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const previewImage = document.getElementById('previewImage');
const emptyTip = document.getElementById('emptyTip');
const dialogList = document.getElementById('dialogList');
const dialogContainer = document.getElementById('dialogContainer');
const clearBtn = document.getElementById('clearBtn');
const previewContainer = document.getElementById('previewContainer');
// ===== 文件上传 =====
dropArea.addEventListener('click', () => fileInput.click());
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
dropArea.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
});
dropArea.addEventListener('drop', e => handleFile(e.dataTransfer.files[0]));
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
previewImage.style.backgroundImage = `url(${e.target.result})`;
emptyTip.classList.add('hidden');
};
reader.readAsDataURL(file);
}
// ===== 点击添加对话框 =====
dialogList.addEventListener('click', e => {
const item = e.target.closest('.dialog-item');
if (!item) return;
createDialog(item.dataset.style);
});
function createDialog(style) {
dialogCount++;
const dialog = document.createElement('div');
dialog.className = `gen-dialog ${style}`;
dialog.dataset.style = style;
// 随机偏移避免完全重叠
const offsetX = 30 + (dialogCount * 25) % 200;
const offsetY = 30 + (dialogCount * 20) % 150;
dialog.style.left = offsetX + 'px';
dialog.style.top = offsetY + 'px';
dialog.style.padding = '12px 14px';
// 删除按钮
const delBtn = document.createElement('div');
delBtn.className = 'gen-delete';
delBtn.innerHTML = '<i class="fa fa-times" style="font-size:9px"></i>';
delBtn.addEventListener('click', e => {
e.stopPropagation();
dialog.remove();
});
// 文本输入区
const textarea = document.createElement('textarea');
textarea.className = 'gen-text';
textarea.rows = 2;
textarea.placeholder = '输入文本...';
// 阻止拖动冲突
textarea.addEventListener('mousedown', e => e.stopPropagation());
textarea.addEventListener('touchstart', e => e.stopPropagation());
// 底部:确认按钮 + 编辑提示
const bottomRow = document.createElement('div');
bottomRow.style.cssText = 'display:flex; justify-content:flex-end; align-items:center;';
const editHint = document.createElement('span');
editHint.className = 'gen-edit-hint';
editHint.textContent = '双击编辑';
const confirmBtn = document.createElement('button');
confirmBtn.className = 'gen-confirm';
confirmBtn.innerHTML = '<i class="fa fa-check" style="font-size:10px"></i> 确认';
bottomRow.appendChild(editHint);
bottomRow.appendChild(confirmBtn);
dialog.appendChild(delBtn);
dialog.appendChild(textarea);
dialog.appendChild(bottomRow);
dialogContainer.appendChild(dialog);
// ===== 确认 =====
confirmBtn.addEventListener('click', e => {
e.stopPropagation();
const text = textarea.value.trim();
if (!text) return;
// 把textarea换成纯文本展示
const textDisplay = document.createElement('div');
textDisplay.className = 'gen-text';
textDisplay.style.whiteSpace = 'pre-wrap';
textDisplay.textContent = text;
textarea.replaceWith(textDisplay);
dialog.classList.add('confirmed');
});
// ===== 双击重新编辑 =====
dialog.addEventListener('dblclick', e => {
if (!dialog.classList.contains('confirmed')) return;
e.stopPropagation();
const textDisplay = dialog.querySelector('.gen-text');
const currentText = textDisplay.textContent;
const newTextarea = document.createElement('textarea');
newTextarea.className = 'gen-text';
newTextarea.rows = 2;
newTextarea.value = currentText;
newTextarea.addEventListener('mousedown', e => e.stopPropagation());
newTextarea.addEventListener('touchstart', e => e.stopPropagation());
textDisplay.replaceWith(newTextarea);
dialog.classList.remove('confirmed');
newTextarea.focus();
});
// ===== 拖动 =====
bindDrag(dialog);
// 自动聚焦输入
textarea.focus();
}
// ===== 拖动逻辑 =====
function bindDrag(element) {
let dragging = false, ox, oy;
element.addEventListener('mousedown', e => {
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
dragging = true;
element.classList.add('active-drag');
const rect = element.getBoundingClientRect();
ox = e.clientX - rect.left;
oy = e.clientY - rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
const cr = dialogContainer.getBoundingClientRect();
let x = e.clientX - ox - cr.left;
let y = e.clientY - oy - cr.top;
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
element.style.left = x + 'px';
element.style.top = y + 'px';
});
document.addEventListener('mouseup', () => {
if (dragging) {
dragging = false;
element.classList.remove('active-drag');
}
});
// 触摸支持
element.addEventListener('touchstart', e => {
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'BUTTON' || e.target.closest('.gen-delete') || e.target.closest('.gen-confirm')) return;
dragging = true;
element.classList.add('active-drag');
const rect = element.getBoundingClientRect();
const touch = e.touches[0];
ox = touch.clientX - rect.left;
oy = touch.clientY - rect.top;
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!dragging) return;
const touch = e.touches[0];
const cr = dialogContainer.getBoundingClientRect();
let x = touch.clientX - ox - cr.left;
let y = touch.clientY - oy - cr.top;
x = Math.max(0, Math.min(x, cr.width - element.offsetWidth));
y = Math.max(0, Math.min(y, cr.height - element.offsetHeight));
element.style.left = x + 'px';
element.style.top = y + 'px';
}, { passive: true });
document.addEventListener('touchend', () => {
if (dragging) {
dragging = false;
element.classList.remove('active-drag');
}
});
}
// ===== 清空 =====
clearBtn.addEventListener('click', () => {
dialogContainer.innerHTML = '';
dialogCount = 0;
});
</script>
</body>
</html>
+1 -5
View File
@@ -48,7 +48,6 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
@@ -111,7 +110,6 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
@@ -125,7 +123,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
@@ -1161,8 +1159,6 @@ function App() {
onSelectView={handleSetView}
/>
);
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report":
return <ReportPage />;
case "providerHealth":
+1 -24
View File
@@ -134,12 +134,6 @@ export interface ChatInput {
temperature?: number;
}
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus {
taskId: string;
projectId?: string;
@@ -506,7 +500,6 @@ export const aiGenerationClient = {
input: ChatInput,
onChunk: (text: string) => void,
signal?: AbortSignal,
onUsage?: (usage: ChatUsage) => void,
): Promise<void> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
@@ -536,24 +529,8 @@ export const aiGenerationClient = {
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return;
} catch (e) {
+5 -79
View File
@@ -4,8 +4,6 @@ export interface ScriptEvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string;
issues: string[];
highlights: string[];
@@ -14,33 +12,6 @@ export interface ScriptEvalResult {
const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】
@@ -75,10 +46,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 },
plot: { maxScore: 20 },
character: { maxScore: 15 },
logic: { maxScore: 15 },
character: { maxScore: 18 },
dialogue: { maxScore: 15 },
visual: { maxScore: 15 },
content: { maxScore: 15 },
content: { maxScore: 12 },
};
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -97,48 +68,6 @@ function extractJson(text: string): unknown {
return JSON.parse(raw);
}
function normalizeScoreValue(value: unknown, maxScore: number): number {
const score = Number(value);
if (!Number.isFinite(score)) return 0;
return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10));
}
function isRecord(value: unknown): value is Record<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> {
const res = await fetch(buildApiUrl("ai/chat"), {
method: "POST",
@@ -147,7 +76,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
model: MODEL,
messages: [
{ role: "system", content: EVAL_SYSTEM_PROMPT },
{ role: "system", content: EVAL_OUTPUT_CONTRACT },
{ role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` },
],
stream: false,
@@ -173,8 +101,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
const val = Number(rawScores[key] ?? 0);
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
}
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -183,8 +111,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
totalScore,
grade,
dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
+4 -38
View File
@@ -1,9 +1,4 @@
import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent {
taskId: string;
@@ -17,28 +12,16 @@ export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean };
timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}
const POLL_INTERVAL = 3000;
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
export function waitForTask(
taskId: string,
options: WaitForTaskOptions = {},
): Promise<string | null> {
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();
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
return new Promise((resolve, reject) => {
let settled = false;
@@ -46,8 +29,6 @@ export function waitForTask(
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let sseConnected = false;
let fallbackTimerId: ReturnType<typeof setTimeout> | null = null;
let lastProgress = 0;
let lastProgressAt = startedAt;
const settle = (fn: () => void) => {
if (settled) return;
@@ -59,7 +40,7 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
timeoutMs,
);
@@ -69,11 +50,6 @@ export function waitForTask(
settle(() => resolve(null));
return;
}
const progress = Number(event.progress || 0);
if (progress > lastProgress || event.status === "completed") {
lastProgress = Math.max(lastProgress, progress);
lastProgressAt = Date.now();
}
onProgress?.(event);
if (event.status === "completed") {
settle(() => resolve(event.resultUrl || null));
@@ -100,16 +76,6 @@ export function waitForTask(
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
if (settled || abortRef?.current) return;
const timeoutReason = isTaskLocallyTimedOut({
startedAt,
lastProgressAt,
progress: lastProgress,
policy: { ...timeoutPolicy, noProgressTimeoutMs },
});
if (timeoutReason) {
settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video"))));
return;
}
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
handleUpdate({
@@ -124,7 +90,7 @@ export function waitForTask(
}
}
};
void poll();
poll();
}
});
}
+2 -4
View File
@@ -13,7 +13,6 @@ import {
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
import { toast } from "./toast/toastStore";
import type { ServerConnectionHealth } from "../api/serverConnection";
import { ossAssets } from "../data/ossAssets";
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
@@ -75,7 +74,7 @@ function AppShell({
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
const toolSurfaceViews = [
"workbench",
"canvas",
@@ -87,7 +86,6 @@ function AppShell({
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[];
@@ -371,7 +369,7 @@ function AppShell({
className="member-button"
type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => toast.info("充值功能即将开放,敬请期待")}
onClick={() => setRechargeOpen(true)}
>
<WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span>
-1
View File
@@ -23,7 +23,6 @@ const NAV_ORDER: string[] = [
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
+1 -1
View File
@@ -21,7 +21,7 @@ export const ossAssets = {
},
home: {
backgroundVideo: muban("hero-bg.mp4"),
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
heroSlides: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
features: {
ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"),
+4 -107
View File
@@ -13,7 +13,7 @@ import {
SendOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebGenerationPreviewTask } from "../../types";
@@ -72,24 +72,6 @@ const agentModes = [
},
];
const agentModelOptions = [
{ id: "gemini-3.1-pro", label: "Gemini 3.1 Pro" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "gpt-4o", label: "GPT-4o" },
];
const thinkingSpeedOptions = [
{ id: "fast", label: "快速" },
{ id: "balanced", label: "均衡" },
{ id: "precise", label: "精细" },
];
const thinkingDepthOptions = [
{ id: "concise", label: "简洁" },
{ id: "standard", label: "标准" },
{ id: "deep", label: "深度" },
];
const quickStarts = ["「新品发布」全链路运营", "「销售日报」自动分析", "「竞品监控」每周报告"];
function getTaskSourceLabel(task: WebGenerationPreviewTask): string | null {
@@ -111,21 +93,6 @@ function AgentPage({
const [prompt, setPrompt] = useState("让 Omni Agent 帮我规划「新品发布会全流程」");
const [isRunning, setIsRunning] = useState(false);
const [notice, setNotice] = useState("选择一个 Agent 模式,输入目标后即可开始。");
const [agentModel, setAgentModel] = useState(agentModelOptions[0].id);
const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[1].id);
const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[1].id);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const controlsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
setActiveDropdown(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const selectedMode = agentModes.find((item) => item.id === activeMode) ?? agentModes[0];
const recentTasks = tasks.slice(0, 3);
@@ -236,85 +203,15 @@ function AgentPage({
/>
<div className="agent-composer__footer">
<div className="agent-composer__controls" aria-label="输入设置" ref={controlsRef}>
<div className="agent-composer__controls" aria-label="输入设置">
<button type="button" className="agent-tool-icon" aria-label="上传附件">
<PaperClipOutlined />
</button>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "model" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
>
<RobotOutlined />
{agentModelOptions.find((m) => m.id === agentModel)?.label ?? "模型选择"}
<DownOutlined />
</button>
{activeDropdown === "model" && (
<div className="agent-dropdown">
{agentModelOptions.map((m) => (
<button
key={m.id}
type="button"
className={`agent-dropdown__item${agentModel === m.id ? " is-active" : ""}`}
onClick={() => { setAgentModel(m.id); setActiveDropdown(null); }}
>
{m.label}
</button>
))}
</div>
)}
</div>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "speed" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
>
<button type="button" className="agent-tool-pill">
<ThunderboltOutlined />
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
<DownOutlined />
</button>
{activeDropdown === "speed" && (
<div className="agent-dropdown">
{thinkingSpeedOptions.map((s) => (
<button
key={s.id}
type="button"
className={`agent-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
>
{s.label}
</button>
))}
</div>
)}
</div>
<div className="agent-tool-pills">
<button
type="button"
className={`agent-tool-pill${activeDropdown === "depth" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
>
<ApartmentOutlined />
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
<DownOutlined />
</button>
{activeDropdown === "depth" && (
<div className="agent-dropdown">
{thinkingDepthOptions.map((d) => (
<button
key={d.id}
type="button"
className={`agent-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
>
{d.label}
</button>
))}
</div>
)}
</div>
<button type="button" className="agent-tool-icon" aria-label="工具集">
<AppstoreOutlined />
</button>
+15 -191
View File
@@ -182,7 +182,6 @@ import {
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -337,7 +336,6 @@ function CanvasPage({
const [imageFocusNodeId, setImageFocusNodeId] = useState<string | null>(null);
const [imageFocusDraft, setImageFocusDraft] = useState<CanvasImageFocusSelection | null>(null);
const [imageFocusDrag, setImageFocusDrag] = useState<CanvasImageFocusDrag | null>(null);
const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null);
const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState<string | null>(null);
const [stylePickerCases, setStylePickerCases] = useState<CanvasStyleCase[]>([]);
const [stylePickerLoading, setStylePickerLoading] = useState(false);
@@ -391,8 +389,6 @@ function CanvasPage({
const canvasRef = useRef<HTMLElement>(null);
const videoGenerationInFlightRef = useRef(new Set<string>());
const canvasReferenceUploadPromisesRef = useRef(new Map<string, Promise<string | null>>());
const canvasDragCounterRef = useRef(0);
const [isCanvasDragging, setIsCanvasDragging] = useState(false);
const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
@@ -1282,7 +1278,7 @@ function CanvasPage({
model: defaultVideoModel,
aspectRatio: "16:9",
resolution: getDefaultVideoQuality(defaultVideoModel),
duration: "5",
duration: "4",
videoMode: "text2video",
sourceTextNodeId: source.id,
position: {
@@ -1306,7 +1302,7 @@ function CanvasPage({
model: defaultVideoModel,
aspectRatio: "16:9",
resolution: getDefaultVideoQuality(defaultVideoModel),
duration: "5",
duration: "4",
videoMode: "text2video",
sourceTextNodeId: "",
position,
@@ -1362,7 +1358,7 @@ function CanvasPage({
imageUrl = "",
fileName = "本地图片",
position = { x: 0, y: 0 },
options?: { title?: string; sourceImageNodeId?: string; sourceTextNodeId?: string }
options?: { title?: string; sourceImageNodeId?: string }
) => {
const nodeNumber = imageNodeIdRef.current;
imageNodeIdRef.current += 1;
@@ -1376,7 +1372,6 @@ function CanvasPage({
imageSize: getDefaultImageQuality(fallbackVisibleImageModel),
fileName,
sourceImageNodeId: options?.sourceImageNodeId,
sourceTextNodeId: options?.sourceTextNodeId,
position,
size: createCanvasNodeSize("image"),
};
@@ -1982,120 +1977,6 @@ function CanvasPage({
setNodeMenu(null);
};
// ── Canvas drag-and-drop file upload ──────────────────────────────
const handleCanvasDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current += 1;
if (canvasDragCounterRef.current === 1) {
setIsCanvasDragging(true);
}
}, []);
const handleCanvasDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current -= 1;
if (canvasDragCounterRef.current <= 0) {
canvasDragCounterRef.current = 0;
setIsCanvasDragging(false);
}
}, []);
const handleCanvasDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleCanvasDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
canvasDragCounterRef.current = 0;
setIsCanvasDragging(false);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith("image/")
);
if (files.length === 0) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const dropPosition = {
x: (e.clientX - rect.left - canvasViewport.x) / canvasViewport.zoom,
y: (e.clientY - rect.top - canvasViewport.y) / canvasViewport.zoom,
};
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
addImageNode(imageUrl, file.name, {
x: dropPosition.x + offsetX,
y: dropPosition.y + offsetY,
});
offsetX += 60;
offsetY += 60;
}
setContextMenu(null);
setNodeMenu(null);
},
[canvasViewport.x, canvasViewport.y, canvasViewport.zoom, addImageNode],
);
// ── Text composer drag-and-drop ──────────────────────────────────
const [textComposerDragNodeId, setTextComposerDragNodeId] = useState<string | null>(null);
const textComposerDragCounterRef = useRef(0);
const handleTextComposerDragEnter = useCallback((_e: React.DragEvent, nodeId: string) => {
_e.preventDefault();
_e.stopPropagation();
textComposerDragCounterRef.current += 1;
if (textComposerDragCounterRef.current === 1) {
setTextComposerDragNodeId(nodeId);
}
}, []);
const handleTextComposerDragLeave = useCallback((_e: React.DragEvent) => {
_e.preventDefault();
_e.stopPropagation();
textComposerDragCounterRef.current -= 1;
if (textComposerDragCounterRef.current <= 0) {
textComposerDragCounterRef.current = 0;
setTextComposerDragNodeId(null);
}
}, []);
const handleTextComposerDragOver = useCallback((_e: React.DragEvent) => {
_e.preventDefault();
_e.stopPropagation();
}, []);
const handleTextComposerDrop = useCallback(
(e: React.DragEvent, sourceNode: CanvasTextNode) => {
e.preventDefault();
e.stopPropagation();
textComposerDragCounterRef.current = 0;
setTextComposerDragNodeId(null);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type.startsWith("image/")
);
if (files.length === 0) return;
let offsetX = 0;
let offsetY = 0;
for (const file of files) {
const imageUrl = URL.createObjectURL(file);
addImageNode(imageUrl, file.name, {
x: sourceNode.position.x + sourceNode.size.width + 40 + offsetX,
y: sourceNode.position.y + offsetY,
}, { sourceTextNodeId: sourceNode.id });
offsetX += 60;
offsetY += 60;
}
},
[addImageNode],
);
const activeTextNode = textNodeMenu
? textNodes.find((node) => node.id === textNodeMenu.nodeId) ?? null
: null;
@@ -2943,7 +2824,7 @@ function CanvasPage({
if (targetPort) {
connectCanvasPorts(connectorDrag.port, targetPort);
} else {
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
setConnectionDropMenu({
...menuPosition,
originLeft: event.clientX,
@@ -3669,7 +3550,7 @@ function CanvasPage({
<WorkspacePageShell title="画布" fullWidth className="canvas-page page-motion">
<div className={`studio-tool-layout studio-tool-layout--no-top studio-tool-layout--no-left studio-tool-layout--no-right studio-tool-layout--canvas${(shouldShowEmptyProjectState || isWaitingForProjects) ? " studio-tool-layout--canvas-empty" : ""}`}>
<section
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${isCanvasDragging ? " is-canvas-dragging" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
className={`studio-canvas${pendingLinkPort ? " is-linking" : ""}${(shouldShowEmptyProjectState || isWaitingForProjects) ? " is-empty-projects" : ""}`}
ref={canvasRef}
onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick}
onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu}
@@ -3677,10 +3558,6 @@ function CanvasPage({
onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick}
onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove}
onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel}
onDragEnter={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragEnter}
onDragOver={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragOver}
onDragLeave={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDragLeave}
onDrop={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDrop}
style={{
"--canvas-bg-size": `${34 * canvasViewport.zoom}px`,
"--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`,
@@ -3873,12 +3750,12 @@ function CanvasPage({
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}%
</button>
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button>
</div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div
@@ -4260,13 +4137,7 @@ function CanvasPage({
};
return (
<div
className={`studio-canvas-text-composer${textComposerDragNodeId === textNode.id ? " is-drag-over" : ""}`}
onDragEnter={(e) => handleTextComposerDragEnter(e, textNode.id)}
onDragOver={handleTextComposerDragOver}
onDragLeave={handleTextComposerDragLeave}
onDrop={(e) => handleTextComposerDrop(e, textNode)}
>
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
@@ -4393,7 +4264,7 @@ function CanvasPage({
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4699,42 +4570,16 @@ function CanvasPage({
)}
<button
type="button"
title="多宫格生成"
disabled={!imageNode.imageUrl}
className={imageNodeFocusActive ? "is-active" : ""}
title="框选聚焦区域"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "multiGrid", imageNode });
openImageFocusMode(imageNode);
}}
>
<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>
<BarsOutlined /><span></span>
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
@@ -5884,27 +5729,6 @@ function CanvasPage({
</section>
</div>
{canvasToolModal && (
<div className="studio-canvas-tool-modal-overlay" onClick={() => setCanvasToolModal(null)}>
<div className="studio-canvas-tool-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}>
<header className="studio-canvas-tool-modal__header">
<h3>{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}</h3>
<button type="button" aria-label="关闭" onClick={() => setCanvasToolModal(null)}><CloseOutlined /></button>
</header>
<div className="studio-canvas-tool-modal__body">
{canvasToolModal.tool === "multiGrid" && (
<CanvasMultiGridPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "upscale" && (
<CanvasUpscalePanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
{canvasToolModal.tool === "inpaint" && (
<CanvasInpaintPanel imageUrl={canvasToolModal.imageNode.imageUrl || ""} imageNode={canvasToolModal.imageNode} onComplete={(url) => { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} />
)}
</div>
</div>
</div>
)}
</WorkspacePageShell>
);
}
+1
View File
@@ -140,6 +140,7 @@ export const videoRatioOptions: CanvasOption[] = [
];
export const videoDurationOptions: CanvasOption[] = [
{ value: "4", label: "4s" },
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },
-221
View File
@@ -1,221 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -251,7 +251,7 @@ export function blobToDataUrl(blob: Blob) {
export async function waitForImageTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, {
kind: "image",
timeoutMs: 10 * 60 * 1000,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, {
kind: "video",
timeoutMs: 30 * 60 * 1000,
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -16,7 +16,7 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import StudioToolLayout from "../../components/StudioToolLayout";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
@@ -58,7 +58,6 @@ function CharacterMixPage({
const [resultUrl, setResultUrl] = useState<string | null>(null);
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
return () => {
@@ -234,32 +233,6 @@ function CharacterMixPage({
}
};
const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (characterPreview) URL.revokeObjectURL(characterPreview);
setCharacterFile(file.name);
setCharacterPreview(URL.createObjectURL(file));
const reader = new FileReader();
reader.onload = () => { if (typeof reader.result === "string") setCharacterDataUrl(reader.result); };
reader.readAsDataURL(file);
setNotice(`已选择人物图 ${file.name}`);
} else if (file.type.startsWith("video/")) {
if (videoPreview) URL.revokeObjectURL(videoPreview);
setVideoFile(file.name);
setVideoPreview(URL.createObjectURL(file));
const reader2 = new FileReader();
reader2.onload = () => { if (typeof reader2.result === "string") setVideoDataUrl(reader2.result); };
reader2.readAsDataURL(file);
setNotice(`已选择参考视频 ${file.name}`);
}
};
return (
<section className="image-workbench-page character-mix-page" aria-label="角色迁移">
<header className="image-workbench-topbar">
@@ -319,17 +292,7 @@ function CharacterMixPage({
<StudioToolLayout
noTop
leftPanel={
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ position: "relative" }}
>
{isDragging ? (
<div style={{ position: "absolute", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.55)", border: "2px dashed var(--primary, #4a9eff)", borderRadius: 12, pointerEvents: "none" }}>
<span style={{ fontSize: 18, color: "#fff", fontWeight: 600 }}></span>
</div>
) : null}
<>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
@@ -407,7 +370,7 @@ function CharacterMixPage({
</label>
</div>
</div>
</div>
</>
}
canvas={
isCreating ? (
@@ -1,479 +0,0 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import { ApartmentOutlined, DownOutlined, RobotOutlined, ThunderboltOutlined } from "@ant-design/icons";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
type GenerationMode = "dialog" | "video";
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: "绿色" },
];
const dialogModelOptions = [
{ id: "gemini", label: "Gemini" },
{ id: "wanxian", label: "万相" },
{ id: "deepseek", label: "DeepSeek" },
];
const thinkingSpeedOptions = [
{ id: "default", label: "默认" },
{ id: "high", label: "高" },
{ id: "ultra", label: "急速" },
];
const thinkingDepthOptions = [
{ id: "default", label: "默认" },
{ id: "strong", label: "强" },
{ id: "extreme", label: "极限" },
];
const videoDurationOptions = [
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },
{ value: "8", label: "8s" },
{ value: "9", label: "9s" },
{ value: "10", label: "10s" },
{ value: "11", label: "11s" },
{ value: "12", label: "12s" },
{ value: "13", label: "13s" },
{ value: "14", label: "14s" },
{ value: "15", label: "15s" },
];
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 controlsRef = useRef<HTMLDivElement>(null);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
// ── Generation state ──
const [generationMode, setGenerationMode] = useState<GenerationMode>("dialog");
const [dialogModel, setDialogModel] = useState(dialogModelOptions[0].id);
const [thinkingSpeed, setThinkingSpeed] = useState(thinkingSpeedOptions[0].id);
const [thinkingDepth, setThinkingDepth] = useState(thinkingDepthOptions[0].id);
const [videoDuration, setVideoDuration] = useState(videoDurationOptions[0].value);
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
setActiveDropdown(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
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>
<div className="dialog-generator-section">
<h2></h2>
<div className="dialog-generator-mode-switch" role="radiogroup" aria-label="生成模式">
<button
type="button"
className={`dialog-generator-mode${generationMode === "dialog" ? " is-active" : ""}`}
role="radio"
aria-checked={generationMode === "dialog"}
onClick={() => setGenerationMode("dialog")}
>
</button>
<button
type="button"
className={`dialog-generator-mode${generationMode === "video" ? " is-active" : ""}`}
role="radio"
aria-checked={generationMode === "video"}
onClick={() => setGenerationMode("video")}
>
</button>
</div>
<div className="dialog-generator-controls" ref={controlsRef}>
{generationMode === "dialog" ? (
<>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "model" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "model" ? null : "model")}
>
<RobotOutlined />
{dialogModelOptions.find((m) => m.id === dialogModel)?.label ?? "模型选择"}
<DownOutlined />
</button>
{activeDropdown === "model" && (
<div className="dialog-generator-dropdown">
{dialogModelOptions.map((m) => (
<button
key={m.id}
type="button"
className={`dialog-generator-dropdown__item${dialogModel === m.id ? " is-active" : ""}`}
onClick={() => { setDialogModel(m.id); setActiveDropdown(null); }}
>
{m.label}
</button>
))}
</div>
)}
</div>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "speed" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "speed" ? null : "speed")}
>
<ThunderboltOutlined />
{thinkingSpeedOptions.find((s) => s.id === thinkingSpeed)?.label ?? "思考速度"}
<DownOutlined />
</button>
{activeDropdown === "speed" && (
<div className="dialog-generator-dropdown">
{thinkingSpeedOptions.map((s) => (
<button
key={s.id}
type="button"
className={`dialog-generator-dropdown__item${thinkingSpeed === s.id ? " is-active" : ""}`}
onClick={() => { setThinkingSpeed(s.id); setActiveDropdown(null); }}
>
{s.label}
</button>
))}
</div>
)}
</div>
<div className="dialog-generator-pills">
<button
type="button"
className={`dialog-generator-pill${activeDropdown === "depth" ? " is-open" : ""}`}
onClick={() => setActiveDropdown(activeDropdown === "depth" ? null : "depth")}
>
<ApartmentOutlined />
{thinkingDepthOptions.find((d) => d.id === thinkingDepth)?.label ?? "思考深度"}
<DownOutlined />
</button>
{activeDropdown === "depth" && (
<div className="dialog-generator-dropdown">
{thinkingDepthOptions.map((d) => (
<button
key={d.id}
type="button"
className={`dialog-generator-dropdown__item${thinkingDepth === d.id ? " is-active" : ""}`}
onClick={() => { setThinkingDepth(d.id); setActiveDropdown(null); }}
>
{d.label}
</button>
))}
</div>
)}
</div>
</>
) : (
<div className="dialog-generator-duration">
<span className="dialog-generator-duration__label"></span>
<div className="dialog-generator-duration__options">
{videoDurationOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={`dialog-generator-duration__btn${videoDuration === opt.value ? " is-active" : ""}`}
onClick={() => setVideoDuration(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
<button
type="button"
className="dialog-generator-run"
disabled={isGenerating}
onClick={() => {
setIsGenerating(true);
// TODO: wire to actual generation API
setTimeout(() => setIsGenerating(false), 2000);
}}
>
{isGenerating ? "生成中..." : "生成"}
</button>
</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;
@@ -17,7 +17,7 @@ import {
ThunderboltOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
@@ -95,7 +95,6 @@ function DigitalHumanPage({
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
return () => {
@@ -147,28 +146,6 @@ function DigitalHumanPage({
setNotice("已取消");
}, [activeTaskId]);
const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent) => { e.preventDefault(); if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type.startsWith("image/")) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImageName(file.name);
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
setNotice(`已拖放参考图 ${file.name}`);
} else if (file.type.startsWith("audio/")) {
if (audioPreview) URL.revokeObjectURL(audioPreview);
setAudioName(file.name);
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
setNotice(`已拖放音频 ${file.name}`);
}
};
const handleDownloadResult = async () => {
if (!resultVideoUrl || isDownloadingResult) return;
setIsDownloadingResult(true);
@@ -440,17 +417,7 @@ function DigitalHumanPage({
<StudioToolLayout
noTop
leftPanel={
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ position: "relative" }}
>
{isDragging ? (
<div style={{ position: "absolute", inset: 0, zIndex: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.55)", border: "2px dashed var(--primary, #4a9eff)", borderRadius: 12, pointerEvents: "none" }}>
<span style={{ fontSize: 18, color: "#fff", fontWeight: 600 }}></span>
</div>
) : null}
<>
<div className="studio-panel__section">
<div className="studio-panel__section-head">
<span className="studio-panel__section-title"></span>
@@ -523,7 +490,7 @@ function DigitalHumanPage({
{audioPreview ? <audio src={audioPreview} controls className="studio-audio-preview" /> : null}
</div>
</div>
</div>
</>
}
canvas={
resultVideoUrl ? (
@@ -591,7 +558,7 @@ function DigitalHumanPage({
</div>
<button type="button" className="studio-generate-btn" onClick={() => void handleCreateTask()} disabled={isCreating || !imageFile || !audioFile}>
<PlayCircleOutlined />
{isCreating ? "生成中..." : "开始生成"}
{isCreating ? "生成中..." : "提交 wan2.2-s2v"}
</button>
{isCreating && (
<button type="button" className="studio-generate-btn digital-human-cancel-btn" onClick={handleCancel} aria-label="取消生成任务">
+38 -313
View File
@@ -59,12 +59,9 @@ interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
mimeType?: string;
ossKey?: string;
}
interface CloneResult {
@@ -101,18 +98,6 @@ interface CloneSavedSetting {
requirement: string;
}
interface EcommerceImagePromptOptions {
gender?: string;
age?: string;
ethnicity?: string;
body?: string;
appearance?: string;
scenes?: string[];
customScene?: string;
smartScene?: boolean;
detailModules?: string[];
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
interface PlatformRatioGroup {
@@ -686,85 +671,15 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
});
}
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
async function createUploadedImageItems(files: File[], limit: number, prefix: string): Promise<CloneImageItem[]> {
const selectedFiles = Array.from(files).slice(0, limit);
const stamp = Date.now();
const items = await Promise.all(selectedFiles.map(async (file, index) => {
const localPreviewUrl = URL.createObjectURL(file);
let dimensions: { width?: number; height?: number } = {};
try {
dimensions = await readImageDimensions(localPreviewUrl);
} catch {
dimensions = {};
} finally {
URL.revokeObjectURL(localPreviewUrl);
}
const mimeType = normalizeEcommerceImageMime(file.type);
const uploadBlob = file.type === mimeType ? file : new Blob([file], { type: mimeType });
const { url, ossKey } = await aiGenerationClient.uploadAssetBinary(uploadBlob, {
function createObjectImageItems(files: File[], limit: number, prefix: string) {
return Array.from(files)
.slice(0, limit)
.map<CloneImageItem>((file, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
src: URL.createObjectURL(file),
name: file.name,
mimeType,
scope: "ecommerce-product",
});
return {
id: `${prefix}-${stamp}-${index}`,
src: url,
name: file.name,
file,
format: getImageFileFormat(file),
mimeType,
ossKey,
...dimensions,
};
}));
return items;
}
async function persistGeneratedImageUrl(sourceUrl: string, scope: string, namePrefix: string): Promise<string> {
if (!sourceUrl) return sourceUrl;
try {
if (sourceUrl.startsWith("data:")) {
const { url } = await aiGenerationClient.uploadAsset({
dataUrl: sourceUrl,
name: `${namePrefix}-${Date.now()}.png`,
scope,
});
return url || sourceUrl;
}
if (sourceUrl.startsWith("blob:")) {
const rawBlob = await fetch(sourceUrl).then((res) => res.blob());
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const { url } = await aiGenerationClient.uploadAssetBinary(blob, {
name: `${namePrefix}-${Date.now()}.png`,
mimeType,
scope,
});
return url;
}
const { url } = await aiGenerationClient.uploadAssetByUrl({
sourceUrl,
name: `${namePrefix}-${Date.now()}`,
scope,
});
return url || sourceUrl;
} catch {
return sourceUrl;
}
}
function notifyRejectedImages(files: File[]): File[] {
@@ -876,7 +791,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [status, setStatus] = useState<ProductCloneStatus>("idle");
const [results, setResults] = useState<CloneResult[]>([]);
const imageAbortRef = useRef({ current: false });
const activeEcommerceTaskIdsRef = useRef<Set<string>>(new Set());
const lastFailedActionRef = useRef<(() => void) | null>(null);
const [garmentImages, setGarmentImages] = useState<CloneImageItem[]>([]);
const [modelSource, setModelSource] = useState<TryOnModelSource>("ai");
@@ -931,30 +845,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const trackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.add(taskId);
};
const untrackEcommerceTask = (taskId: string) => {
activeEcommerceTaskIdsRef.current.delete(taskId);
};
const handleCancelGenerate = () => {
imageAbortRef.current.current = true;
const taskIds = Array.from(activeEcommerceTaskIdsRef.current);
activeEcommerceTaskIdsRef.current.clear();
taskIds.forEach((taskId) => {
aiGenerationClient.cancelTask(taskId).catch(() => {});
});
lastFailedActionRef.current = null;
if (productSetStatus === "generating") setProductSetStatus("idle");
if (status === "generating") setStatus("idle");
if (detailStatus === "generating") setDetailStatus("idle");
if (tryOnStatus === "generating") setTryOnStatus("idle");
if (tryOnStatus === "modeling") setTryOnStatus("ready");
toast.info("\u5df2\u53d6\u6d88\u751f\u6210");
};
const syncRequirementMentionQuery = (value: string, selectionStart: number | null | undefined) => {
setRequirementImageMentionQuery(ecommerceMentionImages.length ? getImageMentionQuery(value, selectionStart) : null);
};
@@ -971,26 +861,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addSetImages = async (files: File[]) => {
const addSetImages = (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
setSetImages((current) => {
if (current.length >= 3) return current;
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addSetImages(Array.from(files));
addSetImages(Array.from(files));
event.target.value = "";
};
@@ -998,7 +883,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addSetImages(files);
if (files.length) addSetImages(files);
};
const removeSetImage = (imageId: string) => {
@@ -1009,26 +894,22 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addProductImages = async (files: File[]) => {
const addProductImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addProductImages(Array.from(files));
addProductImages(Array.from(files));
event.target.value = "";
};
@@ -1036,7 +917,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) void addProductImages(files);
if (files.length) addProductImages(files);
};
const removeProductImage = (imageId: string) => {
@@ -1062,57 +943,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addCloneReferenceImages = async (files: File[]) => {
const addCloneReferenceImages = (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
try {
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
void addCloneReferenceImages(Array.from(files));
addCloneReferenceImages(Array.from(files));
event.target.value = "";
};
const [isCloneReferenceDragging, setIsCloneReferenceDragging] = useState(false);
const handleCloneReferenceDragOver = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsCloneReferenceDragging(true);
}
};
const handleCloneReferenceDragLeave = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsCloneReferenceDragging(false);
}
};
const handleCloneReferenceDrop = (event: DragEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setIsCloneReferenceDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addCloneReferenceImages(files);
};
const updateCloneSetCount = (key: CloneSetCountKey, delta: -1 | 1) => {
setCloneSetCounts((current) => {
const total = Object.values(current).reduce((sum, value) => sum + value, 0);
@@ -1424,15 +1275,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment");
setGarmentImages((current) => [...current, ...nextImages].slice(0, 5));
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
setTryOnStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "服饰图上传失败");
}
})();
event.target.value = "";
};
@@ -1444,15 +1288,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3));
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
setDetailStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "详情图上传失败");
}
})();
event.target.value = "";
};
@@ -1468,15 +1305,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const urls: string[] = [];
for (const item of images) {
try {
if (!item.file && item.src.startsWith("blob:")) {
throw new Error("本地预览图缺少原始文件,无法上传");
}
const rawBlob = item.file ?? (item.src.startsWith("data:") ? null : await (await fetch(item.src)).blob());
const mimeType = normalizeEcommerceImageMime(
rawBlob?.type || item.src.match(/^data:([^;,]+)/)?.[1] || "image/png",
);
const blob = rawBlob ? (rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType })) : null;
const dataUrl = item.src.startsWith("data:") ? item.src : await blobToDataUrl(blob!);
const resp = await fetch(item.src);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await blobToDataUrl(blob);
const { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
@@ -1494,32 +1327,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
};
const buildDetailModulePrompt = (moduleIds: string[]): string => {
if (!moduleIds.length) {
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
}
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
if (!selectedModules.length) return "";
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
};
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
const info = setCountLabels[countKey];
const parts: string[] = [];
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
parts.push(info.promptDesc);
if (countKey === "white") {
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
}
if (countKey === "scene") {
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
}
if (countKey === "selling") {
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
}
if (totalCount > 1) {
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
}
@@ -1531,14 +1343,13 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const buildEcommerceImagePrompt = (
outputKey: CloneOutputKey, userText: string,
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
): string => {
const parts: string[] = [];
if (outputKey === "detail") {
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules));
parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
} else if (outputKey === "model") {
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
@@ -1551,7 +1362,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`);
if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`);
if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context.");
}
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
@@ -1585,10 +1395,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
@@ -1608,26 +1414,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
generatedUrls.push(resultUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
@@ -1635,17 +1432,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
}
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
setResultFn(generatedUrls);
setStatusFn(generatedUrls.some(Boolean) ? "done" : "idle");
} catch (err) {
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
setResultFn([]);
toast.error("余额不足,请充值后继续");
@@ -1665,7 +1454,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: EcommerceImagePromptOptions,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
@@ -1676,10 +1465,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
statusFn?.("idle");
return;
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
const prompt = buildEcommerceImagePrompt(outputKey, userText, pPlatform, pRatio, pLanguage, pMarket, tryOnOptions);
const stamp = Date.now();
@@ -1692,39 +1477,23 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
gridMode: "single",
referenceUrls,
});
trackEcommerceTask(taskId);
const storeId = imageGen.submitTask({ title: `电商${outputKey}`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} catch (err) {
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (err instanceof ServerRequestError && err.status === 402) {
resultFn?.([{ id: `ecommerce-error-402`, src: "", label: "余额不足,请充值后继续" }]);
toast.error("余额不足,请充值后继续");
@@ -1758,38 +1527,21 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
dataUrl: refDataUrl, name: videoOutfitRefFile.name,
mimeType: videoOutfitRefFile.type || "image/png", scope: "video-outfit",
});
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
const { taskId } = await aiGenerationClient.createVideoEditTask({
videoUrl: videoAsset.url,
referenceUrls: [refAsset.url],
prompt: requirement || undefined,
});
trackEcommerceTask(taskId);
const { waitForTask } = await import("../../api/taskSubscription");
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
if (resultUrl) {
setResults([{ id: crypto.randomUUID(), src: resultUrl, label: "换装视频" }]);
}
setStatus("done");
} catch (err) {
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
setStatus("failed");
toast.error(err instanceof Error ? err.message : "视频换装生成失败");
}
@@ -1819,24 +1571,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
(urls) => setProductSetResultImages(urls),
);
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
? {
gender: cloneModelGender,
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
appearance: cloneModelAppearance,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
: cloneOutput === "detail"
? { detailModules: selectedCloneDetailModules }
: undefined;
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
clonePromptOptions,
undefined,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
@@ -1916,7 +1654,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
{ detailModules: selectedDetailModules },
undefined,
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
@@ -1995,7 +1733,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) {
setPreviewCards.push({
id: `${countKey}-${i}`,
src: productSetResultImages[setIndex] || productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src || "",
src: productSetResultImages[setIndex] ?? productSetPreviewCards[setIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
setIndex++;
@@ -2010,7 +1748,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
for (let i = 0; i < count; i++) {
clonePreviewCards.push({
id: `${countKey}-${i}`,
src: results[cloneIndex]?.src || productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src || "",
src: results[cloneIndex]?.src ?? productSetPreviewCards[cloneIndex % productSetPreviewCards.length]?.src ?? "",
label: `${info.label}${count > 1 ? ` ${i + 1}` : ""}`,
});
cloneIndex++;
@@ -2124,10 +1862,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setOpenCloneBasicSelect={setOpenCloneBasicSelect}
setCloneReferenceMode={setCloneReferenceMode}
handleCloneReferenceUpload={handleCloneReferenceUpload}
isCloneReferenceDragging={isCloneReferenceDragging}
handleCloneReferenceDragOver={handleCloneReferenceDragOver}
handleCloneReferenceDragLeave={handleCloneReferenceDragLeave}
handleCloneReferenceDrop={handleCloneReferenceDrop}
setCloneReplicateLevel={setCloneReplicateLevel}
startCloneSetCountHold={startCloneSetCountHold}
clearCloneSetCountHold={clearCloneSetCountHold}
@@ -2143,7 +1877,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
clampCloneVideoDuration={clampCloneVideoDuration}
setCloneVideoSmart={setCloneVideoSmart}
handleGenerate={handleGenerate}
onCancelGenerate={handleCancelGenerate}
formatRatioDisplayValue={formatRatioDisplayValue}
setVideoOutfitFiles={(video, ref) => { setVideoOutfitVideoFile(video); setVideoOutfitRefFile(ref); }}
onStartVideoPlan={() => setVideoPlanTrigger((n) => n + 1)}
@@ -2177,7 +1910,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2215,7 +1947,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2291,11 +2022,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{productSetStatus === "generating" ? <LoadingOutlined /> : null}
{setPrimaryLabel}
</button>
{productSetStatus === "generating" ? (
<button type="button" className="product-set-floating-submit product-set-floating-submit--cancel" onClick={handleCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</section>
<button type="button" className="product-clone-help" aria-label="帮助">
@@ -2647,7 +2373,6 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageFiles={productImages.map((img) => img.file)}
requirement={requirement}
platform={platform}
aspectRatio={ratio.includes("916") || ratio.includes("9:16") ? "9:16" : ratio.includes("169") || ratio.includes("16:9") ? "16:9" : ratio.includes("34") || ratio.includes("3:4") ? "3:4" : "9:16"}
@@ -34,7 +34,6 @@ import {
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string;
platform: string;
aspectRatio: string;
@@ -98,7 +97,6 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
productImageFiles = [],
requirement,
platform,
aspectRatio,
@@ -378,9 +376,8 @@ export default function EcommerceVideoWorkspace({
});
};
try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan(
productImageSources, requirement, buildConfig(),
productImageDataUrls, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => {
+31 -72
View File
@@ -126,61 +126,13 @@ export interface PlanCallbacks {
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.
* Supports resumption: if `resumeFrom` contains data for a step, that step is skipped.
* After each step, `onPartialProgress` fires so callers can persist intermediate state.
*/
export async function runVideoPlan(
imageSources: Array<string | Blob>,
imageDataUrls: string[],
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
@@ -189,30 +141,41 @@ export async function runVideoPlan(
const progress: EcommerceVideoPlanProgress = { ...resumeFrom };
const emit = () => callbacks.onPartialProgress?.({ ...progress });
// Step: upload
// ── Step: upload ──────────────────────────────────────
if (!progress.imageUrls?.length) {
onStepStart("upload");
const imageUrls: string[] = [];
const rejected: string[] = [];
for (const source of imageSources) {
for (const srcUrl of imageDataUrls) {
try {
imageUrls.push(await uploadProductImageSource(source));
const resp = await fetch(srcUrl);
const rawBlob = await resp.blob();
const mimeType = normalizeEcommerceImageMime(rawBlob.type);
const blob = rawBlob.type === mimeType ? rawBlob : new Blob([rawBlob], { type: mimeType });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
reader.readAsDataURL(blob);
});
const result = await aiGenerationClient.uploadAsset({ dataUrl, mimeType, scope: "ecommerce-product" });
imageUrls.push(result.url);
} catch (err) {
rejected.push(err instanceof Error ? err.message : "Image upload failed");
rejected.push(err instanceof Error ? err.message : "图片上传失败");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// Step: analyze
// ── Step: analyze ─────────────────────────────────────
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -220,7 +183,7 @@ export async function runVideoPlan(
emit();
}
// Step: summary
// ── Step: summary ─────────────────────────────────────
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -228,7 +191,7 @@ export async function runVideoPlan(
emit();
}
// Step: selling
// ── Step: selling ─────────────────────────────────────
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -236,16 +199,16 @@ export async function runVideoPlan(
emit();
}
// Step: creative
// ── Step: creative ────────────────────────────────────
if (!progress.creatives?.length) {
onStepStart("creative");
progress.creatives = await generateCreativeOptions(progress.selling, config, signal);
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
if (!progress.creatives.length) throw new Error("未能生成有效的广告创意");
onStepDone("creative");
emit();
}
// Step: storyboard
// ── Step: storyboard ──────────────────────────────────
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -253,7 +216,7 @@ export async function runVideoPlan(
emit();
}
// Step: prompts
// ── Step: prompts ─────────────────────────────────────
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -261,7 +224,7 @@ export async function runVideoPlan(
emit();
}
// Step: compliance
// ── Step: compliance ──────────────────────────────────
if (!progress.compliance) {
onStepStart("compliance");
progress.compliance = await checkCompliance(progress.summary, progress.selling, progress.storyboard, signal);
@@ -312,15 +275,13 @@ export async function renderSceneImage(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "image",
model: "gpt-image-2",
onProgress: (e) => callbacks.onSceneImageProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneImageCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
callbacks.onSceneImageFailed(input.sceneId, "图片生成未返回结果");
}
}
@@ -369,15 +330,13 @@ export async function renderScene(
const resultUrl = await waitForTask(taskId, {
abortRef,
kind: "video",
model,
onProgress: (e) => callbacks.onSceneProgress(input.sceneId, e.progress),
});
if (resultUrl) {
callbacks.onSceneCompleted(input.sceneId, resultUrl);
} else {
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
callbacks.onSceneFailed(input.sceneId, "任务未返回结果");
}
}
@@ -396,7 +355,7 @@ export function buildSceneTasks(
});
}
// Video History API
// ── Video History API ──────────────────────────────────
export interface VideoHistoryScene {
sceneId: number;
@@ -491,7 +450,7 @@ export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promis
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
});
if (!res.ok) throw new Error("Failed to save video history");
if (!res.ok) throw new Error("保存历史记录失败");
return res.json();
}
@@ -515,7 +474,7 @@ export async function fetchVideoHistory(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("Failed to fetch video history");
if (!res.ok) throw new Error("获取历史记录失败");
const history = (await res.json()) as VideoHistoryListResponse;
return {
...history,
@@ -528,5 +487,5 @@ export async function deleteVideoHistory(id: number): Promise<void> {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("Failed to delete video history");
if (!res.ok) throw new Error("删除失败");
}
@@ -7,7 +7,6 @@ import {
ReloadOutlined,
SettingOutlined,
} from "@ant-design/icons";
import { createPortal } from "react-dom";
import type { CSSProperties, ChangeEvent, DragEvent, MutableRefObject, RefObject } from "react";
import { useRef, useState } from "react";
@@ -119,10 +118,6 @@ interface EcommerceClonePanelProps {
setOpenCloneBasicSelect: (value: CloneBasicSelectKey | null) => void;
setCloneReferenceMode: (value: CloneReferenceMode) => void;
handleCloneReferenceUpload: (event: ChangeEvent<HTMLInputElement>) => void;
isCloneReferenceDragging: boolean;
handleCloneReferenceDragOver: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDragLeave: (event: DragEvent<HTMLButtonElement>) => void;
handleCloneReferenceDrop: (event: DragEvent<HTMLButtonElement>) => void;
setCloneReplicateLevel: (value: CloneReplicateLevelKey) => void;
startCloneSetCountHold: (key: CloneSetCountKey, delta: -1 | 1, disabled: boolean) => void;
clearCloneSetCountHold: () => void;
@@ -138,7 +133,6 @@ interface EcommerceClonePanelProps {
clampCloneVideoDuration: (value: number) => number;
setCloneVideoSmart: (updater: (current: boolean) => boolean) => void;
handleGenerate: () => void;
onCancelGenerate: () => void;
formatRatioDisplayValue: (value: string) => string;
setVideoOutfitFiles?: (video: File | null, ref: File | null) => void;
onStartVideoPlan?: () => void;
@@ -191,10 +185,6 @@ export default function EcommerceClonePanel({
setOpenCloneBasicSelect,
setCloneReferenceMode,
handleCloneReferenceUpload,
isCloneReferenceDragging,
handleCloneReferenceDragOver,
handleCloneReferenceDragLeave,
handleCloneReferenceDrop,
setCloneReplicateLevel,
startCloneSetCountHold,
clearCloneSetCountHold,
@@ -210,7 +200,6 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
@@ -219,14 +208,6 @@ export default function EcommerceClonePanel({
const videoOutfitRefRef = useRef<HTMLInputElement>(null);
const [videoOutfitVideoUrl, setVideoOutfitVideoUrl] = useState<string | null>(null);
const [videoOutfitRefUrl, setVideoOutfitRefUrl] = useState<string | null>(null);
const [zoomImage, setZoomImage] = useState<{ src: string; x: number; y: number } | null>(null);
const handleFileMouseEnter = (src: string, event: React.MouseEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
setZoomImage({ src, x: rect.left + rect.width / 2, y: rect.top });
};
const handleFileMouseLeave = () => setZoomImage(null);
const handleVideoOutfitVideoChange = () => {
const file = videoOutfitVideoRef.current?.files?.[0] || null;
@@ -400,44 +381,23 @@ export default function EcommerceClonePanel({
</button>
</div>
{cloneReferenceMode === "upload" ? (
<button
type="button"
className={`clone-ai-replicate-upload${isCloneReferenceDragging ? " is-dragging" : ""}${cloneReferenceImages.length ? " has-files" : ""}`}
onClick={() => cloneReferenceInputRef.current?.click()}
onDragOver={handleCloneReferenceDragOver}
onDragLeave={handleCloneReferenceDragLeave}
onDrop={handleCloneReferenceDrop}
>
{cloneReferenceImages.length ? (
<>
<div className="clone-ai-replicate-files">
{cloneReferenceImages.map((item) => (
<figure
key={item.id}
className="clone-ai-replicate-file"
onMouseEnter={(e) => handleFileMouseEnter(item.src, e)}
onMouseLeave={handleFileMouseLeave}
>
<img src={item.src} alt="" />
</figure>
))}
</div>
<span className="clone-ai-replicate-add-more">
<CloudUploadOutlined />
</span>
</>
) : (
<button type="button" className="clone-ai-replicate-upload" onClick={() => cloneReferenceInputRef.current?.click()}>
<span>
<CloudUploadOutlined />
<span className="clone-ai-replicate-upload-text"></span>
<span className="clone-ai-replicate-upload-text"></span>
</span>
)}
<em>{cloneReferenceImages.length ? `已选 ${cloneReferenceImages.length}/${maxCloneReferenceImages}` : `最多 ${maxCloneReferenceImages}`}</em>
{isCloneReferenceDragging ? (
<div className="clone-ai-replicate-upload-overlay">
<CloudUploadOutlined />
<span></span>
{cloneReferenceImages.length ? (
<div className="clone-ai-replicate-preview" aria-hidden="true">
{cloneReferenceImages.slice(0, 4).map((item) => (
<figure key={item.id}>
<img src={item.src} alt="" />
<span className="uploaded-image-zoom">
<img src={item.src} alt="" />
</span>
</figure>
))}
{cloneReferenceImages.length > 4 ? <b>+{cloneReferenceImages.length - 4}</b> : null}
</div>
) : null}
</button>
@@ -786,24 +746,7 @@ export default function EcommerceClonePanel({
{status === "generating" ? <LoadingOutlined /> : status === "failed" ? <ReloadOutlined /> : null}
{status === "generating" ? "生成中..." : status === "failed" ? "重新生成" : cloneOutput === "video-outfit" ? "✦ 开始换装" : "✦ 开始生成"}
</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>
{zoomImage
? createPortal(
<div
className="clone-ai-zoom-portal"
style={{ left: zoomImage.x, top: zoomImage.y } as CSSProperties}
onMouseLeave={handleFileMouseLeave}
>
<img src={zoomImage.src} alt="" />
</div>,
document.body,
)
: null}
</>
);
}
@@ -28,7 +28,6 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
@@ -57,7 +56,6 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
@@ -164,11 +162,6 @@ export default function EcommerceDetailPanel({
{detailStatus === "generating" ? <LoadingOutlined /> : null}
{detailPrimaryLabel}
</button>
{detailStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
@@ -35,7 +35,6 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
@@ -71,7 +70,6 @@ export default function EcommerceTryOnPanel({
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
@@ -215,11 +213,6 @@ export default function EcommerceTryOnPanel({
{tryOnStatus === "generating" ? <LoadingOutlined /> : null}
{tryOnPrimaryLabel}
</button>
{tryOnStatus === "generating" ? (
<button type="button" className="product-clone-primary product-clone-primary--cancel" onClick={onCancelGenerate}>
{"\u53d6\u6d88\u751f\u6210"}
</button>
) : null}
</footer>
</>
);
@@ -23,7 +23,7 @@ import {
TableOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
@@ -138,8 +138,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const [downloadingResultUrl, setDownloadingResultUrl] = useState<string | null>(null);
const [savingAssetResultUrl, setSavingAssetResultUrl] = useState<string | null>(null);
const [generationError, setGenerationError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCameraDragging, setIsCameraDragging] = useState(false);
const abortRef = useRef(false);
const taskIdRef = useRef<string | null>(null);
const keepaliveRestoredRef = useRef(false);
@@ -231,37 +229,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const files = Array.from(event.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (!files.length) return;
const selectedFiles = mode === 'blend' ? files : files.slice(0, 1);
selectedFiles.forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setReferenceImages((current) => (mode === 'blend' ? [...current, reader.result as string] : [reader.result as string]));
setStatus(mode === 'blend' ? `已追加 ${file.name}` : `已导入 ${file.name}`);
};
reader.readAsDataURL(file);
});
};
const handleAddUrl = () => {
const nextUrl = imageUrlInput.trim();
if (!nextUrl) return;
@@ -294,15 +261,9 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const [isInpaintDragging, setIsInpaintDragging] = useState(false);
const handleInpaintDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsInpaintDragging(true); };
const handleInpaintDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsInpaintDragging(false); };
const handleInpaintDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsInpaintDragging(false);
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
const handleInpaintDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("image/"));
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
@@ -341,7 +302,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
return;
}
if (!hasMask) {
setStatus("请先编辑页面,涂抹需要重绘的区域");
setStatus("请先编辑遮罩,涂抹需要重绘的区域");
return;
}
if (generating) return;
@@ -403,33 +364,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
event.target.value = "";
};
const handleCameraDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(true);
};
const handleCameraDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
};
const handleCameraDrop = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsCameraDragging(false);
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith('image/'));
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') return;
setCameraImage(reader.result);
setStatus(`已导入镜头参考图 ${file.name}`);
};
reader.readAsDataURL(file);
};
const handleAddCameraUrl = () => {
const nextUrl = cameraUrlInput.trim();
if (!nextUrl) return;
@@ -762,13 +696,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
accept="image/png,image/jpeg,image/webp"
onChange={handleInpaintFileChange}
/>
<div
className={`image-workbench-upload-shell${isInpaintDragging ? " is-dragging" : ""}`}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDrop={handleInpaintDrop}
>
{isInpaintDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<div className="image-workbench-upload-shell">
<button type="button" className="image-workbench-upload" onClick={() => inpaintFileInputRef.current?.click()}>
{inpaintImage ? <img src={inpaintImage} alt="" /> : <FileImageOutlined />}
<strong>{inpaintImage ? "更换原图" : "选择图片"}</strong>
@@ -861,7 +789,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<img src={inpaintResultImages[0]} alt="重绘结果" style={{ maxWidth: "95%", maxHeight: "95%", borderRadius: 8, objectFit: "contain" }} />
<div className="image-workbench-inpaint-bottom-bar">
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintResultImages([]); setIsMaskEditing(true); setInpaintTool("brush"); setCanvasInitCounter((c) => c + 1); }}>
<HighlightOutlined />
<HighlightOutlined />
</button>
{renderResultActions(inpaintResultImages[0], 0)}
{inpaintResultImages.length > 1 && (
@@ -917,7 +845,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
<div className="image-workbench-inpaint-bottom-bar">
{!isMaskEditing && (
<button type="button" className="image-workbench-inpaint-edit-btn" onClick={() => { setInpaintTool("brush"); setIsMaskEditing(true); }}>
<HighlightOutlined /> {hasMask ? "重新编辑页面" : "编辑页面"}
<HighlightOutlined /> {hasMask ? "重新编辑遮罩" : "编辑遮罩"}
</button>
)}
<span className="image-workbench-inpaint-zoom-controls">
@@ -930,13 +858,11 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
) : (
<button
type="button"
className={`image-workbench-empty image-workbench-empty--button${isInpaintDragging ? " is-dragging" : ""}`}
className="image-workbench-empty image-workbench-empty--button"
onClick={() => inpaintFileInputRef.current?.click()}
onDragOver={handleInpaintDragOver}
onDragLeave={handleInpaintDragLeave}
onDragOver={(e) => e.preventDefault()}
onDrop={handleInpaintDrop}
>
{isInpaintDragging ? <span className="image-workbench-upload-drop-overlay" style={{ borderRadius: "var(--radius-sm)" }}><span></span></span> : null}
<FileImageOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
@@ -944,6 +870,36 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
)}
</section>
<aside className="image-workbench-panel image-workbench-panel--right">
<section className="image-workbench-right-note">
<div className="image-workbench-section-title">
<h3></h3>
<span>{isMaskEditing ? (inpaintTool === "eraser" ? "橡皮中" : "画笔中") : hasMask ? "已保存" : "待编辑"}</span>
</div>
<span>{inpaintImage ? (hasMask ? "遮罩区域已标记,可开始重绘。" : "点击画布上的「编辑遮罩」开始涂抹。") : "上传原图后可编辑遮罩"}</span>
</section>
<section className="image-workbench-right-note">
<div className="image-workbench-section-title">
<h3></h3>
<span>{inpaintResultImages.length > 0 ? `${inpaintResultImages.length}` : "待生成"}</span>
</div>
{inpaintResultImages.length > 0 ? (
<div className="image-workbench-result-grid">
{inpaintResultImages.map((url, i) => (
<div key={url} className="image-workbench-result-card">
<a href={url} target="_blank" rel="noopener noreferrer" className="image-workbench-result-thumb">
<img src={url} alt={`重绘结果 ${i + 1}`} />
</a>
{renderResultActions(url, i)}
</div>
))}
</div>
) : (
<span></span>
)}
</section>
</aside>
</main>
) : activeTool === "camera" ? (
<main className="image-workbench-layout image-workbench-layout--camera">
@@ -959,13 +915,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
accept="image/png,image/jpeg,image/webp"
onChange={handleCameraFileChange}
/>
<div
className={`image-workbench-upload-shell${isCameraDragging ? " is-dragging" : ""}`}
onDragOver={handleCameraDragOver}
onDragLeave={handleCameraDragLeave}
onDrop={handleCameraDrop}
>
{isCameraDragging && <div className="image-workbench-upload-overlay"></div>}
<div className="image-workbench-upload-shell">
<button type="button" className="image-workbench-upload" onClick={() => cameraFileInputRef.current?.click()}>
{cameraImage ? <img src={cameraImage} alt="" /> : <FileImageOutlined />}
<strong>{cameraImage ? "更换参考图" : "导入参考图"}</strong>
@@ -1244,13 +1194,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</button>
</div>
) : (
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging && <div className="image-workbench-upload-overlay"></div>}
<div className="image-workbench-upload-shell">
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{referenceImage ? <img src={referenceImage} alt="" /> : <FileImageOutlined />}
<strong>{referenceImage ? "更换参考图" : "导入参考图"}</strong>
@@ -1281,33 +1225,6 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
</div>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
{(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s}
</button>
))}
</div>
<div className="image-workbench-count">
<span></span>
<div>
{([1, 2, 3, 4] as OutputCount[]).map((count) => (
<button
key={count}
type="button"
className={outputCount === count ? "is-active" : ""}
onClick={() => setOutputCount(count)}
>
{count}
</button>
))}
</div>
</div>
</section>
<section className="image-workbench-control-card image-workbench-prompt">
<h3></h3>
<textarea
@@ -1380,6 +1297,34 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
)}
</section>
<aside className="image-workbench-panel image-workbench-panel--right">
<section className="image-workbench-control-card">
<h3></h3>
<span className="image-workbench-field-label"></span>
<div className="image-workbench-segmented">
{(["9:16", "16:9", "4:3", "3:4", "1:1"] as OutputSize[]).map((s) => (
<button key={s} type="button" className={outputSize === s ? "is-active" : ""} onClick={() => setOutputSize(s)}>
{s}
</button>
))}
</div>
<div className="image-workbench-count">
<span></span>
<div>
{([1, 2, 3, 4] as OutputCount[]).map((count) => (
<button
key={count}
type="button"
className={outputCount === count ? "is-active" : ""}
onClick={() => setOutputCount(count)}
>
{count}
</button>
))}
</div>
</div>
</section>
</aside>
</main>
)}
-2
View File
@@ -7,7 +7,6 @@ import {
DeleteOutlined,
EditOutlined,
HighlightOutlined,
MessageOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
@@ -43,7 +42,6 @@ const tools: MoreTool[] = [
{ 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: "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: "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 },
+24 -50
View File
@@ -260,30 +260,6 @@ function ProfilePage({
const packageLabel = session?.user.activePackages?.[0]?.name || "按量积分";
const avatarUrl = session?.user.avatarUrl || localAvatarUrl || null;
const displayedBio = profileBio.trim() || "这个人还没有填写个性签名";
const activePanelTitle =
activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核";
const activePanelDescription =
activePanel === "works"
? "最近完成的高质量生成内容"
: activePanel === "projects"
? "云端同步的创作项目"
: activePanel === "assets"
? "可复用的图片、视频与素材"
: "已提交社区的案例状态";
const activePanelCount =
activePanel === "works"
? visibleWorks.length
: activePanel === "projects"
? projects.length
: activePanel === "assets"
? savedAssets.length
: communityCases.length;
const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const phoneLooksValid = /^1[3-9]\d{9}$/.test(phone.trim());
const passwordLooksReady = password.length >= (mode === "register" ? 6 : 1);
@@ -789,9 +765,9 @@ function ProfilePage({
className={`profile-page__banner${bannerUrl ? " has-image" : ""}`}
style={bannerUrl ? { backgroundImage: `url(${bannerUrl})` } : undefined}
>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()}>
<CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</button>
<div className="profile-page__banner-overlay" />
</header>
@@ -871,39 +847,35 @@ function ProfilePage({
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
<span></span>
<strong>{(totalBalance / 100).toFixed(2)}</strong>
{(totalBalance / 100).toFixed(2)}
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
<span></span>
<strong>{tasks.length}</strong>
{tasks.length}
</button>
</div>
<div className="profile-page__account-summary">
<div className="profile-page__upload-card profile-page__upload-card--meta">
{accountPanel === "credits" ? (
<>
<span className="profile-page__account-summary-main">
<span className="profile-page__meta-item">
<small></small>
<strong>{displayName}</strong>
<em>{packageLabel}</em>
</span>
<span className="profile-page__account-summary-metric">
<span className="profile-page__meta-item">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__account-summary-main">
<small></small>
<strong>{tasks.length} </strong>
<em>{completedTasks.length} </em>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
</span>
<span className="profile-page__account-summary-metric">
<span className="profile-page__meta-item">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
@@ -912,7 +884,6 @@ function ProfilePage({
</div>
</div>
<div className="profile-page__actions">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
@@ -930,31 +901,34 @@ function ProfilePage({
<LockOutlined />
退
</button>
</div>
</aside>
<main className="profile-page__main">
<div className="profile-page__main-tabs">
<button type="button" className={activePanel === "works" ? "is-active" : ""} onClick={() => setActivePanel("works")}>
<span></span>
</button>
<button type="button" className={activePanel === "projects" ? "is-active" : ""} onClick={() => setActivePanel("projects")}>
<span></span>
</button>
<button type="button" className={activePanel === "assets" ? "is-active" : ""} onClick={() => setActivePanel("assets")}>
<span></span>
</button>
<button type="button" className={activePanel === "community" ? "is-active" : ""} onClick={() => setActivePanel("community")}>
<span></span>
</button>
</div>
<div className="profile-page__section">
<div className="profile-page__section-head">
<span className="profile-page__section-label">{activePanelTitle}</span>
<span className="profile-page__section-desc">{activePanelDescription}</span>
<span className="profile-page__section-meta">{activePanelCount} </span>
</div>
<span className="profile-page__section-label">
{activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
{renderActivePanel()}
</div>
</main>
@@ -15,7 +15,7 @@ import {
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -87,7 +87,6 @@ function ResolutionUpscalePage({
const [isProcessing, setIsProcessing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
@@ -165,24 +164,6 @@ function ResolutionUpscalePage({
event.currentTarget.value = "";
};
const processDroppedFile = (file: File) => {
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
setSourceUrl("");
setSourcePreview(URL.createObjectURL(file));
setResultPreview("");
setSourceDimensions(null);
setVideoViewMode("source");
setActiveTaskId("");
setTaskProgress(0);
setStatus(`已导入 ${file.name}`);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) processDroppedFile(file); };
const handleImportUrl = () => {
const normalizedUrl = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
@@ -424,13 +405,7 @@ function ResolutionUpscalePage({
accept={mode === "image" ? "image/png,image/jpeg,image/webp" : "video/mp4,video/quicktime,video/webm,video/*"}
onChange={handleFileChange}
/>
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<div className="image-workbench-upload-shell">
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{sourcePreview && mode === "image" ? <img src={sourcePreview} alt="" /> : <FileImageOutlined />}
<strong>{sourceName || (mode === "image" ? "选择图片" : "选择视频")}</strong>
@@ -599,13 +574,11 @@ function ResolutionUpscalePage({
</div>
)
) : (
<div className="studio-canvas-ghost">
<div className="studio-canvas-ghost__icon">
<ThunderboltOutlined />
</div>
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
</div>
<button type="button" className="image-workbench-empty image-workbench-empty--button" onClick={() => fileInputRef.current?.click()}>
<ColumnWidthOutlined />
<strong>{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</strong>
<span>{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</span>
</button>
)}
</section>
</main>
+7 -162
View File
@@ -1,7 +1,6 @@
import {
BarChartOutlined,
CheckCircleFilled,
CloseOutlined,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
@@ -9,7 +8,7 @@ import {
ThunderboltOutlined,
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from "react";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { useSessionStore } from "../../stores";
@@ -26,8 +25,6 @@ interface EvalResult {
totalScore: number;
grade: string;
dimensionScores: Record<string, number>;
subScores?: Record<string, Record<string, number>>;
evidence?: Record<string, string[]>;
summary: string;
issues: string[];
highlights: string[];
@@ -195,60 +192,6 @@ const SCORE_DIMENSIONS: ScoreDimension[] = [
{ key: "content", label: "内容深度", maxScore: 15, hint: "主题表达·情感共鸣·思想内核", detail: "核心设定将科技伦理与人性困境紧密结合,主题表达深刻有力。" },
];
const SUB_SCORE_LABELS: Record<string, string> = {
openingImpact: "开篇冲击",
suspenseChain: "悬念链",
sceneHook: "场内钩子",
structure: "结构完整",
rhythm: "节奏推进",
conflict: "冲突强度",
reversal: "反转效率",
motivation: "动机清晰",
arc: "人物弧光",
voice: "语言辨识",
relationship: "关系张力",
causality: "因果链",
worldRules: "世界规则",
foreshadowing: "伏笔回收",
continuity: "连续性",
sceneDetail: "场景细节",
shotPotential: "镜头潜力",
aigcFeasibility: "AIGC 可实现",
theme: "主题表达",
emotion: "情感共鸣",
marketFit: "市场匹配",
originality: "原创性",
};
function clampScore(score: unknown, maxScore: number): number {
const numeric = Number(score);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(maxScore, numeric));
}
function getDimensionScore(result: EvalResult, dim: ScoreDimension): number {
const value = result.dimensionScores[dim.key] ?? (dim.key === "logic" ? result.dimensionScores.dialogue : undefined);
return clampScore(value, dim.maxScore);
}
function formatSubScoreLabel(key: string): string {
return SUB_SCORE_LABELS[key] ?? key.replace(/([A-Z])/g, " $1").trim();
}
function getDimensionSubScores(result: EvalResult, dim: ScoreDimension): Array<[string, number]> {
const scores = result.subScores?.[dim.key] ?? (dim.key === "logic" ? result.subScores?.dialogue : undefined);
if (!scores) return [];
return Object.entries(scores)
.map(([key, value]) => [key, clampScore(value, dim.maxScore)] as [string, number])
.filter(([, value]) => value > 0)
.slice(0, 5);
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return Array.isArray(evidence) ? evidence.map(String).map((item) => item.trim()).filter(Boolean).slice(0, 3) : [];
}
function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = [];
lines.push(`# 剧本评测报告`);
@@ -260,16 +203,9 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
lines.push("");
lines.push(`## 六维评分`);
for (const dim of SCORE_DIMENSIONS) {
const score = getDimensionScore(result, dim);
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
const nestedReportLines = [
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
...evidence.map((item) => ` - 证据: ${item}`),
];
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
lines.push(...nestedReportLines);
}
if (result.highlights.length > 0) {
lines.push("");
@@ -303,7 +239,6 @@ function ScriptTokensPage() {
const [animatedScore, setAnimatedScore] = useState(0);
const [activeHistoryIndex, setActiveHistoryIndex] = useState<number>(0);
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const scoreFrameRef = useRef<number | null>(null);
@@ -326,7 +261,9 @@ function ScriptTokensPage() {
return () => { if (scoreFrameRef.current) cancelAnimationFrame(scoreFrameRef.current); };
}, [result]);
const processUploadedFile = async (file: File) => {
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const ext = getFileExtension(file.name);
const readable = isReadableTextFile(file, ext);
setUploadedFile({ name: file.name, size: file.size });
@@ -356,12 +293,6 @@ function ScriptTokensPage() {
} else {
setScript(`[已上传文件:${file.name}]\n\n暂不支持解析 ${ext ? ext.toUpperCase() : "未知"} 格式,请上传常见文本类文件。`);
}
};
const handleFileUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await processUploadedFile(file);
event.target.value = "";
};
@@ -462,30 +393,6 @@ function ScriptTokensPage() {
fileInputRef.current?.click();
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.types.includes("Files")) {
setIsDragging(true);
}
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.currentTarget === event.target || !event.currentTarget.contains(event.relatedTarget as Node)) {
setIsDragging(false);
}
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const file = event.dataTransfer.files[0];
if (file) processUploadedFile(file);
};
const grade = result ? getGrade(result.totalScore) : null;
const beatPct = result ? (result.totalScore >= 95 ? 97 : result.totalScore >= 88 ? 92 : result.totalScore >= 80 ? 85 : 72) : 0;
const compactTitle = uploadedFile?.name?.replace(/\.[^.]+$/, "") ?? "剧本评测";
@@ -502,31 +409,14 @@ function ScriptTokensPage() {
<div className="script-eval-v5-lp-section">
<div className="script-eval-v5-lp-label"></div>
<div
className={`script-eval-v5-upload-zone${isDragging ? " is-dragging" : ""}`}
className="script-eval-v5-upload-zone"
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onKeyDown={uploadKeyDown}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{isDragging ? (
<div className="script-eval-v5-upload-drop-overlay">
<UploadOutlined />
<span></span>
</div>
) : null}
{uploadedFile ? (
<div className="script-eval-v5-upload-done is-show">
<button
type="button"
className="script-eval-v5-upload-delete"
onClick={(e) => { e.stopPropagation(); handleReset(); }}
aria-label="删除文件"
>
<CloseOutlined />
</button>
<CheckCircleFilled />
<span className="script-eval-v5-uf-meta">
<span className="script-eval-v5-uf-name">{uploadedFile.name}</span>
@@ -746,7 +636,7 @@ function ScriptTokensPage() {
</div>
<div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = getDimensionScore(result, dim);
const score = result.dimensionScores[dim.key] ?? 0;
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore;
@@ -786,51 +676,6 @@ function ScriptTokensPage() {
</div>
</section>
<div className="script-eval-report__detail-grid">
{SCORE_DIMENSIONS.map((dim) => {
const score = getDimensionScore(result, dim);
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
return (
<section className="script-eval-report__detail-card" key={dim.key}>
<header className="script-eval-report__detail-head">
<div>
<span>{dim.label}</span>
<strong>{score}<small>/{dim.maxScore}</small></strong>
</div>
<em>{pct}%</em>
</header>
<p className="script-eval-report__detail-hint">{dim.hint}</p>
{subScores.length > 0 ? (
<div className="script-eval-report__subscore-list">
{subScores.map(([key, value]) => {
const subPct = Math.max(0, Math.min(100, Math.round((value / dim.maxScore) * 100)));
return (
<div className="script-eval-report__subscore-row" key={key}>
<span>{formatSubScoreLabel(key)}</span>
<div className="script-eval-report__subscore-bar" aria-hidden="true">
<i style={{ width: `${subPct}%` }} />
</div>
<b>{value}</b>
</div>
);
})}
</div>
) : (
<p className="script-eval-report__detail-empty"></p>
)}
{evidence.length > 0 ? (
<ul className="script-eval-report__evidence-list">
{evidence.map((item, index) => <li key={index}>{item}</li>)}
</ul>
) : null}
</section>
);
})}
</div>
<div className="script-eval-report__findings">
{result.highlights.length > 0 ? (
<section className="script-eval-report__finding-group is-highlight">
@@ -12,7 +12,7 @@ import {
SwapOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -73,7 +73,6 @@ function SubtitleRemovalPage({
const [isProcessing, setIsProcessing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
@@ -126,7 +125,10 @@ function SubtitleRemovalPage({
event.currentTarget.value = "";
};
const processDroppedFile = (file: File) => {
const handleFileDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("video/"));
if (!file) return;
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
@@ -138,10 +140,6 @@ function SubtitleRemovalPage({
setStatus(`已导入 ${file.name}`);
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
const handleFileDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("video/")); if (file) processDroppedFile(file); };
const handleImportUrl = () => {
const normalized = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalized)) {
@@ -343,13 +341,7 @@ function SubtitleRemovalPage({
accept="video/mp4"
onChange={handleFileChange}
/>
<div
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<div className="image-workbench-upload-shell" onDragOver={(e) => e.preventDefault()} onDrop={handleFileDrop}>
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
<FileImageOutlined />
<strong>{sourceName || "拖拽或选择视频"}</strong>
@@ -443,17 +435,9 @@ function SubtitleRemovalPage({
)}
</div>
) : (
<div
className="studio-canvas-ghost"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
<div className="studio-canvas-ghost__icon">
<VideoCameraOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div>
<div className="image-workbench-empty-canvas">
<DeleteOutlined style={{ fontSize: 48, opacity: 0.2 }} />
<p></p>
</div>
)}
</section>
+23 -212
View File
@@ -64,12 +64,6 @@ import {
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
import { detectMentionTrigger } from "../../utils/mentionTrigger";
import {
isHappyHorseModel,
@@ -105,14 +99,10 @@ import {
type WorkbenchKeepaliveTask,
MODE_META,
MODE_OPTIONS,
CHAT_MODEL_OPTIONS,
THINKING_SPEED_OPTIONS,
THINKING_DEPTH_OPTIONS,
IMAGE_MODEL_OPTIONS,
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
@@ -260,8 +250,6 @@ function WorkbenchPage({
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
const [isComposerDragging, setIsComposerDragging] = useState(false);
const composerDragCounterRef = useRef(0);
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
@@ -340,13 +328,9 @@ function WorkbenchPage({
const [videoModel, setVideoModel] = useState(VIDEO_MODEL_OPTIONS[0].value);
const [videoFrameMode, setVideoFrameMode] = useState("omni");
const [videoRatio, setVideoRatio] = useState("16:9");
const [videoDuration, setVideoDuration] = useState("5");
const [videoDuration, setVideoDuration] = useState("4");
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
const [chatModel, setChatModel] = useState(CHAT_MODEL_OPTIONS[0].value);
const [thinkingSpeed, setThinkingSpeed] = useState(THINKING_SPEED_OPTIONS[0].value);
const [thinkingDepth, setThinkingDepth] = useState(THINKING_DEPTH_OPTIONS[0].value);
useEffect(() => {
let cancelled = false;
@@ -402,13 +386,13 @@ function WorkbenchPage({
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : chatModel;
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : CHAT_MODEL;
const activeModel =
activeMode === "image"
? imageModelOptions.find((item) => item.value === imageModel)?.label || imageModel
: activeMode === "video"
? videoModelOptions.find((item) => item.value === activeVideoModelValue)?.label || activeVideoModelValue
: CHAT_MODEL_OPTIONS.find((item) => item.value === chatModel)?.label || chatModel;
: "OmniChat";
const conversationRecords = useMemo<WebProjectSummary[]>(
() =>
conversations.map((conversation) => ({
@@ -879,9 +863,6 @@ function WorkbenchPage({
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 0;
let lastProgressAt = task.startedAt || Date.now();
const taskKind = task.mode === "image" ? "image" : "video";
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) {
@@ -928,9 +909,6 @@ function WorkbenchPage({
const progress = status.status === "completed"
? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
if (progress > lastKnownProgress || status.status === "completed") {
lastProgressAt = Date.now();
}
lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel =
@@ -955,28 +933,6 @@ function WorkbenchPage({
setGenerationProgress(progress);
}
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
? isTaskLocallyTimedOut({
startedAt: task.startedAt || Date.now(),
lastProgressAt,
progress,
policy: timeoutPolicy,
})
: null;
if (localTimeoutReason) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: buildLocalTimeoutMessage(taskKind),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: "unknown",
taskProgress: progress,
taskStatusLabel: "本地等待超时",
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return;
}
if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask
@@ -1503,22 +1459,9 @@ function WorkbenchPage({
setReferenceItems(nextItems);
};
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const processReferenceFiles = async (files: File[]) => {
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length === 0) return;
const existingFingerprints = new Set(
@@ -1605,46 +1548,20 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
await processReferenceFiles(files);
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
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 handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const insertPromptMention = (token: string) => {
const rawBefore = inputValue.slice(0, cursorIndex);
@@ -2024,7 +1941,6 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask);
} else {
let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36);
setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, {
@@ -2057,9 +1973,6 @@ function WorkbenchPage({
});
},
abortController.signal,
(usage) => {
chatUsage = usage;
},
);
if (abortController.signal.aborted) return;
@@ -2068,7 +1981,6 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed",
taskUsage: chatUsage,
});
if (!conversationId) {
const conv = await conversationClient.create(
@@ -2196,38 +2108,6 @@ function WorkbenchPage({
}
};
const handleReleaseStuckTask = (message: ChatMessage) => {
if (message.taskId) {
taskAbortControllersRef.current.get(message.taskId)?.abort();
taskAbortControllersRef.current.delete(message.taskId);
removeKeepaliveTask(message.taskId);
}
if (message.conversationId) {
void patchConversationMessage(message.conversationId, message.id, {
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: message.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
});
}
setMessages((current) =>
current.map((item) =>
item.id === message.id
? {
...item,
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: item.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
}
: item,
),
);
syncActiveGenerationUi();
};
const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分");
@@ -2681,11 +2561,6 @@ function WorkbenchPage({
>
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
</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
type="button"
className="wb-composer__ref-remove"
@@ -2727,46 +2602,6 @@ function WorkbenchPage({
ariaLabel="工作台模式"
direction={dropdownDirection}
/>
{activeMode === "chat" && (
<>
<SelectChip
chipId="chat-model"
value={chatModel}
options={CHAT_MODEL_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-model"}
onToggle={() => toggleToolbarMenu("chat-model")}
onClose={closeToolbarMenus}
onChange={setChatModel}
ariaLabel="对话模型"
direction={dropdownDirection}
/>
<SelectChip
chipId="chat-speed"
value={thinkingSpeed}
options={THINKING_SPEED_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-speed"}
onToggle={() => toggleToolbarMenu("chat-speed")}
onClose={closeToolbarMenus}
onChange={setThinkingSpeed}
ariaLabel="思考速度"
direction={dropdownDirection}
/>
<SelectChip
chipId="chat-depth"
value={thinkingDepth}
options={THINKING_DEPTH_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "chat-depth"}
onToggle={() => toggleToolbarMenu("chat-depth")}
onClose={closeToolbarMenus}
onChange={setThinkingDepth}
ariaLabel="思考深度"
direction={dropdownDirection}
/>
</>
)}
{activeMode === "image" && (
<>
<SelectChip
@@ -2777,7 +2612,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus}
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
onChange={setImageModel}
direction={dropdownDirection}
/>
<CompoundSelectChip
@@ -2789,7 +2624,6 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
@@ -2801,7 +2635,6 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
)}
</>
)}
{activeMode === "video" && (
@@ -2985,14 +2818,7 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1>
</div>
<div
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-home__composer" ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
@@ -3128,7 +2954,7 @@ function WorkbenchPage({
))}
</div>
)}
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
<div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined />
@@ -3136,12 +2962,9 @@ function WorkbenchPage({
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
<AppstoreOutlined />
</button>
<button type="button" className="ai-chat-failed-actions__release" onClick={() => handleReleaseStuckTask(message)}>
<StopOutlined />
</button>
</div>
)}
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)}
{message.status === "thinking" && message.mode === "chat" && (
@@ -3149,11 +2972,6 @@ function WorkbenchPage({
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{message.role === "assistant" && message.mode === "chat" && message.status === "completed" && (
<div className="ai-chat-task-billing-note">
{formatTextTokenUsage(message.taskUsage)}
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
@@ -3175,14 +2993,7 @@ function WorkbenchPage({
</div>
</section>
<section
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
+1 -6
View File
@@ -1,5 +1,3 @@
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
export type WorkbenchMode = "chat" | "image" | "video";
export interface WorkbenchChatAttachment {
@@ -18,10 +16,7 @@ export interface WorkbenchChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
status?: "thinking" | "queued" | "completed" | "failed";
taskId?: string;
conversationId?: number;
taskProgress?: number;
+3 -39
View File
@@ -1,15 +1,11 @@
import { isServerRequestError } from "../../api/serverConnection";
import { ENTERPRISE_VIDEO_MODEL_OPTIONS } from "../../utils/enterpriseVideoPolicy";
import type { WebGenerationPreviewTask } from "../../types";
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
import type { ReactNode } from "react";
export type WorkbenchMode = "chat" | "image" | "video";
export type ToolbarMenuId =
| "studio-mode"
| "chat-model"
| "chat-speed"
| "chat-depth"
| "image-model"
| "image-settings"
| "image-grid-mode"
@@ -75,10 +71,7 @@ export interface ChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
status?: "thinking" | "queued" | "completed" | "failed";
taskId?: string;
conversationId?: number;
taskProgress?: number;
@@ -141,24 +134,6 @@ export const REFERENCE_IMAGE_INITIAL_QUALITY = 0.84;
export const REFERENCE_IMAGE_MIN_QUALITY = 0.62;
export const CHAT_MODEL = "gemini-3.1-pro";
export const CHAT_MODEL_OPTIONS: WorkbenchOption[] = [
{ value: "gemini", label: "Gemini" },
{ value: "wanxian", label: "万相" },
{ value: "deepseek", label: "DeepSeek" },
];
export const THINKING_SPEED_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "high", label: "高" },
{ value: "ultra", label: "急速" },
];
export const THINKING_DEPTH_OPTIONS: WorkbenchOption[] = [
{ value: "default", label: "默认" },
{ value: "strong", label: "强" },
{ value: "extreme", label: "极限" },
];
export const CHAT_NATURAL_SYSTEM_PROMPT = [
"你是 OmniAI 的创作协作助手,像一个正在一起工作的同伴一样说话。",
`默认使用自然、简洁的中文,不要官腔,不要机械套话,不要频繁使用“首先、其次、最后”这种模板。`,
@@ -257,19 +232,13 @@ export const GRID_MODE_OPTIONS: WorkbenchOption[] = [
{ value: "grid-25", label: "25 宫格" },
];
export const GRID_SUPPORTED_MODELS = new Set([
"wan2.7-image-pro",
"wan2.7-image",
"gpt-image-2",
"gpt-image-2-vip",
]);
export const VIDEO_FRAME_OPTIONS: WorkbenchOption[] = [
{ value: "omni", label: "全能参考" },
{ value: "start-end", label: "首尾帧" },
];
export const VIDEO_DURATION_OPTIONS: WorkbenchOption[] = [
{ value: "4", label: "4s" },
{ value: "5", label: "5s" },
{ value: "6", label: "6s" },
{ value: "7", label: "7s" },
@@ -397,16 +366,11 @@ export function shouldPersistPatch(patch: Partial<ChatMessage>): boolean {
return (
patch.status === "completed" ||
patch.status === "failed" ||
patch.status === "local_timeout" ||
patch.status === "stopping" ||
typeof patch.taskId === "string" ||
typeof patch.resultUrl === "string" ||
typeof patch.resultOssKey === "string" ||
typeof patch.resultOriginalUrl === "string" ||
typeof patch.resultMimeType === "string" ||
typeof patch.taskRefundStatus === "string" ||
typeof patch.taskLifecycleStatus === "string" ||
typeof patch.taskUsage === "object"
typeof patch.resultMimeType === "string"
);
}
+10 -41
View File
@@ -1,11 +1,5 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient";
import {
buildLocalTimeoutMessage,
buildTaskFailureInfo,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void;
@@ -13,7 +7,7 @@ const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // Keep the previous 10-minute guard as a fallback.
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback);
@@ -24,25 +18,10 @@ function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item));
}
function getQueueItemKind(item: GenerationQueueItem): "image" | "video" | "text" {
if (item.type === "image") return "image";
if (item.type === "video" || item.type === "ecommerce-video") return "video";
return "text";
}
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`;
if (activePollers.has(key)) return;
const kind = getQueueItemKind(item);
const timeoutPolicy = getTaskTimeoutPolicy({ kind, model: getQueueItemModel(item) });
let lastProgress = Math.max(0, Number(item.progress || 0));
let lastProgressAt = Date.now();
const interval = setInterval(async () => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || current.status === "completed" || current.status === "failed" || current.status === "cancelled") {
@@ -51,31 +30,18 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
}
attemptsRef.current++;
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);
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error,
error: "任务超时,请重新提交",
});
notifyCallbacks({ ...item, status: "failed", error });
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const nextProgress = Number(status.progress || 0);
if (nextProgress > lastProgress || status.status === "completed") {
lastProgress = Math.max(lastProgress, nextProgress);
lastProgressAt = Date.now();
}
const patch: Partial<GenerationQueueItem> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
@@ -89,7 +55,6 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(status.error).message;
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
@@ -99,7 +64,7 @@ function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }):
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network errors during polling are retried until the lifecycle guard trips.
// Network error during poll — keep trying
}
}, POLL_INTERVAL);
@@ -140,20 +105,24 @@ export function stopAllPolling(): void {
activePollers.clear();
}
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return;
pendingTasks.forEach((task) => {
if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, {
status: "failed",
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交",
error: "页面刷新后任务丢失,请重新提交",
});
}
});
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500);
}
-1
View File
@@ -20,7 +20,6 @@
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/dialog-generator.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
-226
View File
@@ -722,229 +722,3 @@
right: -9999px;
height: 1px;
}
/* ── Canvas drag-and-drop visual feedback ─────────────────────────── */
.studio-canvas.is-canvas-dragging::after {
content: "释放以上传图片";
position: absolute;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(33, 242, 154, 0.12);
border: 3px dashed #21f29a;
color: #111;
font-size: 20px;
font-weight: 700;
pointer-events: none;
}
.studio-canvas-text-composer.is-drag-over {
outline: 2px dashed #21f29a;
outline-offset: 2px;
background: rgba(33, 242, 154, 0.06);
}
.studio-canvas-text-composer.is-drag-over::after {
content: "释放图片以创建节点";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
color: #333;
font-size: 13px;
font-weight: 600;
pointer-events: none;
}
/* 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;
}
}
-783
View File
@@ -1,783 +0,0 @@
.dialog-generator-page {
min-height: 100%;
overflow: auto;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
linear-gradient(180deg, #070b10 0%, #05080d 100%);
color: #e8eaef;
}
.dialog-generator-shell {
display: grid;
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
gap: clamp(18px, 2.8vw, 34px);
min-height: var(--shell-content-height, 100vh);
padding: clamp(24px, 4vw, 52px);
}
.dialog-generator-panel,
.dialog-generator-preview-card {
border: 1px solid rgba(0, 255, 136, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
0 24px 72px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.dialog-generator-panel {
display: grid;
align-content: start;
gap: 24px;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-heading {
display: grid;
gap: 12px;
}
.dialog-generator-kicker {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-heading h1 {
margin: 0;
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: clamp(32px, 3.6vw, 56px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.1;
}
.dialog-generator-heading p,
.dialog-generator-hint,
.dialog-generator-preview-head p {
margin: 0;
color: #9aa1b8;
font-size: 15px;
font-weight: 650;
line-height: 1.7;
}
.dialog-generator-section {
display: grid;
gap: 12px;
}
.dialog-generator-section h2 {
margin: 0;
color: #f6f8fb;
font-size: 18px;
font-weight: 900;
}
.dialog-generator-drop {
display: grid;
justify-items: center;
gap: 8px;
min-height: 168px;
border: 1px dashed rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: rgba(0, 255, 136, 0.035);
color: #e8eaef;
padding: 24px;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-drop:hover {
border-color: rgba(0, 255, 136, 0.5);
background: rgba(0, 255, 136, 0.06);
transform: translateY(-1px);
}
.dialog-generator-drop-icon {
font-size: 42px;
}
.dialog-generator-drop strong {
font-size: 16px;
font-weight: 900;
}
.dialog-generator-drop small,
.dialog-generator-style small {
color: #62697f;
font-size: 13px;
font-weight: 700;
}
.dialog-generator-style-list {
display: grid;
gap: 10px;
}
.dialog-generator-color-picker {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dialog-generator-color {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #dce3ed;
cursor: pointer;
font-size: 13px;
font-weight: 850;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-color:hover,
.dialog-generator-color.is-active {
border-color: var(--text-color);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.dialog-generator-color span {
width: 14px;
height: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 50%;
background: var(--text-color);
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
}
.dialog-generator-color strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-generator-style {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 14px;
border: 1px solid rgba(0, 255, 136, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #e8eaef;
padding: 15px 18px;
text-align: left;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-style:hover {
border-color: rgba(0, 255, 136, 0.28);
background: rgba(255, 255, 255, 0.06);
transform: translateX(3px);
}
.dialog-generator-style span:last-child {
display: grid;
gap: 4px;
min-width: 0;
}
.dialog-generator-style strong {
color: #f7fafc;
font-size: 16px;
font-weight: 900;
}
.dialog-generator-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.dialog-generator-swatch.is-white {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.dialog-generator-swatch.is-blue {
background: #165dff;
}
.dialog-generator-swatch.is-amber {
background: #f59e0b;
}
.dialog-generator-swatch.is-gray {
background: #6b7280;
}
.dialog-generator-clear {
min-height: 48px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #e8eaef;
cursor: pointer;
font-size: 15px;
font-weight: 900;
transition:
border-color 180ms ease,
background 180ms ease;
}
.dialog-generator-clear:hover {
border-color: rgba(255, 77, 103, 0.32);
background: rgba(255, 77, 103, 0.1);
}
.dialog-generator-preview-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-preview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.dialog-generator-preview-head span {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-preview-head h2 {
margin: 4px 0 0;
color: #ffffff;
font-size: clamp(24px, 2vw, 34px);
font-weight: 950;
}
.dialog-generator-preview-head p {
max-width: 440px;
text-align: right;
}
.dialog-generator-preview {
position: relative;
min-height: 520px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
rgba(5, 8, 13, 0.72);
background-size: 32px 32px, 32px 32px, auto;
touch-action: none;
}
.dialog-generator-image {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.dialog-generator-empty {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 12px;
color: #62697f;
text-align: center;
pointer-events: none;
}
.dialog-generator-empty span {
font-size: 52px;
}
.dialog-generator-empty p {
margin: 0;
font-size: 16px;
font-weight: 800;
}
.dialog-generator-bubble {
position: absolute;
z-index: 10;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
padding: 12px 14px;
user-select: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.dialog-generator-bubble.is-confirmed {
min-width: 0;
max-width: min(420px, 80%);
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
cursor: move;
}
.dialog-generator-bubble:hover {
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
}
.dialog-generator-bubble.is-confirmed:hover {
box-shadow: none;
}
.dialog-generator-bubble.is-dragging {
z-index: 20;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
}
.dialog-generator-bubble.is-confirmed.is-dragging {
box-shadow: none;
}
.dialog-generator-bubble.style1 {
border: 2px solid #cbd5e1;
background: rgba(255, 255, 255, 0.97);
}
.dialog-generator-bubble.style2 {
border: 2px solid #4f8aff;
border-radius: 16px 16px 4px 16px;
background: rgba(22, 93, 255, 0.95);
}
.dialog-generator-bubble.style3 {
border: 2px solid #f59e0b;
background: rgba(255, 247, 237, 0.97);
}
.dialog-generator-bubble.style4 {
border: 2px solid #6b7280;
border-radius: 4px;
background: rgba(248, 250, 252, 0.97);
}
.dialog-generator-bubble.is-confirmed.style1,
.dialog-generator-bubble.is-confirmed.style2,
.dialog-generator-bubble.is-confirmed.style3,
.dialog-generator-bubble.is-confirmed.style4 {
border: 0;
background: transparent;
}
.dialog-generator-delete {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid #fff;
border-radius: 50%;
background: #ef4444;
color: #fff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.dialog-generator-bubble:hover .dialog-generator-delete {
opacity: 1;
}
.dialog-generator-text,
.dialog-generator-text-display {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--dialog-text-color, #1e293b);
padding: 0;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-generator-text-display {
width: max-content;
max-width: min(420px, 80vw);
color: var(--dialog-text-color, #ffffff);
font-size: clamp(18px, 2.2vw, 30px);
font-weight: 900;
line-height: 1.35;
letter-spacing: 0;
overflow-wrap: anywhere;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.72),
0 0 1px rgba(0, 0, 0, 0.9);
}
.dialog-generator-text::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.dialog-generator-bubble.style2 .dialog-generator-text,
.dialog-generator-bubble.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #fff);
}
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #7fb4ff);
}
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.dialog-generator-bubble.style3 .dialog-generator-text,
.dialog-generator-bubble.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #92400e);
}
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #ffd76a);
}
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
color: rgba(146, 64, 14, 0.4);
}
.dialog-generator-bubble.style4 .dialog-generator-text,
.dialog-generator-bubble.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #1f2937);
}
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #111827);
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.72),
0 0 8px rgba(255, 255, 255, 0.58);
}
.dialog-generator-bubble-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
}
.dialog-generator-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: #165dff;
color: #fff;
cursor: pointer;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
transition:
filter 0.15s,
transform 0.15s;
}
.dialog-generator-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.dialog-generator-bubble.style2 .dialog-generator-confirm {
background: #fff;
color: #165dff;
}
.dialog-generator-bubble.style3 .dialog-generator-confirm {
background: #f59e0b;
}
.dialog-generator-bubble.style4 .dialog-generator-confirm {
background: #6b7280;
}
.dialog-generator-edit-hint {
display: none;
color: rgba(0, 0, 0, 0.36);
font-size: 10px;
font-weight: 700;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
display: none;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
display: inline-block;
}
/* ── Generation controls ── */
.dialog-generator-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.dialog-generator-mode {
min-height: 42px;
border: 1px solid rgba(0, 255, 136, 0.16);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #9aa1b8;
cursor: pointer;
font-size: 14px;
font-weight: 800;
transition:
border-color 180ms ease,
background 180ms ease,
color 180ms ease,
transform 180ms ease;
}
.dialog-generator-mode:hover {
border-color: rgba(0, 255, 136, 0.32);
color: #dce3ed;
transform: translateY(-1px);
}
.dialog-generator-mode.is-active {
border-color: rgba(0, 255, 136, 0.42);
background: rgba(0, 255, 136, 0.08);
color: #00ff88;
box-shadow: 0 0 16px rgba(0, 255, 136, 0.08);
}
.dialog-generator-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.dialog-generator-pills {
position: relative;
}
.dialog-generator-pill {
display: flex;
align-items: center;
gap: 6px;
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;
padding: 0 12px;
font-size: 13px;
font-weight: 750;
transition:
border-color 180ms ease,
background 180ms ease,
color 180ms ease;
}
.dialog-generator-pill:hover {
border-color: rgba(0, 255, 136, 0.28);
color: #f6f8fb;
}
.dialog-generator-pill.is-open {
border-color: rgba(0, 255, 136, 0.38);
background: rgba(0, 255, 136, 0.08);
color: #00ff88;
}
.dialog-generator-pill .anticon {
font-size: 14px;
}
.dialog-generator-dropdown {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
left: 0;
min-width: 148px;
border: 1px solid rgba(0, 255, 136, 0.18);
border-radius: 8px;
background: rgba(10, 16, 26, 0.96);
box-shadow:
0 12px 36px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(0, 255, 136, 0.06);
backdrop-filter: blur(18px);
padding: 4px;
overflow: hidden;
}
.dialog-generator-dropdown__item {
display: block;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
color: #bcc4d6;
cursor: pointer;
padding: 9px 12px;
text-align: left;
font-size: 13px;
font-weight: 700;
transition:
background 120ms ease,
color 120ms ease;
}
.dialog-generator-dropdown__item:hover {
background: rgba(0, 255, 136, 0.08);
color: #e8eaef;
}
.dialog-generator-dropdown__item.is-active {
background: rgba(0, 255, 136, 0.12);
color: #00ff88;
font-weight: 850;
}
/* ── Video duration ── */
.dialog-generator-duration {
display: grid;
gap: 8px;
width: 100%;
}
.dialog-generator-duration__label {
color: #9aa1b8;
font-size: 13px;
font-weight: 750;
}
.dialog-generator-duration__options {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dialog-generator-duration__btn {
min-width: 42px;
min-height: 34px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: #9aa1b8;
cursor: pointer;
padding: 0 8px;
font-size: 12px;
font-weight: 750;
transition:
border-color 180ms ease,
background 180ms ease,
color 180ms ease;
}
.dialog-generator-duration__btn:hover {
border-color: rgba(0, 255, 136, 0.28);
color: #dce3ed;
}
.dialog-generator-duration__btn.is-active {
border-color: rgba(0, 255, 136, 0.42);
background: rgba(0, 255, 136, 0.12);
color: #00ff88;
}
/* ── Generate button ── */
.dialog-generator-run {
min-height: 48px;
border: 1px solid rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: linear-gradient(135deg, rgba(0, 255, 136, 0.14) 0%, rgba(34, 240, 192, 0.08) 100%);
color: #00ff88;
cursor: pointer;
font-size: 16px;
font-weight: 900;
letter-spacing: 0.04em;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
}
.dialog-generator-run:hover:not(:disabled) {
border-color: rgba(0, 255, 136, 0.5);
background: linear-gradient(135deg, rgba(0, 255, 136, 0.2) 0%, rgba(34, 240, 192, 0.12) 100%);
transform: translateY(-1px);
box-shadow: 0 4px 24px rgba(0, 255, 136, 0.1);
}
.dialog-generator-run:disabled {
opacity: 0.52;
cursor: not-allowed;
}
@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;
}
}
+55 -141
View File
@@ -418,15 +418,6 @@
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 {
display: none;
}
@@ -990,8 +981,8 @@
overflow-x: hidden;
overflow-y: auto;
padding: 20px 18px;
scrollbar-width: thin;
scrollbar-color: #3a3f49 #15171c;
scrollbar-width: thin;
transition:
opacity 360ms ease,
transform var(--clone-settings-motion-duration) var(--clone-settings-motion-ease);
@@ -1541,11 +1532,12 @@
.product-clone-page[data-tool="clone"] .clone-ai-replicate-panel {
display: grid;
flex: 0 0 auto;
grid-template-rows: auto auto minmax(0, 1fr);
flex: 0 0 272px;
grid-template-rows: auto minmax(0, 1fr);
gap: 9px;
height: 272px;
min-height: 0;
overflow: visible;
overflow: hidden;
border: 1px solid #303540;
border-radius: 14px;
background: #1c1f26;
@@ -1607,7 +1599,7 @@
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload {
position: relative;
display: grid;
min-height: 96px;
min-height: 78px;
overflow: visible;
place-items: center;
align-content: center;
@@ -1616,7 +1608,7 @@
border-radius: 12px;
background: #20242c;
color: #eef2f6;
padding: 16px 12px;
padding: 8px;
cursor: pointer;
transition:
border-color 160ms ease,
@@ -1624,52 +1616,15 @@
transform 160ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload.has-files {
min-height: 120px;
place-items: center;
align-content: center;
gap: 8px;
padding: 10px;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:hover {
border-color: #00ff88;
background: #202c28;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload.is-dragging {
border-color: #00ff88;
background: #1a2e24;
border-style: solid;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:active {
transform: scale(0.98);
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
background: rgba(0, 255, 136, 0.08);
color: #00ff88;
pointer-events: none;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay .anticon {
font-size: 28px;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload-overlay span {
font-size: 14px;
font-weight: 800;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload > span {
display: inline-grid;
grid-template-columns: auto minmax(0, max-content);
@@ -1712,88 +1667,77 @@
font-weight: 800;
}
/* ── Reference image file grid (inside upload button) ── */
.product-clone-page[data-tool="clone"] .clone-ai-replicate-files {
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview {
position: absolute;
inset: 6px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(56px, 1fr));
grid-auto-flow: column;
grid-auto-columns: minmax(0, 56px);
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
overflow: visible;
border-radius: 10px;
background: #20242c;
opacity: 0;
pointer-events: none;
transform: scale(0.98);
transition:
opacity 160ms ease,
transform 160ms ease;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file {
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:hover .clone-ai-replicate-preview,
.product-clone-page[data-tool="clone"] .clone-ai-replicate-upload:focus-visible .clone-ai-replicate-preview {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure {
position: relative;
display: block;
aspect-ratio: 1;
width: 56px;
height: 52px;
min-width: 0;
overflow: visible;
margin: 0;
border-radius: 6px;
border-radius: 8px;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file > img {
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure > img {
display: block;
width: 100%;
height: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid #3a4555;
border-radius: 6px;
border-radius: 8px;
background: #111720;
object-fit: cover;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-file > img:hover {
border-color: #00ff88;
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure:only-child {
width: min(170px, 100%);
height: 52px;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-add-more {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
justify-self: center;
width: fit-content;
max-width: calc(100% - 8px);
height: 28px;
min-width: 0;
border-radius: 7px;
background: #2b3039;
color: #9aa4b4;
padding: 0 10px;
font-size: 12px;
font-weight: 750;
white-space: nowrap;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-add-more .anticon {
font-size: 13px;
color: #5d84bd;
}
/* ── Portal-based zoom preview (avoids overflow clipping) ── */
.clone-ai-zoom-portal {
position: fixed;
z-index: 9999;
width: min(280px, 52vw);
max-height: 340px;
border: 1px solid #3a4555;
border-radius: 14px;
background: #101115;
padding: 8px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.5);
transform: translate(-50%, calc(-100% - 12px));
pointer-events: none;
}
.clone-ai-zoom-portal img {
display: block;
width: 100%;
height: auto;
max-height: 324px;
border-radius: 8px;
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview figure:only-child > img {
object-fit: contain;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-preview b {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid #3a4555;
border-radius: 999px;
background: #151b24;
color: #eef2f6;
font-size: 12px;
font-weight: 900;
}
.product-clone-page[data-tool="clone"] .clone-ai-replicate-link input {
width: 100%;
height: 72px;
@@ -3557,8 +3501,8 @@
.product-set-thumb:focus-within .uploaded-image-zoom,
.product-clone-uploaded-thumb:hover .uploaded-image-zoom,
.product-clone-uploaded-thumb:focus-within .uploaded-image-zoom,
.clone-ai-replicate-file:hover .uploaded-image-zoom,
.clone-ai-replicate-file:focus-within .uploaded-image-zoom {
.clone-ai-replicate-preview figure:hover .uploaded-image-zoom,
.clone-ai-replicate-preview figure:focus-within .uploaded-image-zoom {
opacity: 1;
transform: translate(-50%, 0) scale(1);
visibility: visible;
@@ -4032,7 +3976,6 @@
.product-clone-panel__footer {
display: grid;
align-items: center;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding: 12px 16px;
}
@@ -4057,11 +4000,6 @@
cursor: not-allowed;
}
.product-clone-primary--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-preview {
display: grid;
align-content: center;
@@ -4992,7 +4930,6 @@
}
.product-set-main-card {
position: relative;
height: 380px;
border-radius: 16px;
transition: transform 250ms ease, box-shadow 250ms ease;
@@ -8822,17 +8759,6 @@
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 {
border-color: var(--ecm-line-strong);
background: rgba(20, 23, 25, 0.86);
@@ -9058,17 +8984,6 @@
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 {
background:
radial-gradient(circle at 50% 40%, rgba(var(--ecm-accent-rgb), 0.032), transparent 40%),
@@ -9505,4 +9420,3 @@
min-height: calc(100% - 59px);
}
}
+2 -23
View File
@@ -216,14 +216,14 @@
.image-workbench-layout {
display: grid;
grid-template-columns: 280px 1fr;
grid-template-columns: 280px 1fr 220px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.image-workbench-layout--inpaint {
grid-template-columns: 260px 1fr;
grid-template-columns: 260px 1fr 240px;
}
.image-workbench-layout--camera {
@@ -278,27 +278,6 @@
position: relative;
}
.image-workbench-upload-shell.is-dragging {
border-radius: var(--radius-sm);
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
.image-workbench-upload-drop-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: rgba(var(--accent-rgb), 0.08);
color: var(--accent);
font-size: 15px;
font-weight: 800;
pointer-events: none;
}
.image-workbench-upload {
display: flex;
flex-direction: column;
-49
View File
@@ -14618,55 +14618,6 @@
background: #ddf5e2;
}
.agent-tool-pill.is-open {
background: #ddf5e2;
box-shadow: 1px 1px 0 #111;
}
.agent-tool-pills {
position: relative;
}
.agent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 120;
min-width: 160px;
background: #fff;
border: 3px solid #111;
box-shadow: 3px 3px 0 #111;
border-radius: 0;
overflow: hidden;
}
.agent-dropdown__item {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
border-bottom: 1px solid #ddd;
background: #fff;
color: #111;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.agent-dropdown__item:last-child {
border-bottom: none;
}
.agent-dropdown__item:hover {
background: #ddf5e2;
}
.agent-dropdown__item.is-active {
background: #c8f0d6;
font-weight: 700;
}
.agent-run-button {
display: flex;
align-items: center;
-7
View File
@@ -15,10 +15,3 @@
.profile-page__works-scroll .profile-page__list-grid {
grid-template-columns: repeat(3, 1fr); /* 固定3列,刚好3×3=9个可见 */
}
/* Dashboard uses natural page scrolling instead of a nested works scroller. */
.profile-page--dashboard .profile-page__works-scroll {
max-height: none;
overflow: visible;
scrollbar-width: auto;
}
+2 -215
View File
@@ -142,7 +142,6 @@
/* Upload zone */
.script-eval-v5-upload-zone {
position: relative;
border: 2px dashed var(--v5-border2);
border-radius: 12px;
padding: 22px 18px;
@@ -156,37 +155,6 @@
background: var(--v5-green-deep);
}
.script-eval-v5-upload-zone.is-dragging {
border-color: var(--v5-green);
border-style: solid;
background: var(--v5-green-deep);
}
.script-eval-v5-upload-drop-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 12px;
background: rgba(0, 255, 136, 0.06);
color: var(--v5-green);
pointer-events: none;
}
.script-eval-v5-upload-drop-overlay .anticon {
font-size: 40px;
opacity: 0.8;
}
.script-eval-v5-upload-drop-overlay span {
font-size: 16px;
font-weight: 800;
}
.script-eval-v5-upload-icon {
margin-bottom: 10px;
font-size: 38px;
@@ -227,11 +195,10 @@
}
.script-eval-v5-upload-done {
position: relative;
display: none;
align-items: center;
gap: 10px;
padding: 12px 28px 12px 14px;
padding: 12px 14px;
border-radius: 8px;
background: var(--v5-green-deep);
border: 1px solid var(--v5-green-border);
@@ -241,30 +208,6 @@
display: flex;
}
.script-eval-v5-upload-delete {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: var(--v5-text3);
cursor: pointer;
font-size: 10px;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.script-eval-v5-upload-delete:hover {
background: rgba(255, 77, 103, 0.5);
color: #fff;
}
.script-eval-v5-upload-done .anticon {
font-size: 16px;
color: var(--v5-green);
@@ -275,7 +218,7 @@
font-size: 13px;
color: var(--v5-green);
font-weight: 600;
max-width: 16ch;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2859,10 +2802,6 @@
color: #e9fff5;
font-size: 14px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 16em;
}
.script-eval-v5-uf-size {
@@ -3291,141 +3230,6 @@
color: var(--report-green);
}
.script-eval-report__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 0 18px 18px;
}
.script-eval-report__detail-card {
min-width: 0;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: var(--v5-radius-md);
background: linear-gradient(180deg, rgb(255 255 255 / 3.4%), transparent), var(--report-row);
padding: 14px;
}
.script-eval-report__detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.script-eval-report__detail-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.script-eval-report__detail-head span {
color: #dfe8e4;
font-size: 14px;
font-weight: 800;
}
.script-eval-report__detail-head strong {
color: var(--report-green);
font-size: 22px;
line-height: 1;
}
.script-eval-report__detail-head small {
color: #7e8a86;
font-size: 12px;
}
.script-eval-report__detail-head em {
flex-shrink: 0;
border: 1px solid rgb(0 255 136 / 18%);
border-radius: 999px;
background: rgb(0 255 136 / 7%);
color: #98e8bd;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.script-eval-report__detail-hint,
.script-eval-report__detail-empty {
margin: 10px 0 0;
color: #7f8c88;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.script-eval-report__subscore-list {
display: grid;
gap: 9px;
margin-top: 13px;
}
.script-eval-report__subscore-row {
display: grid;
grid-template-columns: minmax(62px, 86px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
color: #bdcbc6;
font-size: 12px;
font-weight: 750;
}
.script-eval-report__subscore-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-eval-report__subscore-row b {
color: #dfe8e4;
text-align: right;
}
.script-eval-report__subscore-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 5%);
}
.script-eval-report__subscore-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #18de8a, #a3f7c1);
}
.script-eval-report__evidence-list {
display: grid;
gap: 7px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.script-eval-report__evidence-list li {
position: relative;
padding-left: 13px;
color: #aebcb7;
font-size: 12px;
font-weight: 650;
line-height: 1.55;
}
.script-eval-report__evidence-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--report-green);
}
.script-eval-report__findings {
gap: 18px;
}
@@ -3474,10 +3278,6 @@
.script-eval-report--inside .script-eval-report__body {
padding-inline: 24px;
}
.script-eval-report__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -3497,19 +3297,6 @@
}
@media (max-width: 680px) {
.script-eval-report__detail-grid {
grid-template-columns: 1fr;
margin-inline: 0;
}
.script-eval-report__detail-card {
padding: 12px;
}
.script-eval-report__subscore-row {
grid-template-columns: minmax(58px, 78px) minmax(0, 1fr) 30px;
}
.script-eval-v5 {
overflow: hidden;
}
+1 -2
View File
@@ -307,10 +307,9 @@
width: 56px;
height: 56px;
border-radius: var(--radius-sm);
background: rgba(var(--accent-rgb), 0.22);
background: rgba(var(--accent-rgb), 0.13);
color: var(--accent);
font-size: 26px;
box-shadow: 0 0 20px rgba(var(--accent-rgb), 0.08);
}
.studio-canvas-ghost__title {
+2 -2
View File
@@ -202,9 +202,9 @@
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-rows: 1fr 1fr;
gap: 16px;
min-height: clamp(560px, 52vw, 760px);
min-height: clamp(360px, 40vw, 520px);
}
/* ===== Tool Cards ===== */
+8 -8
View File
@@ -9,10 +9,10 @@
.wb-prompt-cases__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-flow: dense;
grid-auto-rows: 10px;
gap: 8px;
gap: 10px;
}
.wb-prompt-case-card {
@@ -34,22 +34,22 @@
.wb-prompt-case-card--ratio-wide {
grid-column: span 1;
grid-row: span 13;
grid-row: span 8;
}
.wb-prompt-case-card--ratio-tall {
grid-column: span 1;
grid-row: span 30;
grid-row: span 23;
}
.wb-prompt-case-card--ratio-square {
grid-column: span 1;
grid-row: span 18;
grid-row: span 13;
}
.wb-prompt-case-card--ratio-portrait {
grid-column: span 1;
grid-row: span 24;
grid-row: span 16;
}
.wb-prompt-case-card img {
@@ -328,7 +328,7 @@
@media (max-width: 980px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
@@ -387,7 +387,7 @@
@media (max-width: 560px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
File diff suppressed because it is too large Load Diff
-1
View File
@@ -22,7 +22,6 @@ export type WebViewKey =
| "more"
| "watermarkRemoval"
| "subtitleRemoval"
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "report"
-160
View File
@@ -1,160 +0,0 @@
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
export type GenerationLifecycleStatus =
| "creating"
| "queued"
| "running"
| "stopping"
| "failed"
| "completed"
| "local_timeout";
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
export interface TaskTimeoutPolicy {
submitTimeoutMs: number;
noProgressTimeoutMs: number;
maxRuntimeMs: number;
}
export interface TaskFailureInfo {
category: TaskErrorCategory;
message: string;
actionLabel: string;
retryable: boolean;
refundStatus: TaskRefundStatus;
refundHint: string;
}
export interface TextTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 10 * 60_000,
};
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 20 * 60_000,
};
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 30 * 60_000,
};
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 15 * 60_000,
};
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 30_000,
noProgressTimeoutMs: 60_000,
maxRuntimeMs: 5 * 60_000,
};
export function getTaskTimeoutPolicy(input: {
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}): TaskTimeoutPolicy {
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
const model = String(input.model || "").toLowerCase();
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
return VIDEO_TIMEOUT_POLICY;
}
export function isTaskLocallyTimedOut(input: {
startedAt: number;
lastProgressAt: number;
now?: number;
policy: TaskTimeoutPolicy;
progress?: number;
}): "no_progress" | "max_runtime" | null {
const now = input.now || Date.now();
const progress = Number(input.progress || 0);
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
return "no_progress";
}
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
return null;
}
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
if (kind === "text") {
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
}
const label = kind === "image" ? "图片" : "视频";
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
}
export function buildTaskFailureInfo(
error: string | undefined | null,
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
): TaskFailureInfo {
const classified = classifyTaskError(error);
const submitted = options.submitted !== false;
const refundStatus: TaskRefundStatus =
options.refundStatus ||
(submitted
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
? "not_charged"
: "unknown"
: "not_charged");
const refundHint = getRefundHint(refundStatus);
return {
category: classified.category,
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
actionLabel: classified.action,
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
refundStatus,
refundHint,
};
}
export function getRefundHint(status: TaskRefundStatus): string {
switch (status) {
case "not_charged":
return "提交未进入扣费结算,未产生积分消耗。";
case "pending_refund":
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
case "refunded":
return "失败扣费已退回,请在积分流水中核对。";
case "manual_review":
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
default:
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}