Compare commits

...

33 Commits

Author SHA1 Message Date
stringadmin 13893bc3a9 Merge remote-tracking branch 'origin/master' into codex/generation-task-reliability
# Conflicts:
#	src/components/AppShell.tsx
2026-06-05 18:38:08 +08:00
stringadmin 5bdeac20fb Merge pull request 'feat: 个人中心仪表盘视觉打磨与交互优化' (#21) from feat/profile-dashboard-polish into master
Reviewed-on: #21
2026-06-05 10:29:24 +00:00
ludan bbc705c8d9 feat: 个人中心仪表盘视觉打磨与交互优化
本次提交对个人中心(Profile Dashboard)进行了全面的 UI/UX 升级:

## AppShell 导航修复
- 修复浮动导航栏显示逻辑:移除未登录状态下多余的 session 判断条件,确保登录页不显示导航

## ProfilePage 功能增强
- 新增面板标题、描述和计数变量,动态展示不同面板(代表作/服务器项目/我的资产/社区审核)的上下文信息
- 背景图更换按钮增加 aria-label 无障碍支持,文字使用独立 span 便于移动端隐藏
- 积分与任务切换按钮拆分为标签+数值结构,信息层级更清晰
- 账号摘要卡片新增套餐名称和已完成任务数展示
- 操作按钮(工作台/社区/退出登录)包裹至 actions 容器,统一布局管理
- 主面板标签页文字包裹 span,支持响应式隐藏

## profile.css 滚动模型调整
- Dashboard 视图改用页面自然滚动,替代嵌套区域滚动,避免双滚动条问题

## dark-green.css 主题样式(约 1160 行新增)
- 背景图与头像区域:毛玻璃按钮、头像环绿边光晕、hover 编辑覆盖层
- 个性签名:圆角胶囊展示态 + 编辑态双向布局,状态提示条
- 账号卡片:标签按钮网格化布局、积分摘要面板、套餐标签
- 操作按钮组:主按钮渐变绿色、次要按钮低对比、退出按钮红色警示
- 内容标签页:胶囊切换 + 计数徽章,激活态绿边高亮
- 列表卡片:hover 微上浮 + 绿色边框过渡,空状态虚线面板
- 多轮视觉迭代:从 graphite 灰调到最终 black+green 参考色调
- 响应式适配:移动端头像环缩小、背景按钮圆形、标签页紧凑排列
- 页面级滚动:让 body 自然滚动,取消 content 内部滚动容器
2026-06-05 18:28:10 +08:00
stringadmin 0d136d8622 perf: reduce chained array traversal 2026-06-05 18:27:08 +08:00
stringadmin d09e5e673e refactor: extract canvas marking popover 2026-06-05 18:19:24 +08:00
stringadmin d68064f529 refactor: share canvas mention textarea 2026-06-05 18:15:29 +08:00
stringadmin 31046eae58 refactor: extract canvas text prompt composer 2026-06-05 18:08:16 +08:00
stringadmin ef05667caa refactor: extract canvas derived state 2026-06-05 18:01:48 +08:00
stringadmin b8b3b8f137 perf: memoize derived render data 2026-06-05 17:35:54 +08:00
stringadmin 6060705345 perf: split page css from main bundle 2026-06-05 17:19:38 +08:00
stringadmin 53f6a02377 fix: reduce store rerenders and cleanup timers 2026-06-05 17:04:01 +08:00
stringadmin 9999e516ae fix: improve generation task reliability 2026-06-05 16:43:02 +08:00
stringadmin 796162de4d Merge pull request 'chore: re-upload current web project code' (#19) from reupload/current-web-20260605 into master
Reviewed-on: #19
2026-06-05 08:13:56 +00:00
stringadmin aebe0ff827 chore: re-upload current web project code 2026-06-05 16:08:47 +08:00
stringadmin c113d82844 Merge pull request 'feat: 实现全局响应式布局,适配不同设备、不同屏幕、不同分辨率' (#17) from feat/responsive-layout into master
Merge PR 17 feat responsive-layout
2026-06-05 07:21:29 +00:00
stringadmin 8cf9ee3519 merge: resolve conflicts between feat/responsive-layout and master
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 15:14:17 +08:00
stringadmin 2129b29dfe Merge pull request 'Feat/profile account polish' (#16) from feat/profile-account-polish into master
Reviewed-on: #16
2026-06-05 06:58:37 +00:00
stringadmin d36d46836f merge: resolve EcommercePage.tsx conflict, integrate master into profile-account-polish
Keep master's EcommercePage.tsx (has more complete upload logic from prior conflict resolution). Accept all other master changes including canvas tool panels, task lifecycle, and workbench updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 14:05:39 +08:00
stringadmin 91c332f567 Merge pull request 'Feat/canvas tool panels' (#15) from feat/canvas-tool-panels into master
Reviewed-on: #15
2026-06-05 05:30:22 +00:00
stringadmin 5097b5ce49 feat: update homepage banners to OSS, fix avatar edit hover, adjust workbench grid layout
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 13:29:17 +08:00
ludan b17a978e9e Merge origin/master: 保留电商上传优化版,接入master新增面板组件
冲突解决:EcommercePage.tsx 保留本地版本(上传预览大图+缩略图切换交互)
master新增:6个独立面板组件、图片校验工具、视频工作区增强、CSS更新
2026-06-05 10:34:19 +08:00
stringadmin 93a7a6d5e6 feat: add canvas tool panels (multi-grid, upscale, inpaint) and conditional grid mode
Add modal-based tool panels for multi-grid, super-resolution, and inpaint in canvas image-to-image workflow. Grid mode selector only appears for models that support multi-image generation (wan2.7-image, gpt-image-2). Also fixes merge conflict markers in CSS and adds missing toast import.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 01:48:13 +08:00
stringadmin d7379af717 feat: disable recharge modal, show coming-soon toast instead
Payment gateway is not yet configured, so hide the recharge flow
and show an informational toast when users click the balance button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 01:06:49 +08:00
stringadmin 178a2c47da feat: add task lifecycle management and improve generation reliability
Centralize timeout policies, stall detection, and error classification
for image/video/text generation tasks. Improve ecommerce OSS upload flow
and add script evaluation enhancements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 01:06:48 +08:00
stringadmin d36a093159 Improve generation task client errors 2026-06-05 01:05:54 +08:00
stringadmin 8fbb2ec95e Merge pull request 'Feat/dialog generator cancel generation' (#14) from feat/dialog-generator-cancel-generation into master
Reviewed-on: #14
2026-06-04 17:01:09 +00:00
OmniAI Developer 90e3b90e34 merge: 合并远程PR#12商业化打磨和PR#13修复 2026-06-05 00:45:21 +08:00
OmniAI Developer 10b8379965 feat: 交互式对话框生成器 + 电商取消生成与上传优化
新增:
- 交互式对话框生成器模块(路由、页面、样式、MorePage入口)
- 电商模块取消生成功能(任务追踪/取消按钮/中止逻辑)
- 视频服务图片上传支持 Blob/dataURL/远程URL 多种来源

优化:
- 电商图片上传修复本地 blob 预览图缺少原始文件的问题
- 视频规划管线错误信息改进
- 生成流程中多处增加中止检查点
2026-06-05 00:37:38 +08:00
OmniAI Developer f0fed2f0fd merge: 解决合并冲突 - 合并视频流程管道UI、v5样式及新功能模块 2026-06-04 18:19:41 +08:00
ludan 6d68ab02bb feat: 个人中心账户摘要区重构、主题色调对齐电商
【账户摘要区重构】
- 新增 profile-page__account-summary 双列网格布局(主信息 + 右侧指标)
- 主信息区(account-summary-main):显示账号名/任务概览
- 指标区(account-summary-metric):品牌绿色数字展示积分/完成数,左侧分隔线
- 任务概览改为"X 个任务"更自然的表达方式
- 替代旧 upload-card--meta/meta-item 类名体系

【主题色调对齐】
- 个人中心页面背景、侧边栏、卡片统一为电商同款平坦暗色
- 移除 box-shadow 深度阴影,使用 var(--bg-panel)/var(--bg-inset) Token
- Tab 切换按钮透明背景,选中态品牌绿边框+浅绿背景
- 统计卡片/简介/Bio/媒体预览卡片统一边框与背景色
- 媒体徽章、分享按钮、头像环去除阴影
2026-06-04 18:06:43 +08:00
ludan 2b65206b84 feat: 电商克隆上传交互升级、视频模型选择器图标
【电商克隆 - 商品图上传交互重构】
- 新增上传预览大图区(clone-ai-upload-preview-wrap),点击缩略图可切换预览
- 选中缩略图增加 is-active 绿色边框高亮
- 预览区显示商品图编号 + 尺寸/比例/格式信息(formatProductImageSpec)
- 上传区到达 7 张上限时显示"已达上限"、阻止拖拽上传、输入框禁用
- 上传图片自动异步读取尺寸(width/height),无需等待上传完成即可展示
- 已上传素材区重构为列表头(标题+计数)+ 缩略图栈式布局
- 缩略图增加序号角标(1-7),删除按钮独立于缩略图下方
- selectedProductImageId 状态自动管理:删除/新增时自动切换到有效图片

【工作台 - 视频模型选择器图标】
- 新增 VIDEO_MODEL_ICON_URLS 映射(HappyHorse/Pixverse/Vidu/Wan/Kling)
- SelectChip 组件在 chipId=video-model 时显示模型品牌图标
- getVideoModelIconUrl 支持中英文模糊匹配

【样式】
- ecommerce.css: 预览区/素材栈/缩略图选中态/上限态完整样式
- dark-green.css: 主题层微调
2026-06-04 17:27:40 +08:00
OmniAI Developer 51762bb2c2 feat: 拖拽上传、图片缩放预览及新功能脚手架
- EcommercePage/WorkbenchPage 增加页面级拖拽文件上传支持
- 上传图片悬停缩放预览效果
- Workbench 参考素材增加图片/视频缩放预览
- CanvasPage 连接菜单位置微调 (-40)
- script-tokens-v5 文本溢出省略号修复
- 新增: CookieConsentBanner, CompliancePage, 电商面板组件, generation store/hooks/service
2026-06-04 17:03:49 +08:00
ludan ecade14bd0 feat: 实现全局响应式布局,适配不同设备、不同屏幕、不同分辨率
- 统一断点体系:560px(手机)、900px(平板)、1180px(小桌面)
- Shell导航:900px底部导航条、560px紧凑布局、顶栏触控优化
- 电商页面:三栏布局在平板/手机下改为单栏堆叠+横向标签切换
- 工作台:启动页编辑器全宽、消息气泡适配窄屏、建议标签换行
- 画布页面:工具栏横向滚动、节点缩小、手机全屏操作
- 社区/资产库/工具页/数字人等全部17个页面补齐响应式断点
- 新增32条@media规则,媒体查询从107条增至139条
2026-06-02 17:45:57 +08:00
63 changed files with 5608 additions and 939 deletions
+2 -2
View File
@@ -42,9 +42,9 @@ assertNoMatch(
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
);
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/);
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/);
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/);
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
assertMatch(
"ecommerce video history must durable-copy media before saving",
ecommerceVideoService,
+143 -50
View File
@@ -15,6 +15,7 @@ import {
WalletOutlined,
} from "@ant-design/icons";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
@@ -48,6 +49,7 @@ 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"));
@@ -110,6 +112,7 @@ const VIEW_KEYS = new Set<WebViewKey>([
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
"digitalHuman",
"avatarConsole",
"characterMix",
@@ -123,7 +126,28 @@ const VIEW_KEYS = new Set<WebViewKey>([
"not-found",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"login",
"workbench",
"canvas",
"community",
"communityReview",
"communityCaseAdd",
"assets",
"ecommerce",
"ecommerceHub",
"digitalHuman",
"characterMix",
"more",
]);
let legacyPageStylesPromise: Promise<unknown> | null = null;
function loadLegacyPageStyles(): Promise<unknown> {
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
return legacyPageStylesPromise;
}
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
@@ -231,61 +255,122 @@ function App() {
const canvasAutoOpenedRecentRef = useRef(false);
// Session store
const session = useSessionStore((s) => s.session);
const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen);
const pendingAction = useSessionStore((s) => s.pendingAction);
const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen);
const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage);
const setSession = useSessionStore((s) => s.setSession);
const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt);
const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt);
const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced);
const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced);
const clearSessionState = useSessionStore((s) => s.clearSession);
const {
session,
loginPromptOpen,
pendingAction,
sessionReplacedOpen,
sessionReplacedMessage,
setSession,
openLoginPrompt,
closeLoginPrompt,
showSessionReplaced,
hideSessionReplaced,
clearSession: clearSessionState,
} = useSessionStore(useShallow((s) => ({
session: s.session,
loginPromptOpen: s.loginPromptOpen,
pendingAction: s.pendingAction,
sessionReplacedOpen: s.sessionReplacedOpen,
sessionReplacedMessage: s.sessionReplacedMessage,
setSession: s.setSession,
openLoginPrompt: s.openLoginPrompt,
closeLoginPrompt: s.closeLoginPrompt,
showSessionReplaced: s.showSessionReplaced,
hideSessionReplaced: s.hideSessionReplaced,
clearSession: s.clearSession,
})));
// Project store
const projects = useProjectStore((s) => s.projects);
const projectsLoaded = useProjectStore((s) => s.projectsLoaded);
const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow);
const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId);
const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject);
const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting);
const setProjects = useProjectStore((s) => s.setProjects);
const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded);
const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow);
const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId);
const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject);
const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject);
const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting);
const clearProjectState = useProjectStore((s) => s.clearProjectState);
const {
projects,
projectsLoaded,
canvasWorkflow,
currentCanvasProjectId,
pendingDeleteProject,
deleteProjectSubmitting,
setProjects,
setProjectsLoaded,
setCanvasWorkflow,
setCurrentCanvasProjectId,
openDeleteProject: openDeleteProjectModal,
closeDeleteProject: closeDeleteProjectModal,
setDeleteProjectSubmitting,
clearProjectState,
} = useProjectStore(useShallow((s) => ({
projects: s.projects,
projectsLoaded: s.projectsLoaded,
canvasWorkflow: s.canvasWorkflow,
currentCanvasProjectId: s.currentCanvasProjectId,
pendingDeleteProject: s.pendingDeleteProject,
deleteProjectSubmitting: s.deleteProjectSubmitting,
setProjects: s.setProjects,
setProjectsLoaded: s.setProjectsLoaded,
setCanvasWorkflow: s.setCanvasWorkflow,
setCurrentCanvasProjectId: s.setCurrentCanvasProjectId,
openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
})));
// Task store
const tasks = useTaskStore((s) => s.tasks);
const appendTask = useTaskStore((s) => s.appendTask);
const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
const clearTasks = useTaskStore((s) => s.clearTasks);
const {
tasks,
appendTask,
mergeServerTasks,
clearTasks,
} = useTaskStore(useShallow((s) => ({
tasks: s.tasks,
appendTask: s.appendTask,
mergeServerTasks: s.mergeServerTasks,
clearTasks: s.clearTasks,
})));
// App store
const usage = useAppStore((s) => s.usage);
const runtimeNotifications = useAppStore((s) => s.runtimeNotifications);
const serverNotifications = useAppStore((s) => s.serverNotifications);
const activeView = useAppStore((s) => s.activeView);
const workspaceExpanded = useAppStore((s) => s.workspaceExpanded);
const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool);
const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate);
const backendHealth = useAppStore((s) => s.backendHealth);
const setUsage = useAppStore((s) => s.setUsage);
const pushNotification = useAppStore((s) => s.pushNotification);
const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications);
const setServerNotifications = useAppStore((s) => s.setServerNotifications);
const setView = useAppStore((s) => s.setView);
const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded);
const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool);
const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate);
const setBackendHealth = useAppStore((s) => s.setBackendHealth);
const markNotificationRead = useAppStore((s) => s.markNotificationRead);
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
const clearAppState = useAppStore((s) => s.clearAppState);
const {
usage,
runtimeNotifications,
serverNotifications,
activeView,
workspaceExpanded,
imageWorkbenchTool,
pendingEcommerceTemplate,
backendHealth,
setUsage,
pushNotification,
setRuntimeNotifications,
setServerNotifications,
setView,
setWorkspaceExpanded,
setImageWorkbenchTool,
setPendingEcommerceTemplate,
setBackendHealth,
markNotificationRead,
markAllNotificationsRead,
clearAppState,
} = useAppStore(useShallow((s) => ({
usage: s.usage,
runtimeNotifications: s.runtimeNotifications,
serverNotifications: s.serverNotifications,
activeView: s.activeView,
workspaceExpanded: s.workspaceExpanded,
imageWorkbenchTool: s.imageWorkbenchTool,
pendingEcommerceTemplate: s.pendingEcommerceTemplate,
backendHealth: s.backendHealth,
setUsage: s.setUsage,
pushNotification: s.pushNotification,
setRuntimeNotifications: s.setRuntimeNotifications,
setServerNotifications: s.setServerNotifications,
setView: s.setView,
setWorkspaceExpanded: s.setWorkspaceExpanded,
setImageWorkbenchTool: s.setImageWorkbenchTool,
setPendingEcommerceTemplate: s.setPendingEcommerceTemplate,
setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
})));
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
@@ -293,6 +378,12 @@ function App() {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
void loadLegacyPageStyles();
}
}, [activeView, ecommerceEverMounted]);
// Dismiss boot splash after first render
useEffect(() => {
const splash = document.getElementById("app-boot-splash");
@@ -1159,6 +1250,8 @@ function App() {
onSelectView={handleSetView}
/>
);
case "dialogGenerator":
return <DialogGeneratorPage />;
case "report":
return <ReportPage />;
case "providerHealth":
+106 -104
View File
@@ -3,6 +3,7 @@ import {
buildAuthHeaders,
isRecord,
readJsonResponse,
serverRequest,
throwResponseError,
} from "./serverConnection";
import { isOptionalApiRouteMissing } from "./apiErrorUtils";
@@ -134,6 +135,12 @@ export interface ChatInput {
temperature?: number;
}
export interface ChatUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export interface AiTaskStatus {
taskId: string;
projectId?: string;
@@ -159,7 +166,7 @@ function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPrevi
function taskTitle(task: AiTaskStatus): string {
const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : "";
if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt;
return task.type === "video" ? "视频生成任务" : "图像生成任务";
return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1";
}
function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask {
@@ -237,6 +244,10 @@ function emitImageRouteDebug(label: string, payload: Record<string, unknown>): v
let taskHistoryRouteMissing = false;
const TASK_SUBMIT_TIMEOUT_MS = 90_000;
const TASK_STATUS_TIMEOUT_MS = 20_000;
const NON_RETRYING_REQUEST = { maxRetries: 0 };
export const aiGenerationClient = {
async createImageTask(input: ImageGenInput): Promise<ImageTaskCreateResponse> {
const requestUrl = buildApiUrl("ai/image");
@@ -250,15 +261,13 @@ export const aiGenerationClient = {
projectId: input.projectId,
conversationId: input.conversationId,
});
const res = await fetch(requestUrl, {
const payload = await serverRequest<ImageTaskCreateResponse>("ai/image", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image generation request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image generation request failed");
}
const payload = await readJsonResponse<ImageTaskCreateResponse>(res, "Image generation response failed");
if (payload.providerDebug) {
emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record<string, unknown>);
}
@@ -266,96 +275,83 @@ export const aiGenerationClient = {
},
async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video"), {
return serverRequest<{ taskId: string }>("ai/video", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video generation request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video generation request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video generation response failed");
},
async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/super-resolve"), {
return serverRequest<{ taskId: string }>("ai/video/super-resolve", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video super-resolution request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video super-resolution response failed");
},
async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/erase-subtitles"), {
return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Subtitle removal request failed",
});
if (!res.ok) {
await throwResponseError(res, "Subtitle removal request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Subtitle removal response failed");
},
async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/video/edit"), {
return serverRequest<{ taskId: string }>("ai/video/edit", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({ ...input, model: input.model || "happyhorse-1.0-video-edit" }),
body: { ...input, model: input.model || "happyhorse-1.0-video-edit" },
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Video edit request failed",
});
if (!res.ok) {
await throwResponseError(res, "Video edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Video edit response failed");
},
async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/super-resolve"), {
return serverRequest<{ taskId: string }>("ai/image/super-resolve", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image super-resolution request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image super-resolution request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image super-resolution response failed");
},
async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> {
const res = await fetch(buildApiUrl("ai/image/edit"), {
return serverRequest<{ taskId: string }>("ai/image/edit", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
timeoutMs: TASK_SUBMIT_TIMEOUT_MS,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Image edit request failed",
});
if (!res.ok) {
await throwResponseError(res, "Image edit request failed");
}
return readJsonResponse<{ taskId: string }>(res, "Image edit response failed");
},
async cancelTask(taskId: string): Promise<void> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/cancel`), {
method: "PATCH",
headers: buildAuthHeaders(),
});
if (!res.ok && res.status !== 404) {
await throwResponseError(res, "Task cancel failed");
try {
await serverRequest<void>(`ai/tasks/${taskId}/cancel`, {
method: "PATCH",
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task cancel failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async getTaskStatus(taskId: string): Promise<AiTaskStatus> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}`), {
method: "GET",
headers: buildAuthHeaders(),
return serverRequest<AiTaskStatus>(`ai/tasks/${taskId}`, {
timeoutMs: TASK_STATUS_TIMEOUT_MS,
fallbackMessage: "Task status request failed",
});
if (!res.ok) {
await throwResponseError(res, "Task status request failed");
}
return readJsonResponse<AiTaskStatus>(res, "Task status response failed");
},
async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> {
@@ -381,49 +377,41 @@ export const aiGenerationClient = {
if (params?.status) search.set("status", params.status);
if (params?.type) search.set("type", params.type);
if (params?.projectId) search.set("projectId", params.projectId);
const res = await fetch(buildApiUrl(`ai/tasks${search.toString() ? `?${search}` : ""}`), {
method: "GET",
headers: buildAuthHeaders(),
});
if (!res.ok) {
try {
await throwResponseError(res, "Task history request failed");
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
try {
const payload = await serverRequest<unknown>(`ai/tasks${search.toString() ? `?${search}` : ""}`, {
fallbackMessage: "Task history request failed",
});
return extractTaskList(payload).map(toPreviewTask);
} catch (error) {
if (isOptionalApiRouteMissing(error)) {
taskHistoryRouteMissing = true;
return [];
}
throw error;
}
const payload = await readJsonResponse<unknown>(res, "Task history response failed");
return extractTaskList(payload).map(toPreviewTask);
},
async bindTaskToConversation(taskId: string, conversationId: number): Promise<void> {
const res = await fetch(buildApiUrl(`ai/tasks/${taskId}/conversation`), {
method: "PATCH",
headers: buildAuthHeaders(),
body: JSON.stringify({ conversationId }),
});
if (res.status === 404) {
return;
}
if (!res.ok) {
await throwResponseError(res, "Task conversation binding failed");
try {
await serverRequest<void>(`ai/tasks/${taskId}/conversation`, {
method: "PATCH",
body: { conversationId },
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Task conversation binding failed",
});
} catch (error) {
if (isOptionalApiRouteMissing(error)) return;
throw error;
}
},
async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload"), {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload failed",
});
if (!res.ok) {
await throwResponseError(res, "Asset upload failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload response failed");
},
async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
@@ -445,15 +433,12 @@ export const aiGenerationClient = {
},
async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> {
const res = await fetch(buildApiUrl("oss/upload-by-url"), {
return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify(input),
body: input,
maxRetries: NON_RETRYING_REQUEST.maxRetries,
fallbackMessage: "Asset upload by URL failed",
});
if (!res.ok) {
await throwResponseError(res, "Asset upload by URL failed");
}
return readJsonResponse<{ url: string; ossKey?: string }>(res, "Asset upload by URL response failed");
},
subscribeTaskStatus(
@@ -500,6 +485,7 @@ 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",
@@ -512,7 +498,7 @@ export const aiGenerationClient = {
}
const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取响应流");
if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41");
const decoder = new TextDecoder();
let buffer = "";
@@ -529,8 +515,24 @@ export const aiGenerationClient = {
const payload = line.slice(6).trim();
if (!payload) continue;
try {
const chunk = JSON.parse(payload) as { delta?: string; done?: boolean; error?: string };
const chunk = JSON.parse(payload) as {
delta?: string;
done?: boolean;
error?: string;
usage?: ChatUsage & {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
};
if (chunk.error) throw new Error(chunk.error);
if (chunk.usage) {
onUsage?.({
promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens,
completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens,
totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens,
});
}
if (chunk.delta) onChunk(chunk.delta);
if (chunk.done) return;
} catch (e) {
+3 -8
View File
@@ -1,4 +1,4 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
import { serverRequest } from "./serverConnection";
export interface ProviderHealthEntry {
status: string;
@@ -32,13 +32,8 @@ export interface ProviderHealthResponse {
export const providerHealthClient = {
async getStatus(): Promise<ProviderHealthResponse> {
const res = await fetch(buildApiUrl("admin/providers/status"), {
method: "GET",
headers: buildAuthHeaders(),
return serverRequest<ProviderHealthResponse>("admin/providers/status", {
fallbackMessage: "Provider health request failed",
});
if (!res.ok) {
throw new Error(`Provider health request failed (${res.status})`);
}
return res.json() as Promise<ProviderHealthResponse>;
},
};
+101 -16
View File
@@ -1,9 +1,11 @@
import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
import { serverRequest } from "./serverConnection";
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[];
@@ -12,6 +14,33 @@ export interface ScriptEvalResult {
const MODEL = "qwen3.7-max";
const EVAL_OUTPUT_CONTRACT = `
强制输出 JSON,主维度键名必须严格为:
hook(20), plot(20), character(15), logic(15), visual(15), content(15)。
不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。
同时返回 subScores 和 evidence
- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。
- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。
返回结构:
{
"dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 },
"subScores": {
"hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 },
"plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 },
"character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 },
"logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 },
"visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 },
"content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 }
},
"evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] },
"summary": "200-300字综合评价",
"issues": ["具体扣分点,带维度和证据", ...],
"highlights": ["具体亮点,带维度和证据", ...],
"suggestions": ["按优先级排列的改稿建议", ...]
}`;
const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。
【剧本类型识别】
@@ -46,10 +75,10 @@ const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有
const DIMENSION_WEIGHTS: Record<string, { maxScore: number }> = {
hook: { maxScore: 20 },
plot: { maxScore: 20 },
character: { maxScore: 18 },
dialogue: { maxScore: 15 },
character: { maxScore: 15 },
logic: { maxScore: 15 },
visual: { maxScore: 15 },
content: { maxScore: 12 },
content: { maxScore: 15 },
};
function computeTotalAndGrade(scores: Record<string, number>): { totalScore: number; grade: string } {
@@ -68,29 +97,83 @@ 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 normalizeEvidenceItems(source: unknown[], limit: number): string[] {
const items: string[] = [];
for (const item of source) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
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 = normalizeEvidenceItems(source, 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"), {
const payload = await serverRequest<{
content?: string;
choices?: Array<{ message?: { content?: string } }>;
text?: string;
}>("ai/chat", {
method: "POST",
headers: buildAuthHeaders(),
body: JSON.stringify({
body: {
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,
temperature: 0.3,
max_tokens: 4096,
}),
},
signal,
timeoutMs: 180_000,
maxRetries: 0,
fallbackMessage: "评测请求失败",
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`);
}
const payload = await res.json();
const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? "";
if (!content) throw new Error("模型未返回有效内容");
@@ -101,8 +184,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常");
for (const key of Object.keys(DIMENSION_WEIGHTS)) {
const val = Number(rawScores[key] ?? 0);
dimensionScores[key] = Math.max(0, Math.min(DIMENSION_WEIGHTS[key].maxScore, val));
const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key];
dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore);
}
const { totalScore, grade } = computeTotalAndGrade(dimensionScores);
@@ -111,6 +194,8 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom
totalScore,
grade,
dimensionScores,
subScores: normalizeNestedScores(parsed.subScores),
evidence: normalizeEvidence(parsed.evidence),
summary: String(parsed.summary || ""),
issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [],
highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [],
+8 -3
View File
@@ -22,6 +22,9 @@ export interface ServerRequestOptions {
signal?: AbortSignal;
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
timeoutMs?: number;
/** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */
maxRetries?: number;
fallbackMessage?: string;
}
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
@@ -343,8 +346,10 @@ const MAX_RETRIES = 2;
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
let lastError: unknown;
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const maxRetries = options?.maxRetries ?? MAX_RETRIES;
const fallbackMessage = options?.fallbackMessage || "Request failed";
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = timeoutMs > 0 ? new AbortController() : null;
const timeoutId =
controller && typeof window !== "undefined"
@@ -366,11 +371,11 @@ export async function serverRequest<T>(path: string, options?: ServerRequestOpti
credentials: "include",
});
const payload = await readJsonResponse<unknown>(response, "Request failed");
const payload = await readJsonResponse<unknown>(response, fallbackMessage);
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) {
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
continue;
}
+43 -8
View File
@@ -1,4 +1,9 @@
import { aiGenerationClient } from "./aiGenerationClient";
import {
buildLocalTimeoutMessage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../utils/taskLifecycle";
export interface TaskProgressEvent {
taskId: string;
@@ -12,16 +17,28 @@ export interface WaitForTaskOptions {
onProgress?: (event: TaskProgressEvent) => void;
abortRef?: { current: boolean };
timeoutMs?: number;
noProgressTimeoutMs?: number;
startedAt?: number;
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}
const POLL_INTERVAL = 3000;
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
export function waitForTask(
taskId: string,
options: WaitForTaskOptions = {},
): Promise<string | null> {
const { onProgress, abortRef, timeoutMs = DEFAULT_TIMEOUT } = options;
const { onProgress, abortRef } = options;
const timeoutPolicy = getTaskTimeoutPolicy({
kind: options.kind,
model: options.model,
operation: options.operation,
});
const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs;
const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs;
const startedAt = options.startedAt ?? Date.now();
return new Promise((resolve, reject) => {
let settled = false;
@@ -29,6 +46,8 @@ 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;
@@ -40,7 +59,7 @@ export function waitForTask(
};
timeoutId = setTimeout(
() => settle(() => reject(new Error("等待任务结果超时,请稍后在任务历史中查看"))),
() => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))),
timeoutMs,
);
@@ -50,19 +69,22 @@ 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));
} else if (event.status === "failed" || event.status === "cancelled") {
settle(() => reject(new Error(event.error || "任务失败")));
settle(() => reject(new Error(event.error || "任务失败,请稍后重试")));
}
};
// Try SSE first
cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate);
sseConnected = true;
// Fallback: if SSE doesn't deliver any event within 5s, switch to polling
fallbackTimerId = setTimeout(() => {
if (settled || !sseConnected) return;
if (cleanup) cleanup();
@@ -72,9 +94,22 @@ export function waitForTask(
function startPolling() {
const poll = async () => {
while (!settled) {
if (abortRef?.current) { settle(() => resolve(null)); return; }
if (abortRef?.current) {
settle(() => resolve(null));
return;
}
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({
@@ -89,7 +124,7 @@ export function waitForTask(
}
}
};
poll();
void poll();
}
});
}
+1 -1
View File
@@ -103,7 +103,7 @@ export const webGenerationGateway = {
prompt,
createdAt,
source: "server",
errorMessage: err instanceof Error ? err.message : "请求失败",
errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试",
};
}
},
+31 -29
View File
@@ -13,6 +13,7 @@ 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";
@@ -40,6 +41,32 @@ interface AppShellProps {
}
const BRAND_LOGO_URL = ossAssets.brand.logo;
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"dialogGenerator",
"avatarConsole",
"characterMix",
] as WebViewKey[]);
const PRIMARY_NAV_ORDER: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
function formatBalance(cents: number): string {
const value = Math.max(0, cents) / 100;
@@ -74,37 +101,12 @@ function AppShell({
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
const isAuthView = activeView === "login";
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home";
const toolSurfaceViews = [
"workbench",
"canvas",
"more",
"scriptTokens",
"tokenUsage",
"ecommerceTemplates",
"sizeTemplate",
"imageWorkbench",
"resolutionUpscale",
"digitalHuman",
"avatarConsole",
"characterMix",
] as WebViewKey[];
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
const visibleNavItems = useMemo(
() => {
const orderedKeys: WebViewKey[] = [
"workbench",
"ecommerce",
"sizeTemplate",
"canvas",
"scriptTokens",
"tokenUsage",
"community",
"assets",
"more",
];
return orderedKeys
return PRIMARY_NAV_ORDER
.map((key) => navItems.find((item) => item.key === key))
.filter((item): item is WebNavItem => Boolean(item));
},
@@ -369,7 +371,7 @@ function AppShell({
className="member-button"
type="button"
aria-label={`积分余额 ${displayedBalanceLabel}`}
onClick={() => setRechargeOpen(true)}
onClick={() => toast.info("充值功能即将开放,敬请期待")}
>
<WalletOutlined />
<span className="member-button__label">{displayedBalanceLabel}</span>
+1
View File
@@ -1,5 +1,6 @@
import { HomeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import "../styles/pages/not-found.css";
interface NotFoundPageProps {
onGoHome: () => void;
+1
View File
@@ -23,6 +23,7 @@ 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: [muban("hero-1.png"), muban("hero-2.png"), muban("hero-3.png")],
heroSlides: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")],
features: {
ecommerce: muban("feature-ecommerce.jpg"),
script: muban("feature-script.jpg"),
+1
View File
@@ -14,6 +14,7 @@ import {
ThunderboltOutlined,
} from "@ant-design/icons";
import { useRef, useState } from "react";
import "../../styles/pages/agent.css";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type { WebGenerationPreviewTask } from "../../types";
+1
View File
@@ -11,6 +11,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react";
import "../../styles/pages/assets.css";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { useDebounce } from "../../hooks/useDebounce";
@@ -0,0 +1,40 @@
interface CanvasMarkingPopoverProps {
value?: string;
placeholder: string;
onChange: (value: string) => void;
onClear: () => void;
onDone: () => void;
}
export function CanvasMarkingPopover({
value,
placeholder,
onChange,
onClear,
onDone,
}: CanvasMarkingPopoverProps) {
return (
<div
className="studio-canvas-marking-popover"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder={placeholder}
value={value || ""}
onChange={(event) => onChange(event.target.value)}
/>
<div className="studio-canvas-marking-actions">
{value ? (
<button type="button" className="studio-canvas-marking-clear" onClick={onClear}>
</button>
) : null}
<button type="button" className="studio-canvas-marking-done" onClick={onDone}>
</button>
</div>
</div>
);
}
+225 -340
View File
@@ -28,10 +28,13 @@
import {
ReactFlow,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "../../styles/pages/canvas.css";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import WorkspacePageShell from "../../components/WorkspacePageShell";
import type {
@@ -52,6 +55,7 @@ import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory
import { useCanvasKeyboard } from "./useCanvasKeyboard";
import { useCanvasNodeDrag } from "./useCanvasNodeDrag";
import { useCanvasGeneration, addCanvasGenKeepalive, removeCanvasGenKeepalive } from "./useCanvasGeneration";
import { useCanvasAssetSummary, useCanvasVisibleNodes } from "./useCanvasDerivedState";
import {
toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting";
@@ -118,7 +122,7 @@ import {
defaultVideoModel,
image4kCapableModels,
imageFocusRatioOptions,
imageModelOptions,
imageModelOptions as fallbackCanvasImageModelOptions,
imageRatioOptions,
textModelOptions,
videoDurationOptions,
@@ -182,6 +186,9 @@ import {
} from "./canvasWorkflowDeserialize";
import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents";
import type { CanvasNodeToolbarAction } from "./canvasComponents";
import { CanvasMarkingPopover } from "./CanvasMarkingPopover";
import { CanvasPromptMentionTextarea, CanvasTextPromptComposer } from "./CanvasTextPromptComposer";
import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels";
import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing";
const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({
@@ -192,7 +199,6 @@ const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL
// --- Canvas generation keep-alive (survives page refresh / view switch) ---
const MENTION_TOKEN_RE = /@(?:图片|视频|文本)\d+/g;
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
function buildNodeMentionOptions(
kind: CanvasNodeKind,
@@ -336,6 +342,7 @@ 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);
@@ -352,6 +359,8 @@ function CanvasPage({
const [projectNameEditing, setProjectNameEditing] = useState(false);
const [videoNodeMenu, setVideoNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null);
const [videoNodes, setVideoNodes] = useState<CanvasVideoNode[]>([]);
const [canvasImageModelOptions, setCanvasImageModelOptions] = useState<CanvasOption[]>(fallbackCanvasImageModelOptions);
const [canvasVideoModelOptions, setCanvasVideoModelOptions] = useState<CanvasOption[]>(canvasEnterpriseVideoModelOptions);
const [selectedNode, setSelectedNode] = useState<CanvasSelectedNode | null>(null);
const [selectedNodes, setSelectedNodes] = useState<CanvasSelectedNode[]>([]);
const [selectionContextMenu, setSelectionContextMenu] = useState<CanvasFloatingMenuPosition | null>(null);
@@ -392,10 +401,12 @@ function CanvasPage({
const suppressNextPaneClickRef = useRef(false);
const canvasAutoSaveTimerRef = useRef<number | null>(null);
const canvasAutoSaveIdleHandleRef = useRef<number | null>(null);
const canvasAutoSaveRetryTimerRef = useRef<number | null>(null);
const canvasAutoSaveInFlightRef = useRef(false);
const canvasAutoSavePendingRef = useRef(false);
const lastAutoSavedWorkflowFingerprintRef = useRef("");
const canvasAutoSaveHydrationRef = useRef(true);
const textNodeMentionFocusTimerRef = useRef<number | null>(null);
const textNodeIdRef = useRef(9);
const imageNodeIdRef = useRef(1);
const videoNodeIdRef = useRef(1);
@@ -456,9 +467,39 @@ function CanvasPage({
callbacksRef: dragCallbacksRef,
suppressNextPaneClickRef,
});
useEffect(() => {
let cancelled = false;
if (!isAuthenticated) {
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
return () => {
cancelled = true;
};
}
modelCapabilitiesClient
.get()
.then((capabilities) => {
if (cancelled) return;
setCanvasImageModelOptions(capabilities.imageModels.length ? capabilities.imageModels : fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(capabilities.videoModels.length ? capabilities.videoModels : canvasEnterpriseVideoModelOptions);
})
.catch(() => {
if (cancelled) return;
setCanvasImageModelOptions(fallbackCanvasImageModelOptions);
setCanvasVideoModelOptions(canvasEnterpriseVideoModelOptions);
});
return () => {
cancelled = true;
};
}, [isAuthenticated]);
const visibleImageModelOptions = useMemo(
() => filterImageModelOptionsForSession(imageModelOptions, session),
[session],
() => filterImageModelOptionsForSession(canvasImageModelOptions, session),
[canvasImageModelOptions, session],
);
const fallbackVisibleImageModel = visibleImageModelOptions[0]?.value || defaultImageModel;
const resolveVisibleImageModel = useCallback(
@@ -484,7 +525,11 @@ function CanvasPage({
else if (kind === "video") updateVideoNodePrompt(nodeId, nextValue);
else updateTextNodePrompt(nodeId, nextValue);
closeTextNodeMention(nodeId);
setTimeout(() => {
if (textNodeMentionFocusTimerRef.current !== null) {
window.clearTimeout(textNodeMentionFocusTimerRef.current);
}
textNodeMentionFocusTimerRef.current = window.setTimeout(() => {
textNodeMentionFocusTimerRef.current = null;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(nextCaret, nextCaret);
@@ -520,10 +565,22 @@ function CanvasPage({
const [autoSaveStatus, setAutoSaveStatus] = useState<"saved" | "saving" | "error" | "idle">("idle");
const autoSaveStatusTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (canvasAutoSaveTimerRef.current !== null) window.clearTimeout(canvasAutoSaveTimerRef.current);
if (canvasAutoSaveRetryTimerRef.current !== null) window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
if (autoSaveStatusTimerRef.current !== null) window.clearTimeout(autoSaveStatusTimerRef.current);
if (textNodeMentionFocusTimerRef.current !== null) window.clearTimeout(textNodeMentionFocusTimerRef.current);
if (canvasAutoSaveIdleHandleRef.current !== null && "cancelIdleCallback" in window) {
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
}
};
}, []);
// Save immediately when user leaves page or switches tab (placed after runCanvasAutoSave definition)
// — see useEffect below near runCanvasAutoSave
const canvasAssets = serverAssets.filter((asset) => asset.imageUrl);
const { canvasAssets, assetCountsByCategory } = useCanvasAssetSummary(serverAssets);
const shouldShowEmptyProjectState =
projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0;
const isWaitingForProjects = isAuthenticated && !projectsLoaded;
@@ -2585,13 +2642,17 @@ function CanvasPage({
setConnectorDrag(null);
};
const collapsedPackageNodeKeys = new Set(
nodePackages.flatMap((nodePackage) =>
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
)
);
const isNodeCollapsedInPackage = (kind: CanvasNodeKind, id: string) =>
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id }));
const {
isNodeCollapsedInPackage,
visibleTextNodes,
visibleImageNodes,
visibleVideoNodes,
} = useCanvasVisibleNodes({
textNodes,
imageNodes,
videoNodes,
nodePackages,
});
const isLinkCollapsedInPackage = (link: { sourceKind: CanvasNodeKind; sourceNodeId: string; targetKind: CanvasNodeKind; targetNodeId: string }) =>
isNodeCollapsedInPackage(link.sourceKind, link.sourceNodeId) ||
isNodeCollapsedInPackage(link.targetKind, link.targetNodeId);
@@ -2824,7 +2885,7 @@ function CanvasPage({
if (targetPort) {
connectCanvasPorts(connectorDrag.port, targetPort);
} else {
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0);
const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40);
setConnectionDropMenu({
...menuPosition,
originLeft: event.clientX,
@@ -3124,7 +3185,13 @@ function CanvasPage({
canvasAutoSaveInFlightRef.current = false;
if (canvasAutoSavePendingRef.current) {
canvasAutoSavePendingRef.current = false;
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
}
}
}, [
@@ -3193,7 +3260,13 @@ function CanvasPage({
);
return;
}
window.setTimeout(() => void runCanvasAutoSave(), canvasAutoSaveIdleTimeoutMs);
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
}
canvasAutoSaveRetryTimerRef.current = window.setTimeout(() => {
canvasAutoSaveRetryTimerRef.current = null;
void runCanvasAutoSave();
}, canvasAutoSaveIdleTimeoutMs);
}, canvasAutoSaveDebounceMs);
return () => {
@@ -3205,6 +3278,10 @@ function CanvasPage({
window.cancelIdleCallback(canvasAutoSaveIdleHandleRef.current);
canvasAutoSaveIdleHandleRef.current = null;
}
if (canvasAutoSaveRetryTimerRef.current !== null) {
window.clearTimeout(canvasAutoSaveRetryTimerRef.current);
canvasAutoSaveRetryTimerRef.current = null;
}
};
}, [
isAuthenticated,
@@ -3750,12 +3827,12 @@ function CanvasPage({
onPaneContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handlePaneContextMenu}
/>
<div className="studio-canvas-zoom-controls" onMouseDown={(e) => e.stopPropagation()}>
<button type="button" title="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" onClick={resetCanvasZoom}>
<button type="button" title="缩小" aria-label="缩小" onClick={zoomCanvasOut}></button>
<button type="button" className="studio-canvas-zoom-controls__pct" title="重置缩放" aria-label="重置缩放" onClick={resetCanvasZoom}>
{Math.round(canvasViewport.zoom * 100)}%
</button>
<button type="button" title="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" onClick={fitCanvasView}></button>
<button type="button" title="放大" aria-label="放大" onClick={zoomCanvasIn}>+</button>
<button type="button" title="适应视图" aria-label="适应视图" onClick={fitCanvasView}></button>
</div>
{(shouldShowEmptyProjectState || isWaitingForProjects) ? (
<div
@@ -3931,7 +4008,7 @@ function CanvasPage({
) : null}
</svg>
) : null}
{textNodes.filter((textNode) => !isNodeCollapsedInPackage("text", textNode.id)).map((textNode) => {
{visibleTextNodes.map((textNode) => {
const textNodeSelected = isSelectedNode("text", textNode.id);
const textNodeActive = isActiveSelectedNode("text", textNode.id);
const textNodeResizing = nodeResizeDrag?.kind === "text" && nodeResizeDrag.nodeId === textNode.id;
@@ -4080,126 +4157,26 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "text", textNode.id, textNode.size)}
/>
</div>
{textNodeActive && !isCanvasNodeMoving ? (() => {
const mentionOptions = buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const mentionState = textNodeMentionStates[textNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const filteredMentions = mentionState.open
? mentionOptions.filter((o) => !mentionState.query || o.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateTextNodePrompt(textNode.id, value);
// Detect @-mention trigger
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(textNode.id);
};
const handlePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length } }));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setTextNodeMentionStates((prev) => ({ ...prev, [textNode.id]: { ...mentionState, activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length } }));
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const opt = filteredMentions[mentionState.activeIndex];
if (opt) {
const ta = e.currentTarget;
insertTextNodeMention(textNode.id, opt, ta);
}
} else if (e.key === "Escape") {
e.preventDefault();
closeTextNodeMention(textNode.id);
}
};
const handlePromptSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const ta = e.currentTarget;
const caret = ta.selectionStart || 0;
setTextNodeMentionStates((prev) => {
const cur = prev[textNode.id];
if (!cur?.open) return prev;
return { ...prev, [textNode.id]: { ...cur, caret } };
});
};
return (
<div className="studio-canvas-text-composer">
<div className="studio-canvas-text-composer__input-wrap">
<textarea
value={textNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(e) => { e.preventDefault(); const ta = e.currentTarget.closest(".studio-canvas-text-composer")?.querySelector("textarea") || null; insertTextNodeMention(textNode.id, opt, ta as HTMLTextAreaElement | null); }}
>
<span className="studio-canvas-mention-thumb">
{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${textNodeCanGenerate && !textNodeGenerating ? " is-ready" : ""}`}
title={textNodeGenerating ? "生成中" : "生成"}
disabled={textNodeGenerating || !textNodeCanGenerate}
aria-busy={textNodeGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!textNodeGenerating && textNodeCanGenerate) {
void handleGenerateTextNode(textNode.id);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
})() : null}
{textNodeActive && !isCanvasNodeMoving ? (
<CanvasTextPromptComposer
nodeId={textNode.id}
prompt={textNode.prompt}
canGenerate={textNodeCanGenerate}
isGenerating={textNodeGenerating}
mentionOptions={buildNodeMentionOptions("text", textNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[textNode.id]}
onPromptChange={updateTextNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
onGenerate={handleGenerateTextNode}
/>
) : null}
</div>
</div>
);
})}
{imageNodes.filter((imageNode) => !isNodeCollapsedInPackage("image", imageNode.id)).map((imageNode) => {
{visibleImageNodes.map((imageNode) => {
const imageNodeSelected = isSelectedNode("image", imageNode.id);
const imageNodeActive = isActiveSelectedNode("image", imageNode.id);
const imageNodeResizing = nodeResizeDrag?.kind === "image" && nodeResizeDrag.nodeId === imageNode.id;
@@ -4264,7 +4241,7 @@ function CanvasPage({
setSelectedExistingCategory("");
setSaveAssetOpen(true);
}
if (key === "upscale") void handleGenerateImageNode(imageNode.id);
if (key === "upscale") setCanvasToolModal({ tool: "upscale", imageNode });
}}
moreActions={[
{ key: "copy", label: "复制链接", icon: <CopyOutlined />, disabled: !imageNode.imageUrl },
@@ -4457,38 +4434,7 @@ function CanvasPage({
</button>
</div>
) : null}
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (() => {
const imgMentionOptions = buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const imgMentionState = textNodeMentionStates[imageNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const imgFilteredMentions = imgMentionState.open
? imgMentionOptions.filter((o) => !imgMentionState.query || o.searchText.includes(imgMentionState.query.toLowerCase()))
: [];
const handleImagePromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateImageNodePrompt(imageNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(imageNode.id);
};
const handleImagePromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!imgMentionState.open || imgFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex + 1) % imgFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [imageNode.id]: { ...imgMentionState, activeIndex: (imgMentionState.activeIndex - 1 + imgFilteredMentions.length) % imgFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = imgFilteredMentions[imgMentionState.activeIndex]; if (opt) insertTextNodeMention(imageNode.id, opt, e.currentTarget, "image"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(imageNode.id); }
};
return (
{imageNodeActive && !isCanvasNodeMoving && !imageNodeFocusActive ? (
<div className="studio-canvas-image-composer">
<div className="studio-canvas-image-composer__tools">
<button
@@ -4527,84 +4473,76 @@ function CanvasPage({
>
<FileImageOutlined /><span></span>
</button>
{markingPopoverNodeId === imageNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
value={imageNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{imageNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setImageNodes((nodes) =>
nodes.map((n) => (n.id === imageNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{markingPopoverNodeId === imageNode.id ? (
<CanvasMarkingPopover
value={imageNode.marking}
placeholder="描述标记内容,如:主角站在桥上,远处是城市天际线"
onChange={(value) => {
setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: value } : node)),
);
}}
onClear={() => {
setImageNodes((nodes) =>
nodes.map((node) => (node.id === imageNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/>
) : null}
<button
type="button"
className={imageNodeFocusActive ? "is-active" : ""}
title="框选聚焦区域"
title="多宫格生成"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openImageFocusMode(imageNode);
setCanvasToolModal({ tool: "multiGrid", imageNode });
}}
>
<BarsOutlined /><span></span>
<BarsOutlined /><span></span>
</button>
<button
type="button"
title="图片超分辨率"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "upscale", imageNode });
}}
>
<ThunderboltOutlined /><span></span>
</button>
<button
type="button"
title="局部重绘"
disabled={!imageNode.imageUrl}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setCanvasToolModal({ tool: "inpaint", imageNode });
}}
>
<EditOutlined /><span></span>
</button>
<button type="button" className="studio-canvas-image-composer__expand" aria-label="展开"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
<CanvasPromptMentionTextarea
nodeId={imageNode.id}
value={imageNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleImagePromptChange}
onKeyDown={handleImagePromptKeyDown}
placeholder="描述你想要生成的画面内容,按/呼出指令,@引用素材"
mentionOptions={buildNodeMentionOptions("image", imageNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[imageNode.id]}
mentionKind="image"
onPromptChange={updateImageNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/>
{imgMentionState.open && (
<div className="studio-canvas-mention-panel">
{imgFilteredMentions.length > 0 ? imgFilteredMentions.map((opt, idx) => (
<button key={opt.token} type="button" className={`studio-canvas-mention-item${idx === imgMentionState.activeIndex ? " is-active" : ""}`} onMouseDown={(e) => { e.preventDefault(); insertTextNodeMention(imageNode.id, opt, e.currentTarget.closest(".studio-canvas-image-composer")?.querySelector("textarea") || null, "image"); }}>
<span className="studio-canvas-mention-thumb">{opt.kind === "image" && opt.previewUrl ? <img src={opt.previewUrl} alt="" /> : opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}><span className="studio-canvas-mention-label"></span></div>
)}
</div>
)}
</div>
<div className="studio-canvas-image-composer__footer">
<CanvasSelectChip
ariaLabel="选择生图模型"
@@ -4672,12 +4610,12 @@ function CanvasPage({
</button>
</div>
</div>
); })() : null}
) : null}
</div>
</div>
);
})}
{videoNodes.filter((videoNode) => !isNodeCollapsedInPackage("video", videoNode.id)).map((videoNode) => {
{visibleVideoNodes.map((videoNode) => {
const videoNodeSelected = isSelectedNode("video", videoNode.id);
const videoNodeActive = isActiveSelectedNode("video", videoNode.id);
const videoNodeResizing = nodeResizeDrag?.kind === "video" && nodeResizeDrag.nodeId === videoNode.id;
@@ -4827,38 +4765,7 @@ function CanvasPage({
onMouseDown={(event) => handleNodeResizeStart(event, "video", videoNode.id, videoNode.size)}
/>
</div>
{videoNodeActive && !isCanvasNodeMoving ? (() => {
const vidMentionOptions = buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks);
const vidMentionState = textNodeMentionStates[videoNode.id] || { open: false, query: "", start: 0, caret: 0, activeIndex: 0 };
const vidFilteredMentions = vidMentionState.open
? vidMentionOptions.filter((o) => !vidMentionState.query || o.searchText.includes(vidMentionState.query.toLowerCase()))
: [];
const handleVideoPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const caret = e.target.selectionStart || 0;
updateVideoNodePrompt(videoNode.id, value);
const beforeCaret = value.slice(0, caret);
const atIdx = beforeCaret.lastIndexOf("@");
if (atIdx >= 0) {
const query = beforeCaret.slice(atIdx + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { open: true, query, start: atIdx, caret, activeIndex: 0 } }));
return;
}
}
closeTextNodeMention(videoNode.id);
};
const handleVideoPromptKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!vidMentionState.open || vidFilteredMentions.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex + 1) % vidFilteredMentions.length } })); }
else if (e.key === "ArrowUp") { e.preventDefault(); setTextNodeMentionStates((prev) => ({ ...prev, [videoNode.id]: { ...vidMentionState, activeIndex: (vidMentionState.activeIndex - 1 + vidFilteredMentions.length) % vidFilteredMentions.length } })); }
else if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); const opt = vidFilteredMentions[vidMentionState.activeIndex]; if (opt) insertTextNodeMention(videoNode.id, opt, e.currentTarget, "video"); }
else if (e.key === "Escape") { e.preventDefault(); closeTextNodeMention(videoNode.id); }
};
return (
{videoNodeActive && !isCanvasNodeMoving ? (
<div className="studio-canvas-video-composer">
<div className="studio-canvas-video-composer__tabs studio-canvas-video-composer__mode-tabs">
<button
@@ -4913,47 +4820,23 @@ function CanvasPage({
>
{videoNode.cameraMotion ? ` ${CAMERA_MOTION_PRESETS.find((p) => p.value === videoNode.cameraMotion)?.label || ""}` : ""}
</button>
{markingPopoverNodeId === videoNode.id && (
<div
className="studio-canvas-marking-popover"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<textarea
className="studio-canvas-marking-input"
placeholder="描述标记内容,如:主角在城市街头行走"
value={videoNode.marking || ""}
onChange={(e) => {
const val = e.target.value;
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: val } : n)),
);
}}
/>
<div className="studio-canvas-marking-actions">
{videoNode.marking && (
<button
type="button"
className="studio-canvas-marking-clear"
onClick={() => {
setVideoNodes((nodes) =>
nodes.map((n) => (n.id === videoNode.id ? { ...n, marking: "" } : n)),
);
}}
>
</button>
)}
<button
type="button"
className="studio-canvas-marking-done"
onClick={() => setMarkingPopoverNodeId(null)}
>
</button>
</div>
</div>
)}
{markingPopoverNodeId === videoNode.id ? (
<CanvasMarkingPopover
value={videoNode.marking}
placeholder="描述标记内容,如:主角在城市街头行走"
onChange={(value) => {
setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: value } : node)),
);
}}
onClear={() => {
setVideoNodes((nodes) =>
nodes.map((node) => (node.id === videoNode.id ? { ...node, marking: "" } : node)),
);
}}
onDone={() => setMarkingPopoverNodeId(null)}
/>
) : null}
{cameraMotionDropdownNodeId === videoNode.id && (
<div
className="studio-canvas-camera-dropdown"
@@ -4980,43 +4863,24 @@ function CanvasPage({
<button type="button"></button>
<button type="button" className="is-active"></button>
</div>
<div className="studio-canvas-text-composer__input-wrap">
<textarea
<CanvasPromptMentionTextarea
nodeId={videoNode.id}
value={videoNode.prompt}
onMouseDown={(event) => event.stopPropagation()}
onChange={handleVideoPromptChange}
onKeyDown={handleVideoPromptKeyDown}
placeholder="根据文字描述生成视频。"
mentionOptions={buildNodeMentionOptions("video", videoNode.id, imageNodes, videoNodes, textNodes, getIncomingCanvasPorts, manualLinks)}
mentionState={textNodeMentionStates[videoNode.id]}
mentionKind="video"
onPromptChange={updateVideoNodePrompt}
onMentionStateChange={setTextNodeMentionStates}
onCloseMention={closeTextNodeMention}
onInsertMention={insertTextNodeMention}
/>
{vidMentionState.open ? (
<div className="studio-canvas-mention-panel">
{vidFilteredMentions.length > 0 ? vidFilteredMentions.map((opt, idx) => (
<button
key={opt.token}
type="button"
className={`studio-canvas-mention-item${idx === vidMentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(ev) => { ev.preventDefault(); insertTextNodeMention(videoNode.id, opt, ev.currentTarget.closest(".studio-canvas-text-composer__input-wrap")?.querySelector("textarea")!, "video"); }}
>
<span className={`studio-canvas-mention-icon studio-canvas-mention-icon--${opt.kind}`}>
{opt.kind === "image" ? "🖼" : opt.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{opt.nodeTitle}</span>
<span className="studio-canvas-mention-token">{opt.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={{ opacity: 0.5, pointerEvents: "none" }}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
<div className="studio-canvas-video-composer__footer studio-canvas-video-composer__settings">
<CanvasSelectChip
ariaLabel="选择视频模型"
className="canvas-select-chip--model studio-canvas-composer-chip"
value={toHappyHorseDisplayModel(videoNode.model || defaultVideoModel)}
options={canvasEnterpriseVideoModelOptions}
options={canvasVideoModelOptions}
open={canvasSelectMenu === `${videoNode.id}:video-model`}
onToggle={() =>
setCanvasSelectMenu((current) =>
@@ -5094,7 +4958,7 @@ function CanvasPage({
</button>
</div>
</div>
); })() : null}
) : null}
</div>
</div>
);
@@ -5388,7 +5252,7 @@ function CanvasPage({
onClick={() => setSelectedExistingCategory(category.key)}
>
{category.label}
<span>{serverAssets.filter((asset) => asset.type === category.key).length} </span>
<span>{assetCountsByCategory.get(category.key) ?? 0} </span>
</button>
))}
</div>
@@ -5729,6 +5593,27 @@ 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>
);
}
@@ -0,0 +1,219 @@
import { SendOutlined } from "@ant-design/icons";
import { useRef, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import type { CanvasNodeKind, CanvasPromptMentionOption, CanvasPromptMentionState } from "./canvasTypes";
const MENTION_BOUNDARY_RE = /\s|[,。、;:!??(){}[\]<>]/;
const EMPTY_MENTION_STYLE: CSSProperties = { opacity: 0.5, pointerEvents: "none" };
const DEFAULT_MENTION_STATE: CanvasPromptMentionState = {
open: false,
query: "",
start: 0,
caret: 0,
activeIndex: 0,
};
interface CanvasPromptMentionTextareaProps {
nodeId: string;
value: string;
placeholder: string;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
mentionKind?: CanvasNodeKind;
}
export function CanvasPromptMentionTextarea({
nodeId,
value,
placeholder,
mentionOptions,
mentionState = DEFAULT_MENTION_STATE,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
mentionKind,
}: CanvasPromptMentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const filteredMentions = mentionState.open
? mentionOptions.filter((option) => !mentionState.query || option.searchText.includes(mentionState.query.toLowerCase()))
: [];
const handlePromptChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
const caret = event.target.selectionStart || 0;
onPromptChange(nodeId, value);
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf("@");
if (atIndex >= 0) {
const query = beforeCaret.slice(atIndex + 1);
if (!MENTION_BOUNDARY_RE.test(query) && !query.includes(" ")) {
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { open: true, query, start: atIndex, caret, activeIndex: 0 },
}));
return;
}
}
onCloseMention(nodeId);
};
const handlePromptKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!mentionState.open || filteredMentions.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: { ...mentionState, activeIndex: (mentionState.activeIndex + 1) % filteredMentions.length },
}));
} else if (event.key === "ArrowUp") {
event.preventDefault();
onMentionStateChange((prev) => ({
...prev,
[nodeId]: {
...mentionState,
activeIndex: (mentionState.activeIndex - 1 + filteredMentions.length) % filteredMentions.length,
},
}));
} else if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
const option = filteredMentions[mentionState.activeIndex];
if (option) {
onInsertMention(nodeId, option, event.currentTarget, mentionKind);
}
} else if (event.key === "Escape") {
event.preventDefault();
onCloseMention(nodeId);
}
};
const handlePromptSelect = (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
const caret = event.currentTarget.selectionStart || 0;
onMentionStateChange((prev) => {
const current = prev[nodeId];
if (!current?.open) return prev;
return { ...prev, [nodeId]: { ...current, caret } };
});
};
return (
<div className="studio-canvas-text-composer__input-wrap">
<textarea
ref={textareaRef}
value={value}
onMouseDown={(event) => event.stopPropagation()}
onChange={handlePromptChange}
onKeyDown={handlePromptKeyDown}
onSelect={handlePromptSelect}
placeholder={placeholder}
/>
{mentionState.open ? (
<div className="studio-canvas-mention-panel">
{filteredMentions.length > 0 ? filteredMentions.map((option, index) => (
<button
key={option.token}
type="button"
className={`studio-canvas-mention-item${index === mentionState.activeIndex ? " is-active" : ""}`}
onMouseDown={(event) => {
event.preventDefault();
onInsertMention(nodeId, option, textareaRef.current, mentionKind);
}}
>
<span className="studio-canvas-mention-thumb">
{option.kind === "image" && option.previewUrl ? (
<img src={option.previewUrl} alt="" />
) : option.kind === "image" ? "🖼" : option.kind === "video" ? "🎬" : "📝"}
</span>
<span className="studio-canvas-mention-label">{option.nodeTitle}</span>
<span className="studio-canvas-mention-token">{option.token}</span>
</button>
)) : (
<div className="studio-canvas-mention-item" style={EMPTY_MENTION_STYLE}>
<span className="studio-canvas-mention-label"></span>
</div>
)}
</div>
) : null}
</div>
);
}
interface CanvasTextPromptComposerProps {
nodeId: string;
prompt: string;
canGenerate: boolean;
isGenerating: boolean;
mentionOptions: CanvasPromptMentionOption[];
mentionState?: CanvasPromptMentionState;
onPromptChange: (nodeId: string, prompt: string) => void;
onMentionStateChange: Dispatch<SetStateAction<Record<string, CanvasPromptMentionState>>>;
onCloseMention: (nodeId: string) => void;
onInsertMention: (
nodeId: string,
option: CanvasPromptMentionOption,
textarea: HTMLTextAreaElement | null,
kind?: CanvasNodeKind,
) => void;
onGenerate: (nodeId: string) => void | Promise<void>;
}
export function CanvasTextPromptComposer({
nodeId,
prompt,
canGenerate,
isGenerating,
mentionOptions,
mentionState,
onPromptChange,
onMentionStateChange,
onCloseMention,
onInsertMention,
onGenerate,
}: CanvasTextPromptComposerProps) {
return (
<div className="studio-canvas-text-composer">
<CanvasPromptMentionTextarea
nodeId={nodeId}
value={prompt}
placeholder="写下你想讲的故事、场景或角色设定。@引用连接的节点"
mentionOptions={mentionOptions}
mentionState={mentionState}
onPromptChange={onPromptChange}
onMentionStateChange={onMentionStateChange}
onCloseMention={onCloseMention}
onInsertMention={onInsertMention}
/>
<div className="studio-canvas-text-composer__footer">
<button
type="button"
className={`studio-canvas-text-composer__send studio-canvas-generate-button${canGenerate && !isGenerating ? " is-ready" : ""}`}
title={isGenerating ? "生成中" : "生成"}
disabled={isGenerating || !canGenerate}
aria-busy={isGenerating}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
if (!isGenerating && canGenerate) {
void onGenerate(nodeId);
}
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<SendOutlined />
</button>
</div>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { toast } from "../../components/toast/toastStore";
import type { CanvasImageNode } from "./canvasTypes";
interface CanvasToolPanelProps {
imageUrl: string;
imageNode: CanvasImageNode;
onComplete: (resultUrl: string) => void;
}
export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleGenerate = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageTask({
model: "gpt-image-2",
prompt: prompt || "基于参考图生成多宫格变体",
referenceUrls: [imageUrl],
gridMode,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("多宫格生成完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, gridMode, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"宫格模式"}</label>
<div className="studio-canvas-tool-panel__options">
{([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => (
<button key={value} type="button" className={gridMode === value ? "is-active" : ""} onClick={() => setGridMode(value)}>{label}</button>
))}
</div>
<label className="studio-canvas-tool-panel__label">{"提示词(可选)"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述多宫格内容变化" />
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleGenerate}>
{loading ? "生成中..." : "生成多宫格"}
</button>
</div>
</div>
);
}
export function CanvasUpscalePanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [scale, setScale] = useState<"2x" | "4x">("2x");
const [loading, setLoading] = useState(false);
const cancelRef = useRef(false);
const handleUpscale = useCallback(async () => {
if (!imageUrl) return;
setLoading(true);
cancelRef.current = false;
try {
const { taskId } = await aiGenerationClient.createImageSuperResolveTask({
imageUrl,
scale,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("超分完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "超分失败");
} finally {
setLoading(false);
}
}, [imageUrl, scale, onComplete]);
return (
<div className="studio-canvas-tool-panel">
<div className="studio-canvas-tool-panel__preview"><img src={imageUrl} alt="" /></div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"放大倍数"}</label>
<div className="studio-canvas-tool-panel__options">
{(["2x", "4x"] as const).map((s) => (
<button key={s} type="button" className={scale === s ? "is-active" : ""} onClick={() => setScale(s)}>{s}</button>
))}
</div>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleUpscale}>
{loading ? "处理中..." : "开始超分"}
</button>
</div>
</div>
);
}
export function CanvasInpaintPanel({ imageUrl, onComplete }: CanvasToolPanelProps) {
const [prompt, setPrompt] = useState("");
const [brushSize, setBrushSize] = useState(30);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDrawingRef = useRef(false);
const cancelRef = useRef(false);
const initCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
img.src = imageUrl;
}, [imageUrl]);
const getPos = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY };
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current) return;
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
const { x, y } = getPos(e);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 0, 0, 0.4)";
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
};
const getMaskDataUrl = (): string => {
const canvas = canvasRef.current!;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = canvas.width;
maskCanvas.height = canvas.height;
const srcCtx = canvas.getContext("2d")!;
const maskCtx = maskCanvas.getContext("2d")!;
const imgData = srcCtx.getImageData(0, 0, canvas.width, canvas.height);
const maskData = maskCtx.createImageData(canvas.width, canvas.height);
for (let i = 0; i < imgData.data.length; i += 4) {
const hasColor = imgData.data[i + 3] > 10;
maskData.data[i] = hasColor ? 255 : 0;
maskData.data[i + 1] = hasColor ? 255 : 0;
maskData.data[i + 2] = hasColor ? 255 : 0;
maskData.data[i + 3] = 255;
}
maskCtx.putImageData(maskData, 0, 0);
return maskCanvas.toDataURL("image/png");
};
const handleInpaint = useCallback(async () => {
if (!imageUrl || !prompt) {
toast.error("请输入重绘提示词");
return;
}
setLoading(true);
cancelRef.current = false;
try {
const maskDataUrl = getMaskDataUrl();
const { taskId } = await aiGenerationClient.createImageEditTask({
imageUrl,
function: "inpaint",
prompt,
});
const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef });
if (resultUrl) {
onComplete(resultUrl);
toast.success("局部重绘完成");
}
} catch (err: unknown) {
if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "局部重绘失败");
} finally {
setLoading(false);
}
}, [imageUrl, prompt, onComplete]);
return (
<div className="studio-canvas-tool-panel studio-canvas-tool-panel--inpaint">
<div className="studio-canvas-tool-panel__canvas-wrap">
<img src={imageUrl} alt="" className="studio-canvas-tool-panel__canvas-bg" />
<canvas
ref={canvasRef}
className="studio-canvas-tool-panel__canvas"
onMouseDown={(e) => { isDrawingRef.current = true; draw(e); }}
onMouseMove={draw}
onMouseUp={() => { isDrawingRef.current = false; }}
onMouseLeave={() => { isDrawingRef.current = false; }}
/>
</div>
<div className="studio-canvas-tool-panel__controls">
<label className="studio-canvas-tool-panel__label">{"画笔大小"}</label>
<input type="range" min={5} max={80} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<label className="studio-canvas-tool-panel__label">{"重绘提示词"}</label>
<textarea className="studio-canvas-tool-panel__textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="描述需要重绘区域的内容" />
<div className="studio-canvas-tool-panel__actions">
<button type="button" className="studio-canvas-tool-panel__reset" onClick={initCanvas}>{"清除蒙版"}</button>
<button type="button" className="studio-canvas-tool-panel__submit" disabled={loading} onClick={handleInpaint}>
{loading ? "处理中..." : "开始重绘"}
</button>
</div>
</div>
</div>
);
}
+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, {
timeoutMs: 10 * 60 * 1000,
kind: "image",
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -262,7 +262,7 @@ export async function waitForImageTaskResult(taskId: string, onStatus?: (status:
export async function waitForVideoTaskResult(taskId: string, onStatus?: (status: AiTaskStatus) => void) {
const resultUrl = await waitForTask(taskId, {
timeoutMs: 30 * 60 * 1000,
kind: "video",
onProgress: (e) => {
onStatus?.({ taskId, status: e.status, progress: e.progress, resultUrl: e.resultUrl ?? undefined, error: e.error ?? undefined } as AiTaskStatus);
},
@@ -0,0 +1,74 @@
import { useCallback, useMemo } from "react";
import type { ServerAssetItem } from "../../api/assetClient";
import type {
CanvasImageNode,
CanvasNodeKind,
CanvasNodePackage,
CanvasTextNode,
CanvasVideoNode,
} from "./canvasTypes";
import { getCanvasSelectionKey } from "./canvasUtils";
export function useCanvasAssetSummary(serverAssets: ServerAssetItem[]) {
return useMemo(() => {
const canvasAssets: ServerAssetItem[] = [];
const assetCountsByCategory = new Map<string, number>();
for (const asset of serverAssets) {
if (asset.imageUrl) {
canvasAssets.push(asset);
}
assetCountsByCategory.set(asset.type, (assetCountsByCategory.get(asset.type) ?? 0) + 1);
}
return { canvasAssets, assetCountsByCategory };
}, [serverAssets]);
}
export function useCanvasVisibleNodes({
textNodes,
imageNodes,
videoNodes,
nodePackages,
}: {
textNodes: CanvasTextNode[];
imageNodes: CanvasImageNode[];
videoNodes: CanvasVideoNode[];
nodePackages: CanvasNodePackage[];
}) {
const collapsedPackageNodeKeys = useMemo(
() => new Set(
nodePackages.flatMap((nodePackage) =>
nodePackage.collapsed ? nodePackage.nodeIds.map((node) => getCanvasSelectionKey(node)) : []
)
),
[nodePackages],
);
const isNodeCollapsedInPackage = useCallback(
(kind: CanvasNodeKind, id: string) =>
collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind, id })),
[collapsedPackageNodeKeys],
);
const visibleTextNodes = useMemo(
() => textNodes.filter((textNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "text", id: textNode.id }))),
[collapsedPackageNodeKeys, textNodes],
);
const visibleImageNodes = useMemo(
() => imageNodes.filter((imageNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "image", id: imageNode.id }))),
[collapsedPackageNodeKeys, imageNodes],
);
const visibleVideoNodes = useMemo(
() => videoNodes.filter((videoNode) => !collapsedPackageNodeKeys.has(getCanvasSelectionKey({ kind: "video", id: videoNode.id }))),
[collapsedPackageNodeKeys, videoNodes],
);
return {
collapsedPackageNodeKeys,
isNodeCollapsedInPackage,
visibleTextNodes,
visibleImageNodes,
visibleVideoNodes,
};
}
+10 -5
View File
@@ -82,11 +82,16 @@ export function useCanvasNodeDrag(params: UseCanvasNodeDragParams) {
const cy = pos.y + size.height / 2;
const right = pos.x + size.width;
const bottom = pos.y + size.height;
const others = [
...textNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
...imageNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
...videoNodesRef.current.filter((n) => n.id !== draggedId).map((n) => ({ pos: n.position, size: n.size })),
];
const others: Array<{ pos: CanvasPoint; size: CanvasNodeSize }> = [];
for (const node of textNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const node of imageNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const node of videoNodesRef.current) {
if (node.id !== draggedId) others.push({ pos: node.position, size: node.size });
}
for (const other of others) {
const ocx = other.pos.x + other.size.width / 2;
const ocy = other.pos.y + other.size.height / 2;
@@ -17,6 +17,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import StudioToolLayout from "../../components/StudioToolLayout";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
+1
View File
@@ -10,6 +10,7 @@ import {
SearchOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/community.css";
import { useDebounce } from "../../hooks/useDebounce";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
import WorkspacePageShell from "../../components/WorkspacePageShell";
@@ -1,4 +1,5 @@
import { FileTextOutlined, SafetyOutlined } from "@ant-design/icons";
import "../../styles/pages/compliance.css";
type ComplianceKind = "agreement" | "privacy";
@@ -0,0 +1,291 @@
import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react";
import "../../styles/pages/dialog-generator.css";
type DialogStyle = "style1" | "style2" | "style3" | "style4";
interface DialogItem {
id: number;
style: DialogStyle;
x: number;
y: number;
text: string;
color: string;
confirmed: boolean;
}
interface DragState {
id: number;
offsetX: number;
offsetY: number;
}
const dialogStyles: Array<{
key: DialogStyle;
label: string;
description: string;
swatchClass: string;
}> = [
{ key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" },
{ key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" },
{ key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" },
{ key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" },
];
const textColorOptions = [
{ value: "#ffffff", label: "白色" },
{ value: "#111827", label: "黑色" },
{ value: "#ef4444", label: "红色" },
{ value: "#f59e0b", label: "黄色" },
{ value: "#165dff", label: "蓝色" },
{ value: "#00ff88", label: "绿色" },
];
function DialogGeneratorPage() {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null);
const nextIdRef = useRef(0);
const [backgroundUrl, setBackgroundUrl] = useState("");
const [dialogs, setDialogs] = useState<DialogItem[]>([]);
const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value);
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const handleFile = useCallback((file?: File | null) => {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setBackgroundUrl(reader.result);
}
};
reader.readAsDataURL(file);
}, []);
const addDialog = useCallback((style: DialogStyle) => {
nextIdRef.current += 1;
const id = nextIdRef.current;
setDialogs((current) => [
...current,
{
id,
style,
x: 30 + (id * 25) % 200,
y: 30 + (id * 20) % 150,
text: "",
color: selectedTextColor,
confirmed: false,
},
]);
}, [selectedTextColor]);
const updateDialog = useCallback((id: number, patch: Partial<DialogItem>) => {
setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item)));
}, []);
const deleteDialog = useCallback((id: number) => {
setDialogs((current) => current.filter((item) => item.id !== id));
}, []);
const startDrag = useCallback((id: number, clientX: number, clientY: number) => {
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${id}"]`);
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
dragRef.current = {
id,
offsetX: clientX - rect.left,
offsetY: clientY - rect.top,
};
setActiveDragId(id);
}, []);
const moveDrag = useCallback((clientX: number, clientY: number) => {
const drag = dragRef.current;
const preview = previewRef.current;
if (!drag || !preview) return;
const dialogEl = document.querySelector<HTMLElement>(`[data-dialog-id="${drag.id}"]`);
if (!dialogEl) return;
const bounds = preview.getBoundingClientRect();
const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth));
const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight));
updateDialog(drag.id, { x: nextX, y: nextY });
}, [updateDialog]);
const endDrag = useCallback(() => {
dragRef.current = null;
setActiveDragId(null);
}, []);
const handleCanvasMouseMove = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
moveDrag(event.clientX, event.clientY);
}, [moveDrag]);
const handleCanvasTouchMove = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
const touch = event.touches[0];
if (!touch) return;
moveDrag(touch.clientX, touch.clientY);
}, [moveDrag]);
return (
<section className="dialog-generator-page page-motion">
<div className="dialog-generator-shell">
<aside className="dialog-generator-panel">
<div className="dialog-generator-heading">
<span className="dialog-generator-kicker">Interactive Dialog</span>
<h1></h1>
<p></p>
</div>
<div className="dialog-generator-section">
<h2></h2>
<button
type="button"
className="dialog-generator-drop"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
handleFile(event.dataTransfer.files[0]);
}}
>
<span className="dialog-generator-drop-icon">🖼</span>
<strong></strong>
<small> JPGPNGWEBP </small>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => handleFile(event.target.files?.[0])}
/>
</div>
<div className="dialog-generator-section">
<h2></h2>
<p className="dialog-generator-hint"></p>
<div className="dialog-generator-color-picker" role="radiogroup" aria-label="文字颜色">
{textColorOptions.map((item) => (
<button
key={item.value}
type="button"
className={`dialog-generator-color${selectedTextColor === item.value ? " is-active" : ""}`}
style={{ "--text-color": item.value } as CSSProperties}
aria-checked={selectedTextColor === item.value}
role="radio"
onClick={() => setSelectedTextColor(item.value)}
>
<span />
<strong>{item.label}</strong>
</button>
))}
</div>
<div className="dialog-generator-style-list">
{dialogStyles.map((item) => (
<button key={item.key} type="button" className="dialog-generator-style" onClick={() => addDialog(item.key)}>
<span className={`dialog-generator-swatch ${item.swatchClass}`} />
<span>
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
</button>
))}
</div>
</div>
<button type="button" className="dialog-generator-clear" onClick={() => setDialogs([])}>
</button>
</aside>
<main className="dialog-generator-preview-card">
<div className="dialog-generator-preview-head">
<div>
<span>Preview</span>
<h2></h2>
</div>
<p></p>
</div>
<div
ref={previewRef}
className="dialog-generator-preview"
onMouseMove={handleCanvasMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchMove={handleCanvasTouchMove}
onTouchEnd={endDrag}
>
{backgroundUrl ? <div className="dialog-generator-image" style={{ backgroundImage: `url(${backgroundUrl})` }} /> : null}
{!backgroundUrl ? (
<div className="dialog-generator-empty">
<span>🖼</span>
<p></p>
</div>
) : null}
{dialogs.map((dialog) => (
<div
key={dialog.id}
data-dialog-id={dialog.id}
className={`dialog-generator-bubble ${dialog.style}${dialog.confirmed ? " is-confirmed" : ""}${activeDragId === dialog.id ? " is-dragging" : ""}`}
style={{ left: dialog.x, top: dialog.y, "--dialog-text-color": dialog.color } as CSSProperties}
onMouseDown={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
startDrag(dialog.id, event.clientX, event.clientY);
event.preventDefault();
}}
onTouchStart={(event) => {
const target = event.target as HTMLElement;
if (target.closest("textarea,button")) return;
const touch = event.touches[0];
if (touch) startDrag(dialog.id, touch.clientX, touch.clientY);
}}
onDoubleClick={() => {
if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false });
}}
>
{!dialog.confirmed ? (
<button type="button" className="dialog-generator-delete" onClick={() => deleteDialog(dialog.id)} aria-label="删除文字">
×
</button>
) : null}
{dialog.confirmed ? (
<div className="dialog-generator-text-display">{dialog.text}</div>
) : (
<textarea
className="dialog-generator-text"
rows={2}
placeholder="输入文本..."
value={dialog.text}
onChange={(event) => updateDialog(dialog.id, { text: event.target.value })}
/>
)}
{!dialog.confirmed ? (
<div className="dialog-generator-bubble-bottom">
<button
type="button"
className="dialog-generator-confirm"
onClick={() => {
if (dialog.text.trim()) {
updateDialog(dialog.id, { text: dialog.text.trim(), confirmed: true });
}
}}
>
</button>
</div>
) : null}
</div>
))}
</div>
</main>
</div>
</section>
);
}
export default DialogGeneratorPage;
@@ -24,6 +24,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
import "../../styles/pages/avatar-console.css";
import type { WebViewKey } from "../../types";
import {
bringAvatarEditorLayerForward,
@@ -18,6 +18,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { uploadAssetWithProgress } from "../../api/uploadWithProgress";
import { waitForTask } from "../../api/taskSubscription";
+392 -80
View File
@@ -12,7 +12,8 @@ import {
SettingOutlined,
SkinOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type ReactNode } from "react";
import "../../styles/pages/ecommerce.css";
import { ossAssets } from "../../data/ossAssets";
import { EcommerceProgressBar } from "./EcommerceProgressBar";
import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu";
@@ -59,9 +60,12 @@ interface CloneImageItem {
id: string;
src: string;
name: string;
file?: File;
width?: number;
height?: number;
format?: string;
mimeType?: string;
ossKey?: string;
}
interface CloneResult {
@@ -98,6 +102,18 @@ interface CloneSavedSetting {
requirement: string;
}
interface EcommerceImagePromptOptions {
gender?: string;
age?: string;
ethnicity?: string;
body?: string;
appearance?: string;
scenes?: string[];
customScene?: string;
smartScene?: boolean;
detailModules?: string[];
}
type PlatformRatioModeKey = ProductSetOutputKey | "hot" | "video-outfit";
interface PlatformRatioGroup {
@@ -671,15 +687,85 @@ function readImageDimensions(src: string): Promise<{ width: number; height: numb
});
}
function createObjectImageItems(files: File[], limit: number, prefix: string) {
return Array.from(files)
.slice(0, limit)
.map<CloneImageItem>((file, index) => ({
id: `${prefix}-${Date.now()}-${index}`,
src: URL.createObjectURL(file),
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, {
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[] {
@@ -791,6 +877,7 @@ 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");
@@ -814,25 +901,58 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const [selectedDetailModules, setSelectedDetailModules] = useState<string[]>(defaultDetailModuleIds);
const [detailStatus, setDetailStatus] = useState<DetailStatus>("idle");
const [detailResultUrl, setDetailResultUrl] = useState<string | null>(null);
const productSetRatioOptions = getPlatformRatioOptions(productSetPlatform, productSetOutput);
const hotUploadedRatioOption = cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null;
const baseCloneRatioOptions = getPlatformRatioOptions(platform, cloneOutput);
const cloneRatioOptions = hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions;
const productSetLanguageOptions = getPlatformLanguageOptions(productSetPlatform, productSetMarket);
const cloneLanguageOptions = getPlatformLanguageOptions(platform, market);
const detailLanguageOptions = getPlatformLanguageOptions(detailPlatform, detailMarket);
const productSetRatioOptions = useMemo(
() => getPlatformRatioOptions(productSetPlatform, productSetOutput),
[productSetOutput, productSetPlatform],
);
const hotUploadedRatioOption = useMemo(
() => cloneOutput === "hot" ? formatUploadedImageRatio(cloneReferenceImages[0]) : null,
[cloneOutput, cloneReferenceImages],
);
const baseCloneRatioOptions = useMemo(
() => getPlatformRatioOptions(platform, cloneOutput),
[cloneOutput, platform],
);
const cloneRatioOptions = useMemo(
() => hotUploadedRatioOption
? getUniqueRatioOptions([...baseCloneRatioOptions, hotUploadedRatioOption])
: baseCloneRatioOptions,
[baseCloneRatioOptions, hotUploadedRatioOption],
);
const productSetLanguageOptions = useMemo(
() => getPlatformLanguageOptions(productSetPlatform, productSetMarket),
[productSetMarket, productSetPlatform],
);
const cloneLanguageOptions = useMemo(
() => getPlatformLanguageOptions(platform, market),
[market, platform],
);
const detailLanguageOptions = useMemo(
() => getPlatformLanguageOptions(detailPlatform, detailMarket),
[detailMarket, detailPlatform],
);
const ecommerceMentionImages: MentionImageOption[] = [
...productImages.map((image, index) => ({ ...image, label: `商品图 ${index + 1}` })),
...cloneReferenceImages.map((image, index) => ({ ...image, label: `参考图 ${index + 1}` })),
];
const ecommerceVideoImageDataUrls = useMemo(
() => productImages.map((img) => img.src),
[productImages],
);
const ecommerceVideoImageFiles = useMemo(
() => productImages.map((img) => img.file),
[productImages],
);
const selectedProductSetOutput =
productSetOutputOptions.find((option) => option.key === productSetOutput) ?? productSetOutputOptions[0]!;
const selectedCloneOutput = cloneOutputOptions.find((option) => option.key === cloneOutput) ?? cloneOutputOptions[1]!;
const productSetPreviewReady = productSetStatus === "done";
const cloneSetTotal = Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0);
const cloneSetTotal = useMemo(
() => Object.values(cloneSetCounts).reduce((sum, value) => sum + value, 0),
[cloneSetCounts],
);
const canGenerateSet = setImages.length > 0 && productSetStatus !== "generating";
const canGenerate = (cloneOutput === "video-outfit"
? Boolean(videoOutfitVideoFile && videoOutfitRefFile)
@@ -841,9 +961,36 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const canGenerateDetail = detailProductImages.length > 0 && detailStatus !== "generating";
const cloneVideoDurationProgress =
((cloneVideoDuration - cloneVideoDurationMin) / (cloneVideoDurationMax - cloneVideoDurationMin)) * 100;
const cloneVideoDurationStyle: CSSProperties = {
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
} as CSSProperties;
const cloneVideoDurationStyle: CSSProperties = useMemo(
() => ({
"--clone-video-duration-progress": `${cloneVideoDurationProgress}%`,
}) as CSSProperties,
[cloneVideoDurationProgress],
);
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);
@@ -861,21 +1008,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addSetImages = (files: File[]) => {
const addSetImages = async (files: File[]) => {
if (setImages.length >= 3) return;
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
setSetImages((current) => {
const nextImages = createObjectImageItems(imageFiles, 3 - current.length, "set");
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
try {
const nextImages = await createUploadedImageItems(imageFiles, 3 - setImages.length, "set");
setSetImages((current) => {
if (current.length >= 3) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, 3) : current;
});
setProductSetStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleSetUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addSetImages(Array.from(files));
void addSetImages(Array.from(files));
event.target.value = "";
};
@@ -883,7 +1035,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsSetUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addSetImages(files);
if (files.length) void addSetImages(files);
};
const removeSetImage = (imageId: string) => {
@@ -894,22 +1046,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addProductImages = (files: File[]) => {
const addProductImages = async (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
const nextImages = createObjectImageItems(imageFiles, maxCloneProductImages - current.length, "product");
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
try {
const nextImages = await createUploadedImageItems(imageFiles, maxCloneProductImages - productImages.length, "product");
setProductImages((current) => {
if (current.length >= maxCloneProductImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneProductImages) : current;
});
setStatus("ready");
setResults([]);
} catch (err) {
toast.error(err instanceof Error ? err.message : "商品图上传失败");
}
};
const handleProductUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addProductImages(Array.from(files));
void addProductImages(Array.from(files));
event.target.value = "";
};
@@ -917,7 +1073,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.preventDefault();
setIsProductUploadDragging(false);
const files = Array.from(event.dataTransfer.files);
if (files.length) addProductImages(files);
if (files.length) void addProductImages(files);
};
const removeProductImage = (imageId: string) => {
@@ -943,24 +1099,28 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
});
};
const addCloneReferenceImages = (files: File[]) => {
const addCloneReferenceImages = async (files: File[]) => {
const imageFiles = notifyRejectedImages(files);
if (!imageFiles.length) return;
const remainingSlots = maxCloneReferenceImages - cloneReferenceImages.length;
if (remainingSlots <= 0) return;
const nextImages = createObjectImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
try {
const nextImages = await createUploadedImageItems(imageFiles, remainingSlots, "reference");
if (!nextImages.length) return;
setCloneReferenceImages((current) => {
if (current.length >= maxCloneReferenceImages) return current;
return nextImages.length ? [...current, ...nextImages].slice(0, maxCloneReferenceImages) : current;
});
hydrateCloneReferenceImageMeta(nextImages);
} catch (err) {
toast.error(err instanceof Error ? err.message : "参考图上传失败");
}
};
const handleCloneReferenceUpload = (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files?.length) return;
addCloneReferenceImages(Array.from(files));
void addCloneReferenceImages(Array.from(files));
event.target.value = "";
};
@@ -975,6 +1135,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
const clearCloneSetCountHold = () => {
window.removeEventListener("pointerup", clearCloneSetCountHold);
window.removeEventListener("pointercancel", clearCloneSetCountHold);
if (countHoldTimeoutRef.current !== null) {
window.clearTimeout(countHoldTimeoutRef.current);
countHoldTimeoutRef.current = null;
@@ -1089,6 +1251,34 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
requirement,
});
const latestCloneSettingSnapshot = useMemo(
() => createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest"),
[
cloneOutput,
platform,
market,
language,
ratio,
cloneSetCounts,
selectedCloneDetailModules,
cloneModelPanelTab,
selectedCloneModelScenes,
cloneModelCustomScene,
cloneModelGender,
cloneModelAge,
cloneModelEthnicity,
cloneModelBody,
cloneModelAppearance,
cloneVideoQuality,
cloneVideoDuration,
cloneVideoSmart,
cloneReferenceMode,
cloneReplicateLevel,
requirement,
cloneSettingName,
],
);
const persistLatestCloneSetting = () => {
const snapshot = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
latestCloneSettingRef.current = snapshot;
@@ -1136,8 +1326,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
};
useEffect(() => {
latestCloneSettingRef.current = createCloneSettingSnapshot(cloneSettingName, "clone-setting-latest");
});
latestCloneSettingRef.current = latestCloneSettingSnapshot;
}, [latestCloneSettingSnapshot]);
useEffect(() => {
const latestSetting = readCloneLatestSetting();
@@ -1275,8 +1465,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
setGarmentImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 5 - current.length, "garment")].slice(0, 5));
setTryOnStatus("ready");
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 5 - garmentImages.length, "garment");
setGarmentImages((current) => [...current, ...nextImages].slice(0, 5));
setTryOnStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "服饰图上传失败");
}
})();
event.target.value = "";
};
@@ -1288,8 +1485,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
event.target.value = "";
return;
}
setDetailProductImages((current) => [...current, ...createObjectImageItems(uploadedFiles, 3 - current.length, "detail")].slice(0, 3));
setDetailStatus("ready");
void (async () => {
try {
const nextImages = await createUploadedImageItems(uploadedFiles, 3 - detailProductImages.length, "detail");
setDetailProductImages((current) => [...current, ...nextImages].slice(0, 3));
setDetailStatus("ready");
} catch (err) {
toast.error(err instanceof Error ? err.message : "详情图上传失败");
}
})();
event.target.value = "";
};
@@ -1305,11 +1509,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const urls: string[] = [];
for (const item of images) {
try {
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);
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 { url } = await aiGenerationClient.uploadAsset({ dataUrl, name: item.name, mimeType, scope: "ecommerce-product" });
urls.push(url);
} catch {
@@ -1327,11 +1535,32 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
scene: { label: "场景图", promptDesc: "lifestyle scene image showing the product in a realistic usage environment with natural surroundings" },
};
const buildDetailModulePrompt = (moduleIds: string[]): string => {
if (!moduleIds.length) {
return "Generate a complete A+ detail layout with hero, selling points, usage scene, product detail, and specification modules.";
}
const selectedModules = cloneDetailModules.filter((module) => moduleIds.includes(module.id));
if (!selectedModules.length) return "";
const moduleList = selectedModules.map((module) => `${module.title}: ${module.desc}`).join("; ");
return `Only generate these selected A+ detail modules, no extra modules: ${moduleList}. Keep the output focused even if only one or two modules are selected.`;
};
const buildSetSubPrompt = (countKey: CloneSetCountKey, index: number, totalCount: number, pPlatform: string, pRatio: string, pLanguage: string, pMarket: string): string => {
const info = setCountLabels[countKey];
const parts: string[] = [];
parts.push(`Generate an e-commerce ${info.label.toLowerCase()} for a product listing.`);
parts.push(info.promptDesc);
if (countKey === "white") {
parts.push("The output must be a clean white-background product image. Do not use lifestyle backgrounds, props, text overlays, or people.");
}
if (countKey === "scene") {
parts.push("The output must be a realistic usage scene image. Keep the product clearly visible and preserve its shape, color, and key details.");
}
if (countKey === "selling") {
parts.push("The output must be a selling-point graphic with clear hierarchy, concise copy, and product detail callouts.");
}
if (totalCount > 1) {
parts.push(`This is variant ${index + 1} of ${totalCount} — vary the angle, composition, or emphasis to make each distinct.`);
}
@@ -1343,13 +1572,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
const buildEcommerceImagePrompt = (
outputKey: CloneOutputKey, userText: string,
pPlatform: string, pRatio: string, pLanguage: string, pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
tryOnOptions?: EcommerceImagePromptOptions,
): string => {
const parts: string[] = [];
if (outputKey === "detail") {
parts.push("Generate a professional A+ detail page hero image for an e-commerce product listing.");
parts.push("Create a high-impact first-screen visual that combines the product photo with key selling points, usage scenes, and detailed specifications in a cohesive layout.");
parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`);
if (outputKey === "detail" && tryOnOptions?.detailModules) parts.push(buildDetailModulePrompt(tryOnOptions.detailModules));
parts.push("Follow platform A+ page best practices — clear hierarchy, professional typography, high visual impact.");
} else if (outputKey === "model") {
parts.push("Generate model/try-on lifestyle images for an e-commerce product listing.");
@@ -1362,6 +1592,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
if (tryOnOptions.body) parts.push(`Model body type: ${tryOnOptions.body}.`);
if (tryOnOptions.appearance) parts.push(`Model appearance details: ${tryOnOptions.appearance}.`);
if (tryOnOptions.scenes?.length) parts.push(`Background scenes: ${tryOnOptions.scenes.join(", ")}.`);
if (tryOnOptions.customScene) parts.push(`Custom background scene: ${tryOnOptions.customScene}.`);
if (tryOnOptions.smartScene) parts.push("Use smart scene matching to select the best background context.");
}
parts.push("Model should appear natural and appealing. Background should complement the product. Image must meet platform standards.");
@@ -1395,6 +1626,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setStatusFn("idle");
return;
}
if (imageAbortRef.current.current) {
setStatusFn("idle");
return;
}
const generatedUrls: string[] = [];
const stamp = Date.now();
@@ -1414,17 +1649,26 @@ 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 });
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) break;
if (resultUrl) {
generatedUrls.push(resultUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${countKey}-${i + 1}`);
generatedUrls.push(persistedUrl);
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
generatedUrls.push("");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
@@ -1432,9 +1676,17 @@ 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("余额不足,请充值后继续");
@@ -1454,7 +1706,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
pRatio: string,
pLanguage: string,
pMarket: string,
tryOnOptions?: { gender?: string; age?: string; ethnicity?: string; body?: string; appearance?: string; scenes?: string[]; smartScene?: boolean },
tryOnOptions?: EcommerceImagePromptOptions,
statusFn?: (status: "generating" | "done" | "idle" | "failed") => void,
resultFn?: (results: CloneResult[]) => void,
): Promise<void> => {
@@ -1465,6 +1717,10 @@ 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();
@@ -1477,23 +1733,39 @@ 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 });
const resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, {
abortRef: imageAbortRef.current,
onProgress: () => {},
});
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
statusFn?.("idle");
return;
}
if (resultUrl) {
resultFn?.([{ id: `ecommerce-${stamp}`, src: resultUrl, label: selectedCloneOutput.label }]);
const persistedUrl = await persistGeneratedImageUrl(resultUrl, "ecommerce-generated", `ecommerce-${outputKey}`);
resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]);
statusFn?.("done");
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl });
imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl });
} else {
statusFn?.("idle");
imageGen.updateTask(storeId, { status: "failed", error: "生成未返回结果" });
}
} 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("余额不足,请充值后继续");
@@ -1527,21 +1799,38 @@ 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");
imageAbortRef.current = { current: false };
const resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
let resultUrl: string | null = null;
try {
resultUrl = await waitForTask(taskId, { abortRef: imageAbortRef.current });
} finally {
untrackEcommerceTask(taskId);
}
if (imageAbortRef.current.current) {
setStatus("idle");
return;
}
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 : "视频换装生成失败");
}
@@ -1571,10 +1860,24 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
(urls) => setProductSetResultImages(urls),
);
} else {
const clonePromptOptions: EcommerceImagePromptOptions | undefined =
cloneOutput === "model"
? {
gender: cloneModelGender,
age: cloneModelAge,
ethnicity: cloneModelEthnicity,
body: cloneModelBody,
appearance: cloneModelAppearance,
scenes: selectedCloneModelScenes,
customScene: cloneModelCustomScene,
}
: cloneOutput === "detail"
? { detailModules: selectedCloneDetailModules }
: undefined;
void generateEcommerceImage(
cloneOutput, productImages, requirement,
platform, ratio, language, market,
undefined,
clonePromptOptions,
(s: string) => setStatus(s as ProductCloneStatus), setResults,
);
lastFailedActionRef.current = () => handleGenerate();
@@ -1654,7 +1957,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
void generateEcommerceImage(
"detail", detailProductImages, detailRequirement,
detailPlatform, getPlatformDefaultRatio(detailPlatform), detailLanguage, detailMarket,
undefined,
{ detailModules: selectedDetailModules },
(s: string) => setDetailStatus(s as DetailStatus),
(res) => setDetailResultUrl(res[0]?.src ?? null),
);
@@ -1733,7 +2036,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++;
@@ -1748,7 +2051,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++;
@@ -1877,6 +2180,7 @@ 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)}
@@ -1910,6 +2214,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
handleDetailAiWrite={handleDetailAiWrite}
toggleDetailModule={toggleDetailModule}
handleDetailGenerate={handleDetailGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -1947,6 +2252,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
setSmartScene={setSmartScene}
setTryOnRatio={setTryOnRatio}
handleTryOnGenerate={handleTryOnGenerate}
onCancelGenerate={handleCancelGenerate}
/>
);
@@ -2022,6 +2328,11 @@ 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="帮助">
@@ -2372,7 +2683,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
<main className="product-clone-preview product-clone-preview--video" style={{ padding: 0, overflow: "hidden" }}>
<EcommerceVideoWorkspace
isAuthenticated={Boolean((_props as Record<string, unknown>).isAuthenticated)}
productImageDataUrls={productImages.map((img) => img.src)}
productImageDataUrls={ecommerceVideoImageDataUrls}
productImageFiles={ecommerceVideoImageFiles}
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"}
@@ -8,6 +8,8 @@ import {
TagsOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/ecommerce.css";
import type { WebProjectSummary } from "../../types";
import { useDebounce } from "../../hooks/useDebounce";
import { templateCarouselCases, templateCases, templateCategories, type TemplateCase } from "./ecommerceTemplates";
@@ -1,4 +1,5 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import "../../styles/pages/ecommerce-video.css";
import {
CloseOutlined,
CopyOutlined,
@@ -34,6 +35,7 @@ import {
interface EcommerceVideoWorkspaceProps {
isAuthenticated: boolean;
productImageDataUrls: string[];
productImageFiles?: Array<File | undefined>;
requirement: string;
platform: string;
aspectRatio: string;
@@ -97,6 +99,7 @@ function stepCompletedFromProgress(step: PlanStep, p: EcommerceVideoPlanProgress
export default function EcommerceVideoWorkspace({
isAuthenticated,
productImageDataUrls,
productImageFiles = [],
requirement,
platform,
aspectRatio,
@@ -119,6 +122,7 @@ export default function EcommerceVideoWorkspace({
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: "image" | "video" } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderAbortRef = useRef({ current: false });
const actionNoticeTimerRef = useRef<number | null>(null);
const setView = useAppStore((s) => s.setView);
const keepaliveRestoredFingerprintRef = useRef<string | null>(null);
const keepalivePollingStartedRef = useRef(false);
@@ -274,9 +278,23 @@ export default function EcommerceVideoWorkspace({
// Note: keep-alive is NOT cleared on completion — results persist across page switches.
// Only cleared when user explicitly starts a new plan via handlePlan.
useEffect(() => {
return () => {
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
};
}, []);
const showNotice = (msg: string) => {
setActionNotice(msg);
setTimeout(() => setActionNotice(null), 3000);
if (actionNoticeTimerRef.current !== null) {
window.clearTimeout(actionNoticeTimerRef.current);
}
actionNoticeTimerRef.current = window.setTimeout(() => {
actionNoticeTimerRef.current = null;
setActionNotice(null);
}, 3000);
};
const handleDownload = async (url: string) => {
@@ -376,8 +394,9 @@ export default function EcommerceVideoWorkspace({
});
};
try {
const productImageSources = productImageDataUrls.map((url, index) => productImageFiles[index] ?? url);
const result = await runVideoPlan(
productImageDataUrls, requirement, buildConfig(),
productImageSources, requirement, buildConfig(),
{
onStepStart: (step) => setCurrentStep(step),
onStepDone: (step) => {
+81 -51
View File
@@ -9,6 +9,7 @@ import {
type AdVideoUserConfig,
} from "../../api/adVideoPlanClient";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { serverRequest } from "../../api/serverConnection";
import { waitForTask } from "../../api/taskSubscription";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { normalizeEcommerceImageMime } from "./ecommerceImageValidation";
@@ -126,13 +127,61 @@ 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(
imageDataUrls: string[],
imageSources: Array<string | Blob>,
manualText: string,
config: AdVideoUserConfig,
callbacks: PlanCallbacks,
@@ -141,41 +190,30 @@ 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 srcUrl of imageDataUrls) {
for (const source of imageSources) {
try {
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);
imageUrls.push(await uploadProductImageSource(source));
} catch (err) {
rejected.push(err instanceof Error ? err.message : "图片上传失败");
rejected.push(err instanceof Error ? err.message : "Image upload failed");
}
}
if (rejected.length) {
progress.uploadWarnings = rejected;
callbacks.onUploadRejected?.(rejected);
}
if (!imageUrls.length) throw new Error("图片上传失败,请检查图片格式或网络后重试");
if (!imageUrls.length) throw new Error("Image upload failed. Please check the image format or network and try again.");
progress.imageUrls = imageUrls;
onStepDone("upload");
callbacks.onImagesUploaded?.(imageUrls);
emit();
}
// ── Step: analyze ─────────────────────────────────────
// Step: analyze
if (progress.imageDescription === undefined) {
onStepStart("analyze");
progress.imageDescription = await analyzeProductImages(progress.imageUrls!, signal);
@@ -183,7 +221,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: summary ─────────────────────────────────────
// Step: summary
if (!progress.summary) {
onStepStart("summary");
progress.summary = await buildProductSummary(progress.imageDescription || "", manualText, signal);
@@ -191,7 +229,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: selling ─────────────────────────────────────
// Step: selling
if (!progress.selling) {
onStepStart("selling");
progress.selling = await extractSellingPoints(progress.summary, signal);
@@ -199,16 +237,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("未能生成有效的广告创意");
if (!progress.creatives.length) throw new Error("Failed to generate valid ad creatives.");
onStepDone("creative");
emit();
}
// ── Step: storyboard ──────────────────────────────────
// Step: storyboard
if (!progress.storyboard) {
onStepStart("storyboard");
progress.storyboard = await generateStoryboard(progress.creatives[0], progress.summary, config, signal);
@@ -216,7 +254,7 @@ export async function runVideoPlan(
emit();
}
// ── Step: prompts ─────────────────────────────────────
// Step: prompts
if (!progress.videoPrompts) {
onStepStart("prompts");
progress.videoPrompts = await generateVideoPrompts(progress.storyboard, progress.summary, signal);
@@ -224,7 +262,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);
@@ -275,13 +313,15 @@ 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, "图片生成未返回结果");
callbacks.onSceneImageFailed(input.sceneId, "Image generation returned no result.");
}
}
@@ -330,13 +370,15 @@ 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, "任务未返回结果");
callbacks.onSceneFailed(input.sceneId, "Task returned no result.");
}
}
@@ -355,7 +397,7 @@ export function buildSceneTasks(
});
}
// ── Video History API ──────────────────────────────────
// Video History API
export interface VideoHistoryScene {
sceneId: number;
@@ -389,15 +431,6 @@ export interface VideoHistoryListResponse {
offset: number;
}
import { getStoredToken } from "../../api/serverConnection";
const API_BASE = "/api/ai/ecommerce/video-history";
function getAuthHeaders(): Record<string, string> {
const token = getStoredToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryPayload): Promise<SaveVideoHistoryPayload> {
const uploadAssetByUrl = payload.uploadAssetByUrl;
const scenes = await Promise.all(
@@ -445,13 +478,12 @@ export async function buildDurableVideoHistoryPayload(payload: SaveVideoHistoryP
export async function saveVideoHistory(payload: SaveVideoHistoryPayload): Promise<{ id: number; createdAt: string }> {
const { uploadAssetByUrl: _uploadAssetByUrl, ...historyPayload } = await buildDurableVideoHistoryPayload(payload);
const res = await fetch(API_BASE, {
return serverRequest<{ id: number; createdAt: string }>("ai/ecommerce/video-history", {
method: "POST",
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
body: JSON.stringify(historyPayload),
body: historyPayload,
maxRetries: 0,
fallbackMessage: "Failed to save video history",
});
if (!res.ok) throw new Error("保存历史记录失败");
return res.json();
}
function removeTemporaryHistoryUrls(item: VideoHistoryItem): VideoHistoryItem {
@@ -470,12 +502,10 @@ export async function fetchVideoHistory(
limit = 20,
offset = 0,
): Promise<VideoHistoryListResponse> {
const res = await fetch(
`${API_BASE}?limit=${limit}&offset=${offset}`,
{ headers: getAuthHeaders() },
);
if (!res.ok) throw new Error("获取历史记录失败");
const history = (await res.json()) as VideoHistoryListResponse;
const search = new URLSearchParams({ limit: String(limit), offset: String(offset) });
const history = await serverRequest<VideoHistoryListResponse>(`ai/ecommerce/video-history?${search}`, {
fallbackMessage: "Failed to fetch video history",
});
return {
...history,
items: history.items.map(removeTemporaryHistoryUrls),
@@ -483,9 +513,9 @@ export async function fetchVideoHistory(
}
export async function deleteVideoHistory(id: number): Promise<void> {
const res = await fetch(`${API_BASE}/${id}`, {
await serverRequest<void>(`ai/ecommerce/video-history/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
maxRetries: 0,
fallbackMessage: "Failed to delete video history",
});
if (!res.ok) throw new Error("删除失败");
}
@@ -133,6 +133,7 @@ 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;
@@ -200,6 +201,7 @@ export default function EcommerceClonePanel({
clampCloneVideoDuration,
setCloneVideoSmart,
handleGenerate,
onCancelGenerate,
formatRatioDisplayValue,
setVideoOutfitFiles,
onStartVideoPlan,
@@ -746,6 +748,11 @@ 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>
</>
);
@@ -28,6 +28,7 @@ interface EcommerceDetailPanelProps {
handleDetailAiWrite: () => void;
toggleDetailModule: (id: string) => void;
handleDetailGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceDetailPanel({
@@ -56,6 +57,7 @@ export default function EcommerceDetailPanel({
handleDetailAiWrite,
toggleDetailModule,
handleDetailGenerate,
onCancelGenerate,
}: EcommerceDetailPanelProps) {
return (
<>
@@ -162,6 +164,11 @@ 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,6 +35,7 @@ interface EcommerceTryOnPanelProps {
setSmartScene: (updater: (current: boolean) => boolean) => void;
setTryOnRatio: (value: string) => void;
handleTryOnGenerate: () => void;
onCancelGenerate: () => void;
}
export default function EcommerceTryOnPanel({
@@ -70,6 +71,7 @@ export default function EcommerceTryOnPanel({
setSmartScene,
setTryOnRatio,
handleTryOnGenerate,
onCancelGenerate,
}: EcommerceTryOnPanelProps) {
return (
<>
@@ -213,6 +215,11 @@ 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>
</>
);
+15 -4
View File
@@ -50,6 +50,12 @@ function ScriptReviewShowcase() {
const scoreRef = useRef<HTMLSpanElement>(null);
const barRefs = useRef<(HTMLDivElement | null)[]>([]);
const scoreValRefs = useRef<(HTMLSpanElement | null)[]>([]);
const animationTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearAnimationTimers = () => {
animationTimersRef.current.forEach((timer) => clearTimeout(timer));
animationTimersRef.current = [];
};
useEffect(() => {
const el = document.getElementById("script-review-showcase");
@@ -69,18 +75,23 @@ function ScriptReviewShowcase() {
useEffect(() => {
if (!animated) return;
const timer = setTimeout(() => {
clearAnimationTimers();
const scheduleAnimation = (callback: () => void, delay: number) => {
const timer = setTimeout(callback, delay);
animationTimersRef.current.push(timer);
};
scheduleAnimation(() => {
animateNumber(scoreRef.current, 77, 1400);
barRefs.current.forEach((bar, i) => {
if (!bar) return;
const pct = parseFloat(bar.dataset.pct ?? "0");
setTimeout(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
scheduleAnimation(() => { bar.style.height = `${pct}%`; }, i * 100 + 400);
});
scoreValRefs.current.forEach((el, i) => {
setTimeout(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
scheduleAnimation(() => animateNumber(el, parseInt(el?.dataset.target ?? "0"), 800), i * 100 + 400);
});
}, 500);
return () => clearTimeout(timer);
return clearAnimationTimers;
}, [animated]);
return (
@@ -24,6 +24,7 @@ import {
ThunderboltOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
@@ -80,6 +81,20 @@ const CAMERA_EFFECT_PRESETS = [
{ key: "hdr", label: "HDR", prompt: "HDR高动态范围,明暗细节丰富,色彩饱和" },
] as const;
const CAMERA_EFFECT_PROMPT_BY_KEY = new Map<string, string>(
CAMERA_EFFECT_PRESETS.map((effect) => [effect.key, effect.prompt]),
);
function getCameraEffectsPrompt(effectKeys: Set<string>): string {
if (effectKeys.size === 0) return "";
const prompts: string[] = [];
for (const key of effectKeys) {
const prompt = CAMERA_EFFECT_PROMPT_BY_KEY.get(key);
if (prompt) prompts.push(prompt);
}
return prompts.join("");
}
function shotScaleToZoom(shotScale: number): number {
const map: Record<number, number> = { 1: 24, 2: 28, 3: 32, 4: 35, 5: 40, 6: 50, 7: 60, 8: 75, 9: 85, 10: 100 };
return map[Math.round(Math.max(1, Math.min(10, shotScale)))] || 40;
@@ -152,6 +167,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
abortRef.current = false;
taskIdRef.current = saved.taskId;
void waitForTask(saved.taskId, {
kind: "image",
onProgress: (e) => {
setStatus(`${e.status} / ${e.progress}%`);
if (e.status === "completed" && e.resultUrl) {
@@ -398,9 +414,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const refUrls = await uploadReferenceImages([cameraImage]);
const model = "wan2.7-image-pro";
const cameraDesc = `镜头预设: ${cameraPreset}, 方向: ${cameraDirection}, 水平: ${cameraHorizontal}°, 垂直: ${cameraVertical}°, 倾斜: ${cameraRoll}°, 焦距: ${cameraZoom}mm`;
const effectsDesc = cameraEffects.size > 0
? Array.from(cameraEffects).map((key) => CAMERA_EFFECT_PRESETS.find((e) => e.key === key)?.prompt).filter(Boolean).join("")
: "";
const effectsDesc = getCameraEffectsPrompt(cameraEffects);
const fullPrompt = cameraPromptEnabled && cameraPrompt.trim()
? `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}${cameraPrompt}`
: `${cameraDesc}${effectsDesc ? `。视觉效果: ${effectsDesc}` : ""}。保持人物和场景一致,按照镜头参数重新构图。`;
@@ -446,6 +460,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
const pollTaskUntilDone = useCallback(async (taskId: string): Promise<string | null> => {
return waitForTask(taskId, {
kind: "image",
abortRef,
onProgress: (e) => setGenerationProgress(e.progress || 0),
});
@@ -559,7 +574,7 @@ function ImageWorkbenchPage({ initialTool = "workbench", onOpenMore, onSelectVie
referenceUrls: refUrls,
});
taskIdRef.current = taskId;
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
saveToolTaskState("imagewb", { taskId, status: "running", progress: 0 });
const tempUrl = await pollTaskUntilDone(taskId);
if (tempUrl) {
+3
View File
@@ -7,12 +7,14 @@ import {
DeleteOutlined,
EditOutlined,
HighlightOutlined,
MessageOutlined,
SwapOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/more.css";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
interface MorePageProps {
@@ -42,6 +44,7 @@ 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 },
+67 -40
View File
@@ -18,6 +18,7 @@ import {
UserOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
import "../../styles/pages/profile.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { assetClient } from "../../api/assetClient";
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
@@ -260,6 +261,30 @@ 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);
@@ -765,9 +790,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()}>
<button type="button" className="profile-page__banner-btn" onClick={() => bannerInputRef.current?.click()} aria-label="更换背景">
<CameraOutlined />
<span className="profile-page__banner-btn-label"></span>
</button>
<div className="profile-page__banner-overlay" />
</header>
@@ -847,35 +872,39 @@ function ProfilePage({
className={accountPanel === "credits" ? "is-active" : ""}
onClick={() => setAccountPanel("credits")}
>
{(totalBalance / 100).toFixed(2)}
<span></span>
<strong>{(totalBalance / 100).toFixed(2)}</strong>
</button>
<button
type="button"
className={accountPanel === "tasks" ? "is-active" : ""}
onClick={() => setAccountPanel("tasks")}
>
{tasks.length}
<span></span>
<strong>{tasks.length}</strong>
</button>
</div>
<div className="profile-page__upload-card profile-page__upload-card--meta">
<div className="profile-page__account-summary">
{accountPanel === "credits" ? (
<>
<span className="profile-page__meta-item">
<span className="profile-page__account-summary-main">
<small></small>
<strong>{displayName}</strong>
<em>{packageLabel}</em>
</span>
<span className="profile-page__meta-item">
<span className="profile-page__account-summary-metric">
<small></small>
<strong>{(usage.balanceCents / 100).toFixed(2)}</strong>
</span>
</>
) : (
<>
<span className="profile-page__meta-item">
<small></small>
<strong>{tasks.length}</strong>
<span className="profile-page__account-summary-main">
<small></small>
<strong>{tasks.length} </strong>
<em>{completedTasks.length} </em>
</span>
<span className="profile-page__meta-item">
<span className="profile-page__account-summary-metric">
<small></small>
<strong>{completedTasks.length}</strong>
</span>
@@ -884,51 +913,49 @@ function ProfilePage({
</div>
</div>
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<div className="profile-page__actions">
<button type="button" className="profile-page__share-btn profile-page__share-btn--plan">
<ShareAltOutlined />
{packageLabel}
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<LockOutlined />
退
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--primary" onClick={onOpenWorkbench}>
<PlusOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--secondary" onClick={onOpenCommunity}>
<ShareAltOutlined />
</button>
<button type="button" className="profile-page__share-btn profile-page__share-btn--danger" onClick={onLogout}>
<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">
<span className="profile-page__section-label">
{activePanel === "works"
? "代表作"
: activePanel === "projects"
? "服务器项目"
: activePanel === "assets"
? "我的资产"
: "社区审核"}
</span>
<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>
{renderActivePanel()}
</div>
</main>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import "../../styles/pages/provider-health.css";
import {
CheckCircleOutlined,
CloseCircleOutlined,
@@ -16,6 +16,7 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
+124 -2
View File
@@ -9,6 +9,8 @@ import {
UploadOutlined,
} from "@ant-design/icons";
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import { evaluateScript } from "../../api/scriptEvalClient";
import { buildApiUrl, getStoredToken } from "../../api/serverConnection";
import { useSessionStore } from "../../stores";
@@ -25,6 +27,8 @@ 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[];
@@ -192,6 +196,72 @@ 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 normalizeEvidenceItems(evidence: unknown[] | undefined, limit: number): string[] {
if (!Array.isArray(evidence)) return [];
const items: string[] = [];
for (const item of evidence) {
const value = String(item).trim();
if (!value) continue;
items.push(value);
if (items.length >= limit) break;
}
return items;
}
function getDimensionEvidence(result: EvalResult, dim: ScoreDimension): string[] {
const evidence = result.evidence?.[dim.key] ?? (dim.key === "logic" ? result.evidence?.dialogue : undefined);
return normalizeEvidenceItems(evidence, 3);
}
function formatReportMarkdown(result: EvalResult, script: string): string {
const lines: string[] = [];
lines.push(`# 剧本评测报告`);
@@ -203,9 +273,16 @@ function formatReportMarkdown(result: EvalResult, script: string): string {
lines.push("");
lines.push(`## 六维评分`);
for (const dim of SCORE_DIMENSIONS) {
const score = result.dimensionScores[dim.key] ?? 0;
const score = getDimensionScore(result, dim);
const pct = Math.round((score / dim.maxScore) * 100);
const subScores = getDimensionSubScores(result, dim);
const evidence = getDimensionEvidence(result, dim);
const nestedReportLines = [
...subScores.map(([key, value]) => ` - ${formatSubScoreLabel(key)}: ${value}`),
...evidence.map((item) => ` - 证据: ${item}`),
];
lines.push(`- **${dim.label}**: ${score}/${dim.maxScore} (${pct}%) — ${dim.hint}`);
lines.push(...nestedReportLines);
}
if (result.highlights.length > 0) {
lines.push("");
@@ -636,7 +713,7 @@ function ScriptTokensPage() {
</div>
<div className="script-eval-report__chart-grid">
{SCORE_DIMENSIONS.map((dim, dimIndex) => {
const score = result.dimensionScores[dim.key] ?? 0;
const score = getDimensionScore(result, dim);
const pct = Math.max(0, Math.min(1, score / dim.maxScore));
const lossPct = 1 - pct;
const isPerfect = score === dim.maxScore;
@@ -676,6 +753,51 @@ 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">
@@ -11,6 +11,8 @@ import {
WarningOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import "../../styles/pages/script-tokens-v5.css";
import "../../styles/pages/script-tokens.css";
import type {
WebEnterpriseUsageMember,
WebEnterpriseUsageRecord,
@@ -13,6 +13,8 @@ import {
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import "../../styles/pages/image-workbench.css";
import "../../styles/pages/subtitle-removal.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
@@ -13,6 +13,7 @@ import {
SwapOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import "../../styles/pages/image-workbench.css";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
+164 -21
View File
@@ -34,6 +34,7 @@ import {
type ReactNode,
type SyntheticEvent,
} from "react";
import "../../styles/pages/workbench.css";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
@@ -64,6 +65,12 @@ import {
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import {
buildLocalTimeoutMessage,
formatTextTokenUsage,
getTaskTimeoutPolicy,
isTaskLocallyTimedOut,
} from "../../utils/taskLifecycle";
import { detectMentionTrigger } from "../../utils/mentionTrigger";
import {
isHappyHorseModel,
@@ -103,6 +110,7 @@ import {
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
GRID_MODE_OPTIONS,
GRID_SUPPORTED_MODELS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
@@ -250,6 +258,8 @@ 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[]>([]);
@@ -360,7 +370,7 @@ function WorkbenchPage({
.get()
.then((capabilities) => {
if (cancelled) return;
const nextVideoModels = VIDEO_MODEL_OPTIONS;
const nextVideoModels = capabilities.videoModels.length ? capabilities.videoModels : VIDEO_MODEL_OPTIONS;
applyImageModels(capabilities.imageModels);
setVideoModelOptions(nextVideoModels);
@@ -863,6 +873,9 @@ function WorkbenchPage({
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 0;
let lastProgressAt = task.startedAt || Date.now();
const taskKind = task.mode === "image" ? "image" : "video";
const timeoutPolicy = getTaskTimeoutPolicy({ kind: taskKind, model: task.modelLabel, operation: task.operation });
const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) {
@@ -909,6 +922,9 @@ function WorkbenchPage({
const progress = status.status === "completed"
? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
if (progress > lastKnownProgress || status.status === "completed") {
lastProgressAt = Date.now();
}
lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel =
@@ -933,6 +949,28 @@ function WorkbenchPage({
setGenerationProgress(progress);
}
const localTimeoutReason = status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled"
? isTaskLocallyTimedOut({
startedAt: task.startedAt || Date.now(),
lastProgressAt,
progress,
policy: timeoutPolicy,
})
: null;
if (localTimeoutReason) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: buildLocalTimeoutMessage(taskKind),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: "unknown",
taskProgress: progress,
taskStatusLabel: "本地等待超时",
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return;
}
if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask
@@ -1459,9 +1497,22 @@ function WorkbenchPage({
setReferenceItems(nextItems);
};
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
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[]) => {
if (files.length === 0) return;
const existingFingerprints = new Set(
@@ -1548,20 +1599,46 @@ function WorkbenchPage({
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
await processReferenceFiles(files);
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
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 insertPromptMention = (token: string) => {
const rawBefore = inputValue.slice(0, cursorIndex);
@@ -1941,6 +2018,7 @@ function WorkbenchPage({
runKeepalivePoll(keepaliveTask);
} else {
let streamedText = "";
let chatUsage: ChatMessage["taskUsage"] | undefined;
setGenerationProgress(36);
setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, {
@@ -1973,6 +2051,9 @@ function WorkbenchPage({
});
},
abortController.signal,
(usage) => {
chatUsage = usage;
},
);
if (abortController.signal.aborted) return;
@@ -1981,6 +2062,7 @@ function WorkbenchPage({
const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed",
taskUsage: chatUsage,
});
if (!conversationId) {
const conv = await conversationClient.create(
@@ -2108,6 +2190,38 @@ function WorkbenchPage({
}
};
const handleReleaseStuckTask = (message: ChatMessage) => {
if (message.taskId) {
taskAbortControllersRef.current.get(message.taskId)?.abort();
taskAbortControllersRef.current.delete(message.taskId);
removeKeepaliveTask(message.taskId);
}
if (message.conversationId) {
void patchConversationMessage(message.conversationId, message.id, {
body: buildLocalTimeoutMessage(message.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: message.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
});
}
setMessages((current) =>
current.map((item) =>
item.id === message.id
? {
...item,
body: buildLocalTimeoutMessage(item.mode === "image" ? "image" : "video"),
status: "local_timeout",
taskLifecycleStatus: "local_timeout",
taskRefundStatus: item.taskRefundStatus || "unknown",
taskStatusLabel: "本地占用已释放",
}
: item,
),
);
syncActiveGenerationUi();
};
const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分");
@@ -2561,6 +2675,11 @@ 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"
@@ -2612,7 +2731,7 @@ function WorkbenchPage({
isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus}
onChange={setImageModel}
onChange={(v) => { setImageModel(v); if (!GRID_SUPPORTED_MODELS.has(v)) setImageGridMode("single"); }}
direction={dropdownDirection}
/>
<CompoundSelectChip
@@ -2624,6 +2743,7 @@ function WorkbenchPage({
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
{GRID_SUPPORTED_MODELS.has(imageModel) && (
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
@@ -2635,6 +2755,7 @@ function WorkbenchPage({
onChange={setImageGridMode}
direction={dropdownDirection}
/>
)}
</>
)}
{activeMode === "video" && (
@@ -2818,7 +2939,14 @@ function WorkbenchPage({
<h1 className="wb-home__title"></h1>
</div>
<div className="wb-home__composer" ref={toolbarRef}>
<div
className={`wb-home__composer${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
@@ -2954,7 +3082,7 @@ function WorkbenchPage({
))}
</div>
)}
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
{(message.status === "failed" || message.status === "local_timeout") && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
<div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined />
@@ -2962,9 +3090,12 @@ 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.resultUrl && (message.mode === "image" || message.mode === "video") && (
{(message.status === "thinking" || message.status === "stopping") && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)}
{message.status === "thinking" && message.mode === "chat" && (
@@ -2972,6 +3103,11 @@ 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}
@@ -2993,7 +3129,14 @@ function WorkbenchPage({
</div>
</section>
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
<section
className={`wb-composer${composerHidden ? " is-hidden" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
+11 -28
View File
@@ -3,6 +3,8 @@
* Persists task state to localStorage so in-progress tasks survive page switches.
*/
import { waitForTask } from "../../api/taskSubscription";
const KEEPALIVE_PREFIX = "omniai:tool-task:";
interface ToolTaskKeepalive {
@@ -59,38 +61,19 @@ export function clearToolTaskState(key: string): void {
try { window.localStorage.removeItem(KEEPALIVE_PREFIX + key); } catch { /* ignore */ }
}
const TASK_POLL_INTERVAL = 3000;
const TASK_POLL_TIMEOUT = 30 * 60 * 1000;
export async function pollTaskUntilDone(
taskId: string,
onProgress?: (progress: number) => void,
abortRef?: { current: boolean },
kind: "image" | "video" = "video",
): Promise<string | null> {
const startTime = Date.now();
const { aiGenerationClient } = await import("../../api/aiGenerationClient");
while (true) {
if (abortRef?.current) return null;
if (Date.now() - startTime > TASK_POLL_TIMEOUT) return null;
try {
const task = await aiGenerationClient.getTaskStatus(taskId);
if (!task) return null;
const progress = Math.min(99, task.progress || 0);
onProgress?.(progress);
if (task.status === "completed") {
return task.resultUrl || null;
}
if (task.status === "failed" || task.status === "cancelled") {
return null;
}
} catch {
// retry on next poll
}
await new Promise((r) => setTimeout(r, TASK_POLL_INTERVAL));
try {
return await waitForTask(taskId, {
kind,
abortRef,
onProgress: (event) => onProgress?.(Math.min(99, Number(event.progress || 0))),
});
} catch {
return null;
}
}
+6 -1
View File
@@ -1,3 +1,5 @@
import type { GenerationLifecycleStatus, TaskRefundStatus, TextTokenUsage } from "../../utils/taskLifecycle";
export type WorkbenchMode = "chat" | "image" | "video";
export interface WorkbenchChatAttachment {
@@ -16,7 +18,10 @@ export interface WorkbenchChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed";
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string;
conversationId?: number;
taskProgress?: number;
+18 -2
View File
@@ -1,6 +1,7 @@
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";
@@ -71,7 +72,10 @@ export interface ChatMessage {
body: string;
prompt?: string;
createdAt: string;
status?: "thinking" | "queued" | "completed" | "failed";
status?: "thinking" | "queued" | "completed" | "failed" | "stopping" | "local_timeout";
taskLifecycleStatus?: GenerationLifecycleStatus;
taskRefundStatus?: TaskRefundStatus;
taskUsage?: TextTokenUsage;
taskId?: string;
conversationId?: number;
taskProgress?: number;
@@ -232,6 +236,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: "首尾帧" },
@@ -366,11 +377,16 @@ 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.resultMimeType === "string" ||
typeof patch.taskRefundStatus === "string" ||
typeof patch.taskLifecycleStatus === "string" ||
typeof patch.taskUsage === "object"
);
}
+29 -18
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import type { GenerationQueueItem } from "../stores/useGenerationStore";
import { useGenerationStore } from "../stores/useGenerationStore";
import {
@@ -13,7 +14,17 @@ interface UseGenerationTasksOptions {
export function useGenerationTasks(options: UseGenerationTasksOptions) {
const { sourceView, autoResume = true } = options;
const store = useGenerationStore();
const {
queue,
addTask,
updateTask: updateStoredTask,
getRunningTasks,
} = useGenerationStore(useShallow((s) => ({
queue: s.queue,
addTask: s.addTask,
updateTask: s.updateTask,
getRunningTasks: s.getRunningTasks,
})));
const pollingStartedRef = useRef(false);
// ── Auto-resume: re-subscribe to running tasks on mount ────
@@ -21,7 +32,7 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
if (!autoResume || pollingStartedRef.current) return;
pollingStartedRef.current = true;
const active = store.getRunningTasks().filter((t) => t.sourceView === sourceView);
const active = getRunningTasks().filter((t) => t.sourceView === sourceView);
if (active.length > 0) {
startBackgroundPolling();
}
@@ -29,19 +40,19 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
return () => {
pollingStartedRef.current = false;
};
}, [autoResume, sourceView, store]);
}, [autoResume, sourceView, getRunningTasks]);
// ── Subscribe to live updates ───────────────────────────
useEffect(() => {
return subscribeToTaskUpdates((updated) => {
store.updateTask(updated.id, updated);
updateStoredTask(updated.id, updated);
});
}, [store]);
}, [updateStoredTask]);
// ── View-scoped computed lists ──────────────────────────
const myTasks = useMemo(
() => store.queue.filter((t) => t.sourceView === sourceView),
[store.queue, sourceView],
() => queue.filter((t) => t.sourceView === sourceView),
[queue, sourceView],
);
const activeTasks = useMemo(
@@ -63,41 +74,41 @@ export function useGenerationTasks(options: UseGenerationTasksOptions) {
const submitTask = useCallback(
(task: Omit<GenerationQueueItem, "id" | "createdAt">) => {
const id = `gen-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
store.addTask({ ...task, id, createdAt: Date.now() });
addTask({ ...task, id, createdAt: Date.now() });
return id;
},
[store],
[addTask],
);
const updateTask = useCallback(
(id: string, patch: Partial<GenerationQueueItem>) => {
store.updateTask(id, patch);
updateStoredTask(id, patch);
},
[store],
[updateStoredTask],
);
const markCompleted = useCallback(
(id: string, resultUrl: string) => {
store.updateTask(id, { status: "completed", progress: 100, resultUrl });
updateStoredTask(id, { status: "completed", progress: 100, resultUrl });
},
[store],
[updateStoredTask],
);
const markFailed = useCallback(
(id: string, error: string) => {
store.updateTask(id, { status: "failed", error });
updateStoredTask(id, { status: "failed", error });
},
[store],
[updateStoredTask],
);
const retryTask = useCallback(
(id: string) => {
const task = store.queue.find((t) => t.id === id);
const task = queue.find((t) => t.id === id);
if (task) {
store.updateTask(id, { status: "pending", progress: 0, error: null });
updateStoredTask(id, { status: "pending", progress: 0, error: null });
}
},
[store],
[queue, updateStoredTask],
);
return {
-1
View File
@@ -1,6 +1,5 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "@xyflow/react/dist/style.css";
import "./styles/index.css";
import App from "./App";
import { reportError } from "./utils/errorReporting";
+98 -75
View File
@@ -1,14 +1,12 @@
import { useGenerationStore, type GenerationQueueItem } from "../stores/useGenerationStore";
import { aiGenerationClient } from "../api/aiGenerationClient";
import { waitForTask, type TaskProgressEvent } from "../api/taskSubscription";
import { buildTaskFailureInfo } from "../utils/taskLifecycle";
type PollCallback = (item: GenerationQueueItem) => void;
const activePollers = new Map<string, ReturnType<typeof setInterval>>();
const activePollers = new Map<string, { current: boolean }>();
const pollCallbacks = new Set<PollCallback>();
const POLL_INTERVAL = 3000;
const MAX_POLL_ATTEMPTS = 200; // 10 minutes max per task
export function subscribeToTaskUpdates(callback: PollCallback): () => void {
pollCallbacks.add(callback);
return () => { pollCallbacks.delete(callback); };
@@ -18,111 +16,136 @@ function notifyCallbacks(item: GenerationQueueItem): void {
pollCallbacks.forEach((cb) => cb(item));
}
function pollTask(item: GenerationQueueItem, attemptsRef: { current: number }): void {
const key = `poll-${item.id}`;
if (activePollers.has(key)) return;
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") {
cleanupPoll(key);
return;
}
attemptsRef.current++;
if (attemptsRef.current > MAX_POLL_ATTEMPTS) {
useGenerationStore.getState().updateTask(item.id, {
status: "failed",
error: "任务超时,请重新提交",
});
notifyCallbacks({ ...item, status: "failed", error: "任务超时,请重新提交" });
cleanupPoll(key);
return;
}
try {
const status = await aiGenerationClient.getTaskStatus(current.taskId || item.taskId || "");
const patch: Partial<GenerationQueueItem> = {
progress: status.progress,
resultUrl: status.resultUrl || current.resultUrl,
error: status.error || current.error,
};
if (status.status === "completed") {
patch.status = "completed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "completed" });
cleanupPoll(key);
} else if (status.status === "failed" || status.status === "cancelled") {
patch.status = "failed";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "failed" });
cleanupPoll(key);
} else {
patch.status = "running";
useGenerationStore.getState().updateTask(item.id, patch);
notifyCallbacks({ ...item, ...patch, status: "running" });
}
} catch {
// Network error during poll — keep trying
}
}, POLL_INTERVAL);
activePollers.set(key, interval);
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 cleanupPoll(key: string): void {
const interval = activePollers.get(key);
if (interval) {
clearInterval(interval);
activePollers.delete(key);
}
function getQueueItemModel(item: GenerationQueueItem): string | undefined {
return typeof item.params?.model === "string" ? item.params.model : undefined;
}
function updateTaskAndNotify(id: string, patch: Partial<GenerationQueueItem>): GenerationQueueItem | null {
const current = useGenerationStore.getState().queue.find((i) => i.id === id);
if (!current) return null;
const next = { ...current, ...patch };
useGenerationStore.getState().updateTask(id, patch);
notifyCallbacks(next);
return next;
}
function isTerminalStatus(status: GenerationQueueItem["status"]): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
}
function pollTask(item: GenerationQueueItem): void {
const key = `poll-${item.id}`;
if (activePollers.has(key) || !item.taskId) return;
const kind = getQueueItemKind(item);
const abortRef = { current: false };
activePollers.set(key, abortRef);
const applyProgress = (event: TaskProgressEvent) => {
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) {
abortRef.current = true;
return;
}
const patch: Partial<GenerationQueueItem> = {
progress: Number(event.progress || 0),
resultUrl: event.resultUrl || current.resultUrl,
error: event.error || current.error,
};
if (event.status === "completed") {
patch.status = "completed";
patch.progress = 100;
} else if (event.status === "failed" || event.status === "cancelled") {
patch.status = "failed";
patch.error = buildTaskFailureInfo(event.error).message;
} else {
patch.status = "running";
}
updateTaskAndNotify(item.id, patch);
};
void waitForTask(item.taskId, {
kind,
model: getQueueItemModel(item),
startedAt: item.createdAt || Date.now(),
abortRef,
onProgress: applyProgress,
})
.then((resultUrl) => {
if (abortRef.current) return;
const current = useGenerationStore.getState().queue.find((i) => i.id === item.id);
if (!current || isTerminalStatus(current.status)) return;
updateTaskAndNotify(item.id, {
status: "completed",
progress: 100,
resultUrl: resultUrl || current.resultUrl,
});
})
.catch((error) => {
if (abortRef.current) return;
const failure = buildTaskFailureInfo(error instanceof Error ? error.message : String(error));
updateTaskAndNotify(item.id, {
status: "failed",
error: failure.message,
});
})
.finally(() => {
cleanupPoll(key, abortRef);
});
}
function cleanupPoll(key: string, abortRef: { current: boolean }): void {
if (activePollers.get(key) !== abortRef) return;
activePollers.delete(key);
}
export function startBackgroundPolling(): void {
const tasks = useGenerationStore.getState().getRunningTasks();
const attemptsMap = new Map<string, { current: number }>();
tasks.forEach((task) => {
if (task.taskId) {
if (!attemptsMap.has(task.id)) {
attemptsMap.set(task.id, { current: 0 });
}
pollTask(task, attemptsMap.get(task.id)!);
pollTask(task);
}
});
}
export function resumeTaskPolling(taskId: string, storeId: string): void {
const task = useGenerationStore.getState().queue.find((i) => i.id === storeId);
if (task && task.status !== "completed" && task.status !== "failed") {
pollTask(task, { current: 0 });
if (task && !isTerminalStatus(task.status)) {
pollTask({ ...task, taskId });
}
}
export function stopAllPolling(): void {
activePollers.forEach((interval) => clearInterval(interval));
activePollers.forEach((abortRef) => {
abortRef.current = true;
});
activePollers.clear();
}
// ── Recovery on page load ──────────────────────────
export function recoverAndResumeTasks(): void {
const pendingTasks = useGenerationStore.getState().getRunningTasks();
if (!pendingTasks.length) return;
pendingTasks.forEach((task) => {
if (task.taskId) {
// Mark as pending so the workbench/ecommerce can re-submit to polling
useGenerationStore.getState().updateTask(task.id, { status: "pending" });
} else {
// No taskId means it was queued but never submitted — mark failed
useGenerationStore.getState().updateTask(task.id, {
status: "failed",
error: "页面刷新后任务丢失,请重新提交",
error: "页面刷新后任务没有服务端 ID,已释放本地占用,请重新提交",
});
}
});
// Start polling recovered tasks
setTimeout(() => startBackgroundPolling(), 500);
}
-18
View File
@@ -9,27 +9,9 @@
@import "./pages/script-review-visual.css";
@import "./pages/script-review-showcase.css";
@import "./pages/model-generation-showcase.css";
@import "./pages/workbench.css";
@import "./pages/ecommerce.css";
@import "./pages/ecommerce-video.css";
@import "./pages/community.css";
@import "./pages/assets.css";
@import "./pages/more.css";
@import "./pages/avatar-console.css";
@import "./pages/more-tools.css";
@import "./pages/studio-layout.css";
@import "./pages/image-workbench.css";
@import "./pages/subtitle-removal.css";
@import "./pages/size-template.css";
@import "./pages/script-tokens-v5.css";
@import "./pages/script-tokens.css";
@import "./pages/profile.css";
@import "./pages/canvas.css";
@import "./pages/agent.css";
@import "./pages/compliance.css";
@import "./pages/provider-health.css";
@import "./pages/legacy-pages.css";
@import "./pages/not-found.css";
@import "./components/recharge-modal.css";
@import "./components/dropzone.css";
@import "./components/skeleton.css";
+188
View File
@@ -722,3 +722,191 @@
right: -9999px;
height: 1px;
}
/* Tool Modal Overlay */
.studio-canvas-tool-modal-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
}
.studio-canvas-tool-modal {
position: relative;
width: 90vw;
max-width: 720px;
max-height: 80vh;
overflow-y: auto;
border-radius: 16px;
background: var(--bg-panel);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-heavy, 0 12px 40px rgba(0,0,0,0.4));
padding: 24px;
}
.studio-canvas-tool-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.studio-canvas-tool-modal__title {
font-size: 16px;
font-weight: 600;
color: var(--fg-default);
}
.studio-canvas-tool-modal__close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: var(--bg-subtle);
color: var(--fg-muted);
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.studio-canvas-tool-modal__close:hover {
background: var(--bg-hover);
}
/* Tool Panel Components */
.studio-canvas-tool-panel {
display: flex;
gap: 20px;
min-height: 280px;
}
.studio-canvas-tool-panel--inpaint {
flex-direction: column;
}
.studio-canvas-tool-panel__preview {
flex: 0 0 260px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
display: flex;
align-items: center;
justify-content: center;
}
.studio-canvas-tool-panel__preview img {
width: 100%;
height: 100%;
object-fit: contain;
}
.studio-canvas-tool-panel__controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.studio-canvas-tool-panel__label {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.studio-canvas-tool-panel__options {
display: flex;
gap: 8px;
}
.studio-canvas-tool-panel__options button {
padding: 6px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.studio-canvas-tool-panel__options button.is-active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.studio-canvas-tool-panel__textarea {
width: 100%;
min-height: 60px;
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
resize: vertical;
}
.studio-canvas-tool-panel__submit {
margin-top: auto;
padding: 10px 20px;
border-radius: 8px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.studio-canvas-tool-panel__submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.studio-canvas-tool-panel__actions {
display: flex;
gap: 10px;
margin-top: auto;
}
.studio-canvas-tool-panel__reset {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-subtle);
background: var(--bg-subtle);
color: var(--fg-default);
font-size: 13px;
cursor: pointer;
}
.studio-canvas-tool-panel__canvas-wrap {
position: relative;
width: 100%;
max-height: 320px;
border-radius: 10px;
overflow: hidden;
background: var(--bg-subtle);
}
.studio-canvas-tool-panel__canvas-bg {
width: 100%;
height: auto;
display: block;
}
.studio-canvas-tool-panel__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
+580
View File
@@ -0,0 +1,580 @@
.dialog-generator-page {
min-height: 100%;
overflow: auto;
background:
radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0, 255, 136, 0.04) 0%, transparent 70%),
radial-gradient(ellipse 60% 50% at 80% 70%, rgba(42, 159, 212, 0.03) 0%, transparent 60%),
linear-gradient(180deg, #070b10 0%, #05080d 100%);
color: #e8eaef;
}
.dialog-generator-shell {
display: grid;
grid-template-columns: minmax(300px, 0.42fr) minmax(0, 0.58fr);
gap: clamp(18px, 2.8vw, 34px);
min-height: var(--shell-content-height, 100vh);
padding: clamp(24px, 4vw, 52px);
}
.dialog-generator-panel,
.dialog-generator-preview-card {
border: 1px solid rgba(0, 255, 136, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
box-shadow:
0 24px 72px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
backdrop-filter: blur(18px);
}
.dialog-generator-panel {
display: grid;
align-content: start;
gap: 24px;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-heading {
display: grid;
gap: 12px;
}
.dialog-generator-kicker {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-heading h1 {
margin: 0;
background: linear-gradient(135deg, #00ff88, #22f0c0, #4fc3f7);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: clamp(32px, 3.6vw, 56px);
font-weight: 950;
letter-spacing: 0;
line-height: 1.1;
}
.dialog-generator-heading p,
.dialog-generator-hint,
.dialog-generator-preview-head p {
margin: 0;
color: #9aa1b8;
font-size: 15px;
font-weight: 650;
line-height: 1.7;
}
.dialog-generator-section {
display: grid;
gap: 12px;
}
.dialog-generator-section h2 {
margin: 0;
color: #f6f8fb;
font-size: 18px;
font-weight: 900;
}
.dialog-generator-drop {
display: grid;
justify-items: center;
gap: 8px;
min-height: 168px;
border: 1px dashed rgba(0, 255, 136, 0.28);
border-radius: 8px;
background: rgba(0, 255, 136, 0.035);
color: #e8eaef;
padding: 24px;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-drop:hover {
border-color: rgba(0, 255, 136, 0.5);
background: rgba(0, 255, 136, 0.06);
transform: translateY(-1px);
}
.dialog-generator-drop-icon {
font-size: 42px;
}
.dialog-generator-drop strong {
font-size: 16px;
font-weight: 900;
}
.dialog-generator-drop small,
.dialog-generator-style small {
color: #62697f;
font-size: 13px;
font-weight: 700;
}
.dialog-generator-style-list {
display: grid;
gap: 10px;
}
.dialog-generator-color-picker {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.dialog-generator-color {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #dce3ed;
cursor: pointer;
font-size: 13px;
font-weight: 850;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-color:hover,
.dialog-generator-color.is-active {
border-color: var(--text-color);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.dialog-generator-color span {
width: 14px;
height: 14px;
border: 1px solid rgba(255, 255, 255, 0.38);
border-radius: 50%;
background: var(--text-color);
box-shadow: 0 0 12px color-mix(in srgb, var(--text-color) 42%, transparent);
}
.dialog-generator-color strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dialog-generator-style {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 14px;
border: 1px solid rgba(0, 255, 136, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #e8eaef;
padding: 15px 18px;
text-align: left;
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
transform 180ms ease;
}
.dialog-generator-style:hover {
border-color: rgba(0, 255, 136, 0.28);
background: rgba(255, 255, 255, 0.06);
transform: translateX(3px);
}
.dialog-generator-style span:last-child {
display: grid;
gap: 4px;
min-width: 0;
}
.dialog-generator-style strong {
color: #f7fafc;
font-size: 16px;
font-weight: 900;
}
.dialog-generator-swatch {
width: 14px;
height: 14px;
border-radius: 4px;
}
.dialog-generator-swatch.is-white {
border: 1px solid #cbd5e1;
background: #ffffff;
}
.dialog-generator-swatch.is-blue {
background: #165dff;
}
.dialog-generator-swatch.is-amber {
background: #f59e0b;
}
.dialog-generator-swatch.is-gray {
background: #6b7280;
}
.dialog-generator-clear {
min-height: 48px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: #e8eaef;
cursor: pointer;
font-size: 15px;
font-weight: 900;
transition:
border-color 180ms ease,
background 180ms ease;
}
.dialog-generator-clear:hover {
border-color: rgba(255, 77, 103, 0.32);
background: rgba(255, 77, 103, 0.1);
}
.dialog-generator-preview-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
padding: clamp(22px, 2.6vw, 34px);
}
.dialog-generator-preview-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
}
.dialog-generator-preview-head span {
color: #00ff88;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dialog-generator-preview-head h2 {
margin: 4px 0 0;
color: #ffffff;
font-size: clamp(24px, 2vw, 34px);
font-weight: 950;
}
.dialog-generator-preview-head p {
max-width: 440px;
text-align: right;
}
.dialog-generator-preview {
position: relative;
min-height: 520px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
rgba(5, 8, 13, 0.72);
background-size: 32px 32px, 32px 32px, auto;
touch-action: none;
}
.dialog-generator-image {
position: absolute;
inset: 0;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
.dialog-generator-empty {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 12px;
color: #62697f;
text-align: center;
pointer-events: none;
}
.dialog-generator-empty span {
font-size: 52px;
}
.dialog-generator-empty p {
margin: 0;
font-size: 16px;
font-weight: 800;
}
.dialog-generator-bubble {
position: absolute;
z-index: 10;
min-width: 140px;
max-width: 280px;
border-radius: 12px;
padding: 12px 14px;
user-select: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
transition: box-shadow 0.2s;
}
.dialog-generator-bubble.is-confirmed {
min-width: 0;
max-width: min(420px, 80%);
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
cursor: move;
}
.dialog-generator-bubble:hover {
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18);
}
.dialog-generator-bubble.is-confirmed:hover {
box-shadow: none;
}
.dialog-generator-bubble.is-dragging {
z-index: 20;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
}
.dialog-generator-bubble.is-confirmed.is-dragging {
box-shadow: none;
}
.dialog-generator-bubble.style1 {
border: 2px solid #cbd5e1;
background: rgba(255, 255, 255, 0.97);
}
.dialog-generator-bubble.style2 {
border: 2px solid #4f8aff;
border-radius: 16px 16px 4px 16px;
background: rgba(22, 93, 255, 0.95);
}
.dialog-generator-bubble.style3 {
border: 2px solid #f59e0b;
background: rgba(255, 247, 237, 0.97);
}
.dialog-generator-bubble.style4 {
border: 2px solid #6b7280;
border-radius: 4px;
background: rgba(248, 250, 252, 0.97);
}
.dialog-generator-bubble.is-confirmed.style1,
.dialog-generator-bubble.is-confirmed.style2,
.dialog-generator-bubble.is-confirmed.style3,
.dialog-generator-bubble.is-confirmed.style4 {
border: 0;
background: transparent;
}
.dialog-generator-delete {
position: absolute;
top: -8px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 2px solid #fff;
border-radius: 50%;
background: #ef4444;
color: #fff;
cursor: pointer;
font-size: 13px;
line-height: 1;
opacity: 0;
transition: opacity 0.15s;
z-index: 5;
}
.dialog-generator-bubble:hover .dialog-generator-delete {
opacity: 1;
}
.dialog-generator-text,
.dialog-generator-text-display {
width: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--dialog-text-color, #1e293b);
padding: 0;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
}
.dialog-generator-text-display {
width: max-content;
max-width: min(420px, 80vw);
color: var(--dialog-text-color, #ffffff);
font-size: clamp(18px, 2.2vw, 30px);
font-weight: 900;
line-height: 1.35;
letter-spacing: 0;
overflow-wrap: anywhere;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.72),
0 0 1px rgba(0, 0, 0, 0.9);
}
.dialog-generator-text::placeholder {
color: rgba(0, 0, 0, 0.3);
}
.dialog-generator-bubble.style2 .dialog-generator-text,
.dialog-generator-bubble.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #fff);
}
.dialog-generator-bubble.is-confirmed.style2 .dialog-generator-text-display {
color: var(--dialog-text-color, #7fb4ff);
}
.dialog-generator-bubble.style2 .dialog-generator-text::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.dialog-generator-bubble.style3 .dialog-generator-text,
.dialog-generator-bubble.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #92400e);
}
.dialog-generator-bubble.is-confirmed.style3 .dialog-generator-text-display {
color: var(--dialog-text-color, #ffd76a);
}
.dialog-generator-bubble.style3 .dialog-generator-text::placeholder {
color: rgba(146, 64, 14, 0.4);
}
.dialog-generator-bubble.style4 .dialog-generator-text,
.dialog-generator-bubble.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #1f2937);
}
.dialog-generator-bubble.is-confirmed.style4 .dialog-generator-text-display {
color: var(--dialog-text-color, #111827);
text-shadow:
0 1px 0 rgba(255, 255, 255, 0.72),
0 0 8px rgba(255, 255, 255, 0.58);
}
.dialog-generator-bubble-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
}
.dialog-generator-confirm {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 6px;
background: #165dff;
color: #fff;
cursor: pointer;
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
transition:
filter 0.15s,
transform 0.15s;
}
.dialog-generator-confirm:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.dialog-generator-bubble.style2 .dialog-generator-confirm {
background: #fff;
color: #165dff;
}
.dialog-generator-bubble.style3 .dialog-generator-confirm {
background: #f59e0b;
}
.dialog-generator-bubble.style4 .dialog-generator-confirm {
background: #6b7280;
}
.dialog-generator-edit-hint {
display: none;
color: rgba(0, 0, 0, 0.36);
font-size: 10px;
font-weight: 700;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-confirm {
display: none;
}
.dialog-generator-bubble.is-confirmed .dialog-generator-edit-hint {
display: inline-block;
}
@media (max-width: 980px) {
.dialog-generator-shell {
grid-template-columns: 1fr;
}
.dialog-generator-preview-head {
align-items: start;
flex-direction: column;
}
.dialog-generator-preview-head p {
max-width: none;
text-align: left;
}
}
@media (max-width: 560px) {
.dialog-generator-shell {
padding: 18px;
}
.dialog-generator-preview {
min-height: 420px;
}
}
+38
View File
@@ -418,6 +418,15 @@
cursor: not-allowed;
}
.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;
}
@@ -3976,6 +3985,7 @@
.product-clone-panel__footer {
display: grid;
align-items: center;
gap: 8px;
border-top: 1px solid #e5e7eb;
padding: 12px 16px;
}
@@ -4000,6 +4010,11 @@
cursor: not-allowed;
}
.product-clone-primary--cancel {
background: #303540;
color: #eef2f6;
}
.product-clone-preview {
display: grid;
align-content: center;
@@ -4930,6 +4945,7 @@
}
.product-set-main-card {
position: relative;
height: 380px;
border-radius: 16px;
transition: transform 250ms ease, box-shadow 250ms ease;
@@ -8759,6 +8775,17 @@
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);
@@ -8984,6 +9011,17 @@
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%),
+7
View File
@@ -15,3 +15,10 @@
.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;
}
+156
View File
@@ -2802,6 +2802,10 @@
color: #e9fff5;
font-size: 14px;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8em;
}
.script-eval-v5-uf-size {
@@ -3230,6 +3234,141 @@
color: var(--report-green);
}
.script-eval-report__detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: 0 18px 18px;
}
.script-eval-report__detail-card {
min-width: 0;
border: 1px solid rgb(255 255 255 / 6%);
border-radius: var(--v5-radius-md);
background: linear-gradient(180deg, rgb(255 255 255 / 3.4%), transparent), var(--report-row);
padding: 14px;
}
.script-eval-report__detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.script-eval-report__detail-head div {
display: grid;
gap: 4px;
min-width: 0;
}
.script-eval-report__detail-head span {
color: #dfe8e4;
font-size: 14px;
font-weight: 800;
}
.script-eval-report__detail-head strong {
color: var(--report-green);
font-size: 22px;
line-height: 1;
}
.script-eval-report__detail-head small {
color: #7e8a86;
font-size: 12px;
}
.script-eval-report__detail-head em {
flex-shrink: 0;
border: 1px solid rgb(0 255 136 / 18%);
border-radius: 999px;
background: rgb(0 255 136 / 7%);
color: #98e8bd;
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 800;
}
.script-eval-report__detail-hint,
.script-eval-report__detail-empty {
margin: 10px 0 0;
color: #7f8c88;
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.script-eval-report__subscore-list {
display: grid;
gap: 9px;
margin-top: 13px;
}
.script-eval-report__subscore-row {
display: grid;
grid-template-columns: minmax(62px, 86px) minmax(0, 1fr) 34px;
align-items: center;
gap: 8px;
color: #bdcbc6;
font-size: 12px;
font-weight: 750;
}
.script-eval-report__subscore-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.script-eval-report__subscore-row b {
color: #dfe8e4;
text-align: right;
}
.script-eval-report__subscore-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 5%);
}
.script-eval-report__subscore-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #18de8a, #a3f7c1);
}
.script-eval-report__evidence-list {
display: grid;
gap: 7px;
margin: 13px 0 0;
padding: 0;
list-style: none;
}
.script-eval-report__evidence-list li {
position: relative;
padding-left: 13px;
color: #aebcb7;
font-size: 12px;
font-weight: 650;
line-height: 1.55;
}
.script-eval-report__evidence-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.62em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--report-green);
}
.script-eval-report__findings {
gap: 18px;
}
@@ -3278,6 +3417,10 @@
.script-eval-report--inside .script-eval-report__body {
padding-inline: 24px;
}
.script-eval-report__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
@@ -3297,6 +3440,19 @@
}
@media (max-width: 680px) {
.script-eval-report__detail-grid {
grid-template-columns: 1fr;
margin-inline: 0;
}
.script-eval-report__detail-card {
padding: 12px;
}
.script-eval-report__subscore-row {
grid-template-columns: minmax(58px, 78px) minmax(0, 1fr) 30px;
}
.script-eval-v5 {
overflow: hidden;
}
+2 -2
View File
@@ -202,9 +202,9 @@
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 16px;
min-height: clamp(360px, 40vw, 520px);
min-height: clamp(560px, 52vw, 760px);
}
/* ===== Tool Cards ===== */
+8 -8
View File
@@ -9,10 +9,10 @@
.wb-prompt-cases__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-auto-flow: dense;
grid-auto-rows: 10px;
gap: 10px;
gap: 8px;
}
.wb-prompt-case-card {
@@ -34,22 +34,22 @@
.wb-prompt-case-card--ratio-wide {
grid-column: span 1;
grid-row: span 8;
grid-row: span 13;
}
.wb-prompt-case-card--ratio-tall {
grid-column: span 1;
grid-row: span 23;
grid-row: span 30;
}
.wb-prompt-case-card--ratio-square {
grid-column: span 1;
grid-row: span 13;
grid-row: span 18;
}
.wb-prompt-case-card--ratio-portrait {
grid-column: span 1;
grid-row: span 16;
grid-row: span 24;
}
.wb-prompt-case-card img {
@@ -328,7 +328,7 @@
@media (max-width: 980px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
@@ -387,7 +387,7 @@
@media (max-width: 560px) {
.wb-prompt-cases__grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 8px;
gap: 8px;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ export type WebViewKey =
| "more"
| "watermarkRemoval"
| "subtitleRemoval"
| "dialogGenerator"
| "communityReview"
| "communityCaseAdd"
| "report"
+3 -3
View File
@@ -3,6 +3,8 @@
* Falls back gracefully when Notification API is unavailable.
*/
import { toast } from "../components/toast/toastStore";
let permissionGranted = false;
async function requestPermission(): Promise<boolean> {
@@ -35,9 +37,7 @@ export function notifyTaskCompleted(label: string, mode: "image" | "video" = "im
// Use the existing toast system for in-app notifications
function dispatchGenToast(msg: string) {
try {
import("../components/toast/toastStore").then((m) => m.toast(msg, "success"));
} catch { /* toast system not loaded */ }
toast(msg, "success");
}
/** Call once on app init to pre-warm permission. */
+160
View File
@@ -0,0 +1,160 @@
import { classifyTaskError, type TaskErrorCategory } from "./translateTaskError";
export type GenerationLifecycleStatus =
| "creating"
| "queued"
| "running"
| "stopping"
| "failed"
| "completed"
| "local_timeout";
export type TaskRefundStatus = "not_charged" | "pending_refund" | "refunded" | "manual_review" | "unknown";
export interface TaskTimeoutPolicy {
submitTimeoutMs: number;
noProgressTimeoutMs: number;
maxRuntimeMs: number;
}
export interface TaskFailureInfo {
category: TaskErrorCategory;
message: string;
actionLabel: string;
retryable: boolean;
refundStatus: TaskRefundStatus;
refundHint: string;
}
export interface TextTokenUsage {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
}
export const TEXT_INPUT_CREDITS_PER_MILLION = 2;
export const TEXT_OUTPUT_CREDITS_PER_MILLION = 5;
const IMAGE_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 90_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 10 * 60_000,
};
const VIDEO_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 120_000,
maxRuntimeMs: 20 * 60_000,
};
const VIDEO_LONG_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 30 * 60_000,
};
const VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 120_000,
noProgressTimeoutMs: 180_000,
maxRuntimeMs: 15 * 60_000,
};
const TEXT_TIMEOUT_POLICY: TaskTimeoutPolicy = {
submitTimeoutMs: 30_000,
noProgressTimeoutMs: 60_000,
maxRuntimeMs: 5 * 60_000,
};
export function getTaskTimeoutPolicy(input: {
kind?: "image" | "video" | "text";
model?: string | null;
operation?: string | null;
}): TaskTimeoutPolicy {
if (input.operation === "video-super-resolution") return VIDEO_SUPER_RESOLUTION_TIMEOUT_POLICY;
if (input.kind === "image") return IMAGE_TIMEOUT_POLICY;
if (input.kind === "text") return TEXT_TIMEOUT_POLICY;
const model = String(input.model || "").toLowerCase();
if (/kling|wan|veo|sora|hailuo|vidu|pixverse|happyhorse/.test(model)) return VIDEO_LONG_TIMEOUT_POLICY;
return VIDEO_TIMEOUT_POLICY;
}
export function isTaskLocallyTimedOut(input: {
startedAt: number;
lastProgressAt: number;
now?: number;
policy: TaskTimeoutPolicy;
progress?: number;
}): "no_progress" | "max_runtime" | null {
const now = input.now || Date.now();
const progress = Number(input.progress || 0);
if (now - input.startedAt >= input.policy.maxRuntimeMs) return "max_runtime";
if (progress > 0 && progress < 100 && now - input.lastProgressAt >= input.policy.noProgressTimeoutMs) {
return "no_progress";
}
if (progress <= 0 && now - input.startedAt >= input.policy.submitTimeoutMs) return "no_progress";
return null;
}
export function buildLocalTimeoutMessage(kind: "image" | "video" | "text" = "video"): string {
if (kind === "text") {
return "本地等待已超时,已停止前端动画。若服务端稍后返回,请以会话记录和积分流水为准。";
}
const label = kind === "image" ? "图片" : "视频";
return `${label}任务长时间没有进展,已停止本地等待并释放前端占用。服务端任务仍可能稍后完成,请到任务历史或资产页查看结果;如已扣费,系统会在失败结算后按积分流水退回。`;
}
export function buildTaskFailureInfo(
error: string | undefined | null,
options: { refundStatus?: TaskRefundStatus; charged?: boolean; submitted?: boolean } = {},
): TaskFailureInfo {
const classified = classifyTaskError(error);
const submitted = options.submitted !== false;
const refundStatus: TaskRefundStatus =
options.refundStatus ||
(submitted
? classified.category === "insufficient_balance" || classified.category === "auth_failure"
? "not_charged"
: "unknown"
: "not_charged");
const refundHint = getRefundHint(refundStatus);
return {
category: classified.category,
message: `${classified.message}${refundHint ? `\n\n${refundHint}` : ""}`,
actionLabel: classified.action,
retryable: !["auth_failure", "insufficient_balance", "content_policy"].includes(classified.category),
refundStatus,
refundHint,
};
}
export function getRefundHint(status: TaskRefundStatus): string {
switch (status) {
case "not_charged":
return "提交未进入扣费结算,未产生积分消耗。";
case "pending_refund":
return "任务已失败,若已扣费,系统会自动退回,请以积分流水为准。";
case "refunded":
return "失败扣费已退回,请在积分流水中核对。";
case "manual_review":
return "退款状态需要人工核对,请联系管理员并提供任务 ID。";
default:
return "如已扣费,系统将在任务失败后自动退回;请以积分流水为准。";
}
}
export function estimateTextTokenCredits(usage: TextTokenUsage): number {
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
return (promptTokens / 1_000_000) * TEXT_INPUT_CREDITS_PER_MILLION +
(completionTokens / 1_000_000) * TEXT_OUTPUT_CREDITS_PER_MILLION;
}
export function formatTextTokenUsage(usage?: TextTokenUsage | null): string {
const rule = "文本计费规则:输入 Token 每百万 2 积分,输出 Token 每百万 5 积分,实际以服务端结算为准。";
if (!usage) return rule;
const promptTokens = Math.max(0, Number(usage.promptTokens || 0));
const completionTokens = Math.max(0, Number(usage.completionTokens || 0));
const estimatedCredits = estimateTextTokenCredits({ promptTokens, completionTokens });
return `本次 Token:输入 ${promptTokens},输出 ${completionTokens},预估 ${estimatedCredits.toFixed(4)} 积分。\n${rule}`;
}